mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add seeding to marketplace
This commit is contained in:
@@ -35,10 +35,13 @@ import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
||||
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
@@ -69,6 +72,7 @@ public class ChannelDirectoryService {
|
||||
private final MediaOptimizationService mediaOptimizationService;
|
||||
private final SettingsService settingsService;
|
||||
private final long uploadLimitBytes;
|
||||
private final MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
|
||||
|
||||
@Autowired
|
||||
public ChannelDirectoryService(
|
||||
@@ -84,7 +88,8 @@ public class ChannelDirectoryService {
|
||||
MediaDetectionService mediaDetectionService,
|
||||
MediaOptimizationService mediaOptimizationService,
|
||||
SettingsService settingsService,
|
||||
long uploadLimitBytes
|
||||
long uploadLimitBytes,
|
||||
MarketplaceScriptSeedLoader marketplaceScriptSeedLoader
|
||||
) {
|
||||
this.channelRepository = channelRepository;
|
||||
this.assetRepository = assetRepository;
|
||||
@@ -99,6 +104,7 @@ public class ChannelDirectoryService {
|
||||
this.mediaOptimizationService = mediaOptimizationService;
|
||||
this.settingsService = settingsService;
|
||||
this.uploadLimitBytes = uploadLimitBytes;
|
||||
this.marketplaceScriptSeedLoader = marketplaceScriptSeedLoader;
|
||||
}
|
||||
|
||||
public Channel getOrCreateChannel(String broadcaster) {
|
||||
@@ -426,8 +432,7 @@ public class ChannelDirectoryService {
|
||||
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query) {
|
||||
String q = normalizeDescription(query);
|
||||
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
|
||||
List<ScriptMarketplaceEntry> entries = new ArrayList<>();
|
||||
DefaultMarketplaceScript.entryForQuery(normalizedQuery).ifPresent(entries::add);
|
||||
List<ScriptMarketplaceEntry> entries = new ArrayList<>(marketplaceScriptSeedLoader.listEntriesForQuery(normalizedQuery));
|
||||
List<ScriptAsset> scripts;
|
||||
try {
|
||||
scripts = scriptAssetRepository.findByIsPublicTrue();
|
||||
@@ -484,8 +489,9 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getMarketplaceLogo(String scriptId) {
|
||||
if (DefaultMarketplaceScript.matches(scriptId)) {
|
||||
return DefaultMarketplaceScript.logoContent();
|
||||
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
|
||||
if (seedScript.isPresent()) {
|
||||
return seedScript.get().loadLogo();
|
||||
}
|
||||
try {
|
||||
return scriptAssetRepository
|
||||
@@ -503,8 +509,9 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId) {
|
||||
if (DefaultMarketplaceScript.matches(scriptId)) {
|
||||
return importDefaultMarketplaceScript(targetBroadcaster);
|
||||
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
|
||||
if (seedScript.isPresent()) {
|
||||
return importSeedMarketplaceScript(targetBroadcaster, seedScript.get());
|
||||
}
|
||||
ScriptAsset sourceScript;
|
||||
try {
|
||||
@@ -573,10 +580,12 @@ public class ChannelDirectoryService {
|
||||
return Optional.of(view);
|
||||
}
|
||||
|
||||
private Optional<AssetView> importDefaultMarketplaceScript(String targetBroadcaster) {
|
||||
AssetContent sourceContent = DefaultMarketplaceScript.sourceContent().orElse(null);
|
||||
AssetContent attachmentContent = DefaultMarketplaceScript.attachmentContent().orElse(null);
|
||||
if (sourceContent == null || attachmentContent == null) {
|
||||
private Optional<AssetView> importSeedMarketplaceScript(
|
||||
String targetBroadcaster,
|
||||
MarketplaceScriptSeedLoader.SeedScript seedScript
|
||||
) {
|
||||
AssetContent sourceContent = seedScript.loadSource().orElse(null);
|
||||
if (sourceContent == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -598,6 +607,72 @@ public class ChannelDirectoryService {
|
||||
assetRepository.save(asset);
|
||||
scriptAssetFileRepository.save(sourceFile);
|
||||
|
||||
ScriptAsset script = new ScriptAsset(asset.getId(), seedScript.name());
|
||||
script.setDescription(seedScript.description());
|
||||
script.setPublic(false);
|
||||
script.setMediaType(sourceContent.mediaType());
|
||||
script.setOriginalMediaType(sourceContent.mediaType());
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
|
||||
String logoFileId = seedScript
|
||||
.loadLogo()
|
||||
.map((logoContent) -> storeScriptAttachmentFile(asset, logoContent))
|
||||
.orElse(null);
|
||||
|
||||
List<ScriptAssetAttachment> attachments = new ArrayList<>();
|
||||
if (logoFileId != null) {
|
||||
script.setLogoFileId(logoFileId);
|
||||
}
|
||||
for (MarketplaceScriptSeedLoader.SeedAttachment attachment : seedScript.attachments()) {
|
||||
AssetContent attachmentContent = loadSeedAttachment(attachment).orElse(null);
|
||||
if (attachmentContent == null) {
|
||||
continue;
|
||||
}
|
||||
String attachmentFileId = storeScriptAttachmentFile(asset, attachmentContent);
|
||||
ScriptAssetAttachment scriptAttachment = new ScriptAssetAttachment(asset.getId(), attachment.name());
|
||||
scriptAttachment.setFileId(attachmentFileId);
|
||||
scriptAttachment.setMediaType(attachmentContent.mediaType());
|
||||
scriptAttachment.setOriginalMediaType(attachmentContent.mediaType());
|
||||
scriptAttachment.setAssetType(AssetType.IMAGE);
|
||||
attachments.add(scriptAttachment);
|
||||
}
|
||||
if (!attachments.isEmpty()) {
|
||||
scriptAssetAttachmentRepository.saveAll(attachments);
|
||||
}
|
||||
|
||||
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
|
||||
AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script);
|
||||
messagingTemplate.convertAndSend(topicFor(targetBroadcaster), AssetEvent.created(targetBroadcaster, view));
|
||||
return Optional.of(view);
|
||||
}
|
||||
|
||||
private Optional<AssetContent> loadSeedAttachment(MarketplaceScriptSeedLoader.SeedAttachment attachment) {
|
||||
AtomicReference<byte[]> cache = attachment.bytes();
|
||||
byte[] bytes = cache.get();
|
||||
if (bytes == null) {
|
||||
bytes = readSeedAttachment(attachment.path()).orElse(null);
|
||||
if (bytes != null) {
|
||||
cache.set(bytes);
|
||||
}
|
||||
}
|
||||
if (bytes == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new AssetContent(bytes, attachment.mediaType()));
|
||||
}
|
||||
|
||||
private Optional<byte[]> readSeedAttachment(Path path) {
|
||||
try {
|
||||
return Optional.of(Files.readAllBytes(path));
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to read marketplace attachment {}", path, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String storeScriptAttachmentFile(Asset asset, AssetContent attachmentContent) {
|
||||
ScriptAssetFile attachmentFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.IMAGE);
|
||||
attachmentFile.setMediaType(attachmentContent.mediaType());
|
||||
attachmentFile.setOriginalMediaType(attachmentContent.mediaType());
|
||||
@@ -612,28 +687,7 @@ public class ChannelDirectoryService {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Unable to store script attachment", e);
|
||||
}
|
||||
scriptAssetFileRepository.save(attachmentFile);
|
||||
|
||||
ScriptAsset script = new ScriptAsset(asset.getId(), DefaultMarketplaceScript.SCRIPT_NAME);
|
||||
script.setDescription(DefaultMarketplaceScript.SCRIPT_DESCRIPTION);
|
||||
script.setPublic(false);
|
||||
script.setMediaType(sourceContent.mediaType());
|
||||
script.setOriginalMediaType(sourceContent.mediaType());
|
||||
script.setSourceFileId(sourceFile.getId());
|
||||
script.setLogoFileId(attachmentFile.getId());
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
|
||||
ScriptAssetAttachment attachment = new ScriptAssetAttachment(asset.getId(), DefaultMarketplaceScript.ATTACHMENT_NAME);
|
||||
attachment.setFileId(attachmentFile.getId());
|
||||
attachment.setMediaType(attachmentContent.mediaType());
|
||||
attachment.setOriginalMediaType(attachmentContent.mediaType());
|
||||
attachment.setAssetType(AssetType.IMAGE);
|
||||
scriptAssetAttachmentRepository.save(attachment);
|
||||
|
||||
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
|
||||
AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script);
|
||||
messagingTemplate.convertAndSend(topicFor(targetBroadcaster), AssetEvent.created(targetBroadcaster, view));
|
||||
return Optional.of(view);
|
||||
return attachmentFile.getId();
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String original) {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
public final class DefaultMarketplaceScript {
|
||||
|
||||
public static final String SCRIPT_ID = "imgfloat-default-rotating-logo";
|
||||
public static final String SCRIPT_NAME = "Rotating Imgfloat logo";
|
||||
public static final String SCRIPT_DESCRIPTION =
|
||||
"Renders the Imgfloat logo and rotates it every tick.";
|
||||
public static final String SCRIPT_BROADCASTER = "Imgfloat";
|
||||
public static final String ATTACHMENT_NAME = "Imgfloat logo";
|
||||
public static final String LOGO_URL = "/api/marketplace/scripts/" + SCRIPT_ID + "/logo";
|
||||
public static final String SOURCE_MEDIA_TYPE = "application/javascript";
|
||||
public static final String LOGO_MEDIA_TYPE = "image/png";
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(DefaultMarketplaceScript.class);
|
||||
private static final String LOGO_RESOURCE = "static/img/brand.png";
|
||||
private static final String SOURCE_RESOURCE = "assets/default-marketplace-script.js";
|
||||
private static final AtomicReference<byte[]> LOGO_BYTES = new AtomicReference<>();
|
||||
private static final AtomicReference<byte[]> SOURCE_BYTES = new AtomicReference<>();
|
||||
|
||||
private DefaultMarketplaceScript() {}
|
||||
|
||||
public static boolean matches(String scriptId) {
|
||||
return SCRIPT_ID.equals(scriptId);
|
||||
}
|
||||
|
||||
public static Optional<ScriptMarketplaceEntry> entryForQuery(String query) {
|
||||
if (query == null || query.isBlank()) {
|
||||
return Optional.of(entry());
|
||||
}
|
||||
String normalized = query.toLowerCase(Locale.ROOT);
|
||||
if (
|
||||
SCRIPT_NAME.toLowerCase(Locale.ROOT).contains(normalized) ||
|
||||
SCRIPT_DESCRIPTION.toLowerCase(Locale.ROOT).contains(normalized)
|
||||
) {
|
||||
return Optional.of(entry());
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
public static ScriptMarketplaceEntry entry() {
|
||||
return new ScriptMarketplaceEntry(
|
||||
SCRIPT_ID,
|
||||
SCRIPT_NAME,
|
||||
SCRIPT_DESCRIPTION,
|
||||
LOGO_URL,
|
||||
SCRIPT_BROADCASTER
|
||||
);
|
||||
}
|
||||
|
||||
public static Optional<AssetContent> logoContent() {
|
||||
return loadContent(LOGO_BYTES, LOGO_RESOURCE, LOGO_MEDIA_TYPE);
|
||||
}
|
||||
|
||||
public static Optional<AssetContent> sourceContent() {
|
||||
return loadContent(SOURCE_BYTES, SOURCE_RESOURCE, SOURCE_MEDIA_TYPE);
|
||||
}
|
||||
|
||||
public static Optional<AssetContent> attachmentContent() {
|
||||
return logoContent();
|
||||
}
|
||||
|
||||
private static Optional<AssetContent> loadContent(
|
||||
AtomicReference<byte[]> cache,
|
||||
String resourcePath,
|
||||
String mediaType
|
||||
) {
|
||||
byte[] bytes = cache.get();
|
||||
if (bytes == null) {
|
||||
bytes = readBytes(resourcePath).orElse(null);
|
||||
if (bytes != null) {
|
||||
cache.set(bytes);
|
||||
}
|
||||
}
|
||||
if (bytes == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new AssetContent(bytes, mediaType));
|
||||
}
|
||||
|
||||
private static Optional<byte[]> readBytes(String resourcePath) {
|
||||
ClassPathResource resource = new ClassPathResource(resourcePath);
|
||||
if (!resource.exists()) {
|
||||
logger.warn("Default marketplace resource {} is missing", resourcePath);
|
||||
return Optional.empty();
|
||||
}
|
||||
try (InputStream input = resource.getInputStream()) {
|
||||
return Optional.of(input.readAllBytes());
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to read default marketplace resource {}", resourcePath, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry;
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class MarketplaceScriptSeedLoader {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(MarketplaceScriptSeedLoader.class);
|
||||
private static final String METADATA_FILENAME = "metadata.json";
|
||||
private static final String SOURCE_FILENAME = "source.js";
|
||||
private static final String LOGO_FILENAME = "logo.png";
|
||||
private static final String ATTACHMENTS_DIR = "attachments";
|
||||
private static final String DEFAULT_SOURCE_MEDIA_TYPE = "application/javascript";
|
||||
private static final String DEFAULT_LOGO_MEDIA_TYPE = "image/png";
|
||||
|
||||
private final List<SeedScript> scripts;
|
||||
|
||||
public MarketplaceScriptSeedLoader(@Value("${IMGFLOAT_MARKETPLACE_SCRIPTS_PATH:#{null}}") String rootPath) {
|
||||
this.scripts = loadScripts(resolveRootPath(rootPath));
|
||||
}
|
||||
|
||||
public List<ScriptMarketplaceEntry> listEntriesForQuery(String query) {
|
||||
if (scripts.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String normalized = query == null ? null : query.toLowerCase(Locale.ROOT);
|
||||
return scripts
|
||||
.stream()
|
||||
.filter((script) -> script.matchesQuery(normalized))
|
||||
.map(SeedScript::entry)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public Optional<SeedScript> findById(String scriptId) {
|
||||
return scripts.stream().filter((script) -> script.id().equals(scriptId)).findFirst();
|
||||
}
|
||||
|
||||
public record SeedScript(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String broadcaster,
|
||||
String sourceMediaType,
|
||||
String logoMediaType,
|
||||
Optional<Path> sourcePath,
|
||||
Optional<Path> logoPath,
|
||||
List<SeedAttachment> attachments,
|
||||
AtomicReference<byte[]> sourceBytes,
|
||||
AtomicReference<byte[]> logoBytes
|
||||
) {
|
||||
ScriptMarketplaceEntry entry() {
|
||||
return new ScriptMarketplaceEntry(
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
logoPath.isPresent() ? "/api/marketplace/scripts/" + id + "/logo" : null,
|
||||
broadcaster
|
||||
);
|
||||
}
|
||||
|
||||
boolean matchesQuery(String normalized) {
|
||||
if (normalized == null || normalized.isBlank()) {
|
||||
return true;
|
||||
}
|
||||
String entryName = Optional.ofNullable(name).orElse("").toLowerCase(Locale.ROOT);
|
||||
String entryDescription = Optional.ofNullable(description).orElse("").toLowerCase(Locale.ROOT);
|
||||
return entryName.contains(normalized) || entryDescription.contains(normalized);
|
||||
}
|
||||
|
||||
Optional<AssetContent> loadSource() {
|
||||
return sourcePath.flatMap((path) -> loadContent(sourceBytes, path, sourceMediaType));
|
||||
}
|
||||
|
||||
Optional<AssetContent> loadLogo() {
|
||||
return logoPath.flatMap((path) -> loadContent(logoBytes, path, logoMediaType));
|
||||
}
|
||||
}
|
||||
|
||||
public record SeedAttachment(String name, String mediaType, Path path, AtomicReference<byte[]> bytes) {}
|
||||
|
||||
private List<SeedScript> loadScripts(Path rootPath) {
|
||||
if (rootPath == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (!Files.isDirectory(rootPath)) {
|
||||
logger.warn("Marketplace script path {} is not a directory", rootPath);
|
||||
return List.of();
|
||||
}
|
||||
List<SeedScript> loaded = new ArrayList<>();
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(rootPath)) {
|
||||
for (Path scriptDir : stream) {
|
||||
if (!Files.isDirectory(scriptDir)) {
|
||||
continue;
|
||||
}
|
||||
SeedScript script = loadScriptDirectory(scriptDir).orElse(null);
|
||||
if (script != null) {
|
||||
loaded.add(script);
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to read marketplace script directory {}", rootPath, ex);
|
||||
}
|
||||
return List.copyOf(loaded);
|
||||
}
|
||||
|
||||
private Optional<SeedScript> loadScriptDirectory(Path scriptDir) {
|
||||
ScriptSeedMetadata metadata = ScriptSeedMetadata.read(scriptDir.resolve(METADATA_FILENAME));
|
||||
if (metadata == null) {
|
||||
logger.warn("Skipping marketplace script {}, missing {}", scriptDir, METADATA_FILENAME);
|
||||
return Optional.empty();
|
||||
}
|
||||
if (metadata.name() == null || metadata.name().isBlank()) {
|
||||
logger.warn("Skipping marketplace script {}, missing name", scriptDir);
|
||||
return Optional.empty();
|
||||
}
|
||||
String sourceMediaType = detectMediaType(scriptDir.resolve(SOURCE_FILENAME), DEFAULT_SOURCE_MEDIA_TYPE);
|
||||
String logoMediaType = detectMediaType(scriptDir.resolve(LOGO_FILENAME), DEFAULT_LOGO_MEDIA_TYPE);
|
||||
String broadcaster = normalizeBroadcaster(metadata.broadcaster());
|
||||
|
||||
Path sourcePath = resolveOptionalFile(scriptDir.resolve(SOURCE_FILENAME));
|
||||
Path logoPath = resolveOptionalFile(scriptDir.resolve(LOGO_FILENAME));
|
||||
if (sourcePath == null) {
|
||||
logger.warn("Skipping marketplace script {}, missing {}", scriptDir, SOURCE_FILENAME);
|
||||
return Optional.empty();
|
||||
}
|
||||
List<SeedAttachment> attachments = loadAttachments(scriptDir.resolve(ATTACHMENTS_DIR)).orElse(null);
|
||||
if (attachments == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(
|
||||
new SeedScript(
|
||||
scriptDir.getFileName().toString(),
|
||||
metadata.name(),
|
||||
metadata.description(),
|
||||
broadcaster,
|
||||
sourceMediaType,
|
||||
logoMediaType,
|
||||
Optional.ofNullable(sourcePath),
|
||||
Optional.ofNullable(logoPath),
|
||||
attachments,
|
||||
new AtomicReference<>(),
|
||||
new AtomicReference<>()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private Optional<List<SeedAttachment>> loadAttachments(Path attachmentsDir) {
|
||||
if (!Files.isDirectory(attachmentsDir)) {
|
||||
return Optional.of(List.of());
|
||||
}
|
||||
List<SeedAttachment> attachments = new ArrayList<>();
|
||||
Set<String> seenNames = new HashSet<>();
|
||||
try (DirectoryStream<Path> stream = Files.newDirectoryStream(attachmentsDir)) {
|
||||
for (Path attachment : stream) {
|
||||
if (Files.isRegularFile(attachment)) {
|
||||
String name = attachment.getFileName().toString();
|
||||
if (!seenNames.add(name)) {
|
||||
logger.warn("Duplicate marketplace attachment name {}", name);
|
||||
return Optional.empty();
|
||||
}
|
||||
String mediaType = Files.probeContentType(attachment);
|
||||
attachments.add(
|
||||
new SeedAttachment(
|
||||
name,
|
||||
mediaType == null ? "application/octet-stream" : mediaType,
|
||||
attachment,
|
||||
new AtomicReference<>()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to read marketplace attachments in {}", attachmentsDir, ex);
|
||||
}
|
||||
return Optional.of(List.copyOf(attachments));
|
||||
}
|
||||
|
||||
private Path resolveRootPath(String rootPath) {
|
||||
if (rootPath != null && !rootPath.isBlank()) {
|
||||
return Path.of(rootPath);
|
||||
}
|
||||
Path devPath = Path.of("dev", "marketplace-scripts");
|
||||
if (Files.isDirectory(devPath)) {
|
||||
return devPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String normalizeBroadcaster(String broadcaster) {
|
||||
if (broadcaster == null || broadcaster.isBlank()) {
|
||||
return "System";
|
||||
}
|
||||
return broadcaster;
|
||||
}
|
||||
|
||||
private static Path resolveOptionalFile(Path path) {
|
||||
if (Files.isRegularFile(path)) {
|
||||
return path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Optional<AssetContent> loadContent(
|
||||
AtomicReference<byte[]> cache,
|
||||
Path filePath,
|
||||
String mediaType
|
||||
) {
|
||||
byte[] bytes = cache.get();
|
||||
if (bytes == null) {
|
||||
bytes = readBytes(filePath).orElse(null);
|
||||
if (bytes != null) {
|
||||
cache.set(bytes);
|
||||
}
|
||||
}
|
||||
if (bytes == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of(new AssetContent(bytes, mediaType));
|
||||
}
|
||||
|
||||
private static Optional<byte[]> readBytes(Path filePath) {
|
||||
if (!Files.isRegularFile(filePath)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(Files.readAllBytes(filePath));
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to read marketplace script asset {}", filePath, ex);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
record ScriptSeedMetadata(String name, String description, String broadcaster) {
|
||||
static ScriptSeedMetadata read(Path path) {
|
||||
if (!Files.isRegularFile(path)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String content = Files.readString(path);
|
||||
return JsonSupport.read(content, ScriptSeedMetadata.class);
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to read marketplace metadata {}", path, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class JsonSupport {
|
||||
private static final AtomicReference<com.fasterxml.jackson.databind.ObjectMapper> OBJECT_MAPPER =
|
||||
new AtomicReference<>();
|
||||
|
||||
private JsonSupport() {}
|
||||
|
||||
static <T> T read(String payload, Class<T> type) throws IOException {
|
||||
return mapper().readValue(payload, type);
|
||||
}
|
||||
|
||||
private static com.fasterxml.jackson.databind.ObjectMapper mapper() {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = OBJECT_MAPPER.get();
|
||||
if (mapper == null) {
|
||||
mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
OBJECT_MAPPER.set(mapper);
|
||||
}
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
|
||||
private String detectMediaType(Path path, String fallback) {
|
||||
if (!Files.isRegularFile(path)) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
String mediaType = Files.probeContentType(path);
|
||||
return mediaType == null ? fallback : mediaType;
|
||||
} catch (IOException ex) {
|
||||
logger.warn("Failed to detect media type for {}", path, ex);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
async function init(context, state) {
|
||||
const asset = Array.isArray(context.assets) ? context.assets[0] : null;
|
||||
if (!asset?.blob) {
|
||||
return;
|
||||
}
|
||||
state.rotation = 0;
|
||||
state.imageReady = false;
|
||||
try {
|
||||
state.image = await createImageBitmap(asset.blob);
|
||||
state.imageReady = true;
|
||||
} catch (error) {
|
||||
state.imageError = error;
|
||||
}
|
||||
}
|
||||
|
||||
function tick(context, state) {
|
||||
const { ctx, width, height, deltaMs } = context;
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
const image = state?.image;
|
||||
if (!image || !state.imageReady) {
|
||||
return;
|
||||
}
|
||||
const size = Math.min(width, height) * 0.35;
|
||||
state.rotation = (state.rotation + (deltaMs || 0) * 0.002) % (Math.PI * 2);
|
||||
ctx.save();
|
||||
ctx.translate(width / 2, height / 2);
|
||||
ctx.rotate(state.rotation);
|
||||
ctx.drawImage(image, -size / 2, -size / 2, size, size);
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -66,7 +66,6 @@ body {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.channels-body,
|
||||
.settings-body,
|
||||
.error-body {
|
||||
min-height: 100vh;
|
||||
@@ -79,7 +78,6 @@ body {
|
||||
padding: clamp(24px, 4vw, 48px);
|
||||
}
|
||||
|
||||
.channels-shell,
|
||||
.settings-shell {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -94,7 +92,6 @@ body {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.channels-header,
|
||||
.settings-header,
|
||||
.error-header {
|
||||
display: flex;
|
||||
@@ -170,34 +167,6 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.channels-main {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.channel-card {
|
||||
width: 100%;
|
||||
background: rgba(11, 18, 32, 0.95);
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 16px;
|
||||
padding: clamp(20px, 3vw, 32px);
|
||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.channel-card h1 {
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
|
||||
.channel-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -336,16 +305,6 @@ body {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.channels-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #1f2937;
|
||||
background: rgba(11, 18, 32, 0.9);
|
||||
}
|
||||
|
||||
.landing-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2042,97 +2001,6 @@ button:disabled:hover {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 10000;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #0b1221;
|
||||
color: #e5e7eb;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
opacity 120ms ease;
|
||||
}
|
||||
|
||||
.toast:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.toast-exit {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.toast-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #a5b4fc;
|
||||
box-shadow: 0 0 0 4px rgba(165, 180, 252, 0.16);
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
background: rgba(16, 185, 129, 0.42);
|
||||
}
|
||||
|
||||
.toast-success .toast-indicator {
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.2);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
background: rgba(248, 113, 113, 0.42);
|
||||
}
|
||||
|
||||
.toast-error .toast-indicator {
|
||||
background: #f87171;
|
||||
box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
border-color: rgba(251, 191, 36, 0.35);
|
||||
background: rgba(251, 191, 36, 0.42);
|
||||
}
|
||||
|
||||
.toast-warning .toast-indicator {
|
||||
background: #facc15;
|
||||
box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.2);
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
border-color: rgba(96, 165, 250, 0.35);
|
||||
background: rgba(96, 165, 250, 0.12);
|
||||
}
|
||||
|
||||
.toast-info .toast-indicator {
|
||||
background: #60a5fa;
|
||||
box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.cookie-consent {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
|
||||
@@ -26,7 +26,7 @@ import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
|
||||
import dev.kruhlmann.imgfloat.service.AssetStorageService;
|
||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||
import dev.kruhlmann.imgfloat.service.DefaultMarketplaceScript;
|
||||
import dev.kruhlmann.imgfloat.service.MarketplaceScriptSeedLoader;
|
||||
import dev.kruhlmann.imgfloat.service.SettingsService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
|
||||
@@ -62,6 +62,7 @@ class ChannelDirectoryServiceTest {
|
||||
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||
private ScriptAssetFileRepository scriptAssetFileRepository;
|
||||
private SettingsService settingsService;
|
||||
private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
|
||||
|
||||
@BeforeEach
|
||||
void setup() throws Exception {
|
||||
@@ -83,6 +84,23 @@ class ChannelDirectoryServiceTest {
|
||||
MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
|
||||
MediaDetectionService mediaDetectionService = new MediaDetectionService();
|
||||
long uploadLimitBytes = 5_000_000L;
|
||||
Path marketplaceRoot = Files.createTempDirectory("imgfloat-marketplace-test");
|
||||
Path scriptRoot = marketplaceRoot.resolve("rotating-logo");
|
||||
Files.createDirectories(scriptRoot);
|
||||
Files.createDirectories(scriptRoot.resolve("attachments"));
|
||||
Files.writeString(
|
||||
scriptRoot.resolve("metadata.json"),
|
||||
"""
|
||||
{
|
||||
"name": "Rotating logo",
|
||||
"description": "Renders the Imgfloat logo and rotates it every tick."
|
||||
}
|
||||
"""
|
||||
);
|
||||
Files.writeString(scriptRoot.resolve("source.js"), "console.log('seeded');");
|
||||
Files.write(scriptRoot.resolve("logo.png"), samplePng());
|
||||
Files.write(scriptRoot.resolve("attachments/rotate.png"), samplePng());
|
||||
marketplaceScriptSeedLoader = new MarketplaceScriptSeedLoader(marketplaceRoot.toString());
|
||||
service = new ChannelDirectoryService(
|
||||
channelRepository,
|
||||
assetRepository,
|
||||
@@ -96,7 +114,8 @@ class ChannelDirectoryServiceTest {
|
||||
mediaDetectionService,
|
||||
mediaOptimizationService,
|
||||
settingsService,
|
||||
uploadLimitBytes
|
||||
uploadLimitBytes,
|
||||
marketplaceScriptSeedLoader
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,7 +204,7 @@ class ChannelDirectoryServiceTest {
|
||||
List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null);
|
||||
|
||||
assertThat(entries)
|
||||
.anyMatch((entry) -> DefaultMarketplaceScript.SCRIPT_ID.equals(entry.id()));
|
||||
.anyMatch((entry) -> "rotating-logo".equals(entry.id()));
|
||||
}
|
||||
|
||||
private byte[] samplePng() throws IOException {
|
||||
|
||||
Reference in New Issue
Block a user