mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Revert
This commit is contained in:
@@ -1,17 +0,0 @@
|
|||||||
package com.imgfloat.app.config;
|
|
||||||
|
|
||||||
import org.springframework.boot.task.TaskExecutorBuilder;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.core.task.TaskExecutor;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
public class TaskConfig {
|
|
||||||
|
|
||||||
@Bean(name = "assetTaskExecutor")
|
|
||||||
public TaskExecutor assetTaskExecutor(TaskExecutorBuilder builder) {
|
|
||||||
return builder
|
|
||||||
.threadNamePrefix("asset-optimizer-")
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,15 +34,6 @@ public class AssetEvent {
|
|||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AssetEvent updated(String channel, AssetView asset) {
|
|
||||||
AssetEvent event = new AssetEvent();
|
|
||||||
event.type = Type.UPDATED;
|
|
||||||
event.channel = channel;
|
|
||||||
event.payload = asset;
|
|
||||||
event.assetId = asset.id();
|
|
||||||
return event;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static AssetEvent play(String channel, AssetView asset, boolean play) {
|
public static AssetEvent play(String channel, AssetView asset, boolean play) {
|
||||||
AssetEvent event = new AssetEvent();
|
AssetEvent event = new AssetEvent();
|
||||||
event.type = Type.PLAY;
|
event.type = Type.PLAY;
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import jakarta.persistence.Column;
|
|||||||
import jakarta.persistence.ElementCollection;
|
import jakarta.persistence.ElementCollection;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.persistence.Entity;
|
||||||
import jakarta.persistence.FetchType;
|
import jakarta.persistence.FetchType;
|
||||||
import jakarta.persistence.Index;
|
|
||||||
import jakarta.persistence.Id;
|
import jakarta.persistence.Id;
|
||||||
import jakarta.persistence.JoinColumn;
|
import jakarta.persistence.JoinColumn;
|
||||||
import jakarta.persistence.PrePersist;
|
import jakarta.persistence.PrePersist;
|
||||||
@@ -19,17 +18,13 @@ import java.util.Set;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "channels", indexes = {
|
@Table(name = "channels")
|
||||||
@Index(name = "idx_channels_broadcaster", columnList = "broadcaster")
|
|
||||||
})
|
|
||||||
public class Channel {
|
public class Channel {
|
||||||
@Id
|
@Id
|
||||||
private String broadcaster;
|
private String broadcaster;
|
||||||
|
|
||||||
@ElementCollection(fetch = FetchType.EAGER)
|
@ElementCollection(fetch = FetchType.EAGER)
|
||||||
@CollectionTable(name = "channel_admins",
|
@CollectionTable(name = "channel_admins", joinColumns = @JoinColumn(name = "channel_id"))
|
||||||
joinColumns = @JoinColumn(name = "channel_id"),
|
|
||||||
indexes = @Index(name = "idx_channel_admins_username", columnList = "admin_username"))
|
|
||||||
@Column(name = "admin_username")
|
@Column(name = "admin_username")
|
||||||
private Set<String> admins = new HashSet<>();
|
private Set<String> admins = new HashSet<>();
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,5 @@ package com.imgfloat.app.repository;
|
|||||||
import com.imgfloat.app.model.Channel;
|
import com.imgfloat.app.model.Channel;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public interface ChannelRepository extends JpaRepository<Channel, String> {
|
public interface ChannelRepository extends JpaRepository<Channel, String> {
|
||||||
List<Channel> findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(String broadcaster);
|
|
||||||
|
|
||||||
List<Channel> findTop50ByOrderByBroadcasterAsc();
|
|
||||||
|
|
||||||
List<Channel> findByAdminsContaining(String username);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import org.jcodec.common.model.Picture;
|
|||||||
import org.jcodec.scale.AWTUtil;
|
import org.jcodec.scale.AWTUtil;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.core.task.TaskExecutor;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
@@ -30,8 +28,6 @@ import java.io.ByteArrayInputStream;
|
|||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URLConnection;
|
import java.net.URLConnection;
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.nio.file.InvalidPathException;
|
import java.nio.file.InvalidPathException;
|
||||||
@@ -39,12 +35,12 @@ import java.nio.file.Files;
|
|||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.nio.file.StandardOpenOption;
|
import java.nio.file.StandardOpenOption;
|
||||||
import java.nio.file.StandardCopyOption;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import javax.imageio.ImageReader;
|
import javax.imageio.ImageReader;
|
||||||
@@ -62,21 +58,17 @@ public class ChannelDirectoryService {
|
|||||||
private static final int MIN_GIF_DELAY_MS = 20;
|
private static final int MIN_GIF_DELAY_MS = 20;
|
||||||
private static final String PREVIEW_MEDIA_TYPE = "image/png";
|
private static final String PREVIEW_MEDIA_TYPE = "image/png";
|
||||||
private static final Path PREVIEW_ROOT = Paths.get("previews");
|
private static final Path PREVIEW_ROOT = Paths.get("previews");
|
||||||
private static final Path ASSET_ROOT = Paths.get("assets");
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
private static final Logger logger = LoggerFactory.getLogger(ChannelDirectoryService.class);
|
||||||
private final ChannelRepository channelRepository;
|
private final ChannelRepository channelRepository;
|
||||||
private final AssetRepository assetRepository;
|
private final AssetRepository assetRepository;
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
private final TaskExecutor assetTaskExecutor;
|
|
||||||
|
|
||||||
public ChannelDirectoryService(ChannelRepository channelRepository,
|
public ChannelDirectoryService(ChannelRepository channelRepository,
|
||||||
AssetRepository assetRepository,
|
AssetRepository assetRepository,
|
||||||
SimpMessagingTemplate messagingTemplate,
|
SimpMessagingTemplate messagingTemplate) {
|
||||||
@Qualifier("assetTaskExecutor") TaskExecutor assetTaskExecutor) {
|
|
||||||
this.channelRepository = channelRepository;
|
this.channelRepository = channelRepository;
|
||||||
this.assetRepository = assetRepository;
|
this.assetRepository = assetRepository;
|
||||||
this.messagingTemplate = messagingTemplate;
|
this.messagingTemplate = messagingTemplate;
|
||||||
this.assetTaskExecutor = assetTaskExecutor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Channel getOrCreateChannel(String broadcaster) {
|
public Channel getOrCreateChannel(String broadcaster) {
|
||||||
@@ -87,13 +79,13 @@ public class ChannelDirectoryService {
|
|||||||
|
|
||||||
public List<String> searchBroadcasters(String query) {
|
public List<String> searchBroadcasters(String query) {
|
||||||
String normalizedQuery = normalize(query);
|
String normalizedQuery = normalize(query);
|
||||||
if (normalizedQuery == null || normalizedQuery.isBlank()) {
|
return channelRepository.findAll().stream()
|
||||||
return channelRepository.findTop50ByOrderByBroadcasterAsc().stream()
|
|
||||||
.map(Channel::getBroadcaster)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
return channelRepository.findTop50ByBroadcasterContainingIgnoreCaseOrderByBroadcasterAsc(normalizedQuery).stream()
|
|
||||||
.map(Channel::getBroadcaster)
|
.map(Channel::getBroadcaster)
|
||||||
|
.map(this::normalize)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(name -> normalizedQuery == null || normalizedQuery.isBlank() || name.contains(normalizedQuery))
|
||||||
|
.sorted()
|
||||||
|
.limit(50)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,139 +134,39 @@ public class ChannelDirectoryService {
|
|||||||
|
|
||||||
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
|
||||||
Channel channel = getOrCreateChannel(broadcaster);
|
Channel channel = getOrCreateChannel(broadcaster);
|
||||||
byte[] bytes;
|
byte[] bytes = file.getBytes();
|
||||||
try (InputStream stream = file.getInputStream()) {
|
|
||||||
bytes = stream.readAllBytes();
|
|
||||||
}
|
|
||||||
String mediaType = detectMediaType(file, bytes);
|
String mediaType = detectMediaType(file, bytes);
|
||||||
|
|
||||||
|
OptimizedAsset optimized = optimizeAsset(bytes, mediaType);
|
||||||
|
if (optimized == null) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
String name = Optional.ofNullable(file.getOriginalFilename())
|
String name = Optional.ofNullable(file.getOriginalFilename())
|
||||||
.map(filename -> filename.replaceAll("^.*[/\\\\]", ""))
|
.map(filename -> filename.replaceAll("^.*[/\\\\]", ""))
|
||||||
.filter(s -> !s.isBlank())
|
.filter(s -> !s.isBlank())
|
||||||
.orElse("Asset " + System.currentTimeMillis());
|
.orElse("Asset " + System.currentTimeMillis());
|
||||||
Asset asset = buildPlaceholderAsset(channel, name, bytes, mediaType);
|
|
||||||
if (asset == null) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
if (!shouldOptimizeAsync(mediaType)) {
|
|
||||||
OptimizedAsset optimized = optimizeAsset(bytes, mediaType);
|
|
||||||
if (optimized == null) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
applyOptimizedAsset(channel.getBroadcaster(), asset, optimized);
|
|
||||||
}
|
|
||||||
|
|
||||||
assetRepository.save(asset);
|
String dataUrl = "data:" + optimized.mediaType() + ";base64," + Base64.getEncoder().encodeToString(optimized.bytes());
|
||||||
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
|
double width = optimized.width() > 0 ? optimized.width() : (optimized.mediaType().startsWith("audio/") ? 400 : 640);
|
||||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
double height = optimized.height() > 0 ? optimized.height() : (optimized.mediaType().startsWith("audio/") ? 80 : 360);
|
||||||
|
Asset asset = new Asset(channel.getBroadcaster(), name, dataUrl, width, height);
|
||||||
if (shouldOptimizeAsync(mediaType)) {
|
|
||||||
enqueueOptimization(channel.getBroadcaster(), asset.getId(), bytes, mediaType);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Optional.of(view);
|
|
||||||
}
|
|
||||||
|
|
||||||
private Asset buildPlaceholderAsset(Channel channel, String name, byte[] bytes, String mediaType) {
|
|
||||||
Dimension dimension = inferDimensions(bytes, mediaType);
|
|
||||||
double width = dimension.width() > 0 ? dimension.width() : (mediaType.startsWith("audio/") ? 400 : 640);
|
|
||||||
double height = dimension.height() > 0 ? dimension.height() : (mediaType.startsWith("audio/") ? 80 : 360);
|
|
||||||
Asset asset = new Asset(channel.getBroadcaster(), name, "", width, height);
|
|
||||||
asset.setOriginalMediaType(mediaType);
|
asset.setOriginalMediaType(mediaType);
|
||||||
asset.setMediaType(optimized.mediaType());
|
asset.setMediaType(optimized.mediaType());
|
||||||
asset.setPreview(null);
|
asset.setPreview(storePreview(channel.getBroadcaster(), asset.getId(), optimized.previewBytes()));
|
||||||
asset.setUrl(storeAsset(channel.getBroadcaster(), asset.getId(), optimized.bytes(), optimized.mediaType()));
|
|
||||||
asset.setSpeed(1.0);
|
asset.setSpeed(1.0);
|
||||||
asset.setMuted(mediaType.startsWith("video/"));
|
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
||||||
asset.setAudioLoop(false);
|
asset.setAudioLoop(false);
|
||||||
asset.setAudioDelayMillis(0);
|
asset.setAudioDelayMillis(0);
|
||||||
asset.setAudioSpeed(1.0);
|
asset.setAudioSpeed(1.0);
|
||||||
asset.setAudioPitch(1.0);
|
asset.setAudioPitch(1.0);
|
||||||
asset.setAudioVolume(1.0);
|
asset.setAudioVolume(1.0);
|
||||||
asset.setZIndex(nextZIndex(channel.getBroadcaster()));
|
asset.setZIndex(nextZIndex(channel.getBroadcaster()));
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean shouldOptimizeAsync(String mediaType) {
|
|
||||||
return "image/gif".equalsIgnoreCase(mediaType) || mediaType.startsWith("video/");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void enqueueOptimization(String broadcaster, String assetId, byte[] bytes, String mediaType) {
|
|
||||||
assetTaskExecutor.execute(() -> {
|
|
||||||
try {
|
|
||||||
OptimizedAsset optimized = optimizeAsset(bytes, mediaType);
|
|
||||||
if (optimized == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
assetRepository.findById(assetId)
|
|
||||||
.filter(asset -> broadcaster.equals(asset.getBroadcaster()))
|
|
||||||
.ifPresent(asset -> {
|
|
||||||
boolean changed = applyOptimizedAsset(broadcaster, asset, optimized);
|
|
||||||
if (changed) {
|
|
||||||
assetRepository.save(asset);
|
assetRepository.save(asset);
|
||||||
AssetView view = AssetView.from(broadcaster, asset);
|
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
|
||||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view));
|
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
||||||
}
|
return Optional.of(view);
|
||||||
});
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("Unable to optimize asset {}", assetId, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean applyOptimizedAsset(String broadcaster, Asset asset, OptimizedAsset optimized) {
|
|
||||||
if (optimized == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
boolean changed = false;
|
|
||||||
String optimizedUrl = toDataUrl(optimized.bytes(), optimized.mediaType());
|
|
||||||
if (!optimizedUrl.equals(asset.getUrl())) {
|
|
||||||
asset.setUrl(optimizedUrl);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (!Objects.equals(asset.getMediaType(), optimized.mediaType())) {
|
|
||||||
asset.setMediaType(optimized.mediaType());
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (optimized.width() > 0 && optimized.height() > 0) {
|
|
||||||
asset.setWidth(optimized.width());
|
|
||||||
asset.setHeight(optimized.height());
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
asset.setMuted(optimized.mediaType().startsWith("video/"));
|
|
||||||
|
|
||||||
String previousPreview = asset.getPreview();
|
|
||||||
try {
|
|
||||||
asset.setPreview(storePreview(broadcaster, asset.getId(), optimized.previewBytes()));
|
|
||||||
if (!Objects.equals(previousPreview, asset.getPreview())) {
|
|
||||||
deletePreviewFile(previousPreview);
|
|
||||||
changed = changed || asset.getPreview() != null;
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Unable to store preview for asset {}", asset.getId(), e);
|
|
||||||
}
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Dimension inferDimensions(byte[] bytes, String mediaType) {
|
|
||||||
try {
|
|
||||||
if (mediaType.startsWith("video/")) {
|
|
||||||
return extractVideoDimensions(bytes);
|
|
||||||
}
|
|
||||||
if (mediaType.startsWith("image/")) {
|
|
||||||
BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes));
|
|
||||||
if (image != null) {
|
|
||||||
return new Dimension(image.getWidth(), image.getHeight());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Unable to infer dimensions for {}", mediaType, e);
|
|
||||||
}
|
|
||||||
return new Dimension(0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String toDataUrl(byte[] bytes, String mediaType) {
|
|
||||||
return "data:" + mediaType + ";base64," + Base64.getEncoder().encodeToString(bytes);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest request) {
|
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest request) {
|
||||||
@@ -352,7 +244,6 @@ public class ChannelDirectoryService {
|
|||||||
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
.filter(asset -> normalized.equals(asset.getBroadcaster()))
|
||||||
.map(asset -> {
|
.map(asset -> {
|
||||||
deletePreviewFile(asset.getPreview());
|
deletePreviewFile(asset.getPreview());
|
||||||
deleteAssetFile(asset.getUrl());
|
|
||||||
assetRepository.delete(asset);
|
assetRepository.delete(asset);
|
||||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
|
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.deleted(broadcaster, assetId));
|
||||||
return true;
|
return true;
|
||||||
@@ -410,7 +301,8 @@ public class ChannelDirectoryService {
|
|||||||
return List.of();
|
return List.of();
|
||||||
}
|
}
|
||||||
String login = username.toLowerCase();
|
String login = username.toLowerCase();
|
||||||
return channelRepository.findByAdminsContaining(login).stream()
|
return channelRepository.findAll().stream()
|
||||||
|
.filter(channel -> channel.getAdmins().contains(login))
|
||||||
.map(Channel::getBroadcaster)
|
.map(Channel::getBroadcaster)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
@@ -432,18 +324,9 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Optional<AssetContent> decodeAssetData(Asset asset) {
|
private Optional<AssetContent> decodeAssetData(Asset asset) {
|
||||||
if (asset.getUrl() == null) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset.getUrl().startsWith("data:")) {
|
|
||||||
return decodeDataUrl(asset.getUrl())
|
return decodeDataUrl(asset.getUrl())
|
||||||
.flatMap(content -> backfillAssetStorage(asset, content));
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadAssetFromStore(asset)
|
|
||||||
.or(() -> {
|
.or(() -> {
|
||||||
logger.warn("Unable to read asset data for {}", asset.getId());
|
logger.warn("Unable to decode asset data for {}", asset.getId());
|
||||||
return Optional.empty();
|
return Optional.empty();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -490,88 +373,6 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String storeAsset(String broadcaster, String assetId, byte[] bytes, String mediaType) throws IOException {
|
|
||||||
Path directory = ASSET_ROOT.resolve(normalize(broadcaster));
|
|
||||||
Files.createDirectories(directory);
|
|
||||||
String extension = extensionFor(mediaType);
|
|
||||||
Path assetFile = directory.resolve(assetId + (extension == null ? "" : ("." + extension)));
|
|
||||||
try (InputStream in = new ByteArrayInputStream(bytes)) {
|
|
||||||
Files.copy(in, assetFile, StandardCopyOption.REPLACE_EXISTING);
|
|
||||||
}
|
|
||||||
return assetFile.toUri().toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Optional<AssetContent> loadAssetFromStore(Asset asset) {
|
|
||||||
String location = asset.getUrl();
|
|
||||||
if (location == null || location.isBlank()) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(location);
|
|
||||||
Path path;
|
|
||||||
if (uri.getScheme() == null) {
|
|
||||||
path = Paths.get(location);
|
|
||||||
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
|
|
||||||
path = Paths.get(uri);
|
|
||||||
} else {
|
|
||||||
logger.warn("Unsupported asset URI scheme {} for {}", uri.getScheme(), asset.getId());
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
if (path == null || !Files.exists(path)) {
|
|
||||||
logger.warn("Asset location {} is not readable", location);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
try (InputStream stream = Files.newInputStream(path)) {
|
|
||||||
byte[] bytes = stream.readAllBytes();
|
|
||||||
String mediaType = asset.getMediaType() == null ? "application/octet-stream" : asset.getMediaType();
|
|
||||||
return Optional.of(new AssetContent(bytes, mediaType));
|
|
||||||
}
|
|
||||||
} catch (IOException | IllegalArgumentException e) {
|
|
||||||
logger.warn("Unable to read asset data from {}", location, e);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Optional<AssetContent> backfillAssetStorage(Asset asset, AssetContent content) {
|
|
||||||
try {
|
|
||||||
String storedLocation = storeAsset(asset.getBroadcaster(), asset.getId(), content.bytes(),
|
|
||||||
asset.getMediaType() == null ? content.mediaType() : asset.getMediaType());
|
|
||||||
asset.setUrl(storedLocation);
|
|
||||||
assetRepository.save(asset);
|
|
||||||
return Optional.of(new AssetContent(content.bytes(),
|
|
||||||
asset.getMediaType() == null ? content.mediaType() : asset.getMediaType()));
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Unable to backfill storage for {}", asset.getId(), e);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void deleteAssetFile(String location) {
|
|
||||||
if (location == null || location.isBlank()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
URI uri = URI.create(location);
|
|
||||||
Path path;
|
|
||||||
if (uri.getScheme() == null) {
|
|
||||||
path = Paths.get(location);
|
|
||||||
} else if ("file".equalsIgnoreCase(uri.getScheme())) {
|
|
||||||
path = Paths.get(uri);
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (path != null) {
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(path);
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Unable to delete asset file {}", path, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
logger.debug("Asset location {} is not a file path; skipping delete", location);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException {
|
private String storePreview(String broadcaster, String assetId, byte[] previewBytes) throws IOException {
|
||||||
if (previewBytes == null || previewBytes.length == 0) {
|
if (previewBytes == null || previewBytes.length == 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -638,24 +439,6 @@ public class ChannelDirectoryService {
|
|||||||
.orElse("application/octet-stream");
|
.orElse("application/octet-stream");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extensionFor(String mediaType) {
|
|
||||||
if (mediaType == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return switch (mediaType.toLowerCase(Locale.ROOT)) {
|
|
||||||
case "image/png" -> "png";
|
|
||||||
case "image/jpeg" -> "jpg";
|
|
||||||
case "image/gif" -> "gif";
|
|
||||||
case "video/mp4" -> "mp4";
|
|
||||||
case "video/webm" -> "webm";
|
|
||||||
case "video/quicktime" -> "mov";
|
|
||||||
case "audio/mpeg" -> "mp3";
|
|
||||||
case "audio/wav" -> "wav";
|
|
||||||
case "audio/ogg" -> "ogg";
|
|
||||||
default -> null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException {
|
private OptimizedAsset optimizeAsset(byte[] bytes, String mediaType) throws IOException {
|
||||||
if ("image/gif".equalsIgnoreCase(mediaType)) {
|
if ("image/gif".equalsIgnoreCase(mediaType)) {
|
||||||
OptimizedAsset transcoded = transcodeGifToVideo(bytes);
|
OptimizedAsset transcoded = transcodeGifToVideo(bytes);
|
||||||
|
|||||||
@@ -11,24 +11,19 @@ import com.imgfloat.app.service.ChannelDirectoryService;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.core.task.TaskExecutor;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
import java.awt.image.BufferedImage;
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.File;
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import org.jcodec.api.awt.AWTSequenceEncoder;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
@@ -43,18 +38,14 @@ class ChannelDirectoryServiceTest {
|
|||||||
private SimpMessagingTemplate messagingTemplate;
|
private SimpMessagingTemplate messagingTemplate;
|
||||||
private ChannelRepository channelRepository;
|
private ChannelRepository channelRepository;
|
||||||
private AssetRepository assetRepository;
|
private AssetRepository assetRepository;
|
||||||
private RecordingTaskExecutor taskExecutor;
|
|
||||||
private Map<String, Channel> channels;
|
|
||||||
private Map<String, Asset> assets;
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setup() {
|
void setup() {
|
||||||
messagingTemplate = mock(SimpMessagingTemplate.class);
|
messagingTemplate = mock(SimpMessagingTemplate.class);
|
||||||
channelRepository = mock(ChannelRepository.class);
|
channelRepository = mock(ChannelRepository.class);
|
||||||
assetRepository = mock(AssetRepository.class);
|
assetRepository = mock(AssetRepository.class);
|
||||||
taskExecutor = new RecordingTaskExecutor();
|
|
||||||
setupInMemoryPersistence();
|
setupInMemoryPersistence();
|
||||||
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate, taskExecutor);
|
service = new ChannelDirectoryService(channelRepository, assetRepository, messagingTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -67,41 +58,6 @@ class ChannelDirectoryServiceTest {
|
|||||||
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
|
verify(messagingTemplate).convertAndSend(org.mockito.ArgumentMatchers.contains("/topic/channel/caster"), captor.capture());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
void asyncGifOptimizationSwitchesToVideo() throws Exception {
|
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "image.gif", "image/gif", largeGif());
|
|
||||||
|
|
||||||
long start = System.currentTimeMillis();
|
|
||||||
AssetView created = service.createAsset("caster", file).orElseThrow();
|
|
||||||
long duration = System.currentTimeMillis() - start;
|
|
||||||
|
|
||||||
assertThat(duration).isLessThan(750L);
|
|
||||||
Asset placeholder = assets.get(created.id());
|
|
||||||
assertThat(placeholder.getMediaType()).isEqualTo("image/gif");
|
|
||||||
assertThat(placeholder.getPreview()).isNull();
|
|
||||||
|
|
||||||
taskExecutor.runAll();
|
|
||||||
|
|
||||||
Asset optimized = assets.get(created.id());
|
|
||||||
assertThat(optimized.getMediaType()).isEqualTo("video/mp4");
|
|
||||||
assertThat(optimized.getPreview()).isNotBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void videoPreviewGeneratedInBackground() throws Exception {
|
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "clip.mp4", "video/mp4", sampleVideo());
|
|
||||||
|
|
||||||
AssetView created = service.createAsset("caster", file).orElseThrow();
|
|
||||||
Asset placeholder = assets.get(created.id());
|
|
||||||
assertThat(placeholder.getPreview()).isNull();
|
|
||||||
|
|
||||||
taskExecutor.runAll();
|
|
||||||
|
|
||||||
Asset optimized = assets.get(created.id());
|
|
||||||
assertThat(optimized.getPreview()).isNotBlank();
|
|
||||||
assertThat(optimized.getMediaType()).isEqualTo("video/mp4");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updatesTransformAndVisibility() throws Exception {
|
void updatesTransformAndVisibility() throws Exception {
|
||||||
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
|
MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng());
|
||||||
@@ -129,33 +85,9 @@ class ChannelDirectoryServiceTest {
|
|||||||
return out.toByteArray();
|
return out.toByteArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] largeGif() throws IOException {
|
|
||||||
BufferedImage image = new BufferedImage(256, 256, BufferedImage.TYPE_INT_ARGB);
|
|
||||||
try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
|
||||||
ImageIO.write(image, "gif", out);
|
|
||||||
return out.toByteArray();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] sampleVideo() throws IOException {
|
|
||||||
File temp = File.createTempFile("sample", ".mp4");
|
|
||||||
temp.deleteOnExit();
|
|
||||||
try {
|
|
||||||
AWTSequenceEncoder encoder = AWTSequenceEncoder.createSequenceEncoder(temp, 10);
|
|
||||||
for (int i = 0; i < 15; i++) {
|
|
||||||
BufferedImage frame = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB);
|
|
||||||
encoder.encodeImage(frame);
|
|
||||||
}
|
|
||||||
encoder.finish();
|
|
||||||
return java.nio.file.Files.readAllBytes(temp.toPath());
|
|
||||||
} finally {
|
|
||||||
temp.delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupInMemoryPersistence() {
|
private void setupInMemoryPersistence() {
|
||||||
channels = new ConcurrentHashMap<>();
|
Map<String, Channel> channels = new ConcurrentHashMap<>();
|
||||||
assets = new ConcurrentHashMap<>();
|
Map<String, Asset> assets = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
when(channelRepository.findById(anyString()))
|
when(channelRepository.findById(anyString()))
|
||||||
.thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0))));
|
.thenAnswer(invocation -> Optional.ofNullable(channels.get(invocation.getArgument(0))));
|
||||||
@@ -190,18 +122,4 @@ class ChannelDirectoryServiceTest {
|
|||||||
.filter(asset -> !onlyVisible || !asset.isHidden())
|
.filter(asset -> !onlyVisible || !asset.isHidden())
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class RecordingTaskExecutor implements TaskExecutor {
|
|
||||||
private final List<Runnable> queued = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void execute(Runnable task) {
|
|
||||||
queued.add(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
void runAll() {
|
|
||||||
new ArrayList<>(queued).forEach(Runnable::run);
|
|
||||||
queued.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.imgfloat.app.service;
|
|
||||||
|
|
||||||
import com.imgfloat.app.model.Channel;
|
|
||||||
import com.imgfloat.app.repository.ChannelRepository;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mockito;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
|
||||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
|
||||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
|
||||||
|
|
||||||
import com.imgfloat.app.repository.AssetRepository;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@ExtendWith(SpringExtension.class)
|
|
||||||
@DataJpaTest
|
|
||||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
|
||||||
class ChannelDirectoryServiceIntegrationTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ChannelRepository channelRepository;
|
|
||||||
|
|
||||||
private ChannelDirectoryService channelDirectoryService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUp() {
|
|
||||||
channelDirectoryService = new ChannelDirectoryService(
|
|
||||||
channelRepository,
|
|
||||||
Mockito.mock(AssetRepository.class),
|
|
||||||
Mockito.mock(SimpMessagingTemplate.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void searchBroadcastersFindsPartialMatchesCaseInsensitive() {
|
|
||||||
Channel alpha = new Channel("Alpha");
|
|
||||||
Channel alphabeta = new Channel("AlphaBeta");
|
|
||||||
Channel bravo = new Channel("Bravo");
|
|
||||||
channelRepository.saveAll(List.of(alpha, alphabeta, bravo));
|
|
||||||
|
|
||||||
List<String> results = channelDirectoryService.searchBroadcasters("lPh");
|
|
||||||
|
|
||||||
assertThat(results).containsExactly("alpha", "alphabeta");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void adminChannelsForFindsNormalizedAdminEntries() {
|
|
||||||
Channel alpha = new Channel("Alpha");
|
|
||||||
alpha.addAdmin("ModOne");
|
|
||||||
Channel bravo = new Channel("Bravo");
|
|
||||||
bravo.addAdmin("modone");
|
|
||||||
Channel charlie = new Channel("Charlie");
|
|
||||||
charlie.addAdmin("other");
|
|
||||||
|
|
||||||
channelRepository.saveAll(List.of(alpha, bravo, charlie));
|
|
||||||
|
|
||||||
List<String> adminChannels = channelDirectoryService.adminChannelsFor("MODONE").stream().sorted().toList();
|
|
||||||
|
|
||||||
assertThat(adminChannels).containsExactly("alpha", "bravo");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user