diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index e69abbb..e28359b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -129,8 +129,12 @@ public class SchemaMigration implements ApplicationRunner { CREATE TABLE IF NOT EXISTS script_assets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + description TEXT, + is_public BOOLEAN, media_type TEXT, - original_media_type TEXT + original_media_type TEXT, + logo_file_id TEXT, + source_file_id TEXT ) """ ); @@ -139,6 +143,7 @@ public class SchemaMigration implements ApplicationRunner { CREATE TABLE IF NOT EXISTS script_asset_attachments ( id TEXT PRIMARY KEY, script_asset_id TEXT NOT NULL, + file_id TEXT, name TEXT NOT NULL, media_type TEXT, original_media_type TEXT, @@ -146,12 +151,75 @@ public class SchemaMigration implements ApplicationRunner { ) """ ); + jdbcTemplate.execute( + """ + CREATE TABLE IF NOT EXISTS script_asset_files ( + id TEXT PRIMARY KEY, + broadcaster TEXT NOT NULL, + media_type TEXT, + original_media_type TEXT, + asset_type TEXT NOT NULL + ) + """ + ); + ensureScriptAssetColumns(); backfillAssetTypes(assetColumns); } catch (DataAccessException ex) { logger.warn("Unable to ensure asset type tables", ex); } } + private void ensureScriptAssetColumns() { + List scriptColumns; + List attachmentColumns; + try { + scriptColumns = jdbcTemplate.query("PRAGMA table_info(script_assets)", (rs, rowNum) -> rs.getString("name")); + attachmentColumns = + jdbcTemplate.query("PRAGMA table_info(script_asset_attachments)", (rs, rowNum) -> rs.getString("name")); + } catch (DataAccessException ex) { + logger.warn("Unable to inspect script asset tables", ex); + return; + } + + if (!scriptColumns.isEmpty()) { + addColumnIfMissing("script_assets", scriptColumns, "description", "TEXT", "NULL"); + addColumnIfMissing("script_assets", scriptColumns, "is_public", "BOOLEAN", "0"); + addColumnIfMissing("script_assets", scriptColumns, "logo_file_id", "TEXT", "NULL"); + addColumnIfMissing("script_assets", scriptColumns, "source_file_id", "TEXT", "NULL"); + } + + if (!attachmentColumns.isEmpty()) { + addColumnIfMissing("script_asset_attachments", attachmentColumns, "file_id", "TEXT", "NULL"); + } + + try { + jdbcTemplate.execute("UPDATE script_assets SET source_file_id = id WHERE source_file_id IS NULL"); + jdbcTemplate.execute( + """ + INSERT OR IGNORE INTO script_asset_files ( + id, broadcaster, media_type, original_media_type, asset_type + ) + SELECT s.id, a.broadcaster, s.media_type, s.original_media_type, 'SCRIPT' + FROM script_assets s + JOIN assets a ON a.id = s.id + """ + ); + jdbcTemplate.execute( + """ + INSERT OR IGNORE INTO script_asset_files ( + id, broadcaster, media_type, original_media_type, asset_type + ) + SELECT sa.id, a.broadcaster, sa.media_type, sa.original_media_type, sa.asset_type + FROM script_asset_attachments sa + JOIN assets a ON a.id = sa.script_asset_id + """ + ); + jdbcTemplate.execute("UPDATE script_asset_attachments SET file_id = id WHERE file_id IS NULL"); + } catch (DataAccessException ex) { + logger.warn("Unable to backfill script asset files", ex); + } + } + private void backfillAssetTypes(List assetColumns) { if (!assetColumns.contains("media_type")) { return; diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index 09b88db..6472c56 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -41,6 +41,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -415,6 +416,29 @@ public class ChannelApiController { .orElseThrow(() -> createAsset404()); } + @GetMapping("/assets/{assetId}/logo") + public ResponseEntity getScriptLogo( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + return channelDirectoryService + .getScriptLogoContent(broadcaster, assetId) + .map((content) -> + ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, contentDispositionFor(content.mediaType())) + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes()) + ) + .orElseThrow(() -> createAsset404()); + } + @GetMapping("/assets/{assetId}/preview") public ResponseEntity getAssetPreview( @PathVariable("broadcaster") String broadcaster, @@ -485,7 +509,7 @@ public class ChannelApiController { public ResponseEntity createScriptAttachment( @PathVariable("broadcaster") String broadcaster, @PathVariable("assetId") String assetId, - @org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, + @RequestPart("file") MultipartFile file, OAuth2AuthenticationToken oauthToken ) { String sessionUsername = OauthSessionUser.from(oauthToken).login(); @@ -507,6 +531,47 @@ public class ChannelApiController { } } + @PostMapping(value = "/assets/{assetId}/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity updateScriptLogo( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + @RequestPart("file") MultipartFile file, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(BAD_REQUEST, "Logo file is required"); + } + try { + return channelDirectoryService + .updateScriptLogo(broadcaster, assetId, file) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save logo")); + } catch (IOException e) { + LOG.error("Failed to process logo upload for {} by {}", broadcaster, sessionUsername, e); + throw new ResponseStatusException(BAD_REQUEST, "Failed to process logo", e); + } + } + + @DeleteMapping("/assets/{assetId}/logo") + public ResponseEntity deleteScriptLogo( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("assetId") String assetId, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + broadcaster, + sessionUsername + ); + channelDirectoryService.clearScriptLogo(broadcaster, assetId); + return ResponseEntity.ok().build(); + } + @DeleteMapping("/assets/{assetId}/attachments/{attachmentId}") public ResponseEntity deleteScriptAttachment( @PathVariable("broadcaster") String broadcaster, diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ScriptMarketplaceController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ScriptMarketplaceController.java new file mode 100644 index 0000000..73732d4 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ScriptMarketplaceController.java @@ -0,0 +1,88 @@ +package dev.kruhlmann.imgfloat.controller; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.NOT_FOUND; + +import dev.kruhlmann.imgfloat.model.AssetView; +import dev.kruhlmann.imgfloat.model.OauthSessionUser; +import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry; +import dev.kruhlmann.imgfloat.model.ScriptMarketplaceImportRequest; +import dev.kruhlmann.imgfloat.service.AuthorizationService; +import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; +import dev.kruhlmann.imgfloat.util.LogSanitizer; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/marketplace") +@SecurityRequirement(name = "twitchOAuth") +public class ScriptMarketplaceController { + + private static final Logger LOG = LoggerFactory.getLogger(ScriptMarketplaceController.class); + private final ChannelDirectoryService channelDirectoryService; + private final AuthorizationService authorizationService; + + public ScriptMarketplaceController( + ChannelDirectoryService channelDirectoryService, + AuthorizationService authorizationService + ) { + this.channelDirectoryService = channelDirectoryService; + this.authorizationService = authorizationService; + } + + @GetMapping("/scripts") + public List listMarketplaceScripts(@RequestParam(value = "query", required = false) String query) { + return channelDirectoryService.listMarketplaceScripts(query); + } + + @GetMapping("/scripts/{scriptId}/logo") + public ResponseEntity getMarketplaceLogo(@PathVariable("scriptId") String scriptId) { + String logScriptId = LogSanitizer.sanitize(scriptId); + LOG.debug("Serving marketplace logo for script {}", logScriptId); + return channelDirectoryService + .getMarketplaceLogo(scriptId) + .map((content) -> + ResponseEntity.ok() + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, "inline") + .contentType(MediaType.parseMediaType(content.mediaType())) + .body(content.bytes()) + ) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Logo not found")); + } + + @PostMapping("/scripts/{scriptId}/import") + public ResponseEntity importMarketplaceScript( + @PathVariable("scriptId") String scriptId, + @Valid @RequestBody ScriptMarketplaceImportRequest request, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError( + request.getTargetBroadcaster(), + sessionUsername + ); + String logScriptId = LogSanitizer.sanitize(scriptId); + String logTarget = LogSanitizer.sanitize(request.getTargetBroadcaster()); + LOG.info("Importing marketplace script {} into {}", logScriptId, logTarget); + return channelDirectoryService + .importMarketplaceScript(request.getTargetBroadcaster(), scriptId) + .map(ResponseEntity::ok) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to import script")); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 5fa9235..4fa592b 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -136,6 +136,7 @@ public class ViewController { Settings settings = settingsService.get(); model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("username", sessionUsername); + model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername)); model.addAttribute("uploadLimitBytes", uploadLimitBytes); try { model.addAttribute("settingsJson", objectMapper.writeValueAsString(settings)); diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java index c4c9e17..05e5b1c 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetView.java @@ -7,6 +7,9 @@ public record AssetView( String id, String broadcaster, String name, + String description, + String logoUrl, + Boolean isPublic, String url, String previewUrl, double x, @@ -37,6 +40,9 @@ public record AssetView( asset.getId(), asset.getBroadcaster(), visual.getName(), + null, + null, + null, "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", hasPreview ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" : null, visual.getX(), @@ -68,6 +74,9 @@ public record AssetView( asset.getId(), asset.getBroadcaster(), audio.getName(), + null, + null, + null, "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", null, 0, @@ -99,6 +108,11 @@ public record AssetView( asset.getId(), asset.getBroadcaster(), script.getName(), + script.getDescription(), + script.getLogoFileId() == null + ? null + : "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/logo", + script.isPublic(), "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", null, 0, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java index 2e9baeb..0d8c7f7 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/CodeAssetRequest.java @@ -10,6 +10,10 @@ public class CodeAssetRequest { @NotBlank private String source; + private String description; + + private Boolean isPublic; + public String getName() { return name; } @@ -25,4 +29,20 @@ public class CodeAssetRequest { public void setSource(String source) { this.source = source; } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Boolean getIsPublic() { + return isPublic; + } + + public void setIsPublic(Boolean isPublic) { + this.isPublic = isPublic; + } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java index 72cd21e..7a5b399 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAsset.java @@ -19,9 +19,20 @@ public class ScriptAsset { @Column(nullable = false) private String name; + private String description; + + @Column(name = "is_public") + private boolean isPublic; + private String mediaType; private String originalMediaType; + @Column(name = "logo_file_id") + private String logoFileId; + + @Column(name = "source_file_id") + private String sourceFileId; + @Transient private List attachments = List.of(); @@ -72,6 +83,38 @@ public class ScriptAsset { this.originalMediaType = originalMediaType; } + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public boolean isPublic() { + return isPublic; + } + + public void setPublic(boolean isPublic) { + this.isPublic = isPublic; + } + + public String getLogoFileId() { + return logoFileId; + } + + public void setLogoFileId(String logoFileId) { + this.logoFileId = logoFileId; + } + + public String getSourceFileId() { + return sourceFileId; + } + + public void setSourceFileId(String sourceFileId) { + this.sourceFileId = sourceFileId; + } + public List getAttachments() { return attachments == null ? List.of() : attachments; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java index d8902ab..25f0f5d 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetAttachment.java @@ -20,6 +20,9 @@ public class ScriptAssetAttachment { @Column(name = "script_asset_id", nullable = false) private String scriptAssetId; + @Column(name = "file_id") + private String fileId; + @Column(nullable = false) private String name; @@ -69,6 +72,14 @@ public class ScriptAssetAttachment { this.scriptAssetId = scriptAssetId; } + public String getFileId() { + return fileId; + } + + public void setFileId(String fileId) { + this.fileId = fileId; + } + public String getName() { return name; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetFile.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetFile.java new file mode 100644 index 0000000..24d2886 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptAssetFile.java @@ -0,0 +1,94 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.util.Locale; +import java.util.UUID; + +@Entity +@Table(name = "script_asset_files") +public class ScriptAssetFile { + + @Id + private String id; + + @Column(nullable = false) + private String broadcaster; + + private String mediaType; + private String originalMediaType; + + @Enumerated(EnumType.STRING) + @Column(name = "asset_type", nullable = false) + private AssetType assetType; + + public ScriptAssetFile() {} + + public ScriptAssetFile(String broadcaster, AssetType assetType) { + this.id = UUID.randomUUID().toString(); + this.broadcaster = normalize(broadcaster); + this.assetType = assetType == null ? AssetType.OTHER : assetType; + } + + @PrePersist + @PreUpdate + public void prepare() { + if (this.id == null) { + this.id = UUID.randomUUID().toString(); + } + this.broadcaster = normalize(broadcaster); + if (this.assetType == null) { + this.assetType = AssetType.OTHER; + } + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getBroadcaster() { + return broadcaster; + } + + public void setBroadcaster(String broadcaster) { + this.broadcaster = normalize(broadcaster); + } + + public String getMediaType() { + return mediaType; + } + + public void setMediaType(String mediaType) { + this.mediaType = mediaType; + } + + public String getOriginalMediaType() { + return originalMediaType; + } + + public void setOriginalMediaType(String originalMediaType) { + this.originalMediaType = originalMediaType; + } + + public AssetType getAssetType() { + return assetType == null ? AssetType.OTHER : assetType; + } + + public void setAssetType(AssetType assetType) { + this.assetType = assetType == null ? AssetType.OTHER : assetType; + } + + private static String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceEntry.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceEntry.java new file mode 100644 index 0000000..d59f43b --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceEntry.java @@ -0,0 +1,9 @@ +package dev.kruhlmann.imgfloat.model; + +public record ScriptMarketplaceEntry( + String id, + String name, + String description, + String logoUrl, + String broadcaster +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceImportRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceImportRequest.java new file mode 100644 index 0000000..0d0a8a0 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/ScriptMarketplaceImportRequest.java @@ -0,0 +1,17 @@ +package dev.kruhlmann.imgfloat.model; + +import jakarta.validation.constraints.NotBlank; + +public class ScriptMarketplaceImportRequest { + + @NotBlank + private String targetBroadcaster; + + public String getTargetBroadcaster() { + return targetBroadcaster; + } + + public void setTargetBroadcaster(String targetBroadcaster) { + this.targetBroadcaster = targetBroadcaster; + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java index 7128d23..aa578a6 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetAttachmentRepository.java @@ -11,4 +11,6 @@ public interface ScriptAssetAttachmentRepository extends JpaRepository findByScriptAssetIdIn(Collection scriptAssetIds); void deleteByScriptAssetId(String scriptAssetId); + + long countByFileId(String fileId); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetFileRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetFileRepository.java new file mode 100644 index 0000000..6a4f46b --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetFileRepository.java @@ -0,0 +1,6 @@ +package dev.kruhlmann.imgfloat.repository; + +import dev.kruhlmann.imgfloat.model.ScriptAssetFile; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ScriptAssetFileRepository extends JpaRepository {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java index 5442e7e..a1884f0 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/ScriptAssetRepository.java @@ -7,4 +7,10 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface ScriptAssetRepository extends JpaRepository { List findByIdIn(Collection ids); + + List findByIsPublicTrue(); + + long countBySourceFileId(String sourceFileId); + + long countByLogoFileId(String logoFileId); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java index 49d7c5c..8763288 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/AssetCleanupService.java @@ -2,8 +2,10 @@ package dev.kruhlmann.imgfloat.service; import dev.kruhlmann.imgfloat.model.Asset; import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; +import dev.kruhlmann.imgfloat.model.ScriptAsset; import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; +import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository; import java.util.Set; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -22,15 +24,18 @@ public class AssetCleanupService { private final AssetRepository assetRepository; private final AssetStorageService assetStorageService; private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; + private final ScriptAssetRepository scriptAssetRepository; public AssetCleanupService( AssetRepository assetRepository, AssetStorageService assetStorageService, - ScriptAssetAttachmentRepository scriptAssetAttachmentRepository + ScriptAssetAttachmentRepository scriptAssetAttachmentRepository, + ScriptAssetRepository scriptAssetRepository ) { this.assetRepository = assetRepository; this.assetStorageService = assetStorageService; this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository; + this.scriptAssetRepository = scriptAssetRepository; } @Async @@ -48,7 +53,23 @@ public class AssetCleanupService { scriptAssetAttachmentRepository .findAll() .stream() - .map(ScriptAssetAttachment::getId) + .map((attachment) -> attachment.getFileId() != null ? attachment.getFileId() : attachment.getId()) + .collect(Collectors.toSet()) + ); + referencedIds.addAll( + scriptAssetRepository + .findAll() + .stream() + .map(ScriptAsset::getSourceFileId) + .filter((id) -> id != null && !id.isBlank()) + .collect(Collectors.toSet()) + ); + referencedIds.addAll( + scriptAssetRepository + .findAll() + .stream() + .map(ScriptAsset::getLogoFileId) + .filter((id) -> id != null && !id.isBlank()) .collect(Collectors.toSet()) ); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index 6a3fdf5..9cf66b7 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -17,6 +17,8 @@ import dev.kruhlmann.imgfloat.model.PlaybackRequest; import dev.kruhlmann.imgfloat.model.ScriptAsset; import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; +import dev.kruhlmann.imgfloat.model.ScriptAssetFile; +import dev.kruhlmann.imgfloat.model.ScriptMarketplaceEntry; import dev.kruhlmann.imgfloat.model.Settings; import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.VisibilityRequest; @@ -26,6 +28,7 @@ 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.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; @@ -57,6 +60,7 @@ public class ChannelDirectoryService { private final AudioAssetRepository audioAssetRepository; private final ScriptAssetRepository scriptAssetRepository; private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; + private final ScriptAssetFileRepository scriptAssetFileRepository; private final SimpMessagingTemplate messagingTemplate; private final AssetStorageService assetStorageService; private final MediaDetectionService mediaDetectionService; @@ -72,6 +76,7 @@ public class ChannelDirectoryService { AudioAssetRepository audioAssetRepository, ScriptAssetRepository scriptAssetRepository, ScriptAssetAttachmentRepository scriptAssetAttachmentRepository, + ScriptAssetFileRepository scriptAssetFileRepository, SimpMessagingTemplate messagingTemplate, AssetStorageService assetStorageService, MediaDetectionService mediaDetectionService, @@ -85,6 +90,7 @@ public class ChannelDirectoryService { this.audioAssetRepository = audioAssetRepository; this.scriptAssetRepository = scriptAssetRepository; this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository; + this.scriptAssetFileRepository = scriptAssetFileRepository; this.messagingTemplate = messagingTemplate; this.assetStorageService = assetStorageService; this.mediaDetectionService = mediaDetectionService; @@ -226,8 +232,14 @@ public class ChannelDirectoryService { ScriptAsset script = new ScriptAsset(asset.getId(), safeName); script.setMediaType(optimized.mediaType()); script.setOriginalMediaType(mediaType); + script.setSourceFileId(asset.getId()); script.setAttachments(List.of()); scriptAssetRepository.save(script); + ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); + sourceFile.setId(asset.getId()); + sourceFile.setMediaType(optimized.mediaType()); + sourceFile.setOriginalMediaType(mediaType); + scriptAssetFileRepository.save(sourceFile); view = AssetView.fromScript(channel.getBroadcaster(), asset, script); } else { double defaultWidth = 640; @@ -257,17 +269,30 @@ public class ChannelDirectoryService { enforceUploadLimit(bytes.length); Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT); + ScriptAssetFile sourceFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); + sourceFile.setId(asset.getId()); + sourceFile.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + sourceFile.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); try { - assetStorageService.storeAsset(channel.getBroadcaster(), asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); + assetStorageService.storeAsset( + sourceFile.getBroadcaster(), + sourceFile.getId(), + bytes, + DEFAULT_CODE_MEDIA_TYPE + ); } catch (IOException e) { throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); } asset = assetRepository.save(asset); + scriptAssetFileRepository.save(sourceFile); ScriptAsset script = new ScriptAsset(asset.getId(), request.getName().trim()); script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + script.setSourceFileId(sourceFile.getId()); + script.setDescription(normalizeDescription(request.getDescription())); + script.setPublic(Boolean.TRUE.equals(request.getIsPublic())); script.setAttachments(List.of()); scriptAssetRepository.save(script); AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); @@ -291,12 +316,38 @@ public class ChannelDirectoryService { ScriptAsset script = scriptAssetRepository .findById(asset.getId()) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + String sourceFileId = script.getSourceFileId(); + if (sourceFileId == null || sourceFileId.isBlank()) { + sourceFileId = asset.getId(); + script.setSourceFileId(sourceFileId); + } + String resolvedSourceFileId = sourceFileId; + ScriptAssetFile sourceFile = scriptAssetFileRepository + .findById(resolvedSourceFileId) + .orElseGet(() -> { + ScriptAssetFile file = new ScriptAssetFile(asset.getBroadcaster(), AssetType.SCRIPT); + file.setId(resolvedSourceFileId); + file.setMediaType(DEFAULT_CODE_MEDIA_TYPE); + file.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); + return scriptAssetFileRepository.save(file); + }); script.setName(request.getName().trim()); + if (request.getDescription() != null) { + script.setDescription(normalizeDescription(request.getDescription())); + } + if (request.getIsPublic() != null) { + script.setPublic(request.getIsPublic()); + } script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); try { - assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE); + assetStorageService.storeAsset( + sourceFile.getBroadcaster(), + sourceFile.getId(), + bytes, + DEFAULT_CODE_MEDIA_TYPE + ); } catch (IOException e) { throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); } @@ -308,6 +359,185 @@ public class ChannelDirectoryService { }); } + public Optional updateScriptLogo(String broadcaster, String assetId, MultipartFile file) + throws IOException { + Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId); + byte[] bytes = file.getBytes(); + enforceUploadLimit(bytes.length); + String mediaType = mediaDetectionService + .detectAllowedMediaType(file, bytes) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unsupported media type")); + OptimizedAsset optimized = mediaOptimizationService.optimizeAsset(bytes, mediaType); + if (optimized == null) { + return Optional.empty(); + } + AssetType assetType = AssetType.fromMediaType(optimized.mediaType(), mediaType); + if (assetType != AssetType.IMAGE) { + throw new ResponseStatusException(BAD_REQUEST, "Logo must be an image."); + } + ScriptAsset script = scriptAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + + String previousLogoFileId = script.getLogoFileId(); + ScriptAssetFile logoFile = new ScriptAssetFile(asset.getBroadcaster(), AssetType.IMAGE); + logoFile.setMediaType(optimized.mediaType()); + logoFile.setOriginalMediaType(mediaType); + scriptAssetFileRepository.save(logoFile); + + assetStorageService.storeAsset( + logoFile.getBroadcaster(), + logoFile.getId(), + optimized.bytes(), + optimized.mediaType() + ); + + script.setLogoFileId(logoFile.getId()); + script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); + scriptAssetRepository.save(script); + + removeScriptAssetFileIfOrphaned(previousLogoFileId); + + AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + return Optional.of(view); + } + + public Optional clearScriptLogo(String broadcaster, String assetId) { + Asset asset = requireScriptAssetForBroadcaster(broadcaster, assetId); + ScriptAsset script = scriptAssetRepository + .findById(asset.getId()) + .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); + String previousLogoFileId = script.getLogoFileId(); + if (previousLogoFileId == null) { + return Optional.empty(); + } + script.setLogoFileId(null); + script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null)); + scriptAssetRepository.save(script); + removeScriptAssetFileIfOrphaned(previousLogoFileId); + AssetView view = AssetView.fromScript(asset.getBroadcaster(), asset, script); + messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view)); + return Optional.of(view); + } + + public List listMarketplaceScripts(String query) { + String q = normalizeDescription(query); + String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT); + List scripts = scriptAssetRepository.findByIsPublicTrue(); + if (normalizedQuery != null && !normalizedQuery.isBlank()) { + scripts = + scripts + .stream() + .filter((script) -> { + String name = Optional.ofNullable(script.getName()).orElse(""); + String description = Optional.ofNullable(script.getDescription()).orElse(""); + return name.toLowerCase(Locale.ROOT).contains(normalizedQuery) || + description.toLowerCase(Locale.ROOT).contains(normalizedQuery); + }) + .toList(); + } + Map assets = assetRepository + .findAllById(scripts.stream().map(ScriptAsset::getId).toList()) + .stream() + .collect(Collectors.toMap(Asset::getId, (asset) -> asset)); + + return 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 + ); + }) + .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()) + ); + } + + public Optional importMarketplaceScript(String targetBroadcaster, String scriptId) { + ScriptAsset sourceScript = scriptAssetRepository + .findById(scriptId) + .filter(ScriptAsset::isPublic) + .orElse(null); + Asset sourceAsset = sourceScript == null ? null : assetRepository.findById(scriptId).orElse(null); + if (sourceScript == null || sourceAsset == null) { + return Optional.empty(); + } + AssetContent sourceContent = loadScriptSourceContent(sourceAsset, sourceScript).orElse(null); + if (sourceContent == 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); + + ScriptAsset script = new ScriptAsset(asset.getId(), sourceScript.getName()); + script.setDescription(sourceScript.getDescription()); + script.setPublic(false); + script.setMediaType(sourceContent.mediaType()); + script.setOriginalMediaType(sourceContent.mediaType()); + script.setSourceFileId(sourceFile.getId()); + script.setLogoFileId(sourceScript.getLogoFileId()); + script.setAttachments(List.of()); + scriptAssetRepository.save(script); + + List sourceAttachments = scriptAssetAttachmentRepository + .findByScriptAssetId(sourceScript.getId()); + List newAttachments = sourceAttachments + .stream() + .map((attachment) -> { + ScriptAssetAttachment copy = new ScriptAssetAttachment(asset.getId(), attachment.getName()); + String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); + copy.setFileId(fileId); + copy.setMediaType(attachment.getMediaType()); + copy.setOriginalMediaType(attachment.getOriginalMediaType()); + copy.setAssetType(attachment.getAssetType()); + return copy; + }) + .toList(); + if (!newAttachments.isEmpty()) { + scriptAssetAttachmentRepository.saveAll(newAttachments); + } + 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("_"); @@ -524,16 +754,30 @@ public class ChannelDirectoryService { return assetRepository .findById(assetId) .map((asset) -> { - deleteAssetStorage(asset); - switch (asset.getAssetType()) { - case AUDIO -> audioAssetRepository.deleteById(asset.getId()); - case SCRIPT -> { - scriptAssetAttachmentRepository.deleteByScriptAssetId(asset.getId()); - scriptAssetRepository.deleteById(asset.getId()); + if (asset.getAssetType() == AssetType.SCRIPT) { + ScriptAsset script = scriptAssetRepository.findById(asset.getId()).orElse(null); + List attachmentFileIds = scriptAssetAttachmentRepository + .findByScriptAssetId(asset.getId()) + .stream() + .map((attachment) -> attachment.getFileId() != null ? attachment.getFileId() : attachment.getId()) + .toList(); + scriptAssetAttachmentRepository.deleteByScriptAssetId(asset.getId()); + scriptAssetRepository.deleteById(asset.getId()); + assetRepository.delete(asset); + if (script != null) { + removeScriptAssetFileIfOrphaned(script.getSourceFileId()); + removeScriptAssetFileIfOrphaned(script.getLogoFileId()); } - default -> visualAssetRepository.deleteById(asset.getId()); + attachmentFileIds.forEach(this::removeScriptAssetFileIfOrphaned); + } else { + deleteAssetStorage(asset); + switch (asset.getAssetType()) { + case AUDIO -> audioAssetRepository.deleteById(asset.getId()); + case SCRIPT -> scriptAssetRepository.deleteById(asset.getId()); + default -> visualAssetRepository.deleteById(asset.getId()); + } + assetRepository.delete(asset); } - assetRepository.delete(asset); messagingTemplate.convertAndSend( topicFor(asset.getBroadcaster()), AssetEvent.deleted(asset.getBroadcaster(), assetId) @@ -590,12 +834,23 @@ public class ChannelDirectoryService { .filter((s) -> !s.isBlank()) .orElse("script_attachment_" + System.currentTimeMillis()); + ScriptAssetFile attachmentFile = new ScriptAssetFile(asset.getBroadcaster(), assetType); + attachmentFile.setMediaType(optimized.mediaType()); + attachmentFile.setOriginalMediaType(mediaType); + scriptAssetFileRepository.save(attachmentFile); + ScriptAssetAttachment attachment = new ScriptAssetAttachment(asset.getId(), safeName); + attachment.setFileId(attachmentFile.getId()); attachment.setMediaType(optimized.mediaType()); attachment.setOriginalMediaType(mediaType); attachment.setAssetType(assetType); - assetStorageService.storeAsset(asset.getBroadcaster(), attachment.getId(), optimized.bytes(), optimized.mediaType()); + assetStorageService.storeAsset( + attachmentFile.getBroadcaster(), + attachmentFile.getId(), + optimized.bytes(), + optimized.mediaType() + ); attachment = scriptAssetAttachmentRepository.save(attachment); ScriptAssetAttachmentView view = ScriptAssetAttachmentView.fromAttachment(asset.getBroadcaster(), attachment); @@ -618,13 +873,9 @@ public class ChannelDirectoryService { if (attachment == null) { return false; } - assetStorageService.deleteAsset( - asset.getBroadcaster(), - attachment.getId(), - attachment.getMediaType(), - false - ); + String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); scriptAssetAttachmentRepository.deleteById(attachment.getId()); + removeScriptAssetFileIfOrphaned(fileId); ScriptAsset script = scriptAssetRepository .findById(asset.getId()) @@ -651,12 +902,17 @@ public class ChannelDirectoryService { return scriptAssetAttachmentRepository .findById(attachmentId) .filter((item) -> item.getScriptAssetId().equals(scriptAssetId)) - .flatMap((attachment) -> - assetStorageService.loadAssetFileSafely( - asset.getBroadcaster(), - attachment.getId(), - attachment.getMediaType() - ) + .flatMap((attachment) -> loadScriptAttachmentContent(asset, attachment)); + } + + public Optional getScriptLogoContent(String broadcaster, String scriptAssetId) { + Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId); + return scriptAssetRepository + .findById(asset.getId()) + .map(ScriptAsset::getLogoFileId) + .flatMap((logoFileId) -> scriptAssetFileRepository.findById(logoFileId)) + .flatMap((file) -> + assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType()) ); } @@ -701,6 +957,32 @@ public class ChannelDirectoryService { } } + private String normalizeDescription(String description) { + if (description == null) { + return null; + } + String trimmed = description.trim(); + return trimmed.isBlank() ? null : trimmed; + } + + private void removeScriptAssetFileIfOrphaned(String fileId) { + if (fileId == null || fileId.isBlank()) { + return; + } + long attachmentRefs = scriptAssetAttachmentRepository.countByFileId(fileId); + long sourceRefs = scriptAssetRepository.countBySourceFileId(fileId); + long logoRefs = scriptAssetRepository.countByLogoFileId(fileId); + if (attachmentRefs + sourceRefs + logoRefs > 0) { + return; + } + scriptAssetFileRepository + .findById(fileId) + .ifPresent((file) -> { + assetStorageService.deleteAsset(file.getBroadcaster(), file.getId(), file.getMediaType(), false); + scriptAssetFileRepository.delete(file); + }); + } + private void enforceUploadLimit(long sizeBytes) { if (sizeBytes > uploadLimitBytes) { throw new ResponseStatusException( @@ -851,13 +1133,7 @@ public class ChannelDirectoryService { case SCRIPT -> { return scriptAssetRepository .findById(asset.getId()) - .flatMap((script) -> - assetStorageService.loadAssetFileSafely( - asset.getBroadcaster(), - asset.getId(), - script.getMediaType() - ) - ); + .flatMap((script) -> loadScriptSourceContent(asset, script)); } default -> { return visualAssetRepository @@ -873,6 +1149,44 @@ public class ChannelDirectoryService { } } + private Optional loadScriptSourceContent(Asset asset, ScriptAsset script) { + if (script == null || asset == null) { + return Optional.empty(); + } + String sourceFileId = script.getSourceFileId() != null ? script.getSourceFileId() : script.getId(); + return scriptAssetFileRepository + .findById(sourceFileId) + .flatMap((file) -> + assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType()) + ) + .or(() -> + assetStorageService.loadAssetFileSafely( + asset.getBroadcaster(), + sourceFileId, + script.getMediaType() + ) + ); + } + + private Optional loadScriptAttachmentContent(Asset asset, ScriptAssetAttachment attachment) { + if (attachment == null || asset == null) { + return Optional.empty(); + } + String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId(); + return scriptAssetFileRepository + .findById(fileId) + .flatMap((file) -> + assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType()) + ) + .or(() -> + assetStorageService.loadAssetFileSafely( + asset.getBroadcaster(), + fileId, + attachment.getMediaType() + ) + ); + } + private List loadScriptAttachments( String broadcaster, String scriptAssetId, @@ -930,15 +1244,13 @@ public class ChannelDirectoryService { case SCRIPT -> scriptAssetRepository .findById(asset.getId()) .ifPresent((script) -> { - assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), script.getMediaType(), false); + removeScriptAssetFileIfOrphaned(script.getSourceFileId()); + removeScriptAssetFileIfOrphaned(script.getLogoFileId()); scriptAssetAttachmentRepository .findByScriptAssetId(asset.getId()) .forEach((attachment) -> - assetStorageService.deleteAsset( - asset.getBroadcaster(), - attachment.getId(), - attachment.getMediaType(), - false + removeScriptAssetFileIfOrphaned( + attachment.getFileId() != null ? attachment.getFileId() : attachment.getId() ) ); }); diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 09fb599..a61efb8 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -40,13 +40,26 @@ CREATE TABLE IF NOT EXISTS audio_assets ( CREATE TABLE IF NOT EXISTS script_assets ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + description TEXT, + is_public BOOLEAN, media_type TEXT, - original_media_type TEXT + original_media_type TEXT, + logo_file_id TEXT, + source_file_id TEXT +); + +CREATE TABLE IF NOT EXISTS script_asset_files ( + id TEXT PRIMARY KEY, + broadcaster TEXT NOT NULL, + media_type TEXT, + original_media_type TEXT, + asset_type TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS script_asset_attachments ( id TEXT PRIMARY KEY, script_asset_id TEXT NOT NULL, + file_id TEXT, name TEXT NOT NULL, media_type TEXT, original_media_type TEXT, diff --git a/src/main/resources/static/css/customAssets.css b/src/main/resources/static/css/customAssets.css index 9c8154c..b7dafdc 100644 --- a/src/main/resources/static/css/customAssets.css +++ b/src/main/resources/static/css/customAssets.css @@ -21,6 +21,21 @@ overflow: auto; } +.modal .modal-inner.small { + width: 460px; +} + +.modal .modal-inner.wide { + width: 960px; +} + +.modal .modal-inner .modal-header-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + .modal .modal-inner form { display: flex; flex-direction: column; @@ -38,6 +53,17 @@ justify-content: space-between; } +.modal .modal-inner .form-actions.split { + justify-content: flex-end; + gap: 10px; +} + +.modal .modal-inner .checkbox-row { + flex-direction: row; + align-items: center; + gap: 10px; +} + .modal .modal-inner textarea { max-width: 100%; resize: vertical; @@ -54,6 +80,7 @@ display: flex; gap: 12px; align-items: center; + flex-wrap: wrap; } .modal .modal-inner .attachment-actions .file-input-trigger.small { @@ -100,3 +127,79 @@ color: rgba(226, 232, 240, 0.7); font-size: 0.9rem; } + +.modal .modal-inner .logo-preview { + margin-top: 8px; + min-height: 60px; + border-radius: 8px; + border: 1px dashed rgba(148, 163, 184, 0.35); + padding: 8px; + display: flex; + align-items: center; + justify-content: center; +} + +.modal .modal-inner .logo-preview img { + max-height: 80px; + max-width: 100%; + border-radius: 8px; +} + +.modal .modal-inner .marketplace-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.modal .modal-inner .marketplace-card { + display: grid; + grid-template-columns: 80px 1fr auto; + gap: 16px; + align-items: center; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 10px; + background-color: rgba(15, 23, 42, 0.6); +} + +.modal .modal-inner .marketplace-logo { + width: 72px; + height: 72px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.8); + display: grid; + place-items: center; + object-fit: cover; + border: 1px solid rgba(148, 163, 184, 0.3); +} + +.modal .modal-inner .marketplace-logo.placeholder { + color: rgba(226, 232, 240, 0.7); + font-size: 24px; +} + +.modal .modal-inner .marketplace-content { + display: flex; + flex-direction: column; + gap: 6px; +} + +.modal .modal-inner .marketplace-content p { + margin: 0; + color: rgba(226, 232, 240, 0.8); +} + +.modal .modal-inner .marketplace-actions { + display: flex; + align-items: center; +} + +.modal .modal-inner .marketplace-empty, +.modal .modal-inner .marketplace-loading { + padding: 14px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.6); + border: 1px dashed rgba(148, 163, 184, 0.3); + color: rgba(226, 232, 240, 0.8); +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 5ea3fc4..65f0825 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -4,6 +4,7 @@ import { createCustomAssetModal } from "./customAssets.js"; let adminConsole; const customAssetModal = createCustomAssetModal({ broadcaster, + adminChannels: ADMIN_CHANNELS, showToast: globalThis.showToast, onAssetSaved: (asset) => adminConsole?.handleCustomAssetSaved(asset), }); diff --git a/src/main/resources/static/js/admin/console.js b/src/main/resources/static/js/admin/console.js index ea26705..bfd12f3 100644 --- a/src/main/resources/static/js/admin/console.js +++ b/src/main/resources/static/js/admin/console.js @@ -111,8 +111,8 @@ export function createAdminConsole({ }); } const customAssetButton = document.getElementById("custom-asset-button"); - if (customAssetButton && customAssetModal?.openNew) { - customAssetButton.addEventListener("click", () => customAssetModal.openNew()); + if (customAssetButton && customAssetModal?.openLauncher) { + customAssetButton.addEventListener("click", () => customAssetModal.openLauncher()); } globalThis.addEventListener("resize", () => { resizeCanvas(); diff --git a/src/main/resources/static/js/customAssets.js b/src/main/resources/static/js/customAssets.js index 6dc6bd7..75ce2b1 100644 --- a/src/main/resources/static/js/customAssets.js +++ b/src/main/resources/static/js/customAssets.js @@ -1,6 +1,24 @@ -export function createCustomAssetModal({ broadcaster, showToast = globalThis.showToast, onAssetSaved }) { +export function createCustomAssetModal({ + broadcaster, + adminChannels = [], + showToast = globalThis.showToast, + onAssetSaved, +}) { + const launchModal = document.getElementById("custom-asset-launch-modal"); + const launchNewButton = document.getElementById("custom-asset-launch-new"); + const launchMarketplaceButton = document.getElementById("custom-asset-launch-marketplace"); + const marketplaceModal = document.getElementById("custom-asset-marketplace-modal"); + const marketplaceCloseButton = document.getElementById("custom-asset-marketplace-close"); + const marketplaceSearchInput = document.getElementById("custom-asset-marketplace-search"); + const marketplaceList = document.getElementById("custom-asset-marketplace-list"); + const marketplaceChannelSelect = document.getElementById("custom-asset-marketplace-channel"); const assetModal = document.getElementById("custom-asset-modal"); const userNameInput = document.getElementById("custom-asset-name"); + const descriptionInput = document.getElementById("custom-asset-description"); + const publicCheckbox = document.getElementById("custom-asset-public"); + const logoInput = document.getElementById("custom-asset-logo-file"); + const logoPreview = document.getElementById("custom-asset-logo-preview"); + const logoClearButton = document.getElementById("custom-asset-logo-clear"); const userSourceTextArea = document.getElementById("custom-asset-code"); const formErrorWrapper = document.getElementById("custom-asset-error"); const jsErrorTitle = document.getElementById("js-error-title"); @@ -11,7 +29,10 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho const attachmentList = document.getElementById("custom-asset-attachment-list"); const attachmentHint = document.getElementById("custom-asset-attachment-hint"); let currentAssetId = null; + let pendingLogoFile = null; + let logoRemoved = false; let attachmentState = []; + let marketplaceEntries = []; const resetErrors = () => { if (formErrorWrapper) { @@ -25,6 +46,30 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho } }; + const openLaunchModal = () => { + launchModal?.classList.remove("hidden"); + }; + + const closeLaunchModal = () => { + launchModal?.classList.add("hidden"); + }; + + const openMarketplaceModal = () => { + closeLaunchModal(); + marketplaceModal?.classList.remove("hidden"); + if (marketplaceChannelSelect) { + marketplaceChannelSelect.value = broadcaster?.toLowerCase() || marketplaceChannelSelect.value; + } + if (marketplaceSearchInput) { + marketplaceSearchInput.value = ""; + } + loadMarketplace(); + }; + + const closeMarketplaceModal = () => { + marketplaceModal?.classList.add("hidden"); + }; + const openModal = () => { assetModal?.classList.remove("hidden"); }; @@ -34,9 +79,17 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho }; const openNew = () => { + closeLaunchModal(); if (userNameInput) { userNameInput.value = ""; } + if (descriptionInput) { + descriptionInput.value = ""; + } + if (publicCheckbox) { + publicCheckbox.checked = false; + } + resetLogoState(); if (userSourceTextArea) { userSourceTextArea.value = ""; userSourceTextArea.disabled = false; @@ -57,6 +110,19 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho if (userNameInput) { userNameInput.value = asset.name || ""; } + if (descriptionInput) { + descriptionInput.value = asset.description || ""; + } + if (publicCheckbox) { + publicCheckbox.checked = !!asset.isPublic; + } + resetLogoState(); + if (logoPreview && asset.logoUrl) { + const img = document.createElement("img"); + img.src = asset.logoUrl; + img.alt = asset.name || "Script logo"; + logoPreview.appendChild(img); + } if (userSourceTextArea) { userSourceTextArea.value = ""; userSourceTextArea.placeholder = "Loading script..."; @@ -119,18 +185,28 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho return false; } const assetId = userSourceTextArea?.dataset?.assetId; + const description = descriptionInput?.value?.trim(); + const isPublic = !!publicCheckbox?.checked; const submitButton = formEvent.currentTarget?.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; submitButton.textContent = "Saving..."; } - saveCodeAsset({ name, src, assetId }) + saveCodeAsset({ name, src, assetId, description, isPublic }) .then((asset) => { if (asset) { - onAssetSaved?.(asset); + return syncLogoChanges(asset).then((updated) => { + onAssetSaved?.(updated || asset); + return updated || asset; + }); } + return null; + }) + .then((asset) => { closeModal(); - showToast?.(assetId ? "Custom asset updated." : "Custom asset created.", "success"); + if (asset) { + showToast?.(assetId ? "Custom asset updated." : "Custom asset created.", "success"); + } }) .catch((e) => { showToast?.("Unable to save custom asset. Please try again.", "error"); @@ -145,6 +221,29 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho return false; }; + if (launchModal) { + launchModal.addEventListener("click", (event) => { + if (event.target === launchModal) { + closeLaunchModal(); + } + }); + } + if (launchNewButton) { + launchNewButton.addEventListener("click", () => openNew()); + } + if (launchMarketplaceButton) { + launchMarketplaceButton.addEventListener("click", () => openMarketplaceModal()); + } + if (marketplaceModal) { + marketplaceModal.addEventListener("click", (event) => { + if (event.target === marketplaceModal) { + closeMarketplaceModal(); + } + }); + } + if (marketplaceCloseButton) { + marketplaceCloseButton.addEventListener("click", () => closeMarketplaceModal()); + } if (assetModal) { assetModal.addEventListener("click", (event) => { if (event.target === assetModal) { @@ -158,6 +257,36 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho if (cancelButton) { cancelButton.addEventListener("click", () => closeModal()); } + if (logoInput) { + logoInput.addEventListener("change", (event) => { + const file = event.target?.files?.[0]; + if (!file) { + return; + } + pendingLogoFile = file; + logoRemoved = false; + renderLogoPreview(file); + }); + } + if (logoClearButton) { + logoClearButton.addEventListener("click", () => { + logoRemoved = true; + pendingLogoFile = null; + if (logoInput) { + logoInput.value = ""; + } + clearLogoPreview(); + }); + } + if (marketplaceSearchInput) { + const handler = debounce((event) => { + loadMarketplace(event.target?.value); + }, 250); + marketplaceSearchInput.addEventListener("input", handler); + } + if (marketplaceChannelSelect) { + buildChannelOptions(); + } if (attachmentInput) { attachmentInput.addEventListener("change", (event) => { const file = event.target?.files?.[0]; @@ -187,7 +316,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho }); } - return { openNew, openEditor }; + return { openLauncher: openLaunchModal, openNew, openEditor }; function setAttachmentState(assetId, attachments) { currentAssetId = assetId || null; @@ -300,8 +429,13 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho }); } - function saveCodeAsset({ name, src, assetId }) { - const payload = { name, source: src }; + function saveCodeAsset({ name, src, assetId, description, isPublic }) { + const payload = { + name, + source: src, + description: description || null, + isPublic, + }; const method = assetId ? "PUT" : "POST"; const url = assetId ? `/api/channels/${encodeURIComponent(broadcaster)}/assets/${assetId}/code` @@ -318,6 +452,195 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho }); } + function resetLogoState() { + pendingLogoFile = null; + logoRemoved = false; + if (logoInput) { + logoInput.value = ""; + } + clearLogoPreview(); + } + + function clearLogoPreview() { + if (logoPreview) { + logoPreview.innerHTML = ""; + } + } + + function renderLogoPreview(file) { + if (!logoPreview) { + return; + } + clearLogoPreview(); + const img = document.createElement("img"); + img.alt = "Script logo preview"; + if (file instanceof File) { + const url = URL.createObjectURL(file); + img.src = url; + img.onload = () => URL.revokeObjectURL(url); + } + logoPreview.appendChild(img); + } + + function syncLogoChanges(asset) { + if (!asset?.id) { + return Promise.resolve(null); + } + if (logoRemoved) { + return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${asset.id}/logo`, { + method: "DELETE", + }).then(() => { + logoRemoved = false; + return { ...asset, logoUrl: null }; + }); + } + if (!pendingLogoFile) { + return Promise.resolve(null); + } + const payload = new FormData(); + payload.append("file", pendingLogoFile); + return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${asset.id}/logo`, { + method: "POST", + body: payload, + }).then((response) => { + if (!response.ok) { + throw new Error("Failed to upload logo"); + } + pendingLogoFile = null; + return response.json(); + }); + } + + function buildChannelOptions() { + if (!marketplaceChannelSelect) { + return; + } + const channels = [broadcaster, ...adminChannels].filter(Boolean); + const uniqueChannels = [...new Set(channels.map((channel) => channel.toLowerCase()))]; + marketplaceChannelSelect.innerHTML = ""; + uniqueChannels.forEach((channel) => { + const option = document.createElement("option"); + option.value = channel; + option.textContent = channel; + marketplaceChannelSelect.appendChild(option); + }); + marketplaceChannelSelect.value = broadcaster?.toLowerCase() || uniqueChannels[0] || ""; + } + + function loadMarketplace(query = "") { + if (!marketplaceList) { + return; + } + const queryString = query ? `?query=${encodeURIComponent(query)}` : ""; + marketplaceList.innerHTML = '
Loading scripts...
'; + fetch(`/api/marketplace/scripts${queryString}`) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to load marketplace"); + } + return response.json(); + }) + .then((entries) => { + marketplaceEntries = Array.isArray(entries) ? entries : []; + renderMarketplace(); + }) + .catch((error) => { + console.error(error); + marketplaceList.innerHTML = + '
Unable to load marketplace scripts.
'; + }); + } + + function renderMarketplace() { + if (!marketplaceList) { + return; + } + marketplaceList.innerHTML = ""; + if (!marketplaceEntries.length) { + marketplaceList.innerHTML = '
No scripts found.
'; + return; + } + marketplaceEntries.forEach((entry) => { + const card = document.createElement("div"); + card.className = "marketplace-card"; + + if (entry.logoUrl) { + const logo = document.createElement("img"); + logo.src = entry.logoUrl; + logo.alt = entry.name || "Script logo"; + logo.className = "marketplace-logo"; + card.appendChild(logo); + } else { + const placeholder = document.createElement("div"); + placeholder.className = "marketplace-logo placeholder"; + placeholder.innerHTML = ''; + card.appendChild(placeholder); + } + + const content = document.createElement("div"); + content.className = "marketplace-content"; + const title = document.createElement("strong"); + title.textContent = entry.name || "Untitled script"; + const description = document.createElement("p"); + description.textContent = entry.description || "No description provided."; + const meta = document.createElement("small"); + meta.textContent = entry.broadcaster ? `By ${entry.broadcaster}` : ""; + content.appendChild(title); + content.appendChild(description); + content.appendChild(meta); + + const actions = document.createElement("div"); + actions.className = "marketplace-actions"; + const importButton = document.createElement("button"); + importButton.type = "button"; + importButton.className = "primary"; + importButton.textContent = "Import"; + importButton.addEventListener("click", () => importMarketplaceScript(entry)); + actions.appendChild(importButton); + + card.appendChild(content); + card.appendChild(actions); + marketplaceList.appendChild(card); + }); + } + + function importMarketplaceScript(entry) { + if (!entry?.id) { + return; + } + const target = marketplaceChannelSelect?.value || broadcaster; + fetch(`/api/marketplace/scripts/${entry.id}/import`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targetBroadcaster: target }), + }) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to import script"); + } + return response.json(); + }) + .then((asset) => { + closeMarketplaceModal(); + showToast?.("Script imported.", "success"); + onAssetSaved?.(asset); + }) + .catch((error) => { + console.error(error); + showToast?.("Unable to import script. Please try again.", "error"); + }); + } + + function debounce(fn, wait = 150) { + let timeout; + return (...args) => { + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(() => fn(...args), wait); + }; + } + function getUserJavaScriptSourceError(src) { let ast; diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 5c6aa0e..4ad1017 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -368,6 +368,47 @@ + + +
+ + +
+
+ +
+ + + +
+
+
+
+ + +