Add seeding to marketplace

This commit is contained in:
2026-01-13 11:11:54 +01:00
parent b34963c287
commit 94d0787b75
11 changed files with 448 additions and 273 deletions

View File

@@ -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) {

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}