mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Asset marketplace
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public record ScriptMarketplaceEntry(
|
||||
String id,
|
||||
String name,
|
||||
String description,
|
||||
String logoUrl,
|
||||
String broadcaster
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,6 @@ public interface ScriptAssetAttachmentRepository extends JpaRepository<ScriptAss
|
||||
List<ScriptAssetAttachment> findByScriptAssetIdIn(Collection<String> scriptAssetIds);
|
||||
|
||||
void deleteByScriptAssetId(String scriptAssetId);
|
||||
|
||||
long countByFileId(String fileId);
|
||||
}
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user