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 (
|
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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
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));
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
List<ScriptAssetAttachment> findByScriptAssetIdIn(Collection<String> scriptAssetIds);
|
||||||
|
|
||||||
void deleteByScriptAssetId(String scriptAssetId);
|
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> {
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user