Add script asset sub-assets

This commit is contained in:
2026-01-09 18:42:37 +01:00
parent c4354782a8
commit 96b1cf501c
16 changed files with 770 additions and 17 deletions

View File

@@ -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);

View File

@@ -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");
} }

View File

@@ -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,

View File

@@ -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;
}
} }

View File

@@ -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;
}
}

View File

@@ -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()
);
}
}

View File

@@ -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);
}

View File

@@ -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);
} }

View File

@@ -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.

View File

@@ -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;
}

View File

@@ -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 || [],
}, },
}); });
} }

View File

@@ -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();
}
}); });

View File

@@ -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";

View File

@@ -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 }) => {&#10;&#10;};&#10;&#10;exports.tick = () => {&#10;&#10;};" placeholder="exports.init = (context) => {&#10; const { assets } = context;&#10;&#10;};&#10;&#10;exports.tick = () => {&#10;&#10;};"
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>

View File

@@ -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,