mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Improve channel admin page
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
43
src/main/java/com/imgfloat/app/model/AssetView.java
Normal file
43
src/main/java/com/imgfloat/app/model/AssetView.java
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
|
||||
Reference in New Issue
Block a user