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)
|
@Column(name = "banned", nullable = false)
|
||||||
private boolean banned = false;
|
private boolean banned = false;
|
||||||
|
|
||||||
|
@Column(name = "active_playlist_id")
|
||||||
|
private String activePlaylistId;
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false, updatable = false)
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
private Instant createdAt;
|
private Instant createdAt;
|
||||||
|
|
||||||
@@ -131,6 +134,14 @@ public class Channel {
|
|||||||
this.banned = banned;
|
this.banned = banned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getActivePlaylistId() {
|
||||||
|
return activePlaylistId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setActivePlaylistId(String activePlaylistId) {
|
||||||
|
this.activePlaylistId = activePlaylistId;
|
||||||
|
}
|
||||||
|
|
||||||
public Instant getCreatedAt() {
|
public Instant getCreatedAt() {
|
||||||
return createdAt;
|
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