diff --git a/.gitattributes b/.gitattributes index 7ff1bd1..6a84827 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,5 @@ src/main/resources/assets/icon/linux/16x16.png filter=lfs diff=lfs merge=lfs -te doc/demo.webm filter=lfs diff=lfs merge=lfs -text doc/raw.png filter=lfs diff=lfs merge=lfs -text doc/raw.xcf filter=lfs diff=lfs merge=lfs -text +dev/marketplace-scripts/rotating-logo/attachments/rotate.png filter=lfs diff=lfs merge=lfs -text +dev/marketplace-scripts/rotating-logo/logo.png filter=lfs diff=lfs merge=lfs -text diff --git a/README.md b/README.md index a1c258a..2df9425 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Optional: | Variable | Description | Example Value | |----------|-------------|---------------| | `IMGFLOAT_COMMIT_URL_PREFIX` | Git commit URL prefix used for the build link badge (unset to hide the badge) | https://github.com/imgfloat/server/commit/ | +| `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` | Filesystem path to marketplace script seed directories (each containing `metadata.json`, optional `source.js`, optional `logo.png`, and optional `attachments/`) | /var/imgfloat/marketplace-scripts | | `TWITCH_REDIRECT_URI` | Override default redirect URI | http://localhost:8080/login/oauth2/code/twitch | | `IMGFLOAT_TOKEN_ENCRYPTION_PREVIOUS_KEYS` | Comma-delimited base64 keys to allow decryption after key rotation (oldest last) | oldKey1==,oldKey2== | @@ -46,6 +47,35 @@ IMGFLOAT_GITHUB_CLIENT_VERSION=... IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN=... ``` +### Marketplace seed scripts + +To pre-seed marketplace scripts from the filesystem, set `IMGFLOAT_MARKETPLACE_SCRIPTS_PATH` to a directory of script seed folders. Each folder name can be anything (it is not used for display); the folder name is the listing identifier and the metadata controls the title. + +Each script folder supports the following structure (files marked optional can be omitted). The folder name is used as the marketplace script identifier, so keep it stable even if the display name changes: + +``` +marketplace-scripts/ + / + metadata.json # required + source.js # required (script source) + logo.png # optional (logo image) + attachments/ # optional (additional attachments) + + rotate.png # optional (example attachment copied from logo) +``` + +`metadata.json` fields: + +```json +{ + "name": "Script display name", + "description": "Short description", + "broadcaster": "Optional display name (defaults to System)" +} +``` + +Only `name` is required. The folder name is used to identify the marketplace listing; when a script is imported, the asset receives a new generated ID. If `broadcaster` is omitted or blank, the script will be listed as coming from `System`. Media types are inferred from the files on disk. Attachments are loaded from the `attachments/` folder and appear in the imported script's attachments list, referenced by filename (for example `rotate.png`). Attachment filenames must be unique within a script. The logo is optional and remains separate from attachments; if you want to use the same image inside the script, add a copy of it under `attachments/`. + ### Build and run To run the application: diff --git a/dev/marketplace-scripts/rotating-logo/attachments/rotate.png b/dev/marketplace-scripts/rotating-logo/attachments/rotate.png new file mode 100644 index 0000000..bebb644 --- /dev/null +++ b/dev/marketplace-scripts/rotating-logo/attachments/rotate.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ec08d7610f373892433ed67f85c3afb377aa08a097cec2b0b94656f00540f68 +size 1301 diff --git a/dev/marketplace-scripts/rotating-logo/logo.png b/dev/marketplace-scripts/rotating-logo/logo.png new file mode 100644 index 0000000..bebb644 --- /dev/null +++ b/dev/marketplace-scripts/rotating-logo/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ec08d7610f373892433ed67f85c3afb377aa08a097cec2b0b94656f00540f68 +size 1301 diff --git a/dev/marketplace-scripts/rotating-logo/metadata.json b/dev/marketplace-scripts/rotating-logo/metadata.json new file mode 100644 index 0000000..3c529cc --- /dev/null +++ b/dev/marketplace-scripts/rotating-logo/metadata.json @@ -0,0 +1,4 @@ +{ + "name": "Rotating logo", + "description": "Renders the Imgfloat logo and rotates it every tick." +} diff --git a/src/main/resources/assets/default-marketplace-script.js b/dev/marketplace-scripts/rotating-logo/source.js similarity index 100% rename from src/main/resources/assets/default-marketplace-script.js rename to dev/marketplace-scripts/rotating-logo/source.js diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 3e9ce34..ba40003 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -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 listMarketplaceScripts(String query) { String q = normalizeDescription(query); String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT); - List entries = new ArrayList<>(); - DefaultMarketplaceScript.entryForQuery(normalizedQuery).ifPresent(entries::add); + List entries = new ArrayList<>(marketplaceScriptSeedLoader.listEntriesForQuery(normalizedQuery)); List scripts; try { scripts = scriptAssetRepository.findByIsPublicTrue(); @@ -484,8 +489,9 @@ public class ChannelDirectoryService { } public Optional getMarketplaceLogo(String scriptId) { - if (DefaultMarketplaceScript.matches(scriptId)) { - return DefaultMarketplaceScript.logoContent(); + Optional seedScript = marketplaceScriptSeedLoader.findById(scriptId); + if (seedScript.isPresent()) { + return seedScript.get().loadLogo(); } try { return scriptAssetRepository @@ -503,8 +509,9 @@ public class ChannelDirectoryService { } public Optional importMarketplaceScript(String targetBroadcaster, String scriptId) { - if (DefaultMarketplaceScript.matches(scriptId)) { - return importDefaultMarketplaceScript(targetBroadcaster); + Optional 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 importDefaultMarketplaceScript(String targetBroadcaster) { - AssetContent sourceContent = DefaultMarketplaceScript.sourceContent().orElse(null); - AssetContent attachmentContent = DefaultMarketplaceScript.attachmentContent().orElse(null); - if (sourceContent == null || attachmentContent == null) { + private Optional 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 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 loadSeedAttachment(MarketplaceScriptSeedLoader.SeedAttachment attachment) { + AtomicReference 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 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) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/DefaultMarketplaceScript.java b/src/main/java/dev/kruhlmann/imgfloat/service/DefaultMarketplaceScript.java deleted file mode 100644 index 3bbb0c2..0000000 --- a/src/main/java/dev/kruhlmann/imgfloat/service/DefaultMarketplaceScript.java +++ /dev/null @@ -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 LOGO_BYTES = new AtomicReference<>(); - private static final AtomicReference SOURCE_BYTES = new AtomicReference<>(); - - private DefaultMarketplaceScript() {} - - public static boolean matches(String scriptId) { - return SCRIPT_ID.equals(scriptId); - } - - public static Optional 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 logoContent() { - return loadContent(LOGO_BYTES, LOGO_RESOURCE, LOGO_MEDIA_TYPE); - } - - public static Optional sourceContent() { - return loadContent(SOURCE_BYTES, SOURCE_RESOURCE, SOURCE_MEDIA_TYPE); - } - - public static Optional attachmentContent() { - return logoContent(); - } - - private static Optional loadContent( - AtomicReference 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 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(); - } - } -} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java b/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java new file mode 100644 index 0000000..745720b --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/MarketplaceScriptSeedLoader.java @@ -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 scripts; + + public MarketplaceScriptSeedLoader(@Value("${IMGFLOAT_MARKETPLACE_SCRIPTS_PATH:#{null}}") String rootPath) { + this.scripts = loadScripts(resolveRootPath(rootPath)); + } + + public List 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 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 sourcePath, + Optional logoPath, + List attachments, + AtomicReference sourceBytes, + AtomicReference 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 loadSource() { + return sourcePath.flatMap((path) -> loadContent(sourceBytes, path, sourceMediaType)); + } + + Optional loadLogo() { + return logoPath.flatMap((path) -> loadContent(logoBytes, path, logoMediaType)); + } + } + + public record SeedAttachment(String name, String mediaType, Path path, AtomicReference bytes) {} + + private List 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 loaded = new ArrayList<>(); + try (DirectoryStream 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 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 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> loadAttachments(Path attachmentsDir) { + if (!Files.isDirectory(attachmentsDir)) { + return Optional.of(List.of()); + } + List attachments = new ArrayList<>(); + Set seenNames = new HashSet<>(); + try (DirectoryStream 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 loadContent( + AtomicReference 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 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 OBJECT_MAPPER = + new AtomicReference<>(); + + private JsonSupport() {} + + static T read(String payload, Class 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; + } + } +} diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index c5df6d2..a2c8d5d 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -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; diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index 604485a..3e98db3 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -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 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 {