diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 9cf66b7..697405d 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -41,9 +41,11 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.dao.DataAccessException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -424,7 +426,20 @@ public class ChannelDirectoryService { public List listMarketplaceScripts(String query) { String q = normalizeDescription(query); String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT); - List scripts = scriptAssetRepository.findByIsPublicTrue(); + List entries = new ArrayList<>(); + DefaultMarketplaceScript.entryForQuery(normalizedQuery).ifPresent(entries::add); + List scripts; + try { + scripts = scriptAssetRepository.findByIsPublicTrue(); + } catch (DataAccessException ex) { + logger.warn("Unable to load marketplace scripts", ex); + return entries + .stream() + .sorted( + Comparator.comparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase)) + ) + .toList(); + } if (normalizedQuery != null && !normalizedQuery.isBlank()) { scripts = scripts @@ -442,42 +457,62 @@ public class ChannelDirectoryService { .stream() .collect(Collectors.toMap(Asset::getId, (asset) -> asset)); - return scripts + entries.addAll( + scripts + .stream() + .map((script) -> { + Asset asset = assets.get(script.getId()); + String broadcaster = asset != null ? asset.getBroadcaster() : ""; + String logoUrl = script.getLogoFileId() == null + ? null + : "/api/marketplace/scripts/" + script.getId() + "/logo"; + return new ScriptMarketplaceEntry( + script.getId(), + script.getName(), + script.getDescription(), + logoUrl, + broadcaster + ); + }) + .toList() + ); + + return entries .stream() - .map((script) -> { - Asset asset = assets.get(script.getId()); - String broadcaster = asset != null ? asset.getBroadcaster() : ""; - String logoUrl = script.getLogoFileId() == null - ? null - : "/api/marketplace/scripts/" + script.getId() + "/logo"; - return new ScriptMarketplaceEntry( - script.getId(), - script.getName(), - script.getDescription(), - logoUrl, - broadcaster - ); - }) .sorted(Comparator.comparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase))) .toList(); } public Optional getMarketplaceLogo(String scriptId) { - return scriptAssetRepository - .findById(scriptId) - .filter(ScriptAsset::isPublic) - .map(ScriptAsset::getLogoFileId) - .flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId)) - .flatMap((file) -> - assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType()) - ); + if (DefaultMarketplaceScript.matches(scriptId)) { + return DefaultMarketplaceScript.logoContent(); + } + try { + return scriptAssetRepository + .findById(scriptId) + .filter(ScriptAsset::isPublic) + .map(ScriptAsset::getLogoFileId) + .flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId)) + .flatMap((file) -> + assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType()) + ); + } catch (DataAccessException ex) { + logger.warn("Unable to load marketplace logo", ex); + return Optional.empty(); + } } public Optional importMarketplaceScript(String targetBroadcaster, String scriptId) { - ScriptAsset sourceScript = scriptAssetRepository - .findById(scriptId) - .filter(ScriptAsset::isPublic) - .orElse(null); + if (DefaultMarketplaceScript.matches(scriptId)) { + return importDefaultMarketplaceScript(targetBroadcaster); + } + ScriptAsset sourceScript; + try { + sourceScript = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null); + } catch (DataAccessException ex) { + logger.warn("Unable to import marketplace script {}", scriptId, ex); + return Optional.empty(); + } Asset sourceAsset = sourceScript == null ? null : assetRepository.findById(scriptId).orElse(null); if (sourceScript == null || sourceAsset == null) { return Optional.empty(); @@ -538,6 +573,69 @@ 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) { + return Optional.empty(); + } + + Asset asset = new Asset(targetBroadcaster, AssetType.SCRIPT); + ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); + sourceFile.setId(asset.getId()); + sourceFile.setMediaType(sourceContent.mediaType()); + sourceFile.setOriginalMediaType(sourceContent.mediaType()); + try { + assetStorageService.storeAsset( + sourceFile.getBroadcaster(), + sourceFile.getId(), + sourceContent.bytes(), + sourceContent.mediaType() + ); + } catch (IOException e) { + throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); + } + assetRepository.save(asset); + scriptAssetFileRepository.save(sourceFile); + + ScriptAssetFile attachmentFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.IMAGE); + attachmentFile.setMediaType(attachmentContent.mediaType()); + attachmentFile.setOriginalMediaType(attachmentContent.mediaType()); + try { + assetStorageService.storeAsset( + attachmentFile.getBroadcaster(), + attachmentFile.getId(), + attachmentContent.bytes(), + attachmentContent.mediaType() + ); + } catch (IOException e) { + 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); + } + private String sanitizeFilename(String original) { String stripped = original.replaceAll("^.*[/\\\\]", ""); return SAFE_FILENAME.matcher(stripped).replaceAll("_"); @@ -750,6 +848,7 @@ public class ChannelDirectoryService { }); } + @Transactional public boolean deleteAsset(String assetId) { return assetRepository .findById(assetId) diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/DefaultMarketplaceScript.java b/src/main/java/dev/kruhlmann/imgfloat/service/DefaultMarketplaceScript.java new file mode 100644 index 0000000..3bbb0c2 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/DefaultMarketplaceScript.java @@ -0,0 +1,105 @@ +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/resources/assets/default-marketplace-script.js b/src/main/resources/assets/default-marketplace-script.js new file mode 100644 index 0000000..f8c3ae4 --- /dev/null +++ b/src/main/resources/assets/default-marketplace-script.js @@ -0,0 +1,33 @@ +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(); +} diff --git a/src/main/resources/static/js/broadcast/renderer.js b/src/main/resources/static/js/broadcast/renderer.js index c123a89..1027273 100644 --- a/src/main/resources/static/js/broadcast/renderer.js +++ b/src/main/resources/static/js/broadcast/renderer.js @@ -21,6 +21,7 @@ export class BroadcastRenderer { this.scriptWorker = null; this.scriptWorkerReady = false; this.scriptErrorKeys = new Set(); + this.scriptAttachmentCache = new Map(); this.obsBrowser = !!globalThis.obsstudio; this.supportsAnimatedDecode = @@ -487,12 +488,12 @@ export class BroadcastRenderer { payload: { id: asset.id, source: assetSource, - attachments: asset.scriptAttachments || [], + attachments: await this.resolveScriptAttachments(asset.scriptAttachments), }, }); } - updateScriptWorkerAttachments(asset) { + async updateScriptWorkerAttachments(asset) { if (!this.scriptWorker || !this.scriptWorkerReady || !asset?.id) { return; } @@ -500,7 +501,7 @@ export class BroadcastRenderer { type: "updateAttachments", payload: { id: asset.id, - attachments: asset.scriptAttachments || [], + attachments: await this.resolveScriptAttachments(asset.scriptAttachments), }, }); } @@ -514,4 +515,35 @@ export class BroadcastRenderer { payload: { id: assetId }, }); } + + async resolveScriptAttachments(attachments) { + if (!Array.isArray(attachments) || attachments.length === 0) { + return []; + } + const resolved = await Promise.all( + attachments.map(async (attachment) => { + if (!attachment?.url || !attachment.mediaType?.startsWith("image/")) { + return attachment; + } + const cacheKey = attachment.id || attachment.url; + const cached = this.scriptAttachmentCache.get(cacheKey); + if (cached?.blob) { + return { ...attachment, blob: cached.blob }; + } + try { + const response = await fetch(attachment.url); + if (!response.ok) { + throw new Error("Failed to fetch script attachment"); + } + const blob = await response.blob(); + this.scriptAttachmentCache.set(cacheKey, { blob }); + return { ...attachment, blob }; + } catch (error) { + console.error("Unable to load script attachment", error); + return attachment; + } + }), + ); + return resolved; + } } diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index da5a0f8..604485a 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -22,9 +22,11 @@ import dev.kruhlmann.imgfloat.repository.AudioAssetRepository; import dev.kruhlmann.imgfloat.repository.ChannelRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; +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.SettingsService; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaOptimizationService; @@ -58,6 +60,7 @@ class ChannelDirectoryServiceTest { private AudioAssetRepository audioAssetRepository; private ScriptAssetRepository scriptAssetRepository; private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; + private ScriptAssetFileRepository scriptAssetFileRepository; private SettingsService settingsService; @BeforeEach @@ -69,6 +72,7 @@ class ChannelDirectoryServiceTest { audioAssetRepository = mock(AudioAssetRepository.class); scriptAssetRepository = mock(ScriptAssetRepository.class); scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class); + scriptAssetFileRepository = mock(ScriptAssetFileRepository.class); settingsService = mock(SettingsService.class); when(settingsService.get()).thenReturn(Settings.defaults()); setupInMemoryPersistence(); @@ -86,6 +90,7 @@ class ChannelDirectoryServiceTest { audioAssetRepository, scriptAssetRepository, scriptAssetAttachmentRepository, + scriptAssetFileRepository, messagingTemplate, assetStorageService, mediaDetectionService, @@ -173,6 +178,16 @@ class ChannelDirectoryServiceTest { assertThat(view.zIndex()).isEqualTo(1); } + @Test + void includesDefaultMarketplaceScript() { + when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of()); + + List entries = service.listMarketplaceScripts(null); + + assertThat(entries) + .anyMatch((entry) -> DefaultMarketplaceScript.SCRIPT_ID.equals(entry.id())); + } + private byte[] samplePng() throws IOException { BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); ByteArrayOutputStream out = new ByteArrayOutputStream();