Asset marketplace

This commit is contained in:
2026-01-10 01:24:59 +01:00
parent b1dd57da82
commit c3736e682b
22 changed files with 1342 additions and 50 deletions

View File

@@ -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<String> scriptColumns;
List<String> 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<String> assetColumns) {
if (!assetColumns.contains("media_type")) {
return;

View File

@@ -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<byte[]> 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<byte[]> getAssetPreview(
@PathVariable("broadcaster") String broadcaster,
@@ -485,7 +509,7 @@ public class ChannelApiController {
public ResponseEntity<ScriptAssetAttachmentView> 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<AssetView> 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<Void> 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<Void> deleteScriptAttachment(
@PathVariable("broadcaster") String broadcaster,

View File

@@ -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<ScriptMarketplaceEntry> listMarketplaceScripts(@RequestParam(value = "query", required = false) String query) {
return channelDirectoryService.listMarketplaceScripts(query);
}
@GetMapping("/scripts/{scriptId}/logo")
public ResponseEntity<byte[]> 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<AssetView> 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"));
}
}

View File

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

View File

@@ -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,

View File

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

View File

@@ -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<ScriptAssetAttachmentView> 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<ScriptAssetAttachmentView> getAttachments() {
return attachments == null ? List.of() : attachments;
}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
package dev.kruhlmann.imgfloat.model;
public record ScriptMarketplaceEntry(
String id,
String name,
String description,
String logoUrl,
String broadcaster
) {}

View File

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

View File

@@ -11,4 +11,6 @@ public interface ScriptAssetAttachmentRepository extends JpaRepository<ScriptAss
List<ScriptAssetAttachment> findByScriptAssetIdIn(Collection<String> scriptAssetIds);
void deleteByScriptAssetId(String scriptAssetId);
long countByFileId(String fileId);
}

View File

@@ -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<ScriptAssetFile, String> {}

View File

@@ -7,4 +7,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface ScriptAssetRepository extends JpaRepository<ScriptAsset, String> {
List<ScriptAsset> findByIdIn(Collection<String> ids);
List<ScriptAsset> findByIsPublicTrue();
long countBySourceFileId(String sourceFileId);
long countByLogoFileId(String logoFileId);
}

View File

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

View File

@@ -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<AssetView> 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<AssetView> 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<ScriptMarketplaceEntry> listMarketplaceScripts(String query) {
String q = normalizeDescription(query);
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
List<ScriptAsset> 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<String, Asset> 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<AssetContent> 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<AssetView> 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<ScriptAssetAttachment> sourceAttachments = scriptAssetAttachmentRepository
.findByScriptAssetId(sourceScript.getId());
List<ScriptAssetAttachment> 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<String> 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<AssetContent> 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<AssetContent> 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<AssetContent> 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<ScriptAssetAttachmentView> 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()
)
);
});