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

2
.gitattributes vendored
View File

@@ -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/demo.webm filter=lfs diff=lfs merge=lfs -text
doc/raw.png 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 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

View File

@@ -28,6 +28,7 @@ Optional:
| Variable | Description | Example Value | | 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_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 | | `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== | | `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=... 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/
<any-folder-name>/
metadata.json # required
source.js # required (script source)
logo.png # optional (logo image)
attachments/ # optional (additional attachments)
<any-filename>
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 ### Build and run
To run the application: To run the application:

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,4 @@
{
"name": "Rotating logo",
"description": "Renders the Imgfloat logo and rotates it every tick."
}

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.MediaOptimizationService;
import dev.kruhlmann.imgfloat.service.media.OptimizedAsset; import dev.kruhlmann.imgfloat.service.media.OptimizedAsset;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.concurrent.atomic.AtomicReference;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException; import org.springframework.dao.DataAccessException;
@@ -69,6 +72,7 @@ public class ChannelDirectoryService {
private final MediaOptimizationService mediaOptimizationService; private final MediaOptimizationService mediaOptimizationService;
private final SettingsService settingsService; private final SettingsService settingsService;
private final long uploadLimitBytes; private final long uploadLimitBytes;
private final MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
@Autowired @Autowired
public ChannelDirectoryService( public ChannelDirectoryService(
@@ -84,7 +88,8 @@ public class ChannelDirectoryService {
MediaDetectionService mediaDetectionService, MediaDetectionService mediaDetectionService,
MediaOptimizationService mediaOptimizationService, MediaOptimizationService mediaOptimizationService,
SettingsService settingsService, SettingsService settingsService,
long uploadLimitBytes long uploadLimitBytes,
MarketplaceScriptSeedLoader marketplaceScriptSeedLoader
) { ) {
this.channelRepository = channelRepository; this.channelRepository = channelRepository;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
@@ -99,6 +104,7 @@ public class ChannelDirectoryService {
this.mediaOptimizationService = mediaOptimizationService; this.mediaOptimizationService = mediaOptimizationService;
this.settingsService = settingsService; this.settingsService = settingsService;
this.uploadLimitBytes = uploadLimitBytes; this.uploadLimitBytes = uploadLimitBytes;
this.marketplaceScriptSeedLoader = marketplaceScriptSeedLoader;
} }
public Channel getOrCreateChannel(String broadcaster) { public Channel getOrCreateChannel(String broadcaster) {
@@ -426,8 +432,7 @@ public class ChannelDirectoryService {
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query) { public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query) {
String q = normalizeDescription(query); String q = normalizeDescription(query);
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT); String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
List<ScriptMarketplaceEntry> entries = new ArrayList<>(); List<ScriptMarketplaceEntry> entries = new ArrayList<>(marketplaceScriptSeedLoader.listEntriesForQuery(normalizedQuery));
DefaultMarketplaceScript.entryForQuery(normalizedQuery).ifPresent(entries::add);
List<ScriptAsset> scripts; List<ScriptAsset> scripts;
try { try {
scripts = scriptAssetRepository.findByIsPublicTrue(); scripts = scriptAssetRepository.findByIsPublicTrue();
@@ -484,8 +489,9 @@ public class ChannelDirectoryService {
} }
public Optional<AssetContent> getMarketplaceLogo(String scriptId) { public Optional<AssetContent> getMarketplaceLogo(String scriptId) {
if (DefaultMarketplaceScript.matches(scriptId)) { Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
return DefaultMarketplaceScript.logoContent(); if (seedScript.isPresent()) {
return seedScript.get().loadLogo();
} }
try { try {
return scriptAssetRepository return scriptAssetRepository
@@ -503,8 +509,9 @@ public class ChannelDirectoryService {
} }
public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId) { public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId) {
if (DefaultMarketplaceScript.matches(scriptId)) { Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
return importDefaultMarketplaceScript(targetBroadcaster); if (seedScript.isPresent()) {
return importSeedMarketplaceScript(targetBroadcaster, seedScript.get());
} }
ScriptAsset sourceScript; ScriptAsset sourceScript;
try { try {
@@ -573,10 +580,12 @@ public class ChannelDirectoryService {
return Optional.of(view); return Optional.of(view);
} }
private Optional<AssetView> importDefaultMarketplaceScript(String targetBroadcaster) { private Optional<AssetView> importSeedMarketplaceScript(
AssetContent sourceContent = DefaultMarketplaceScript.sourceContent().orElse(null); String targetBroadcaster,
AssetContent attachmentContent = DefaultMarketplaceScript.attachmentContent().orElse(null); MarketplaceScriptSeedLoader.SeedScript seedScript
if (sourceContent == null || attachmentContent == null) { ) {
AssetContent sourceContent = seedScript.loadSource().orElse(null);
if (sourceContent == null) {
return Optional.empty(); return Optional.empty();
} }
@@ -598,6 +607,72 @@ public class ChannelDirectoryService {
assetRepository.save(asset); assetRepository.save(asset);
scriptAssetFileRepository.save(sourceFile); 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); ScriptAssetFile attachmentFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.IMAGE);
attachmentFile.setMediaType(attachmentContent.mediaType()); attachmentFile.setMediaType(attachmentContent.mediaType());
attachmentFile.setOriginalMediaType(attachmentContent.mediaType()); attachmentFile.setOriginalMediaType(attachmentContent.mediaType());
@@ -612,28 +687,7 @@ public class ChannelDirectoryService {
throw new ResponseStatusException(BAD_REQUEST, "Unable to store script attachment", e); throw new ResponseStatusException(BAD_REQUEST, "Unable to store script attachment", e);
} }
scriptAssetFileRepository.save(attachmentFile); scriptAssetFileRepository.save(attachmentFile);
return attachmentFile.getId();
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);
} }
private String sanitizeFilename(String original) { 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;
}
}
}

View File

@@ -66,7 +66,6 @@ body {
text-decoration: underline; text-decoration: underline;
} }
.channels-body,
.settings-body, .settings-body,
.error-body { .error-body {
min-height: 100vh; min-height: 100vh;
@@ -79,7 +78,6 @@ body {
padding: clamp(24px, 4vw, 48px); padding: clamp(24px, 4vw, 48px);
} }
.channels-shell,
.settings-shell { .settings-shell {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -94,7 +92,6 @@ body {
gap: 20px; gap: 20px;
} }
.channels-header,
.settings-header, .settings-header,
.error-header { .error-header {
display: flex; display: flex;
@@ -170,34 +167,6 @@ body {
word-break: break-word; 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 { .settings-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -336,16 +305,6 @@ body {
gap: 6px; 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 { .landing-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2042,97 +2001,6 @@ button:disabled:hover {
border: 1px solid rgba(255, 255, 255, 0.08); 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 { .cookie-consent {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;

View File

@@ -26,7 +26,7 @@ import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository; import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
import dev.kruhlmann.imgfloat.service.AssetStorageService; import dev.kruhlmann.imgfloat.service.AssetStorageService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; 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.SettingsService;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService;
@@ -62,6 +62,7 @@ class ChannelDirectoryServiceTest {
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
private ScriptAssetFileRepository scriptAssetFileRepository; private ScriptAssetFileRepository scriptAssetFileRepository;
private SettingsService settingsService; private SettingsService settingsService;
private MarketplaceScriptSeedLoader marketplaceScriptSeedLoader;
@BeforeEach @BeforeEach
void setup() throws Exception { void setup() throws Exception {
@@ -83,6 +84,23 @@ class ChannelDirectoryServiceTest {
MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService); MediaOptimizationService mediaOptimizationService = new MediaOptimizationService(mediaPreviewService);
MediaDetectionService mediaDetectionService = new MediaDetectionService(); MediaDetectionService mediaDetectionService = new MediaDetectionService();
long uploadLimitBytes = 5_000_000L; 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( service = new ChannelDirectoryService(
channelRepository, channelRepository,
assetRepository, assetRepository,
@@ -96,7 +114,8 @@ class ChannelDirectoryServiceTest {
mediaDetectionService, mediaDetectionService,
mediaOptimizationService, mediaOptimizationService,
settingsService, settingsService,
uploadLimitBytes uploadLimitBytes,
marketplaceScriptSeedLoader
); );
} }
@@ -185,7 +204,7 @@ class ChannelDirectoryServiceTest {
List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null); List<dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null);
assertThat(entries) assertThat(entries)
.anyMatch((entry) -> DefaultMarketplaceScript.SCRIPT_ID.equals(entry.id())); .anyMatch((entry) -> "rotating-logo".equals(entry.id()));
} }
private byte[] samplePng() throws IOException { private byte[] samplePng() throws IOException {