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);
|
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);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import dev.kruhlmann.imgfloat.model.CanvasSettingsRequest;
|
|||||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
||||||
|
import dev.kruhlmann.imgfloat.model.ScriptAssetAttachmentView;
|
||||||
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
import dev.kruhlmann.imgfloat.model.TransformRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
import dev.kruhlmann.imgfloat.model.TwitchUserProfile;
|
||||||
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
import dev.kruhlmann.imgfloat.model.VisibilityRequest;
|
||||||
@@ -387,6 +388,33 @@ public class ChannelApiController {
|
|||||||
.orElseThrow(() -> createAsset404());
|
.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")
|
@GetMapping("/assets/{assetId}/preview")
|
||||||
public ResponseEntity<byte[]> getAssetPreview(
|
public ResponseEntity<byte[]> getAssetPreview(
|
||||||
@PathVariable("broadcaster") String broadcaster,
|
@PathVariable("broadcaster") String broadcaster,
|
||||||
@@ -439,6 +467,65 @@ public class ChannelApiController {
|
|||||||
return ResponseEntity.ok().build();
|
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() {
|
private ResponseStatusException createAsset404() {
|
||||||
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
return new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.kruhlmann.imgfloat.model;
|
package dev.kruhlmann.imgfloat.model;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public record AssetView(
|
public record AssetView(
|
||||||
String id,
|
String id,
|
||||||
@@ -18,6 +19,7 @@ public record AssetView(
|
|||||||
String mediaType,
|
String mediaType,
|
||||||
String originalMediaType,
|
String originalMediaType,
|
||||||
AssetType assetType,
|
AssetType assetType,
|
||||||
|
List<ScriptAssetAttachmentView> scriptAttachments,
|
||||||
Integer zIndex,
|
Integer zIndex,
|
||||||
Boolean audioLoop,
|
Boolean audioLoop,
|
||||||
Integer audioDelayMillis,
|
Integer audioDelayMillis,
|
||||||
@@ -47,6 +49,7 @@ public record AssetView(
|
|||||||
visual.getMediaType(),
|
visual.getMediaType(),
|
||||||
visual.getOriginalMediaType(),
|
visual.getOriginalMediaType(),
|
||||||
asset.getAssetType(),
|
asset.getAssetType(),
|
||||||
|
null,
|
||||||
visual.getZIndex(),
|
visual.getZIndex(),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
@@ -78,6 +81,7 @@ public record AssetView(
|
|||||||
audio.getOriginalMediaType(),
|
audio.getOriginalMediaType(),
|
||||||
asset.getAssetType(),
|
asset.getAssetType(),
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
audio.isAudioLoop(),
|
audio.isAudioLoop(),
|
||||||
audio.getAudioDelayMillis(),
|
audio.getAudioDelayMillis(),
|
||||||
audio.getAudioSpeed(),
|
audio.getAudioSpeed(),
|
||||||
@@ -107,6 +111,7 @@ public record AssetView(
|
|||||||
script.getMediaType(),
|
script.getMediaType(),
|
||||||
script.getOriginalMediaType(),
|
script.getOriginalMediaType(),
|
||||||
asset.getAssetType(),
|
asset.getAssetType(),
|
||||||
|
script.getAttachments(),
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import jakarta.persistence.Id;
|
|||||||
import jakarta.persistence.PrePersist;
|
import jakarta.persistence.PrePersist;
|
||||||
import jakarta.persistence.PreUpdate;
|
import jakarta.persistence.PreUpdate;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
|
import jakarta.persistence.Transient;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "script_assets")
|
@Table(name = "script_assets")
|
||||||
@@ -20,6 +22,9 @@ public class ScriptAsset {
|
|||||||
private String mediaType;
|
private String mediaType;
|
||||||
private String originalMediaType;
|
private String originalMediaType;
|
||||||
|
|
||||||
|
@Transient
|
||||||
|
private List<ScriptAssetAttachmentView> attachments = List.of();
|
||||||
|
|
||||||
public ScriptAsset() {}
|
public ScriptAsset() {}
|
||||||
|
|
||||||
public ScriptAsset(String assetId, String name) {
|
public ScriptAsset(String assetId, String name) {
|
||||||
@@ -66,4 +71,12 @@ public class ScriptAsset {
|
|||||||
public void setOriginalMediaType(String originalMediaType) {
|
public void setOriginalMediaType(String originalMediaType) {
|
||||||
this.originalMediaType = 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;
|
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.repository.AssetRepository;
|
import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
||||||
|
import dev.kruhlmann.imgfloat.repository.ScriptAssetAttachmentRepository;
|
||||||
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;
|
||||||
@@ -19,10 +21,16 @@ public class AssetCleanupService {
|
|||||||
|
|
||||||
private final AssetRepository assetRepository;
|
private final AssetRepository assetRepository;
|
||||||
private final AssetStorageService assetStorageService;
|
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.assetRepository = assetRepository;
|
||||||
this.assetStorageService = assetStorageService;
|
this.assetStorageService = assetStorageService;
|
||||||
|
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
@@ -31,7 +39,18 @@ public class AssetCleanupService {
|
|||||||
public void cleanup() {
|
public void cleanup() {
|
||||||
logger.info("Collecting referenced assets");
|
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);
|
assetStorageService.deleteOrphanedAssets(referencedIds);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import dev.kruhlmann.imgfloat.model.Channel;
|
|||||||
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
import dev.kruhlmann.imgfloat.model.CodeAssetRequest;
|
||||||
import dev.kruhlmann.imgfloat.model.PlaybackRequest;
|
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.ScriptAssetAttachmentView;
|
||||||
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;
|
||||||
@@ -23,6 +25,7 @@ import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
|||||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
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.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;
|
||||||
@@ -53,6 +56,7 @@ public class ChannelDirectoryService {
|
|||||||
private final VisualAssetRepository visualAssetRepository;
|
private final VisualAssetRepository visualAssetRepository;
|
||||||
private final AudioAssetRepository audioAssetRepository;
|
private final AudioAssetRepository audioAssetRepository;
|
||||||
private final ScriptAssetRepository scriptAssetRepository;
|
private final ScriptAssetRepository scriptAssetRepository;
|
||||||
|
private final ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||||
private final SimpMessagingTemplate messagingTemplate;
|
private final SimpMessagingTemplate messagingTemplate;
|
||||||
private final AssetStorageService assetStorageService;
|
private final AssetStorageService assetStorageService;
|
||||||
private final MediaDetectionService mediaDetectionService;
|
private final MediaDetectionService mediaDetectionService;
|
||||||
@@ -67,6 +71,7 @@ public class ChannelDirectoryService {
|
|||||||
VisualAssetRepository visualAssetRepository,
|
VisualAssetRepository visualAssetRepository,
|
||||||
AudioAssetRepository audioAssetRepository,
|
AudioAssetRepository audioAssetRepository,
|
||||||
ScriptAssetRepository scriptAssetRepository,
|
ScriptAssetRepository scriptAssetRepository,
|
||||||
|
ScriptAssetAttachmentRepository scriptAssetAttachmentRepository,
|
||||||
SimpMessagingTemplate messagingTemplate,
|
SimpMessagingTemplate messagingTemplate,
|
||||||
AssetStorageService assetStorageService,
|
AssetStorageService assetStorageService,
|
||||||
MediaDetectionService mediaDetectionService,
|
MediaDetectionService mediaDetectionService,
|
||||||
@@ -79,6 +84,7 @@ public class ChannelDirectoryService {
|
|||||||
this.visualAssetRepository = visualAssetRepository;
|
this.visualAssetRepository = visualAssetRepository;
|
||||||
this.audioAssetRepository = audioAssetRepository;
|
this.audioAssetRepository = audioAssetRepository;
|
||||||
this.scriptAssetRepository = scriptAssetRepository;
|
this.scriptAssetRepository = scriptAssetRepository;
|
||||||
|
this.scriptAssetAttachmentRepository = scriptAssetAttachmentRepository;
|
||||||
this.messagingTemplate = messagingTemplate;
|
this.messagingTemplate = messagingTemplate;
|
||||||
this.assetStorageService = assetStorageService;
|
this.assetStorageService = assetStorageService;
|
||||||
this.mediaDetectionService = mediaDetectionService;
|
this.mediaDetectionService = mediaDetectionService;
|
||||||
@@ -220,6 +226,7 @@ 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.setAttachments(List.of());
|
||||||
scriptAssetRepository.save(script);
|
scriptAssetRepository.save(script);
|
||||||
view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
|
view = AssetView.fromScript(channel.getBroadcaster(), asset, script);
|
||||||
} else {
|
} else {
|
||||||
@@ -261,6 +268,7 @@ public class ChannelDirectoryService {
|
|||||||
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.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);
|
||||||
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
|
||||||
@@ -286,6 +294,7 @@ public class ChannelDirectoryService {
|
|||||||
script.setName(request.getName().trim());
|
script.setName(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.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
|
||||||
try {
|
try {
|
||||||
assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE);
|
assetStorageService.storeAsset(broadcaster, asset.getId(), bytes, DEFAULT_CODE_MEDIA_TYPE);
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
@@ -341,6 +350,7 @@ 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"));
|
||||||
|
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
|
||||||
return AssetView.fromScript(normalized, asset, script);
|
return AssetView.fromScript(normalized, asset, script);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,6 +497,7 @@ 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"));
|
||||||
|
script.setAttachments(loadScriptAttachments(normalized, asset.getId(), null));
|
||||||
return AssetView.fromScript(normalized, asset, script);
|
return AssetView.fromScript(normalized, asset, script);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +527,10 @@ public class ChannelDirectoryService {
|
|||||||
deleteAssetStorage(asset);
|
deleteAssetStorage(asset);
|
||||||
switch (asset.getAssetType()) {
|
switch (asset.getAssetType()) {
|
||||||
case AUDIO -> audioAssetRepository.deleteById(asset.getId());
|
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());
|
default -> visualAssetRepository.deleteById(asset.getId());
|
||||||
}
|
}
|
||||||
assetRepository.delete(asset);
|
assetRepository.delete(asset);
|
||||||
@@ -533,6 +547,119 @@ public class ChannelDirectoryService {
|
|||||||
return assetRepository.findById(assetId).flatMap(this::loadAssetContent);
|
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) {
|
public Optional<AssetContent> getAssetPreview(String assetId, boolean includeHidden) {
|
||||||
return assetRepository.findById(assetId).flatMap((asset) -> loadAssetPreview(asset, includeHidden));
|
return assetRepository.findById(assetId).flatMap((asset) -> loadAssetPreview(asset, includeHidden));
|
||||||
}
|
}
|
||||||
@@ -625,10 +752,24 @@ public class ChannelDirectoryService {
|
|||||||
.findByIdIn(scriptIds)
|
.findByIdIn(scriptIds)
|
||||||
.stream()
|
.stream()
|
||||||
.collect(Collectors.toMap(ScriptAsset::getId, (asset) -> asset));
|
.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
|
return assets
|
||||||
.stream()
|
.stream()
|
||||||
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts))
|
.map((asset) -> resolveAssetView(broadcaster, asset, visuals, audios, scripts, scriptAttachments))
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.sorted(
|
.sorted(
|
||||||
Comparator.comparing((AssetView view) ->
|
Comparator.comparing((AssetView view) ->
|
||||||
@@ -662,7 +803,7 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private AssetView resolveAssetView(String broadcaster, Asset asset) {
|
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(
|
private AssetView resolveAssetView(
|
||||||
@@ -670,7 +811,8 @@ public class ChannelDirectoryService {
|
|||||||
Asset asset,
|
Asset asset,
|
||||||
Map<String, VisualAsset> visuals,
|
Map<String, VisualAsset> visuals,
|
||||||
Map<String, AudioAsset> audios,
|
Map<String, AudioAsset> audios,
|
||||||
Map<String, ScriptAsset> scripts
|
Map<String, ScriptAsset> scripts,
|
||||||
|
Map<String, List<ScriptAssetAttachmentView>> scriptAttachments
|
||||||
) {
|
) {
|
||||||
if (asset.getAssetType() == AssetType.AUDIO) {
|
if (asset.getAssetType() == AssetType.AUDIO) {
|
||||||
AudioAsset audio = audios != null
|
AudioAsset audio = audios != null
|
||||||
@@ -682,6 +824,9 @@ public class ChannelDirectoryService {
|
|||||||
ScriptAsset script = scripts != null
|
ScriptAsset script = scripts != null
|
||||||
? scripts.get(asset.getId())
|
? scripts.get(asset.getId())
|
||||||
: scriptAssetRepository.findById(asset.getId()).orElse(null);
|
: 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);
|
return script == null ? null : AssetView.fromScript(broadcaster, asset, script);
|
||||||
}
|
}
|
||||||
VisualAsset visual = visuals != null
|
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) {
|
private Optional<AssetContent> loadAssetPreview(Asset asset, boolean includeHidden) {
|
||||||
if (
|
if (
|
||||||
asset.getAssetType() != AssetType.VIDEO &&
|
asset.getAssetType() != AssetType.VIDEO &&
|
||||||
@@ -757,9 +929,19 @@ 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)
|
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
|
default -> visualAssetRepository
|
||||||
.findById(asset.getId())
|
.findById(asset.getId())
|
||||||
.ifPresent((visual) ->
|
.ifPresent((visual) ->
|
||||||
|
|||||||
Binary file not shown.
@@ -49,3 +49,54 @@
|
|||||||
border: 1px solid #a00;
|
border: 1px solid #a00;
|
||||||
border-radius: 4px;
|
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);
|
const wasExisting = this.state.assets.has(asset.id);
|
||||||
this.state.assets.set(asset.id, asset);
|
this.state.assets.set(asset.id, asset);
|
||||||
ensureLayerPosition(this.state, asset.id, placement);
|
ensureLayerPosition(this.state, asset.id, placement);
|
||||||
|
if (isCodeAsset(asset)) {
|
||||||
|
this.updateScriptWorkerAttachments(asset);
|
||||||
|
}
|
||||||
if (!wasExisting && !this.state.visibilityStates.has(asset.id)) {
|
if (!wasExisting && !this.state.visibilityStates.has(asset.id)) {
|
||||||
const initialAlpha = 0; // Fade in newly discovered assets
|
const initialAlpha = 0; // Fade in newly discovered assets
|
||||||
this.state.visibilityStates.set(asset.id, {
|
this.state.visibilityStates.set(asset.id, {
|
||||||
@@ -484,6 +487,20 @@ export class BroadcastRenderer {
|
|||||||
payload: {
|
payload: {
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
source: assetSource,
|
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 scripts = new Map();
|
||||||
|
const allowedFetchUrls = new Set();
|
||||||
let canvas = null;
|
let canvas = null;
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
let channelName = "";
|
let channelName = "";
|
||||||
@@ -9,9 +10,18 @@ const tickIntervalMs = 1000 / 60;
|
|||||||
const errorKeys = new Set();
|
const errorKeys = new Set();
|
||||||
|
|
||||||
function disableNetworkApis() {
|
function disableNetworkApis() {
|
||||||
|
const nativeFetch = typeof self.fetch === "function" ? self.fetch.bind(self) : null;
|
||||||
const blockedApis = {
|
const blockedApis = {
|
||||||
fetch: () => {
|
fetch: (...args) => {
|
||||||
|
if (!nativeFetch) {
|
||||||
throw new Error("Network access is disabled in asset scripts.");
|
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,
|
XMLHttpRequest: undefined,
|
||||||
WebSocket: undefined,
|
WebSocket: undefined,
|
||||||
@@ -43,6 +53,32 @@ function disableNetworkApis() {
|
|||||||
|
|
||||||
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) {
|
function reportScriptError(id, stage, error) {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return;
|
return;
|
||||||
@@ -115,7 +151,8 @@ function stopTickLoopIfIdle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createScriptHandlers(source, context, state, sourceLabel = "") {
|
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 sourceUrl = sourceLabel ? `\n//# sourceURL=${sourceLabel}` : "";
|
||||||
const factory = new Function(
|
const factory = new Function(
|
||||||
"context",
|
"context",
|
||||||
@@ -172,6 +209,7 @@ self.addEventListener("message", (event) => {
|
|||||||
now: 0,
|
now: 0,
|
||||||
deltaMs: 0,
|
deltaMs: 0,
|
||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
|
assets: Array.isArray(payload.attachments) ? payload.attachments : [],
|
||||||
};
|
};
|
||||||
let handlers = {};
|
let handlers = {};
|
||||||
try {
|
try {
|
||||||
@@ -189,6 +227,7 @@ self.addEventListener("message", (event) => {
|
|||||||
tick: handlers.tick,
|
tick: handlers.tick,
|
||||||
};
|
};
|
||||||
scripts.set(payload.id, script);
|
scripts.set(payload.id, script);
|
||||||
|
refreshAllowedFetchUrls();
|
||||||
if (script.init) {
|
if (script.init) {
|
||||||
try {
|
try {
|
||||||
script.init(script.context, script.state);
|
script.init(script.context, script.state);
|
||||||
@@ -206,6 +245,19 @@ self.addEventListener("message", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scripts.delete(payload.id);
|
scripts.delete(payload.id);
|
||||||
|
refreshAllowedFetchUrls();
|
||||||
stopTickLoopIfIdle();
|
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 jsErrorDetails = document.getElementById("js-error-details");
|
||||||
const form = document.getElementById("custom-asset-form");
|
const form = document.getElementById("custom-asset-form");
|
||||||
const cancelButton = document.getElementById("custom-asset-cancel");
|
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 = () => {
|
const resetErrors = () => {
|
||||||
if (formErrorWrapper) {
|
if (formErrorWrapper) {
|
||||||
@@ -37,8 +42,9 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
|
|||||||
userSourceTextArea.disabled = false;
|
userSourceTextArea.disabled = false;
|
||||||
userSourceTextArea.dataset.assetId = "";
|
userSourceTextArea.dataset.assetId = "";
|
||||||
userSourceTextArea.placeholder =
|
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();
|
resetErrors();
|
||||||
openModal();
|
openModal();
|
||||||
};
|
};
|
||||||
@@ -57,6 +63,7 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
|
|||||||
userSourceTextArea.disabled = true;
|
userSourceTextArea.disabled = true;
|
||||||
userSourceTextArea.dataset.assetId = asset.id;
|
userSourceTextArea.dataset.assetId = asset.id;
|
||||||
}
|
}
|
||||||
|
setAttachmentState(asset.id, asset.scriptAttachments || []);
|
||||||
openModal();
|
openModal();
|
||||||
|
|
||||||
fetch(asset.url)
|
fetch(asset.url)
|
||||||
@@ -151,9 +158,148 @@ export function createCustomAssetModal({ broadcaster, showToast = globalThis.sho
|
|||||||
if (cancelButton) {
|
if (cancelButton) {
|
||||||
cancelButton.addEventListener("click", () => closeModal());
|
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 };
|
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 }) {
|
function saveCodeAsset({ name, src, assetId }) {
|
||||||
const payload = { name, source: src };
|
const payload = { name, source: src };
|
||||||
const method = assetId ? "PUT" : "POST";
|
const method = assetId ? "PUT" : "POST";
|
||||||
|
|||||||
@@ -387,7 +387,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
class="text-input"
|
class="text-input"
|
||||||
id="custom-asset-code"
|
id="custom-asset-code"
|
||||||
placeholder="exports.init = ({ surface, assets, channel }) => { }; exports.tick = () => { };"
|
placeholder="exports.init = (context) => { const { assets } = context; }; exports.tick = () => { };"
|
||||||
rows="25"
|
rows="25"
|
||||||
required
|
required
|
||||||
></textarea>
|
></textarea>
|
||||||
@@ -395,6 +395,29 @@
|
|||||||
By submitting your script, you agree to release it under the MIT License to the public.
|
By submitting your script, you agree to release it under the MIT License to the public.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="form-error hidden" id="custom-asset-error">
|
||||||
<strong>JavaScript error: <span id="js-error-title"></span></strong>
|
<strong>JavaScript error: <span id="js-error-title"></span></strong>
|
||||||
<pre id="js-error-details"></pre>
|
<pre id="js-error-details"></pre>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import dev.kruhlmann.imgfloat.repository.AssetRepository;
|
|||||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
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.VisualAssetRepository;
|
import dev.kruhlmann.imgfloat.repository.VisualAssetRepository;
|
||||||
import dev.kruhlmann.imgfloat.service.AssetStorageService;
|
import dev.kruhlmann.imgfloat.service.AssetStorageService;
|
||||||
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
|
||||||
@@ -56,6 +57,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
private VisualAssetRepository visualAssetRepository;
|
private VisualAssetRepository visualAssetRepository;
|
||||||
private AudioAssetRepository audioAssetRepository;
|
private AudioAssetRepository audioAssetRepository;
|
||||||
private ScriptAssetRepository scriptAssetRepository;
|
private ScriptAssetRepository scriptAssetRepository;
|
||||||
|
private ScriptAssetAttachmentRepository scriptAssetAttachmentRepository;
|
||||||
private SettingsService settingsService;
|
private SettingsService settingsService;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
@@ -66,6 +68,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
visualAssetRepository = mock(VisualAssetRepository.class);
|
visualAssetRepository = mock(VisualAssetRepository.class);
|
||||||
audioAssetRepository = mock(AudioAssetRepository.class);
|
audioAssetRepository = mock(AudioAssetRepository.class);
|
||||||
scriptAssetRepository = mock(ScriptAssetRepository.class);
|
scriptAssetRepository = mock(ScriptAssetRepository.class);
|
||||||
|
scriptAssetAttachmentRepository = mock(ScriptAssetAttachmentRepository.class);
|
||||||
settingsService = mock(SettingsService.class);
|
settingsService = mock(SettingsService.class);
|
||||||
when(settingsService.get()).thenReturn(Settings.defaults());
|
when(settingsService.get()).thenReturn(Settings.defaults());
|
||||||
setupInMemoryPersistence();
|
setupInMemoryPersistence();
|
||||||
@@ -82,6 +85,7 @@ class ChannelDirectoryServiceTest {
|
|||||||
visualAssetRepository,
|
visualAssetRepository,
|
||||||
audioAssetRepository,
|
audioAssetRepository,
|
||||||
scriptAssetRepository,
|
scriptAssetRepository,
|
||||||
|
scriptAssetAttachmentRepository,
|
||||||
messagingTemplate,
|
messagingTemplate,
|
||||||
assetStorageService,
|
assetStorageService,
|
||||||
mediaDetectionService,
|
mediaDetectionService,
|
||||||
|
|||||||
Reference in New Issue
Block a user