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:
2026-04-29 16:37:56 +02:00
parent 4c934e84a6
commit 156b88ba40
16 changed files with 808 additions and 0 deletions
@@ -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;