mirror of
https://github.com/imgfloat/server.git
synced 2026-06-22 21:01:23 +00:00
feat: add audio playlist backend (migrations, entities, service, API)
- V17: playlists and playlist_tracks tables with indexes
- V18: active_playlist_id FK on channels table
- Playlist, PlaylistTrack JPA entities
- PlaylistRepository, PlaylistTrackRepository
- PlaylistView, PlaylistTrackView records; PlaylistEvent with all
lifecycle types (CREATED, UPDATED, DELETED, SELECTED, PLAY, PAUSE,
NEXT, PREV, ENDED)
- Request records: PlaylistRequest, PlaylistTrackRequest,
PlaylistTrackOrderRequest, ActivePlaylistRequest
- PlaylistService: full CRUD, track add/remove/reorder, active-playlist
persistence, playback command methods that publish PlaylistEvents over
STOMP to /topic/channel/{broadcaster}
- PlaylistApiController at /api/channels/{broadcaster}/playlists
covering all CRUD, track management, active selection, and play/pause/
next/prev/track-ended commands
- Channel entity gains activePlaylistId nullable column
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
package dev.kruhlmann.imgfloat.controller;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.OauthSessionUser;
|
||||
import dev.kruhlmann.imgfloat.model.api.request.ActivePlaylistRequest;
|
||||
import dev.kruhlmann.imgfloat.model.api.request.PlaylistRequest;
|
||||
import dev.kruhlmann.imgfloat.model.api.request.PlaylistTrackOrderRequest;
|
||||
import dev.kruhlmann.imgfloat.model.api.request.PlaylistTrackRequest;
|
||||
import dev.kruhlmann.imgfloat.model.api.response.PlaylistView;
|
||||
import dev.kruhlmann.imgfloat.service.AuthorizationService;
|
||||
import dev.kruhlmann.imgfloat.service.PlaylistService;
|
||||
import dev.kruhlmann.imgfloat.util.LogSanitizer;
|
||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Manages audio playlists for a channel.
|
||||
* All endpoints require the caller to be the broadcaster or a channel admin.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/channels/{broadcaster}/playlists")
|
||||
@SecurityRequirement(name = "twitchOAuth")
|
||||
public class PlaylistApiController {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PlaylistApiController.class);
|
||||
|
||||
private final PlaylistService playlistService;
|
||||
private final AuthorizationService authorizationService;
|
||||
|
||||
public PlaylistApiController(PlaylistService playlistService, AuthorizationService authorizationService) {
|
||||
this.playlistService = playlistService;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
// ── Playlist CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping
|
||||
public List<PlaylistView> list(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.debug("Listing playlists for {} by {}", LogSanitizer.sanitize(broadcaster), LogSanitizer.sanitize(sessionUsername));
|
||||
return playlistService.listPlaylists(broadcaster);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<PlaylistView> create(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@Valid @RequestBody PlaylistRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("Creating playlist '{}' for {} by {}", LogSanitizer.sanitize(request.name()),
|
||||
LogSanitizer.sanitize(broadcaster), LogSanitizer.sanitize(sessionUsername));
|
||||
PlaylistView view = playlistService.createPlaylist(broadcaster, request.name());
|
||||
return ResponseEntity.ok(view);
|
||||
}
|
||||
|
||||
@PutMapping("/{playlistId}")
|
||||
public ResponseEntity<PlaylistView> rename(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@Valid @RequestBody PlaylistRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("Renaming playlist {} for {}", LogSanitizer.sanitize(playlistId), LogSanitizer.sanitize(broadcaster));
|
||||
return ResponseEntity.ok(playlistService.renamePlaylist(broadcaster, playlistId, request.name()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{playlistId}")
|
||||
public ResponseEntity<Void> delete(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("Deleting playlist {} for {}", LogSanitizer.sanitize(playlistId), LogSanitizer.sanitize(broadcaster));
|
||||
playlistService.deletePlaylist(broadcaster, playlistId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
// ── Track management ──────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{playlistId}/tracks")
|
||||
public ResponseEntity<PlaylistView> addTrack(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@Valid @RequestBody PlaylistTrackRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
return ResponseEntity.ok(playlistService.addTrack(broadcaster, playlistId, request.audioAssetId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{playlistId}/tracks/{trackId}")
|
||||
public ResponseEntity<PlaylistView> removeTrack(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@PathVariable("trackId") String trackId,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
return ResponseEntity.ok(playlistService.removeTrack(broadcaster, playlistId, trackId));
|
||||
}
|
||||
|
||||
@PutMapping("/{playlistId}/tracks/order")
|
||||
public ResponseEntity<PlaylistView> reorderTracks(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@Valid @RequestBody PlaylistTrackOrderRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
return ResponseEntity.ok(playlistService.reorderTracks(broadcaster, playlistId, request.trackIds()));
|
||||
}
|
||||
|
||||
// ── Active playlist ───────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/active")
|
||||
public ResponseEntity<PlaylistView> getActive(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
return playlistService.getActivePlaylist(broadcaster)
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@PutMapping("/active")
|
||||
public ResponseEntity<PlaylistView> selectActive(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@RequestBody ActivePlaylistRequest request,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
LOG.info("Selecting active playlist {} for {}", LogSanitizer.sanitize(request.playlistId()),
|
||||
LogSanitizer.sanitize(broadcaster));
|
||||
return playlistService.selectPlaylist(broadcaster, request.playlistId())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/{playlistId}/play")
|
||||
public ResponseEntity<Void> play(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@RequestBody(required = false) java.util.Map<String, String> body,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
String trackId = body != null ? body.get("trackId") : null;
|
||||
playlistService.commandPlay(broadcaster, playlistId, trackId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{playlistId}/pause")
|
||||
public ResponseEntity<Void> pause(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
playlistService.commandPause(broadcaster, playlistId);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{playlistId}/next")
|
||||
public ResponseEntity<Void> next(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@RequestBody java.util.Map<String, String> body,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
playlistService.commandNext(broadcaster, playlistId, body.get("currentTrackId"));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{playlistId}/prev")
|
||||
public ResponseEntity<Void> prev(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@RequestBody java.util.Map<String, String> body,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
playlistService.commandPrev(broadcaster, playlistId, body.get("currentTrackId"));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{playlistId}/track-ended")
|
||||
public ResponseEntity<Void> trackEnded(
|
||||
@PathVariable("broadcaster") String broadcaster,
|
||||
@PathVariable("playlistId") String playlistId,
|
||||
@RequestBody java.util.Map<String, String> body,
|
||||
OAuth2AuthenticationToken oauthToken
|
||||
) {
|
||||
String sessionUsername = OauthSessionUser.from(oauthToken).login();
|
||||
authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername);
|
||||
playlistService.commandTrackEnded(broadcaster, playlistId, body.get("trackId"));
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.kruhlmann.imgfloat.model.api.request;
|
||||
|
||||
public record ActivePlaylistRequest(
|
||||
String playlistId // nullable — null means deselect
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.model.api.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record PlaylistRequest(
|
||||
@NotBlank @Size(max = 100) String name
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.kruhlmann.imgfloat.model.api.request;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
public record PlaylistTrackOrderRequest(
|
||||
@NotNull List<String> trackIds
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.kruhlmann.imgfloat.model.api.request;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record PlaylistTrackRequest(
|
||||
@NotBlank String audioAssetId
|
||||
) {}
|
||||
@@ -0,0 +1,111 @@
|
||||
package dev.kruhlmann.imgfloat.model.api.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class PlaylistEvent {
|
||||
|
||||
public enum Type {
|
||||
PLAYLIST_CREATED,
|
||||
PLAYLIST_UPDATED,
|
||||
PLAYLIST_DELETED,
|
||||
PLAYLIST_SELECTED,
|
||||
PLAYLIST_PLAY,
|
||||
PLAYLIST_PAUSE,
|
||||
PLAYLIST_NEXT,
|
||||
PLAYLIST_PREV,
|
||||
PLAYLIST_ENDED,
|
||||
}
|
||||
|
||||
private Type type;
|
||||
private String channel;
|
||||
private String playlistId;
|
||||
private String trackId;
|
||||
private PlaylistView payload;
|
||||
|
||||
private PlaylistEvent() {}
|
||||
|
||||
public static PlaylistEvent created(String channel, PlaylistView view) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_CREATED;
|
||||
e.channel = channel;
|
||||
e.playlistId = view.id();
|
||||
e.payload = view;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent updated(String channel, PlaylistView view) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_UPDATED;
|
||||
e.channel = channel;
|
||||
e.playlistId = view.id();
|
||||
e.payload = view;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent deleted(String channel, String playlistId) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_DELETED;
|
||||
e.channel = channel;
|
||||
e.playlistId = playlistId;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent selected(String channel, PlaylistView view) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_SELECTED;
|
||||
e.channel = channel;
|
||||
e.playlistId = view != null ? view.id() : null;
|
||||
e.payload = view;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent play(String channel, String playlistId, String trackId) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_PLAY;
|
||||
e.channel = channel;
|
||||
e.playlistId = playlistId;
|
||||
e.trackId = trackId;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent pause(String channel, String playlistId) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_PAUSE;
|
||||
e.channel = channel;
|
||||
e.playlistId = playlistId;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent next(String channel, String playlistId, String trackId) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_NEXT;
|
||||
e.channel = channel;
|
||||
e.playlistId = playlistId;
|
||||
e.trackId = trackId;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent prev(String channel, String playlistId, String trackId) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_PREV;
|
||||
e.channel = channel;
|
||||
e.playlistId = playlistId;
|
||||
e.trackId = trackId;
|
||||
return e;
|
||||
}
|
||||
|
||||
public static PlaylistEvent ended(String channel, String playlistId) {
|
||||
PlaylistEvent e = new PlaylistEvent();
|
||||
e.type = Type.PLAYLIST_ENDED;
|
||||
e.channel = channel;
|
||||
e.playlistId = playlistId;
|
||||
return e;
|
||||
}
|
||||
|
||||
public Type getType() { return type; }
|
||||
public String getChannel() { return channel; }
|
||||
public String getPlaylistId() { return playlistId; }
|
||||
public String getTrackId() { return trackId; }
|
||||
public PlaylistView getPayload() { return payload; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.kruhlmann.imgfloat.model.api.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PlaylistTrackView(
|
||||
String id,
|
||||
String audioAssetId,
|
||||
String assetName,
|
||||
int trackOrder
|
||||
) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package dev.kruhlmann.imgfloat.model.api.response;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record PlaylistView(
|
||||
String id,
|
||||
String name,
|
||||
List<PlaylistTrackView> tracks
|
||||
) {}
|
||||
@@ -47,6 +47,9 @@ public class Channel {
|
||||
@Column(name = "banned", nullable = false)
|
||||
private boolean banned = false;
|
||||
|
||||
@Column(name = "active_playlist_id")
|
||||
private String activePlaylistId;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@@ -131,6 +134,14 @@ public class Channel {
|
||||
this.banned = banned;
|
||||
}
|
||||
|
||||
public String getActivePlaylistId() {
|
||||
return activePlaylistId;
|
||||
}
|
||||
|
||||
public void setActivePlaylistId(String activePlaylistId) {
|
||||
this.activePlaylistId = activePlaylistId;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "playlists")
|
||||
public class Playlist {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String broadcaster;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
public Playlist() {}
|
||||
|
||||
public Playlist(String broadcaster, String name) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
this.broadcaster = broadcaster;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
private void onCreate() {
|
||||
Instant now = Instant.now();
|
||||
if (id == null) id = UUID.randomUUID().toString();
|
||||
if (createdAt == null) createdAt = now;
|
||||
updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
private void onUpdate() {
|
||||
updatedAt = Instant.now();
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getBroadcaster() { return broadcaster; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public Instant getUpdatedAt() { return updatedAt; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.kruhlmann.imgfloat.model.db.imgfloat;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "playlist_tracks")
|
||||
public class PlaylistTrack {
|
||||
|
||||
@Id
|
||||
private String id;
|
||||
|
||||
@Column(name = "playlist_id", nullable = false)
|
||||
private String playlistId;
|
||||
|
||||
@Column(name = "audio_asset_id", nullable = false)
|
||||
private String audioAssetId;
|
||||
|
||||
@Column(name = "track_order", nullable = false)
|
||||
private int trackOrder;
|
||||
|
||||
public PlaylistTrack() {}
|
||||
|
||||
public PlaylistTrack(String playlistId, String audioAssetId, int trackOrder) {
|
||||
this.id = UUID.randomUUID().toString();
|
||||
this.playlistId = playlistId;
|
||||
this.audioAssetId = audioAssetId;
|
||||
this.trackOrder = trackOrder;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
private void onCreate() {
|
||||
if (id == null) id = UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public String getPlaylistId() { return playlistId; }
|
||||
public String getAudioAssetId() { return audioAssetId; }
|
||||
public int getTrackOrder() { return trackOrder; }
|
||||
public void setTrackOrder(int trackOrder) { this.trackOrder = trackOrder; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.db.imgfloat.Playlist;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PlaylistRepository extends JpaRepository<Playlist, String> {
|
||||
List<Playlist> findAllByBroadcasterOrderByCreatedAtAsc(String broadcaster);
|
||||
Optional<Playlist> findByIdAndBroadcaster(String id, String broadcaster);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.kruhlmann.imgfloat.repository;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.db.imgfloat.PlaylistTrack;
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PlaylistTrackRepository extends JpaRepository<PlaylistTrack, String> {
|
||||
List<PlaylistTrack> findAllByPlaylistIdOrderByTrackOrderAsc(String playlistId);
|
||||
void deleteAllByPlaylistId(String playlistId);
|
||||
int countByPlaylistId(String playlistId);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package dev.kruhlmann.imgfloat.service;
|
||||
|
||||
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||
|
||||
import dev.kruhlmann.imgfloat.model.api.response.PlaylistEvent;
|
||||
import dev.kruhlmann.imgfloat.model.api.response.PlaylistTrackView;
|
||||
import dev.kruhlmann.imgfloat.model.api.response.PlaylistView;
|
||||
import dev.kruhlmann.imgfloat.model.db.imgfloat.AudioAsset;
|
||||
import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel;
|
||||
import dev.kruhlmann.imgfloat.model.db.imgfloat.Playlist;
|
||||
import dev.kruhlmann.imgfloat.model.db.imgfloat.PlaylistTrack;
|
||||
import dev.kruhlmann.imgfloat.repository.AudioAssetRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.ChannelRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.PlaylistRepository;
|
||||
import dev.kruhlmann.imgfloat.repository.PlaylistTrackRepository;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class PlaylistService {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(PlaylistService.class);
|
||||
|
||||
private final PlaylistRepository playlistRepository;
|
||||
private final PlaylistTrackRepository playlistTrackRepository;
|
||||
private final AudioAssetRepository audioAssetRepository;
|
||||
private final ChannelRepository channelRepository;
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
public PlaylistService(
|
||||
PlaylistRepository playlistRepository,
|
||||
PlaylistTrackRepository playlistTrackRepository,
|
||||
AudioAssetRepository audioAssetRepository,
|
||||
ChannelRepository channelRepository,
|
||||
SimpMessagingTemplate messagingTemplate
|
||||
) {
|
||||
this.playlistRepository = playlistRepository;
|
||||
this.playlistTrackRepository = playlistTrackRepository;
|
||||
this.audioAssetRepository = audioAssetRepository;
|
||||
this.channelRepository = channelRepository;
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
}
|
||||
|
||||
// ── CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<PlaylistView> listPlaylists(String broadcaster) {
|
||||
List<Playlist> playlists = playlistRepository.findAllByBroadcasterOrderByCreatedAtAsc(normalize(broadcaster));
|
||||
return playlists.stream().map(p -> toView(p, loadTracks(p.getId()))).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlaylistView createPlaylist(String broadcaster, String name) {
|
||||
Playlist playlist = new Playlist(normalize(broadcaster), name.trim());
|
||||
playlistRepository.save(playlist);
|
||||
PlaylistView view = toView(playlist, List.of());
|
||||
publish(broadcaster, PlaylistEvent.created(normalize(broadcaster), view));
|
||||
return view;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlaylistView renamePlaylist(String broadcaster, String playlistId, String name) {
|
||||
Playlist playlist = requirePlaylist(broadcaster, playlistId);
|
||||
playlist.setName(name.trim());
|
||||
playlistRepository.save(playlist);
|
||||
List<PlaylistTrackView> tracks = loadTracks(playlistId);
|
||||
PlaylistView view = toView(playlist, tracks);
|
||||
publish(broadcaster, PlaylistEvent.updated(normalize(broadcaster), view));
|
||||
return view;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deletePlaylist(String broadcaster, String playlistId) {
|
||||
Playlist playlist = requirePlaylist(broadcaster, playlistId);
|
||||
// Clear active_playlist_id on channel if it pointed to this playlist
|
||||
channelRepository.findById(normalize(broadcaster)).ifPresent(channel -> {
|
||||
if (playlistId.equals(channel.getActivePlaylistId())) {
|
||||
channel.setActivePlaylistId(null);
|
||||
channelRepository.save(channel);
|
||||
}
|
||||
});
|
||||
playlistTrackRepository.deleteAllByPlaylistId(playlistId);
|
||||
playlistRepository.delete(playlist);
|
||||
publish(broadcaster, PlaylistEvent.deleted(normalize(broadcaster), playlistId));
|
||||
}
|
||||
|
||||
// ── Tracks ────────────────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public PlaylistView addTrack(String broadcaster, String playlistId, String audioAssetId) {
|
||||
requirePlaylist(broadcaster, playlistId);
|
||||
AudioAsset audio = audioAssetRepository.findById(audioAssetId)
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Audio asset not found"));
|
||||
int nextOrder = playlistTrackRepository.countByPlaylistId(playlistId);
|
||||
PlaylistTrack track = new PlaylistTrack(playlistId, audioAssetId, nextOrder);
|
||||
playlistTrackRepository.save(track);
|
||||
return refreshAndPublish(broadcaster, playlistId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlaylistView removeTrack(String broadcaster, String playlistId, String trackId) {
|
||||
requirePlaylist(broadcaster, playlistId);
|
||||
PlaylistTrack track = playlistTrackRepository.findById(trackId)
|
||||
.filter(t -> t.getPlaylistId().equals(playlistId))
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Track not found"));
|
||||
playlistTrackRepository.delete(track);
|
||||
// Re-number remaining tracks
|
||||
List<PlaylistTrack> remaining = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
|
||||
for (int i = 0; i < remaining.size(); i++) {
|
||||
remaining.get(i).setTrackOrder(i);
|
||||
}
|
||||
playlistTrackRepository.saveAll(remaining);
|
||||
return refreshAndPublish(broadcaster, playlistId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PlaylistView reorderTracks(String broadcaster, String playlistId, List<String> trackIds) {
|
||||
requirePlaylist(broadcaster, playlistId);
|
||||
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
|
||||
Map<String, PlaylistTrack> byId = tracks.stream().collect(Collectors.toMap(PlaylistTrack::getId, t -> t));
|
||||
if (trackIds.size() != tracks.size() || !byId.keySet().containsAll(trackIds)) {
|
||||
throw new ResponseStatusException(BAD_REQUEST, "trackIds must contain exactly the current track IDs");
|
||||
}
|
||||
for (int i = 0; i < trackIds.size(); i++) {
|
||||
byId.get(trackIds.get(i)).setTrackOrder(i);
|
||||
}
|
||||
playlistTrackRepository.saveAll(byId.values());
|
||||
return refreshAndPublish(broadcaster, playlistId);
|
||||
}
|
||||
|
||||
// ── Active playlist ───────────────────────────────────────────────────
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<PlaylistView> getActivePlaylist(String broadcaster) {
|
||||
Channel channel = channelRepository.findById(normalize(broadcaster)).orElse(null);
|
||||
if (channel == null || channel.getActivePlaylistId() == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return playlistRepository.findByIdAndBroadcaster(channel.getActivePlaylistId(), normalize(broadcaster))
|
||||
.map(p -> toView(p, loadTracks(p.getId())));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Optional<PlaylistView> selectPlaylist(String broadcaster, String playlistId) {
|
||||
Channel channel = channelRepository.findById(normalize(broadcaster))
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Channel not found"));
|
||||
|
||||
PlaylistView view = null;
|
||||
if (playlistId != null) {
|
||||
Playlist playlist = requirePlaylist(broadcaster, playlistId);
|
||||
view = toView(playlist, loadTracks(playlistId));
|
||||
}
|
||||
channel.setActivePlaylistId(playlistId);
|
||||
channelRepository.save(channel);
|
||||
publish(broadcaster, PlaylistEvent.selected(normalize(broadcaster), view));
|
||||
return Optional.ofNullable(view);
|
||||
}
|
||||
|
||||
// ── Playback commands ─────────────────────────────────────────────────
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void commandPlay(String broadcaster, String playlistId, String trackId) {
|
||||
requirePlaylist(broadcaster, playlistId);
|
||||
publish(broadcaster, PlaylistEvent.play(normalize(broadcaster), playlistId, trackId));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void commandPause(String broadcaster, String playlistId) {
|
||||
requirePlaylist(broadcaster, playlistId);
|
||||
publish(broadcaster, PlaylistEvent.pause(normalize(broadcaster), playlistId));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void commandNext(String broadcaster, String playlistId, String currentTrackId) {
|
||||
requirePlaylist(broadcaster, playlistId);
|
||||
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
|
||||
if (tracks.isEmpty()) return;
|
||||
String nextTrackId = null;
|
||||
for (int i = 0; i < tracks.size(); i++) {
|
||||
if (tracks.get(i).getId().equals(currentTrackId) && i + 1 < tracks.size()) {
|
||||
nextTrackId = tracks.get(i + 1).getId();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nextTrackId != null) {
|
||||
publish(broadcaster, PlaylistEvent.next(normalize(broadcaster), playlistId, nextTrackId));
|
||||
} else {
|
||||
// End of playlist
|
||||
publish(broadcaster, PlaylistEvent.ended(normalize(broadcaster), playlistId));
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public void commandPrev(String broadcaster, String playlistId, String currentTrackId) {
|
||||
requirePlaylist(broadcaster, playlistId);
|
||||
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
|
||||
if (tracks.isEmpty()) return;
|
||||
String prevTrackId = null;
|
||||
for (int i = tracks.size() - 1; i >= 0; i--) {
|
||||
if (tracks.get(i).getId().equals(currentTrackId) && i - 1 >= 0) {
|
||||
prevTrackId = tracks.get(i - 1).getId();
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If null, there is no previous track — the client restarts the current track
|
||||
publish(broadcaster, PlaylistEvent.prev(normalize(broadcaster), playlistId, prevTrackId));
|
||||
}
|
||||
|
||||
public void commandTrackEnded(String broadcaster, String playlistId, String finishedTrackId) {
|
||||
commandNext(broadcaster, playlistId, finishedTrackId);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private Playlist requirePlaylist(String broadcaster, String playlistId) {
|
||||
return playlistRepository.findByIdAndBroadcaster(playlistId, normalize(broadcaster))
|
||||
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Playlist not found"));
|
||||
}
|
||||
|
||||
private List<PlaylistTrackView> loadTracks(String playlistId) {
|
||||
List<PlaylistTrack> tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId);
|
||||
List<String> audioIds = tracks.stream().map(PlaylistTrack::getAudioAssetId).toList();
|
||||
Map<String, String> nameById = audioAssetRepository.findAllById(audioIds).stream()
|
||||
.collect(Collectors.toMap(AudioAsset::getId, AudioAsset::getName));
|
||||
return tracks.stream()
|
||||
.map(t -> new PlaylistTrackView(t.getId(), t.getAudioAssetId(),
|
||||
nameById.getOrDefault(t.getAudioAssetId(), t.getAudioAssetId()), t.getTrackOrder()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private PlaylistView toView(Playlist playlist, List<PlaylistTrackView> tracks) {
|
||||
return new PlaylistView(playlist.getId(), playlist.getName(), tracks);
|
||||
}
|
||||
|
||||
private PlaylistView refreshAndPublish(String broadcaster, String playlistId) {
|
||||
Playlist playlist = requirePlaylist(broadcaster, playlistId);
|
||||
List<PlaylistTrackView> tracks = loadTracks(playlistId);
|
||||
PlaylistView view = toView(playlist, tracks);
|
||||
publish(broadcaster, PlaylistEvent.updated(normalize(broadcaster), view));
|
||||
return view;
|
||||
}
|
||||
|
||||
private void publish(String broadcaster, PlaylistEvent event) {
|
||||
messagingTemplate.convertAndSend("/topic/channel/" + normalize(broadcaster), event);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return value == null ? null : value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE playlists (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
broadcaster TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE playlist_tracks (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
audio_asset_id TEXT NOT NULL REFERENCES audio_assets(id) ON DELETE CASCADE,
|
||||
track_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX idx_playlists_broadcaster ON playlists(broadcaster);
|
||||
CREATE INDEX idx_playlist_tracks_playlist ON playlist_tracks(playlist_id);
|
||||
CREATE INDEX idx_playlist_tracks_order ON playlist_tracks(playlist_id, track_order);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE channels ADD COLUMN active_playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL;
|
||||
Reference in New Issue
Block a user