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) -> {
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());
}
attachmentFileIds.forEach(this::removeScriptAssetFileIfOrphaned);
} else {
deleteAssetStorage(asset);
switch (asset.getAssetType()) {
case AUDIO -> audioAssetRepository.deleteById(asset.getId());
case SCRIPT -> {
scriptAssetAttachmentRepository.deleteByScriptAssetId(asset.getId());
scriptAssetRepository.deleteById(asset.getId());
}
case SCRIPT -> scriptAssetRepository.deleteById(asset.getId());
default -> visualAssetRepository.deleteById(asset.getId());
}
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()
)
);
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
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 = '<div class="marketplace-loading">Loading scripts...</div>';
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 =
'<div class="marketplace-empty">Unable to load marketplace scripts.</div>';
});
}
function renderMarketplace() {
if (!marketplaceList) {
return;
}
marketplaceList.innerHTML = "";
if (!marketplaceEntries.length) {
marketplaceList.innerHTML = '<div class="marketplace-empty">No scripts found.</div>';
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 = '<i class="fa-solid fa-code"></i>';
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;

View File

@@ -368,6 +368,47 @@
</section>
</div>
</div>
<div id="custom-asset-launch-modal" class="modal hidden">
<section class="modal-inner small">
<h1>Custom scripts</h1>
<p>Start a new script or browse scripts shared by other creators.</p>
<div class="form-actions split">
<button type="button" class="secondary" id="custom-asset-launch-marketplace">
Browse marketplace
</button>
<button type="button" class="primary" id="custom-asset-launch-new">
Create new script
</button>
</div>
</section>
</div>
<div id="custom-asset-marketplace-modal" class="modal hidden">
<section class="modal-inner wide">
<div class="modal-header-row">
<div>
<h1>Custom script marketplace</h1>
<p>Search public scripts by name or description.</p>
</div>
<button type="button" class="ghost icon-button" id="custom-asset-marketplace-close" aria-label="Close">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="form-group">
<label for="custom-asset-marketplace-search">Search scripts</label>
<input
id="custom-asset-marketplace-search"
type="search"
class="text-input"
placeholder="Search by name or description"
/>
</div>
<div class="form-group">
<label for="custom-asset-marketplace-channel">Import into channel</label>
<select id="custom-asset-marketplace-channel" class="text-input"></select>
</div>
<div class="marketplace-list" id="custom-asset-marketplace-list"></div>
</section>
</div>
<div id="custom-asset-modal" class="modal hidden">
<section class="modal-inner">
<h1>Create Custom Asset</h1>
@@ -382,6 +423,39 @@
required
/>
</div>
<div class="form-group">
<label for="custom-asset-description">Description</label>
<textarea
id="custom-asset-description"
class="text-input"
placeholder="Describe what this script does"
rows="3"
></textarea>
</div>
<div class="form-group">
<label>Logo (optional)</label>
<div class="attachment-actions">
<input
id="custom-asset-logo-file"
class="file-input-field"
type="file"
accept="image/*"
/>
<label for="custom-asset-logo-file" class="file-input-trigger small">
<span class="file-input-icon"><i class="fa-solid fa-image"></i></span>
<span class="file-input-copy">
<strong>Upload logo</strong>
<small>PNG, JPG, or GIF</small>
</span>
</label>
<button type="button" class="secondary" id="custom-asset-logo-clear">Remove logo</button>
</div>
<div class="logo-preview" id="custom-asset-logo-preview"></div>
</div>
<div class="form-group checkbox-row">
<input id="custom-asset-public" type="checkbox" />
<label for="custom-asset-public">Make this script public in the marketplace</label>
</div>
<div class="form-group">
<label for="custom-asset-type">Asset code</label>
<textarea
@@ -434,6 +508,7 @@
const username = /*[[${username}]]*/ "";
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
const SETTINGS = JSON.parse(/*[[${settingsJson}]]*/);
const ADMIN_CHANNELS = /*[[${adminChannels}]]*/ [];
</script>
<script src="/js/cookie-consent.js"></script>
<script src="/js/toast.js"></script>