Improve channel admin page

This commit is contained in:
2025-12-09 16:58:42 +01:00
parent 77c2775b7f
commit 0aac788eda
10 changed files with 434 additions and 218 deletions

View File

@@ -1,7 +1,7 @@
package com.imgfloat.app.controller;
import com.imgfloat.app.model.AdminRequest;
import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetView;
import com.imgfloat.app.model.CanvasSettingsRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.TwitchUserProfile;
@@ -94,8 +94,8 @@ public class ChannelApiController {
}
@GetMapping("/assets")
public Collection<Asset> listAssets(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
public Collection<AssetView> listAssets(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
@@ -105,8 +105,8 @@ public class ChannelApiController {
}
@GetMapping("/assets/visible")
public Collection<Asset> listVisible(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
public Collection<AssetView> listVisible(@PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
if (!channelDirectoryService.isBroadcaster(broadcaster, login)) {
throw new ResponseStatusException(FORBIDDEN, "Only broadcaster can load public overlay");
@@ -132,7 +132,7 @@ public class ChannelApiController {
}
@PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Asset> createAsset(@PathVariable("broadcaster") String broadcaster,
public ResponseEntity<AssetView> createAsset(@PathVariable("broadcaster") String broadcaster,
@org.springframework.web.bind.annotation.RequestPart("file") MultipartFile file,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
@@ -150,10 +150,10 @@ public class ChannelApiController {
}
@PutMapping("/assets/{assetId}/transform")
public ResponseEntity<Asset> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken authentication) {
public ResponseEntity<AssetView> transform(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@Valid @RequestBody TransformRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateTransform(broadcaster, assetId, request)
@@ -162,10 +162,10 @@ public class ChannelApiController {
}
@PutMapping("/assets/{assetId}/visibility")
public ResponseEntity<Asset> visibility(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@RequestBody VisibilityRequest request,
OAuth2AuthenticationToken authentication) {
public ResponseEntity<AssetView> visibility(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
@RequestBody VisibilityRequest request,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.updateVisibility(broadcaster, assetId, request)
@@ -173,6 +173,19 @@ public class ChannelApiController {
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
}
@GetMapping("/assets/{assetId}/content")
public ResponseEntity<byte[]> getAssetContent(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,
OAuth2AuthenticationToken authentication) {
String login = TwitchUser.from(authentication).login();
ensureAuthorized(broadcaster, login);
return channelDirectoryService.getAssetContent(broadcaster, assetId)
.map(content -> ResponseEntity.ok()
.contentType(MediaType.parseMediaType(content.mediaType()))
.body(content.bytes()))
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found"));
}
@DeleteMapping("/assets/{assetId}")
public ResponseEntity<?> delete(@PathVariable("broadcaster") String broadcaster,
@PathVariable("assetId") String assetId,

View File

@@ -53,7 +53,7 @@ public class Asset {
this.rotation = 0;
this.speed = 1.0;
this.muted = false;
this.zIndex = 0;
this.zIndex = 1;
this.hidden = false;
this.createdAt = Instant.now();
}
@@ -71,14 +71,14 @@ public class Asset {
if (this.name == null || this.name.isBlank()) {
this.name = this.id;
}
if (this.speed == null || this.speed <= 0) {
if (this.speed == null) {
this.speed = 1.0;
}
if (this.muted == null) {
this.muted = Boolean.FALSE;
}
if (this.zIndex == null) {
this.zIndex = 0;
if (this.zIndex == null || this.zIndex < 1) {
this.zIndex = 1;
}
}
@@ -203,11 +203,11 @@ public class Asset {
}
public Integer getZIndex() {
return zIndex == null ? 0 : zIndex;
return zIndex == null ? 1 : Math.max(1, zIndex);
}
public void setZIndex(Integer zIndex) {
this.zIndex = zIndex;
this.zIndex = zIndex == null ? null : Math.max(1, zIndex);
}
private static String normalize(String value) {

View File

@@ -10,33 +10,33 @@ public class AssetEvent {
private Type type;
private String channel;
private Asset payload;
private AssetView payload;
private String assetId;
public static AssetEvent created(String channel, Asset asset) {
public static AssetEvent created(String channel, AssetView asset) {
AssetEvent event = new AssetEvent();
event.type = Type.CREATED;
event.channel = channel;
event.payload = asset;
event.assetId = asset.getId();
event.assetId = asset.id();
return event;
}
public static AssetEvent updated(String channel, Asset asset) {
public static AssetEvent updated(String channel, AssetView asset) {
AssetEvent event = new AssetEvent();
event.type = Type.UPDATED;
event.channel = channel;
event.payload = asset;
event.assetId = asset.getId();
event.assetId = asset.id();
return event;
}
public static AssetEvent visibility(String channel, Asset asset) {
public static AssetEvent visibility(String channel, AssetView asset) {
AssetEvent event = new AssetEvent();
event.type = Type.VISIBILITY;
event.channel = channel;
event.payload = asset;
event.assetId = asset.getId();
event.assetId = asset.id();
return event;
}
@@ -56,7 +56,7 @@ public class AssetEvent {
return channel;
}
public Asset getPayload() {
public AssetView getPayload() {
return payload;
}

View File

@@ -0,0 +1,43 @@
package com.imgfloat.app.model;
import java.time.Instant;
public record AssetView(
String id,
String broadcaster,
String name,
String url,
double x,
double y,
double width,
double height,
double rotation,
Double speed,
Boolean muted,
String mediaType,
String originalMediaType,
Integer zIndex,
boolean hidden,
Instant createdAt
) {
public static AssetView from(String broadcaster, Asset asset) {
return new AssetView(
asset.getId(),
asset.getBroadcaster(),
asset.getName(),
"/api/channels/" + broadcaster + "/assets/" + asset.getId() + "/content",
asset.getX(),
asset.getY(),
asset.getWidth(),
asset.getHeight(),
asset.getRotation(),
asset.getSpeed(),
asset.isMuted(),
asset.getMediaType(),
asset.getOriginalMediaType(),
asset.getZIndex(),
asset.isHidden(),
asset.getCreatedAt()
);
}
}

View File

@@ -3,6 +3,7 @@ package com.imgfloat.app.service;
import com.imgfloat.app.model.Asset;
import com.imgfloat.app.model.AssetEvent;
import com.imgfloat.app.model.Channel;
import com.imgfloat.app.model.AssetView;
import com.imgfloat.app.model.CanvasSettingsRequest;
import com.imgfloat.app.model.TransformRequest;
import com.imgfloat.app.model.VisibilityRequest;
@@ -85,12 +86,14 @@ public class ChannelDirectoryService {
return removed;
}
public Collection<Asset> getAssetsForAdmin(String broadcaster) {
return sortByZIndex(assetRepository.findByBroadcaster(normalize(broadcaster)));
public Collection<AssetView> getAssetsForAdmin(String broadcaster) {
String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcaster(normalized));
}
public Collection<Asset> getVisibleAssets(String broadcaster) {
return sortByZIndex(assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
public Collection<AssetView> getVisibleAssets(String broadcaster) {
String normalized = normalize(broadcaster);
return sortAndMapAssets(normalized, assetRepository.findByBroadcasterAndHiddenFalse(normalize(broadcaster)));
}
public CanvasSettingsRequest getCanvasSettings(String broadcaster) {
@@ -106,7 +109,7 @@ public class ChannelDirectoryService {
return new CanvasSettingsRequest(channel.getCanvasWidth(), channel.getCanvasHeight());
}
public Optional<Asset> createAsset(String broadcaster, MultipartFile file) throws IOException {
public Optional<AssetView> createAsset(String broadcaster, MultipartFile file) throws IOException {
Channel channel = getOrCreateChannel(broadcaster);
byte[] bytes = file.getBytes();
String mediaType = detectMediaType(file, bytes);
@@ -132,11 +135,12 @@ public class ChannelDirectoryService {
asset.setZIndex(nextZIndex(channel.getBroadcaster()));
assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, asset));
return Optional.of(asset);
AssetView view = AssetView.from(channel.getBroadcaster(), asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.created(broadcaster, view));
return Optional.of(view);
}
public Optional<Asset> updateTransform(String broadcaster, String assetId, TransformRequest request) {
public Optional<AssetView> updateTransform(String broadcaster, String assetId, TransformRequest request) {
String normalized = normalize(broadcaster);
return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster()))
@@ -149,27 +153,29 @@ public class ChannelDirectoryService {
if (request.getZIndex() != null) {
asset.setZIndex(request.getZIndex());
}
if (request.getSpeed() != null && request.getSpeed() > 0) {
if (request.getSpeed() != null && request.getSpeed() >= 0) {
asset.setSpeed(request.getSpeed());
}
if (request.getMuted() != null && asset.isVideo()) {
asset.setMuted(request.getMuted());
}
assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, asset));
return asset;
AssetView view = AssetView.from(normalized, asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.updated(broadcaster, view));
return view;
});
}
public Optional<Asset> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
public Optional<AssetView> updateVisibility(String broadcaster, String assetId, VisibilityRequest request) {
String normalized = normalize(broadcaster);
return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster()))
.map(asset -> {
asset.setHidden(request.isHidden());
assetRepository.save(asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, asset));
return asset;
AssetView view = AssetView.from(normalized, asset);
messagingTemplate.convertAndSend(topicFor(broadcaster), AssetEvent.visibility(broadcaster, view));
return view;
});
}
@@ -185,6 +191,13 @@ public class ChannelDirectoryService {
.orElse(false);
}
public Optional<AssetContent> getAssetContent(String broadcaster, String assetId) {
String normalized = normalize(broadcaster);
return assetRepository.findById(assetId)
.filter(asset -> normalized.equals(asset.getBroadcaster()))
.flatMap(this::decodeAssetData);
}
public boolean isBroadcaster(String broadcaster, String username) {
return broadcaster != null && broadcaster.equalsIgnoreCase(username);
}
@@ -215,13 +228,36 @@ public class ChannelDirectoryService {
return value == null ? null : value.toLowerCase();
}
private List<Asset> sortByZIndex(Collection<Asset> assets) {
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {
return assets.stream()
.sorted(Comparator.comparingInt(Asset::getZIndex)
.thenComparing(Asset::getCreatedAt, Comparator.nullsFirst(Comparator.naturalOrder())))
.map(asset -> AssetView.from(broadcaster, asset))
.toList();
}
private Optional<AssetContent> decodeAssetData(Asset asset) {
String url = asset.getUrl();
if (url == null || !url.startsWith("data:")) {
return Optional.empty();
}
int commaIndex = url.indexOf(',');
if (commaIndex < 0) {
return Optional.empty();
}
String metadata = url.substring(5, commaIndex);
String[] parts = metadata.split(";", 2);
String mediaType = parts.length > 0 && !parts[0].isBlank() ? parts[0] : "application/octet-stream";
String encoded = url.substring(commaIndex + 1);
try {
byte[] bytes = Base64.getDecoder().decode(encoded);
return Optional.of(new AssetContent(bytes, mediaType));
} catch (IllegalArgumentException e) {
logger.warn("Unable to decode asset data for {}", asset.getId(), e);
return Optional.empty();
}
}
private int nextZIndex(String broadcaster) {
return assetRepository.findByBroadcaster(normalize(broadcaster)).stream()
.mapToInt(Asset::getZIndex)
@@ -426,6 +462,8 @@ public class ChannelDirectoryService {
return new Dimension(640, 360);
}
public record AssetContent(byte[] bytes, String mediaType) { }
private record OptimizedAsset(byte[] bytes, String mediaType, int width, int height) { }
private record GifFrame(BufferedImage image, int delayMs) { }