diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java new file mode 100644 index 0000000..502ad88 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/PlaylistApiController.java @@ -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 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 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 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 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 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 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 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 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 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 play( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("playlistId") String playlistId, + @RequestBody(required = false) java.util.Map 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 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 next( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("playlistId") String playlistId, + @RequestBody java.util.Map 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 prev( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("playlistId") String playlistId, + @RequestBody java.util.Map 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 trackEnded( + @PathVariable("broadcaster") String broadcaster, + @PathVariable("playlistId") String playlistId, + @RequestBody java.util.Map body, + OAuth2AuthenticationToken oauthToken + ) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsBroadcasterOrChannelAdminForBroadcasterOrThrowHttpError(broadcaster, sessionUsername); + playlistService.commandTrackEnded(broadcaster, playlistId, body.get("trackId")); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/ActivePlaylistRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/ActivePlaylistRequest.java new file mode 100644 index 0000000..5b9a257 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/ActivePlaylistRequest.java @@ -0,0 +1,5 @@ +package dev.kruhlmann.imgfloat.model.api.request; + +public record ActivePlaylistRequest( + String playlistId // nullable — null means deselect +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistRequest.java new file mode 100644 index 0000000..d92a03f --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistRequest.java @@ -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 +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistTrackOrderRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistTrackOrderRequest.java new file mode 100644 index 0000000..72bf3b6 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistTrackOrderRequest.java @@ -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 trackIds +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistTrackRequest.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistTrackRequest.java new file mode 100644 index 0000000..d60fe32 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/request/PlaylistTrackRequest.java @@ -0,0 +1,7 @@ +package dev.kruhlmann.imgfloat.model.api.request; + +import jakarta.validation.constraints.NotBlank; + +public record PlaylistTrackRequest( + @NotBlank String audioAssetId +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistEvent.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistEvent.java new file mode 100644 index 0000000..d861bc7 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistEvent.java @@ -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; } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistTrackView.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistTrackView.java new file mode 100644 index 0000000..d4e1c22 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistTrackView.java @@ -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 +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistView.java b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistView.java new file mode 100644 index 0000000..e646911 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/api/response/PlaylistView.java @@ -0,0 +1,9 @@ +package dev.kruhlmann.imgfloat.model.api.response; + +import java.util.List; + +public record PlaylistView( + String id, + String name, + List tracks +) {} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java index 05df837..c25c795 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Channel.java @@ -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; } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Playlist.java b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Playlist.java new file mode 100644 index 0000000..18240ee --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/Playlist.java @@ -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; } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/PlaylistTrack.java b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/PlaylistTrack.java new file mode 100644 index 0000000..49fa015 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/model/db/imgfloat/PlaylistTrack.java @@ -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; } +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/PlaylistRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/PlaylistRepository.java new file mode 100644 index 0000000..60b8d7a --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/PlaylistRepository.java @@ -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 { + List findAllByBroadcasterOrderByCreatedAtAsc(String broadcaster); + Optional findByIdAndBroadcaster(String id, String broadcaster); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/repository/PlaylistTrackRepository.java b/src/main/java/dev/kruhlmann/imgfloat/repository/PlaylistTrackRepository.java new file mode 100644 index 0000000..227a84a --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/repository/PlaylistTrackRepository.java @@ -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 { + List findAllByPlaylistIdOrderByTrackOrderAsc(String playlistId); + void deleteAllByPlaylistId(String playlistId); + int countByPlaylistId(String playlistId); +} diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/PlaylistService.java b/src/main/java/dev/kruhlmann/imgfloat/service/PlaylistService.java new file mode 100644 index 0000000..8abceb2 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/PlaylistService.java @@ -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 listPlaylists(String broadcaster) { + List 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 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 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 trackIds) { + requirePlaylist(broadcaster, playlistId); + List tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId); + Map 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 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 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 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 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 loadTracks(String playlistId) { + List tracks = playlistTrackRepository.findAllByPlaylistIdOrderByTrackOrderAsc(playlistId); + List audioIds = tracks.stream().map(PlaylistTrack::getAudioAssetId).toList(); + Map 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 tracks) { + return new PlaylistView(playlist.getId(), playlist.getName(), tracks); + } + + private PlaylistView refreshAndPublish(String broadcaster, String playlistId) { + Playlist playlist = requirePlaylist(broadcaster, playlistId); + List 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); + } +} diff --git a/src/main/resources/db/migration/V17__playlists.sql b/src/main/resources/db/migration/V17__playlists.sql new file mode 100644 index 0000000..98afc4a --- /dev/null +++ b/src/main/resources/db/migration/V17__playlists.sql @@ -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); diff --git a/src/main/resources/db/migration/V18__channel_active_playlist.sql b/src/main/resources/db/migration/V18__channel_active_playlist.sql new file mode 100644 index 0000000..ec814b3 --- /dev/null +++ b/src/main/resources/db/migration/V18__channel_active_playlist.sql @@ -0,0 +1 @@ +ALTER TABLE channels ADD COLUMN active_playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL;