mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add script asset sub-assets
This commit is contained in:
@@ -134,6 +134,18 @@ public class SchemaMigration implements ApplicationRunner {
|
||||
)
|
||||
"""
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS script_asset_attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
script_asset_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
media_type TEXT,
|
||||
original_media_type TEXT,
|
||||
asset_type TEXT
|
||||
)
|
||||
"""
|
||||
);
|
||||
backfillAssetTypes(assetColumns);
|
||||
} catch (DataAccessException ex) {
|
||||
logger.warn("Unable to ensure asset type tables", ex);
|
||||
|
||||
@@ -10,6 +10,7 @@ import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView;
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
@@ -387,6 +388,33 @@ public class ChannelApiController {
|
||||
.orElseThrow(() -> createAsset404());
|
||||
}
|
||||
|
||||
@GetMapping("/script-assets/{assetId}/attachments/{attachmentId}/content")
|
||||
public ResponseEntity<byte[]> getScriptAttachmentContent(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@PathVariable("attachmentId") String attachmentId
|
||||
) {
|
||||
String logBroadcaster = LogSanitizer.sanitize(broadcaster);
|
||||
String logAssetId = LogSanitizer.sanitize(assetId);
|
||||
String logAttachmentId = LogSanitizer.sanitize(attachmentId);
|
||||
LOG.debug(
|
||||
"Serving script attachment {} for asset {} for broadcaster {}",
|
||||
logAttachmentId,
|
||||
logAssetId,
|
||||
logBroadcaster
|
||||
);
|
||||
return channelDirectoryService
|
||||
.getScriptAttachmentContent(broadcaster, assetId, attachmentId)
|
||||
.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,
|
||||
@@ -439,6 +467,65 @@ public class ChannelApiController {
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/assets/{assetId}/attachments")
|
||||
public Collection<ScriptAssetAttachmentView> listScriptAttachments(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
return channelDirectoryService.listScriptAttachments(broadcaster, assetId);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/assets/{assetId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<ScriptAssetAttachmentView> createScriptAttachment(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@org.springframework.web.bind.annotation.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, "Attachment file is required");
|
||||
}
|
||||
try {
|
||||
return channelDirectoryService
|
||||
.createScriptAttachment(broadcaster, assetId, file)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to save attachment"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to process attachment upload for {} by {}", broadcaster, sessionUsername, e);
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Failed to process attachment", e);
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/assets/{assetId}/attachments/{attachmentId}")
|
||||
public ResponseEntity<Void> deleteScriptAttachment(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("assetId") String assetId,
|
||||
@PathVariable("attachmentId") String attachmentId,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(
|
||||
broadcaster,
|
||||
sessionUsername
|
||||
);
|
||||
boolean removed = channelDirectoryService.deleteScriptAttachment(broadcaster, assetId, attachmentId);
|
||||
if (!removed) {
|
||||
throw createAsset404();
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
private ResponseStatusException createAsset404() {
|
||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record AssetView(
|
||||
String id,
|
||||
@@ -18,6 +19,7 @@ public record AssetView(
|
||||
String mediaType,
|
||||
String originalMediaType,
|
||||
AssetType assetType,
|
||||
List<ScriptAssetAttachmentView> scriptAttachments,
|
||||
Integer zIndex,
|
||||
Boolean audioLoop,
|
||||
Integer audioDelayMillis,
|
||||
@@ -47,6 +49,7 @@ public record AssetView(
|
||||
visual.getMediaType(),
|
||||
visual.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
null,
|
||||
visual.getZIndex(),
|
||||
null,
|
||||
null,
|
||||
@@ -78,6 +81,7 @@ public record AssetView(
|
||||
audio.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
null,
|
||||
null,
|
||||
audio.isAudioLoop(),
|
||||
audio.getAudioDelayMillis(),
|
||||
audio.getAudioSpeed(),
|
||||
@@ -107,6 +111,7 @@ public record AssetView(
|
||||
script.getMediaType(),
|
||||
script.getOriginalMediaType(),
|
||||
asset.getAssetType(),
|
||||
script.getAttachments(),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
|
||||
@@ -6,6 +6,8 @@ import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Transient;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "script_assets")
|
||||
@@ -20,6 +22,9 @@ public class ScriptAsset {
|
||||
private String mediaType;
|
||||
private String originalMediaType;
|
||||
|
||||
@Transient
|
||||
private List<ScriptAssetAttachmentView> attachments = List.of();
|
||||
|
||||
public ScriptAsset() {}
|
||||
|
||||
public ScriptAsset(String assetId, String name) {
|
||||
@@ -66,4 +71,12 @@ public class ScriptAsset {
|
||||
public void setOriginalMediaType(String originalMediaType) {
|
||||
this.originalMediaType = originalMediaType;
|
||||
}
|
||||
|
||||
public List<ScriptAssetAttachmentView> getAttachments() {
|
||||
return attachments == null ? List.of() : attachments;
|
||||
}
|
||||
|
||||
public void setAttachments(List<ScriptAssetAttachmentView> attachments) {
|
||||
this.attachments = attachments;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
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.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "script_asset_attachments")
|
||||
public class ScriptAssetAttachment {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(name = "script_asset_id", nullable = false)
|
||||
private String scriptAssetId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
private String mediaType;
|
||||
private String originalMediaType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "asset_type", nullable = false)
|
||||
private AssetType assetType;
|
||||
|
||||
public ScriptAssetAttachment() {}
|
||||
|
||||
public ScriptAssetAttachment(String scriptAssetId, String name) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
this.scriptAssetId = scriptAssetId;
|
||||
this.name = name;
|
||||
this.assetType = AssetType.OTHER;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
public void prepare() {
|
||||
if (this.id == null) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
}
|
||||
if (this.name == null || this.name.isBlank()) {
|
||||
this.name = this.id;
|
||||
}
|
||||
if (this.assetType == null) {
|
||||
this.assetType = AssetType.OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getScriptAssetId() {
|
||||
return scriptAssetId;
|
||||
}
|
||||
|
||||
public void setScriptAssetId(String scriptAssetId) {
|
||||
this.scriptAssetId = scriptAssetId;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.kruhlmann.imgfloat.model;
|
||||
|
||||
public record ScriptAssetAttachmentView(
|
||||
String id,
|
||||
String scriptAssetId,
|
||||
String name,
|
||||
String url,
|
||||
String mediaType,
|
||||
String originalMediaType,
|
||||
AssetType assetType
|
||||
) {
|
||||
public static ScriptAssetAttachmentView fromAttachment(String broadcaster, ScriptAssetAttachment attachment) {
|
||||
if (attachment == null) {
|
||||
return null;
|
||||
}
|
||||
return new ScriptAssetAttachmentView(
|
||||
attachment.getId(),
|
||||
attachment.getScriptAssetId(),
|
||||
attachment.getName(),
|
||||
"/api/channels/" + broadcaster + "/script-assets/" + attachment.getScriptAssetId() + "/attachments/" +
|
||||
attachment.getId() +
|
||||
"/content",
|
||||
attachment.getMediaType(),
|
||||
attachment.getOriginalMediaType(),
|
||||
attachment.getAssetType()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ScriptAssetAttachmentRepository extends JpaRepository<ScriptAssetAttachment, String> {
|
||||
List<ScriptAssetAttachment> findByScriptAssetId(String scriptAssetId);
|
||||
|
||||
List<ScriptAssetAttachment> findByScriptAssetIdIn(Collection<String> scriptAssetIds);
|
||||
|
||||
void deleteByScriptAssetId(String scriptAssetId);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.Asset;
|
||||
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachment;
|
||||
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
@@ -19,10 +21,16 @@ public class AssetCleanupService {
|
||||
|
||||
private final AssetRepository assetRepository;
|
||||
private final AssetStorageService assetStorageService;
|
||||
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||
|
||||
public AssetCleanupService(AssetRepository assetRepository, AssetStorageService assetStorageService) {
|
||||
public AssetCleanupService(
|
||||
AssetRepository assetRepository,
|
||||
AssetStorageService assetStorageService,
|
||||
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository
|
||||
) {
|
||||
this.assetRepository = assetRepository;
|
||||
this.assetStorageService = assetStorageService;
|
||||
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
|
||||
}
|
||||
|
||||
@Async
|
||||
@@ -31,7 +39,18 @@ public class AssetCleanupService {
|
||||
public void cleanup() {
|
||||
logger.info("Collecting referenced assets");
|
||||
|
||||
Set<String> referencedIds = assetRepository.findAll().stream().map(Asset::getId).collect(Collectors.toSet());
|
||||
Set<String> referencedIds = assetRepository
|
||||
.findAll()
|
||||
.stream()
|
||||
.map(Asset::getId)
|
||||
.collect(Collectors.toSet());
|
||||
referencedIds.addAll(
|
||||
scriptAssetAttachmentRepository
|
||||
.findAll()
|
||||
.stream()
|
||||
.map(ScriptAssetAttachment::getId)
|
||||
.collect(Collectors.toSet())
|
||||
);
|
||||
|
||||
assetStorageService.deleteOrphanedAssets(referencedIds);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import dev.kruhlmann.imgfloat.model.Channel;
|
||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
||||
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.Settings;
|
||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||
@@ -23,6 +25,7 @@ import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||
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.VisualAssetRepository;
|
||||
import dev.kruhlmann.imgfloat.service.media.AssetContent;
|
||||
import dev.kruhlmann.imgfloat.service.media.MediaDetectionService;
|
||||
@@ -53,6 +56,7 @@ public class ChannelDirectoryService {
|
||||
private final VisualAssetRepository visualAssetRepository;
|
||||
private final AudioAssetRepository audioAssetRepository;
|
||||
private final ScriptAssetRepository scriptAssetRepository;
|
||||
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final AssetStorageService assetStorageService;
|
||||
private final MediaDetectionService mediaDetectionService;
|
||||
@@ -67,6 +71,7 @@ public class ChannelDirectoryService {
|
||||
VisualAssetRepository visualAssetRepository,
|
||||
AudioAssetRepository audioAssetRepository,
|
||||
ScriptAssetRepository scriptAssetRepository,
|
||||
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository,
|
||||
SimpMessagingTemplate messagingTemplate,
|
||||
AssetStorageService assetStorageService,
|
||||
MediaDetectionService mediaDetectionService,
|
||||
@@ -79,6 +84,7 @@ public class ChannelDirectoryService {
|
||||
this.visualAssetRepository = visualAssetRepository;
|
||||
this.audioAssetRepository = audioAssetRepository;
|
||||
this.scriptAssetRepository = scriptAssetRepository;
|
||||
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.assetStorageService = assetStorageService;
|
||||
this.mediaDetectionService = mediaDetectionService;
|
||||
@@ -220,6 +226,7 @@ public class ChannelDirectoryService {
|
||||
ScriptAsset script = new ScriptAsset(asset.getId(), safeName);
|
||||
script.setMediaType(optimized.mediaType());
|
||||
script.setOriginalMediaType(mediaType);
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
|
||||
} else {
|
||||
@@ -261,6 +268,7 @@ public class ChannelDirectoryService {
|
||||
ScriptAsset script = new ScriptAsset(asset.getId(), request.getName().trim());
|
||||
script.setOriginalMediaType(DEFAULT_CODE_MEDIA_TYPE);
|
||||
script.setMediaType(DEFAULT_CODE_MEDIA_TYPE);
|
||||
script.setAttachments(List.of());
|
||||
scriptAssetRepository.save(script);
|
||||
AssetView view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
||||
@@ -286,6 +294,7 @@ public class ChannelDirectoryService {
|
||||
script.setName(request.getName().trim());
|
||||
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);
|
||||
} catch (IOException e) {
|
||||
@@ -341,6 +350,7 @@ public class ChannelDirectoryService {
|
||||
ScriptAsset script = scriptAssetRepository
|
||||
.findById(asset.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
|
||||
return AssetView.fromScript(normalized, asset, script);
|
||||
}
|
||||
|
||||
@@ -487,6 +497,7 @@ public class ChannelDirectoryService {
|
||||
ScriptAsset script = scriptAssetRepository
|
||||
.findById(asset.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
|
||||
return AssetView.fromScript(normalized, asset, script);
|
||||
}
|
||||
|
||||
@@ -516,7 +527,10 @@ public class ChannelDirectoryService {
|
||||
deleteAssetStorage(asset);
|
||||
switch (asset.getAssetType()) {
|
||||
case AUDIO -> audioAssetRepository.deleteById(asset.getId());
|
||||
case SCRIPT -> scriptAssetRepository.deleteById(asset.getId());
|
||||
case SCRIPT -> {
|
||||
scriptAssetAttachmentRepository.deleteByScriptAssetId(asset.getId());
|
||||
scriptAssetRepository.deleteById(asset.getId());
|
||||
}
|
||||
default -> visualAssetRepository.deleteById(asset.getId());
|
||||
}
|
||||
assetRepository.delete(asset);
|
||||
@@ -533,6 +547,119 @@ public class ChannelDirectoryService {
|
||||
return assetRepository.findById(assetId).flatMap(this::loadAssetContent);
|
||||
}
|
||||
|
||||
public List<ScriptAssetAttachmentView> listScriptAttachments(String broadcaster, String scriptAssetId) {
|
||||
Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId);
|
||||
return loadScriptAttachments(normalize(broadcaster), asset.getId(), null);
|
||||
}
|
||||
|
||||
public Optional<ScriptAssetAttachmentView> createScriptAttachment(
|
||||
String broadcaster,
|
||||
String scriptAssetId,
|
||||
MultipartFile file
|
||||
) throws IOException {
|
||||
long fileSize = file.getSize();
|
||||
if (fileSize > uploadLimitBytes) {
|
||||
throw new ResponseStatusException(
|
||||
PAYLOAD_TOO_LARGE,
|
||||
String.format(
|
||||
"Uploaded file is too large (%d bytes). Maximum allowed is %d bytes.",
|
||||
fileSize,
|
||||
uploadLimitBytes
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId);
|
||||
byte[] bytes = file.getBytes();
|
||||
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.AUDIO && assetType != AssetType.IMAGE && assetType != AssetType.VIDEO) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "Only image, video, or audio attachments are supported.");
|
||||
}
|
||||
|
||||
String safeName = Optional.ofNullable(file.getOriginalFilename())
|
||||
.map(this::sanitizeFilename)
|
||||
.filter((s) -> !s.isBlank())
|
||||
.orElse("script_attachment_" + System.currentTimeMillis());
|
||||
|
||||
ScriptAssetAttachment attachment = new ScriptAssetAttachment(asset.getId(), safeName);
|
||||
attachment.setMediaType(optimized.mediaType());
|
||||
attachment.setOriginalMediaType(mediaType);
|
||||
attachment.setAssetType(assetType);
|
||||
|
||||
assetStorageService.storeAsset(asset.getBroadcaster(), attachment.getId(), optimized.bytes(), optimized.mediaType());
|
||||
attachment = scriptAssetAttachmentRepository.save(attachment);
|
||||
ScriptAssetAttachmentView view = ScriptAssetAttachmentView.fromAttachment(asset.getBroadcaster(), attachment);
|
||||
|
||||
ScriptAsset script = scriptAssetRepository
|
||||
.findById(asset.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
|
||||
AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView));
|
||||
|
||||
return Optional.of(view);
|
||||
}
|
||||
|
||||
public boolean deleteScriptAttachment(String broadcaster, String scriptAssetId, String attachmentId) {
|
||||
Asset asset = requireScriptAssetForBroadcaster(broadcaster, scriptAssetId);
|
||||
ScriptAssetAttachment attachment = scriptAssetAttachmentRepository
|
||||
.findById(attachmentId)
|
||||
.filter((item) -> item.getScriptAssetId().equals(asset.getId()))
|
||||
.orElse(null);
|
||||
if (attachment == null) {
|
||||
return false;
|
||||
}
|
||||
assetStorageService.deleteAsset(
|
||||
asset.getBroadcaster(),
|
||||
attachment.getId(),
|
||||
attachment.getMediaType(),
|
||||
false
|
||||
);
|
||||
scriptAssetAttachmentRepository.deleteById(attachment.getId());
|
||||
|
||||
ScriptAsset script = scriptAssetRepository
|
||||
.findById(asset.getId())
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||
script.setAttachments(loadScriptAttachments(asset.getBroadcaster(), asset.getId(), null));
|
||||
AssetView scriptView = AssetView.fromScript(asset.getBroadcaster(), asset, script);
|
||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, scriptView));
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getScriptAttachmentContent(
|
||||
String broadcaster,
|
||||
String scriptAssetId,
|
||||
String attachmentId
|
||||
) {
|
||||
Asset asset = assetRepository
|
||||
.findById(scriptAssetId)
|
||||
.filter((stored) -> normalize(broadcaster).equals(stored.getBroadcaster()))
|
||||
.filter((stored) -> stored.getAssetType() == AssetType.SCRIPT)
|
||||
.orElse(null);
|
||||
if (asset == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return scriptAssetAttachmentRepository
|
||||
.findById(attachmentId)
|
||||
.filter((item) -> item.getScriptAssetId().equals(scriptAssetId))
|
||||
.flatMap((attachment) ->
|
||||
assetStorageService.loadAssetFileSafely(
|
||||
asset.getBroadcaster(),
|
||||
attachment.getId(),
|
||||
attachment.getMediaType()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
|
||||
return assetRepository.findById(assetId).flatMap((asset) -> loadAssetPreview(asset, includeHidden));
|
||||
}
|
||||
@@ -625,10 +752,24 @@ public class ChannelDirectoryService {
|
||||
.findByIdIn(scriptIds)
|
||||
.stream()
|
||||
.collect(Collectors.toMap(ScriptAsset::getId, (asset) -> asset));
|
||||
Map<String, List<ScriptAssetAttachmentView>> scriptAttachments = scriptIds.isEmpty()
|
||||
? Map.of()
|
||||
: Optional.ofNullable(scriptAssetAttachmentRepository.findByScriptAssetIdIn(scriptIds))
|
||||
.orElse(List.of())
|
||||
.stream()
|
||||
.collect(
|
||||
Collectors.groupingBy(
|
||||
ScriptAssetAttachment::getScriptAssetId,
|
||||
Collectors.mapping(
|
||||
(attachment) -> ScriptAssetAttachmentView.fromAttachment(broadcaster, attachment),
|
||||
Collectors.toList()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return assets
|
||||
.stream()
|
||||
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts))
|
||||
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts, scriptAttachments))
|
||||
.filter(Objects::nonNull)
|
||||
.sorted(
|
||||
Comparator.comparing((AssetView view) ->
|
||||
@@ -662,7 +803,7 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
|
||||
private AssetView resolveAssetView(String broadcaster, Asset asset) {
|
||||
return resolveAssetView(broadcaster, asset, null, null, null);
|
||||
return resolveAssetView(broadcaster, asset, null, null, null, null);
|
||||
}
|
||||
|
||||
private AssetView resolveAssetView(
|
||||
@@ -670,7 +811,8 @@ public class ChannelDirectoryService {
|
||||
Asset asset,
|
||||
Map<String, VisualAsset> visuals,
|
||||
Map<String, AudioAsset> audios,
|
||||
Map<String, ScriptAsset> scripts
|
||||
Map<String, ScriptAsset> scripts,
|
||||
Map<String, List<ScriptAssetAttachmentView>> scriptAttachments
|
||||
) {
|
||||
if (asset.getAssetType() == AssetType.AUDIO) {
|
||||
AudioAsset audio = audios != null
|
||||
@@ -682,6 +824,9 @@ public class ChannelDirectoryService {
|
||||
ScriptAsset script = scripts != null
|
||||
? scripts.get(asset.getId())
|
||||
: scriptAssetRepository.findById(asset.getId()).orElse(null);
|
||||
if (script != null) {
|
||||
script.setAttachments(loadScriptAttachments(broadcaster, asset.getId(), scriptAttachments));
|
||||
}
|
||||
return script == null ? null : AssetView.fromScript(broadcaster, asset, script);
|
||||
}
|
||||
VisualAsset visual = visuals != null
|
||||
@@ -728,6 +873,33 @@ public class ChannelDirectoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<ScriptAssetAttachmentView> loadScriptAttachments(
|
||||
String broadcaster,
|
||||
String scriptAssetId,
|
||||
Map<String, List<ScriptAssetAttachmentView>> scriptAttachments
|
||||
) {
|
||||
if (scriptAttachments != null) {
|
||||
return scriptAttachments.getOrDefault(scriptAssetId, List.of());
|
||||
}
|
||||
List<ScriptAssetAttachment> attachments = Optional.ofNullable(
|
||||
scriptAssetAttachmentRepository.findByScriptAssetId(scriptAssetId)
|
||||
)
|
||||
.orElse(List.of());
|
||||
return attachments
|
||||
.stream()
|
||||
.map((attachment) -> ScriptAssetAttachmentView.fromAttachment(broadcaster, attachment))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private Asset requireScriptAssetForBroadcaster(String broadcaster, String scriptAssetId) {
|
||||
String normalized = normalize(broadcaster);
|
||||
return assetRepository
|
||||
.findById(scriptAssetId)
|
||||
.filter((asset) -> normalized.equals(asset.getBroadcaster()))
|
||||
.filter((asset) -> asset.getAssetType() == AssetType.SCRIPT)
|
||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Asset is not a script"));
|
||||
}
|
||||
|
||||
private Optional<AssetContent> loadAssetPreview(Asset asset, boolean includeHidden) {
|
||||
if (
|
||||
asset.getAssetType() != AssetType.VIDEO &&
|
||||
@@ -757,9 +929,19 @@ public class ChannelDirectoryService {
|
||||
);
|
||||
case SCRIPT -> scriptAssetRepository
|
||||
.findById(asset.getId())
|
||||
.ifPresent((script) ->
|
||||
assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), script.getMediaType(), false)
|
||||
);
|
||||
.ifPresent((script) -> {
|
||||
assetStorageService.deleteAsset(asset.getBroadcaster(), asset.getId(), script.getMediaType(), false);
|
||||
scriptAssetAttachmentRepository
|
||||
.findByScriptAssetId(asset.getId())
|
||||
.forEach((attachment) ->
|
||||
assetStorageService.deleteAsset(
|
||||
asset.getBroadcaster(),
|
||||
attachment.getId(),
|
||||
attachment.getMediaType(),
|
||||
false
|
||||
)
|
||||
);
|
||||
});
|
||||
default -> visualAssetRepository
|
||||
.findById(asset.getId())
|
||||
.ifPresent((visual) ->
|
||||
|
||||
Binary file not shown.
@@ -49,3 +49,54 @@
|
||||
border: 1px solid #a00;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-actions .file-input-trigger.small {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 6px;
|
||||
background-color: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-meta span {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-actions-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal .modal-inner .attachment-empty {
|
||||
color: rgba(226, 232, 240, 0.7);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ export class BroadcastRenderer {
|
||||
const wasExisting = this.state.assets.has(asset.id);
|
||||
this.state.assets.set(asset.id, asset);
|
||||
ensureLayerPosition(this.state, asset.id, placement);
|
||||
if (isCodeAsset(asset)) {
|
||||
this.updateScriptWorkerAttachments(asset);
|
||||
}
|
||||
if (!wasExisting && !this.state.visibilityStates.has(asset.id)) {
|
||||
const initialAlpha = 0; // Fade in newly discovered assets
|
||||
this.state.visibilityStates.set(asset.id, {
|
||||
@@ -484,6 +487,20 @@ export class BroadcastRenderer {
|
||||
payload: {
|
||||
id: asset.id,
|
||||
source: assetSource,
|
||||
attachments: asset.scriptAttachments || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateScriptWorkerAttachments(asset) {
|
||||
if (!this.scriptWorker || !this.scriptWorkerReady || !asset?.id) {
|
||||
return;
|
||||
}
|
||||
this.scriptWorker.postMessage({
|
||||
type: "updateAttachments",
|
||||
payload: {
|
||||
id: asset.id,
|
||||
attachments: asset.scriptAttachments || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const scripts = new Map();
|
||||
const allowedFetchUrls = new Set();
|
||||
let canvas = null;
|
||||
let ctx = null;
|
||||
let channelName = "";
|
||||
@@ -9,9 +10,18 @@ const tickIntervalMs = 1000 / 60;
|
||||
const errorKeys = new Set();
|
||||
|
||||
function disableNetworkApis() {
|
||||
const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
|
||||
const blockedApis = {
|
||||
fetch: () => {
|
||||
throw new Error("Network access is disabled in asset scripts.");
|
||||
fetch: (...args) => {
|
||||
if (!nativeFetch) {
|
||||
throw new Error("Network access is disabled in asset scripts.");
|
||||
}
|
||||
const request = new Request(...args);
|
||||
const url = normalizeUrl(request.url);
|
||||
if (!allowedFetchUrls.has(url)) {
|
||||
throw new Error("Network access is disabled in asset scripts.");
|
||||
}
|
||||
return nativeFetch(request);
|
||||
},
|
||||
XMLHttpRequest: undefined,
|
||||
WebSocket: undefined,
|
||||
@@ -43,6 +53,32 @@ function disableNetworkApis() {
|
||||
|
||||
disableNetworkApis();
|
||||
|
||||
function normalizeUrl(url) {
|
||||
try {
|
||||
return new URL(url, self.location?.href || "http://localhost").toString();
|
||||
} catch (_error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAllowedFetchUrls() {
|
||||
allowedFetchUrls.clear();
|
||||
scripts.forEach((script) => {
|
||||
const assets = script?.context?.assets;
|
||||
if (!Array.isArray(assets)) {
|
||||
return;
|
||||
}
|
||||
assets.forEach((asset) => {
|
||||
if (asset?.url) {
|
||||
const normalized = normalizeUrl(asset.url);
|
||||
if (normalized) {
|
||||
allowedFetchUrls.add(normalized);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function reportScriptError(id, stage, error) {
|
||||
if (!id) {
|
||||
return;
|
||||
@@ -115,7 +151,8 @@ function stopTickLoopIfIdle() {
|
||||
}
|
||||
|
||||
function createScriptHandlers(source, context, state, sourceLabel = "") {
|
||||
const contextPrelude = "const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs } = context;";
|
||||
const contextPrelude =
|
||||
"const { canvas, ctx, channelName, width, height, now, deltaMs, elapsedMs, assets } = context;";
|
||||
const sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
|
||||
const factory = new Function(
|
||||
"context",
|
||||
@@ -172,6 +209,7 @@ self.addEventListener("message", (event) => {
|
||||
now: 0,
|
||||
deltaMs: 0,
|
||||
elapsedMs: 0,
|
||||
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
|
||||
};
|
||||
let handlers = {};
|
||||
try {
|
||||
@@ -189,6 +227,7 @@ self.addEventListener("message", (event) => {
|
||||
tick: handlers.tick,
|
||||
};
|
||||
scripts.set(payload.id, script);
|
||||
refreshAllowedFetchUrls();
|
||||
if (script.init) {
|
||||
try {
|
||||
script.init(script.context, script.state);
|
||||
@@ -206,6 +245,19 @@ self.addEventListener("message", (event) => {
|
||||
return;
|
||||
}
|
||||
scripts.delete(payload.id);
|
||||
refreshAllowedFetchUrls();
|
||||
stopTickLoopIfIdle();
|
||||
}
|
||||
|
||||
if (type === "updateAttachments") {
|
||||
if (!payload?.id) {
|
||||
return;
|
||||
}
|
||||
const script = scripts.get(payload.id);
|
||||
if (!script) {
|
||||
return;
|
||||
}
|
||||
script.context.assets = Array.isArray(payload.attachments) ? payload.attachments : [];
|
||||
refreshAllowedFetchUrls();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
|
||||
const jsErrorDetails = document.getElementById("js-error-details");
|
||||
const form = document.getElementById("custom-asset-form");
|
||||
const cancelButton = document.getElementById("custom-asset-cancel");
|
||||
const attachmentInput = document.getElementById("custom-asset-attachment-file");
|
||||
const attachmentList = document.getElementById("custom-asset-attachment-list");
|
||||
const attachmentHint = document.getElementById("custom-asset-attachment-hint");
|
||||
let currentAssetId = null;
|
||||
let attachmentState = [];
|
||||
|
||||
const resetErrors = () => {
|
||||
if (formErrorWrapper) {
|
||||
@@ -37,8 +42,9 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
|
||||
userSourceTextArea.disabled = false;
|
||||
userSourceTextArea.dataset.assetId = "";
|
||||
userSourceTextArea.placeholder =
|
||||
"function init(context, state) {\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};";
|
||||
"function init(context, state) {\n const { assets } = context;\n\n}\n\nfunction tick(context, state) {\n\n}\n\n// or\n// module.exports.init = (context, state) => {};\n// module.exports.tick = (context, state) => {};";
|
||||
}
|
||||
setAttachmentState(null, []);
|
||||
resetErrors();
|
||||
openModal();
|
||||
};
|
||||
@@ -57,6 +63,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
|
||||
userSourceTextArea.disabled = true;
|
||||
userSourceTextArea.dataset.assetId = asset.id;
|
||||
}
|
||||
setAttachmentState(asset.id, asset.scriptAttachments || []);
|
||||
openModal();
|
||||
|
||||
fetch(asset.url)
|
||||
@@ -151,9 +158,148 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
|
||||
if (cancelButton) {
|
||||
cancelButton.addEventListener("click", () => closeModal());
|
||||
}
|
||||
if (attachmentInput) {
|
||||
attachmentInput.addEventListener("change", (event) => {
|
||||
const file = event.target?.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (!currentAssetId) {
|
||||
showToast?.("Save the script before adding attachments.", "info");
|
||||
attachmentInput.value = "";
|
||||
return;
|
||||
}
|
||||
uploadAttachment(file)
|
||||
.then((attachment) => {
|
||||
if (attachment) {
|
||||
attachmentState = [...attachmentState, attachment];
|
||||
renderAttachmentList();
|
||||
showToast?.("Attachment added.", "success");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showToast?.("Unable to upload attachment. Please try again.", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
attachmentInput.value = "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { openNew, openEditor };
|
||||
|
||||
function setAttachmentState(assetId, attachments) {
|
||||
currentAssetId = assetId || null;
|
||||
attachmentState = Array.isArray(attachments) ? [...attachments] : [];
|
||||
renderAttachmentList();
|
||||
}
|
||||
|
||||
function renderAttachmentList() {
|
||||
if (!attachmentList) {
|
||||
return;
|
||||
}
|
||||
attachmentList.innerHTML = "";
|
||||
if (!currentAssetId) {
|
||||
if (attachmentInput) {
|
||||
attachmentInput.disabled = true;
|
||||
}
|
||||
if (attachmentHint) {
|
||||
attachmentHint.textContent = "Save the script before adding attachments.";
|
||||
}
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "attachment-empty";
|
||||
empty.textContent = "Attachments will appear here once the script is saved.";
|
||||
attachmentList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
if (attachmentInput) {
|
||||
attachmentInput.disabled = false;
|
||||
}
|
||||
if (attachmentHint) {
|
||||
attachmentHint.textContent =
|
||||
"Attachments are available to this script only and are not visible on the canvas.";
|
||||
}
|
||||
if (!attachmentState.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "attachment-empty";
|
||||
empty.textContent = "No attachments yet.";
|
||||
attachmentList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
attachmentState.forEach((attachment) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "attachment-item";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "attachment-meta";
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = attachment.name || "Untitled";
|
||||
const type = document.createElement("span");
|
||||
type.textContent = attachment.assetType || attachment.mediaType || "Attachment";
|
||||
meta.appendChild(name);
|
||||
meta.appendChild(type);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "attachment-actions-row";
|
||||
if (attachment.url) {
|
||||
const link = document.createElement("a");
|
||||
link.href = attachment.url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener";
|
||||
link.className = "button ghost";
|
||||
link.textContent = "Open";
|
||||
actions.appendChild(link);
|
||||
}
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "secondary danger";
|
||||
remove.textContent = "Remove";
|
||||
remove.addEventListener("click", () => removeAttachment(attachment.id));
|
||||
actions.appendChild(remove);
|
||||
|
||||
item.appendChild(meta);
|
||||
item.appendChild(actions);
|
||||
attachmentList.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function uploadAttachment(file) {
|
||||
const payload = new FormData();
|
||||
payload.append("file", file);
|
||||
return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments`, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload attachment");
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
}
|
||||
|
||||
function removeAttachment(attachmentId) {
|
||||
if (!attachmentId || !currentAssetId) {
|
||||
return;
|
||||
}
|
||||
fetch(
|
||||
`/api/channels/${encodeURIComponent(broadcaster)}/assets/${currentAssetId}/attachments/${attachmentId}`,
|
||||
{ method: "DELETE" },
|
||||
)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete attachment");
|
||||
}
|
||||
attachmentState = attachmentState.filter((attachment) => attachment.id !== attachmentId);
|
||||
renderAttachmentList();
|
||||
showToast?.("Attachment removed.", "success");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
showToast?.("Unable to remove attachment. Please try again.", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function saveCodeAsset({ name, src, assetId }) {
|
||||
const payload = { name, source: src };
|
||||
const method = assetId ? "PUT" : "POST";
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
<textarea
|
||||
class="text-input"
|
||||
id="custom-asset-code"
|
||||
placeholder="exports.init = ({ surface, assets, channel }) => { }; exports.tick = () => { };"
|
||||
placeholder="exports.init = (context) => { const { assets } = context; }; exports.tick = () => { };"
|
||||
rows="25"
|
||||
required
|
||||
></textarea>
|
||||
@@ -395,6 +395,29 @@
|
||||
By submitting your script, you agree to release it under the MIT License to the public.
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Script attachments</label>
|
||||
<div class="attachment-actions">
|
||||
<input
|
||||
id="custom-asset-attachment-file"
|
||||
class="file-input-field"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
/>
|
||||
<label for="custom-asset-attachment-file" class="file-input-trigger small">
|
||||
<span class="file-input-icon"><i class="fa-solid fa-paperclip"></i></span>
|
||||
<span class="file-input-copy">
|
||||
<strong>Add attachment</strong>
|
||||
<small>Images, video, or audio</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="field-note" id="custom-asset-attachment-hint">
|
||||
Attachments are stored with the script and are available for scripts to render. Save the
|
||||
script before adding attachments.
|
||||
</p>
|
||||
<ul id="custom-asset-attachment-list" class="attachment-list"></ul>
|
||||
</div>
|
||||
<div class="form-error hidden" id="custom-asset-error">
|
||||
<strong>JavaScript error: <span id="js-error-title"></span></strong>
|
||||
<pre id="js-error-details"></pre>
|
||||
|
||||
Reference in New Issue
Block a user