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 ( CREATE TABLE IF NOT EXISTS script_assets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT,
is_public BOOLEAN,
media_type TEXT, 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 ( CREATE TABLE IF NOT EXISTS script_asset_attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
script_asset_id TEXT NOT NULL, script_asset_id TEXT NOT NULL,
file_id TEXT,
name TEXT NOT NULL, name TEXT NOT NULL,
media_type TEXT, media_type TEXT,
original_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); backfillAssetTypes(assetColumns);
} catch (DataAccessException ex) { } catch (DataAccessException ex) {
logger.warn("Unable to ensure asset type tables", 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) { private void backfillAssetTypes(List<String> assetColumns) {
if (!assetColumns.contains("media_type")) { if (!assetColumns.contains("media_type")) {
return; 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.PostMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody; 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.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -415,6 +416,29 @@ public class ChannelApiController {
.orElseThrow(() -> createAsset404()); .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") @GetMapping("/assets/{assetId}/preview")
public ResponseEntity<byte[]> getAssetPreview( public ResponseEntity<byte[]> getAssetPreview(
@PathVariable("broadcaster") String broadcaster, @PathVariable("broadcaster") String broadcaster,
@@ -485,7 +509,7 @@ public class ChannelApiController {
public ResponseEntity<ScriptAssetAttachmentView> createScriptAttachment( public ResponseEntity<ScriptAssetAttachmentView> createScriptAttachment(
@PathVariable("broadcaster") String broadcaster, @PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId, @PathVariable("assetId") String assetId,
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file, @RequestPart("file") MultipartFile file,
OAuth2AuthenticationToken oauthToken OAuth2AuthenticationToken oauthToken
) { ) {
String sessionUsername = OauthSessionUser.from(oauthToken).login(); 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}") @DeleteMapping("/assets/{assetId}/attachments/{attachmentId}")
public ResponseEntity<Void> deleteScriptAttachment( public ResponseEntity<Void> deleteScriptAttachment(
@PathVariable("broadcaster") String broadcaster, @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(); Settings settings = settingsService.get();
model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("broadcaster", broadcaster.toLowerCase());
model.addAttribute("username", sessionUsername); model.addAttribute("username", sessionUsername);
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(sessionUsername));
model.addAttribute("uploadLimitBytes", uploadLimitBytes); model.addAttribute("uploadLimitBytes", uploadLimitBytes);
try { try {
model.addAttribute("settingsJson", objectMapper.writeValueAsString(settings)); model.addAttribute("settingsJson", objectMapper.writeValueAsString(settings));

View File

@@ -7,6 +7,9 @@ public record AssetView(
String id, String id,
String broadcaster, String broadcaster,
String name, String name,
String description,
String logoUrl,
Boolean isPublic,
String url, String url,
String previewUrl, String previewUrl,
double x, double x,
@@ -37,6 +40,9 @@ public record AssetView(
asset.getId(), asset.getId(),
asset.getBroadcaster(), asset.getBroadcaster(),
visual.getName(), visual.getName(),
null,
null,
null,
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
hasPreview ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" : null, hasPreview ? "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/preview" : null,
visual.getX(), visual.getX(),
@@ -68,6 +74,9 @@ public record AssetView(
asset.getId(), asset.getId(),
asset.getBroadcaster(), asset.getBroadcaster(),
audio.getName(), audio.getName(),
null,
null,
null,
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
null, null,
0, 0,
@@ -99,6 +108,11 @@ public record AssetView(
asset.getId(), asset.getId(),
asset.getBroadcaster(), asset.getBroadcaster(),
script.getName(), script.getName(),
script.getDescription(),
script.getLogoFileId() == null
? null
: "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/logo",
script.isPublic(),
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content", "/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
null, null,
0, 0,

View File

@@ -10,6 +10,10 @@ public class CodeAssetRequest {
@NotBlank @NotBlank
private String source; private String source;
private String description;
private Boolean isPublic;
public String getName() { public String getName() {
return name; return name;
} }
@@ -25,4 +29,20 @@ public class CodeAssetRequest {
public void setSource(String source) { public void setSource(String source) {
this.source = 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) @Column(nullable = false)
private String name; private String name;
private String description;
@Column(name = "is_public")
private boolean isPublic;
private String mediaType; private String mediaType;
private String originalMediaType; private String originalMediaType;
@Column(name = "logo_file_id")
private String logoFileId;
@Column(name = "source_file_id")
private String sourceFileId;
@Transient @Transient
private List<ScriptAssetAttachmentView> attachments = List.of(); private List<ScriptAssetAttachmentView> attachments = List.of();
@@ -72,6 +83,38 @@ public class ScriptAsset {
this.originalMediaType = originalMediaType; 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() { public List<ScriptAssetAttachmentView> getAttachments() {
return attachments == null ? List.of() : attachments; return attachments == null ? List.of() : attachments;
} }

View File

@@ -20,6 +20,9 @@ public class ScriptAssetAttachment {
@Column(name = "script_asset_id", nullable = false) @Column(name = "script_asset_id", nullable = false)
private String scriptAssetId; private String scriptAssetId;
@Column(name = "file_id")
private String fileId;
@Column(nullable = false) @Column(nullable = false)
private String name; private String name;
@@ -69,6 +72,14 @@ public class ScriptAssetAttachment {
this.scriptAssetId = scriptAssetId; this.scriptAssetId = scriptAssetId;
} }
public String getFileId() {
return fileId;
}
public void setFileId(String fileId) {
this.fileId = fileId;
}
public String getName() { public String getName() {
return name; 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); List<ScriptAssetAttachment> findByScriptAssetIdIn(Collection<String> scriptAssetIds);
void deleteByScriptAssetId(String scriptAssetId); 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> { public interface ScriptAssetRepository extends JpaRepository<ScriptAsset, String> {
List<ScriptAsset> findByIdIn(Collection<String> ids); 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.Asset;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
import dev.kruhlmann.imgfloat.model.ScriptAsset;
import dev.kruhlmann.imgfloat.repository.AssetRepository; import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -22,15 +24,18 @@ public class AssetCleanupService {
private final AssetRepository assetRepository; private final AssetRepository assetRepository;
private final AssetStorageService assetStorageService; private final AssetStorageService assetStorageService;
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
private final ScriptAssetRepository scriptAssetRepository;
public AssetCleanupService( public AssetCleanupService(
AssetRepository assetRepository, AssetRepository assetRepository,
AssetStorageService assetStorageService, AssetStorageService assetStorageService,
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository ScriptAssetAttachmentRepository scriptAssetAttachmentRepository,
ScriptAssetRepository scriptAssetRepository
) { ) {
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.assetStorageService = assetStorageService; this.assetStorageService = assetStorageService;
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository; this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
this.scriptAssetRepository = scriptAssetRepository;
} }
@Async @Async
@@ -48,7 +53,23 @@ public class AssetCleanupService {
scriptAssetAttachmentRepository scriptAssetAttachmentRepository
.findAll() .findAll()
.stream() .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()) .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.ScriptAsset;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment; import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView; 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.Settings;
import dev.kruhlmann.imgfloat.model.TransformRequest; import dev.kruhlmann.imgfloat.model.TransformRequest;
import dev.kruhlmann.imgfloat.model.VisibilityRequest; 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.ChannelRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository; import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository; import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
import dev.kruhlmann.imgfloat.service.media.AssetContent; import dev.kruhlmann.imgfloat.service.media.AssetContent;
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService; import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
@@ -57,6 +60,7 @@ public class ChannelDirectoryService {
private final AudioAssetRepository audioAssetRepository; private final AudioAssetRepository audioAssetRepository;
private final ScriptAssetRepository scriptAssetRepository; private final ScriptAssetRepository scriptAssetRepository;
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository; private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
private final ScriptAssetFileRepository scriptAssetFileRepository;
private final SimpMessagingTemplate messagingTemplate; private final SimpMessagingTemplate messagingTemplate;
private final AssetStorageService assetStorageService; private final AssetStorageService assetStorageService;
private final MediaDetectionService mediaDetectionService; private final MediaDetectionService mediaDetectionService;
@@ -72,6 +76,7 @@ public class ChannelDirectoryService {
AudioAssetRepository audioAssetRepository, AudioAssetRepository audioAssetRepository,
ScriptAssetRepository scriptAssetRepository, ScriptAssetRepository scriptAssetRepository,
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository, ScriptAssetAttachmentRepository scriptAssetAttachmentRepository,
ScriptAssetFileRepository scriptAssetFileRepository,
SimpMessagingTemplate messagingTemplate, SimpMessagingTemplate messagingTemplate,
AssetStorageService assetStorageService, AssetStorageService assetStorageService,
MediaDetectionService mediaDetectionService, MediaDetectionService mediaDetectionService,
@@ -85,6 +90,7 @@ public class ChannelDirectoryService {
this.audioAssetRepository = audioAssetRepository; this.audioAssetRepository = audioAssetRepository;
this.scriptAssetRepository = scriptAssetRepository; this.scriptAssetRepository = scriptAssetRepository;
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository; this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
this.scriptAssetFileRepository = scriptAssetFileRepository;
this.messagingTemplate = messagingTemplate; this.messagingTemplate = messagingTemplate;
this.assetStorageService = assetStorageService; this.assetStorageService = assetStorageService;
this.mediaDetectionService = mediaDetectionService; this.mediaDetectionService = mediaDetectionService;
@@ -226,8 +232,14 @@ public class ChannelDirectoryService {
ScriptAsset script = new ScriptAsset(asset.getId(), safeName); ScriptAsset script = new ScriptAsset(asset.getId(), safeName);
script.setMediaType(optimized.mediaType()); script.setMediaType(optimized.mediaType());
script.setOriginalMediaType(mediaType); script.setOriginalMediaType(mediaType);
script.setSourceFileId(asset.getId());
script.setAttachments(List.of()); script.setAttachments(List.of());
scriptAssetRepository.save(script); 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); view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
} else { } else {
double defaultWidth = 640; double defaultWidth = 640;
@@ -257,17 +269,30 @@ public class ChannelDirectoryService {
enforceUploadLimit(bytes.length); enforceUploadLimit(bytes.length);
Asset asset = new Asset(channel.getBroadcaster(), AssetType.SCRIPT); 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 { 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) { } catch (IOException e) {
throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e); throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", e);
} }
asset = assetRepository.save(asset); asset = assetRepository.save(asset);
scriptAssetFileRepository.save(sourceFile);
ScriptAsset script = new ScriptAsset(asset.getId(), request.getName().trim()); ScriptAsset script = new ScriptAsset(asset.getId(), request.getName().trim());
script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE);
script.setMediaType(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()); script.setAttachments(List.of());
scriptAssetRepository.save(script); scriptAssetRepository.save(script);
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script); AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
@@ -291,12 +316,38 @@ public class ChannelDirectoryService {
ScriptAsset script = scriptAssetRepository ScriptAsset script = scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script")); .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()); 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.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE);
script.setMediaType(DEFAULT_CODE_MEDIA_TYPE); script.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null)); script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
try { 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) { } catch (IOException e) {
throw new ResponseStatusException(BAD_REQUEST, "Unable to store custom script", 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) { private String sanitizeFilename(String original) {
String stripped = original.replaceAll("^.*[/\\\\]", ""); String stripped = original.replaceAll("^.*[/\\\\]", "");
return SAFE_FILENAME.matcher(stripped).replaceAll("_"); return SAFE_FILENAME.matcher(stripped).replaceAll("_");
@@ -524,16 +754,30 @@ public class ChannelDirectoryService {
return assetRepository return assetRepository
.findById(assetId) .findById(assetId)
.map((asset) -> { .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); deleteAssetStorage(asset);
switch (asset.getAssetType()) { switch (asset.getAssetType()) {
case AUDIO -> audioAssetRepository.deleteById(asset.getId()); case AUDIO -> audioAssetRepository.deleteById(asset.getId());
case SCRIPT -> { case SCRIPT -> scriptAssetRepository.deleteById(asset.getId());
scriptAssetAttachmentRepository.deleteByScriptAssetId(asset.getId());
scriptAssetRepository.deleteById(asset.getId());
}
default -> visualAssetRepository.deleteById(asset.getId()); default -> visualAssetRepository.deleteById(asset.getId());
} }
assetRepository.delete(asset); assetRepository.delete(asset);
}
messagingTemplate.convertAndSend( messagingTemplate.convertAndSend(
topicFor(asset.getBroadcaster()), topicFor(asset.getBroadcaster()),
AssetEvent.deleted(asset.getBroadcaster(), assetId) AssetEvent.deleted(asset.getBroadcaster(), assetId)
@@ -590,12 +834,23 @@ public class ChannelDirectoryService {
.filter((s) -> !s.isBlank()) .filter((s) -> !s.isBlank())
.orElse("script_attachment_" + System.currentTimeMillis()); .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); ScriptAssetAttachment attachment = new ScriptAssetAttachment(asset.getId(), safeName);
attachment.setFileId(attachmentFile.getId());
attachment.setMediaType(optimized.mediaType()); attachment.setMediaType(optimized.mediaType());
attachment.setOriginalMediaType(mediaType); attachment.setOriginalMediaType(mediaType);
attachment.setAssetType(assetType); 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); attachment = scriptAssetAttachmentRepository.save(attachment);
ScriptAssetAttachmentView view = ScriptAssetAttachmentView.fromAttachment(asset.getBroadcaster(), attachment); ScriptAssetAttachmentView view = ScriptAssetAttachmentView.fromAttachment(asset.getBroadcaster(), attachment);
@@ -618,13 +873,9 @@ public class ChannelDirectoryService {
if (attachment == null) { if (attachment == null) {
return false; return false;
} }
assetStorageService.deleteAsset( String fileId = attachment.getFileId() != null ? attachment.getFileId() : attachment.getId();
asset.getBroadcaster(),
attachment.getId(),
attachment.getMediaType(),
false
);
scriptAssetAttachmentRepository.deleteById(attachment.getId()); scriptAssetAttachmentRepository.deleteById(attachment.getId());
removeScriptAssetFileIfOrphaned(fileId);
ScriptAsset script = scriptAssetRepository ScriptAsset script = scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
@@ -651,12 +902,17 @@ public class ChannelDirectoryService {
return scriptAssetAttachmentRepository return scriptAssetAttachmentRepository
.findById(attachmentId) .findById(attachmentId)
.filter((item) -> item.getScriptAssetId().equals(scriptAssetId)) .filter((item) -> item.getScriptAssetId().equals(scriptAssetId))
.flatMap((attachment) -> .flatMap((attachment) -> loadScriptAttachmentContent(asset, attachment));
assetStorageService.loadAssetFileSafely( }
asset.getBroadcaster(),
attachment.getId(), public Optional<AssetContent> getScriptLogoContent(String broadcaster, String scriptAssetId) {
attachment.getMediaType() 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) { private void enforceUploadLimit(long sizeBytes) {
if (sizeBytes > uploadLimitBytes) { if (sizeBytes > uploadLimitBytes) {
throw new ResponseStatusException( throw new ResponseStatusException(
@@ -851,13 +1133,7 @@ public class ChannelDirectoryService {
case SCRIPT -> { case SCRIPT -> {
return scriptAssetRepository return scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
.flatMap((script) -> .flatMap((script) -> loadScriptSourceContent(asset, script));
assetStorageService.loadAssetFileSafely(
asset.getBroadcaster(),
asset.getId(),
script.getMediaType()
)
);
} }
default -> { default -> {
return visualAssetRepository 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( private List<ScriptAssetAttachmentView> loadScriptAttachments(
String broadcaster, String broadcaster,
String scriptAssetId, String scriptAssetId,
@@ -930,15 +1244,13 @@ public class ChannelDirectoryService {
case SCRIPT -> scriptAssetRepository case SCRIPT -> scriptAssetRepository
.findById(asset.getId()) .findById(asset.getId())
.ifPresent((script) -> { .ifPresent((script) -> {
assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), script.getMediaType(), false); removeScriptAssetFileIfOrphaned(script.getSourceFileId());
removeScriptAssetFileIfOrphaned(script.getLogoFileId());
scriptAssetAttachmentRepository scriptAssetAttachmentRepository
.findByScriptAssetId(asset.getId()) .findByScriptAssetId(asset.getId())
.forEach((attachment) -> .forEach((attachment) ->
assetStorageService.deleteAsset( removeScriptAssetFileIfOrphaned(
asset.getBroadcaster(), attachment.getFileId() != null ? attachment.getFileId() : attachment.getId()
attachment.getId(),
attachment.getMediaType(),
false
) )
); );
}); });

View File

@@ -40,13 +40,26 @@ CREATE TABLE IF NOT EXISTS audio_assets (
CREATE TABLE IF NOT EXISTS script_assets ( CREATE TABLE IF NOT EXISTS script_assets (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
description TEXT,
is_public BOOLEAN,
media_type TEXT, 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 ( CREATE TABLE IF NOT EXISTS script_asset_attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
script_asset_id TEXT NOT NULL, script_asset_id TEXT NOT NULL,
file_id TEXT,
name TEXT NOT NULL, name TEXT NOT NULL,
media_type TEXT, media_type TEXT,
original_media_type TEXT, original_media_type TEXT,

View File

@@ -21,6 +21,21 @@
overflow: auto; 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 { .modal .modal-inner form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -38,6 +53,17 @@
justify-content: space-between; 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 { .modal .modal-inner textarea {
max-width: 100%; max-width: 100%;
resize: vertical; resize: vertical;
@@ -54,6 +80,7 @@
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
flex-wrap: wrap;
} }
.modal .modal-inner .attachment-actions .file-input-trigger.small { .modal .modal-inner .attachment-actions .file-input-trigger.small {
@@ -100,3 +127,79 @@
color: rgba(226, 232, 240, 0.7); color: rgba(226, 232, 240, 0.7);
font-size: 0.9rem; 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; let adminConsole;
const customAssetModal = createCustomAssetModal({ const customAssetModal = createCustomAssetModal({
broadcaster, broadcaster,
adminChannels: ADMIN_CHANNELS,
showToast: globalThis.showToast, showToast: globalThis.showToast,
onAssetSaved: (asset) => adminConsole?.handleCustomAssetSaved(asset), onAssetSaved: (asset) => adminConsole?.handleCustomAssetSaved(asset),
}); });

View File

@@ -111,8 +111,8 @@ export function createAdminConsole({
}); });
} }
const customAssetButton = document.getElementById("custom-asset-button"); const customAssetButton = document.getElementById("custom-asset-button");
if (customAssetButton && customAssetModal?.openNew) { if (customAssetButton && customAssetModal?.openLauncher) {
customAssetButton.addEventListener("click", () => customAssetModal.openNew()); customAssetButton.addEventListener("click", () => customAssetModal.openLauncher());
} }
globalThis.addEventListener("resize", () => { globalThis.addEventListener("resize", () => {
resizeCanvas(); 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 assetModal = document.getElementById("custom-asset-modal");
const userNameInput = document.getElementById("custom-asset-name"); 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 userSourceTextArea = document.getElementById("custom-asset-code");
const formErrorWrapper = document.getElementById("custom-asset-error"); const formErrorWrapper = document.getElementById("custom-asset-error");
const jsErrorTitle = document.getElementById("js-error-title"); 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 attachmentList = document.getElementById("custom-asset-attachment-list");
const attachmentHint = document.getElementById("custom-asset-attachment-hint"); const attachmentHint = document.getElementById("custom-asset-attachment-hint");
let currentAssetId = null; let currentAssetId = null;
let pendingLogoFile = null;
let logoRemoved = false;
let attachmentState = []; let attachmentState = [];
let marketplaceEntries = [];
const resetErrors = () => { const resetErrors = () => {
if (formErrorWrapper) { 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 = () => { const openModal = () => {
assetModal?.classList.remove("hidden"); assetModal?.classList.remove("hidden");
}; };
@@ -34,9 +79,17 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
}; };
const openNew = () => { const openNew = () => {
closeLaunchModal();
if (userNameInput) { if (userNameInput) {
userNameInput.value = ""; userNameInput.value = "";
} }
if (descriptionInput) {
descriptionInput.value = "";
}
if (publicCheckbox) {
publicCheckbox.checked = false;
}
resetLogoState();
if (userSourceTextArea) { if (userSourceTextArea) {
userSourceTextArea.value = ""; userSourceTextArea.value = "";
userSourceTextArea.disabled = false; userSourceTextArea.disabled = false;
@@ -57,6 +110,19 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
if (userNameInput) { if (userNameInput) {
userNameInput.value = asset.name || ""; 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) { if (userSourceTextArea) {
userSourceTextArea.value = ""; userSourceTextArea.value = "";
userSourceTextArea.placeholder = "Loading script..."; userSourceTextArea.placeholder = "Loading script...";
@@ -119,18 +185,28 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
return false; return false;
} }
const assetId = userSourceTextArea?.dataset?.assetId; const assetId = userSourceTextArea?.dataset?.assetId;
const description = descriptionInput?.value?.trim();
const isPublic = !!publicCheckbox?.checked;
const submitButton = formEvent.currentTarget?.querySelector('button[type="submit"]'); const submitButton = formEvent.currentTarget?.querySelector('button[type="submit"]');
if (submitButton) { if (submitButton) {
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = "Saving..."; submitButton.textContent = "Saving...";
} }
saveCodeAsset({ name, src, assetId }) saveCodeAsset({ name, src, assetId, description, isPublic })
.then((asset) => { .then((asset) => {
if (asset) { if (asset) {
onAssetSaved?.(asset); return syncLogoChanges(asset).then((updated) => {
onAssetSaved?.(updated || asset);
return updated || asset;
});
} }
return null;
})
.then((asset) => {
closeModal(); closeModal();
if (asset) {
showToast?.(assetId ? "Custom asset updated." : "Custom asset created.", "success"); showToast?.(assetId ? "Custom asset updated." : "Custom asset created.", "success");
}
}) })
.catch((e) => { .catch((e) => {
showToast?.("Unable to save custom asset. Please try again.", "error"); showToast?.("Unable to save custom asset. Please try again.", "error");
@@ -145,6 +221,29 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
return false; 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) { if (assetModal) {
assetModal.addEventListener("click", (event) => { assetModal.addEventListener("click", (event) => {
if (event.target === assetModal) { if (event.target === assetModal) {
@@ -158,6 +257,36 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
if (cancelButton) { if (cancelButton) {
cancelButton.addEventListener("click", () => closeModal()); 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) { if (attachmentInput) {
attachmentInput.addEventListener("change", (event) => { attachmentInput.addEventListener("change", (event) => {
const file = event.target?.files?.[0]; 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) { function setAttachmentState(assetId, attachments) {
currentAssetId = assetId || null; currentAssetId = assetId || null;
@@ -300,8 +429,13 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
}); });
} }
function saveCodeAsset({ name, src, assetId }) { function saveCodeAsset({ name, src, assetId, description, isPublic }) {
const payload = { name, source: src }; const payload = {
name,
source: src,
description: description || null,
isPublic,
};
const method = assetId ? "PUT" : "POST"; const method = assetId ? "PUT" : "POST";
const url = assetId const url = assetId
? `/api/channels/${encodeURIComponent(broadcaster)}/assets/${assetId}/code` ? `/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) { function getUserJavaScriptSourceError(src) {
let ast; let ast;

View File

@@ -368,6 +368,47 @@
</section> </section>
</div> </div>
</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"> <div id="custom-asset-modal" class="modal hidden">
<section class="modal-inner"> <section class="modal-inner">
<h1>Create Custom Asset</h1> <h1>Create Custom Asset</h1>
@@ -382,6 +423,39 @@
required required
/> />
</div> </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"> <div class="form-group">
<label for="custom-asset-type">Asset code</label> <label for="custom-asset-type">Asset code</label>
<textarea <textarea
@@ -434,6 +508,7 @@
const username = /*[[${username}]]*/ ""; const username = /*[[${username}]]*/ "";
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0; const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
const SETTINGS = JSON.parse(/*[[${settingsJson}]]*/); const SETTINGS = JSON.parse(/*[[${settingsJson}]]*/);
const ADMIN_CHANNELS = /*[[${adminChannels}]]*/ [];
</script> </script>
<script src="/js/cookie-consent.js"></script> <script src="/js/cookie-consent.js"></script>
<script src="/js/toast.js"></script> <script src="/js/toast.js"></script>