feat(audiobooks): POST /playlists — ABS playlist create + event

First handler of the playlists surface. Body {name, description?,
cover_item?, isPublic?} returns the created playlist in full-shape
(empty items[]). Fires playlist_added realtime event. Adds the
in-memory test harness (memPlaylistStore) parallel to
memCollectionStore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 19:06:37 +02:00
co-authored by Claude Opus 4.7
parent 2be46146f2
commit f5dd7f79ed
3 changed files with 341 additions and 0 deletions
+3
View File
@@ -185,6 +185,9 @@ type Dependencies struct {
// CollectionStore persists ABS user-collection rows (migrations 149 + 150).
// May be nil; handlers respond 503 when unset.
CollectionStore CollectionStore
// PlaylistStore persists ABS playlist rows (migrations 151 + 152).
// May be nil; handlers respond 503 when unset.
PlaylistStore PlaylistStore
// SocketIO is the Socket.io server mounted at /abs/socket.io/. May be nil;
// the route is only registered when a non-nil value is supplied.
SocketIO SocketIOServer
@@ -0,0 +1,131 @@
package abs
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
)
// playlistBody is the JSON body for POST and PATCH /playlists[/{id}].
// Fields are pointers so PATCH can distinguish "field absent" from
// "field set to empty/false".
type playlistBody struct {
Name *string `json:"name"`
Description *string `json:"description"`
CoverItem *string `json:"cover_item"`
IsPublic *bool `json:"isPublic"`
}
// playlistItemRef is the JSON body for adding/removing a single
// playlist item (and an element of the batch arrays).
type playlistItemRef struct {
LibraryItemID string `json:"libraryItemId"`
EpisodeID string `json:"episodeId"`
}
// handleCreatePlaylist — POST /playlists.
// Body: {name, description?, cover_item?, isPublic?}.
// Returns the created playlist in full-shape (empty items[]).
// Fires playlist_added on success.
func (h *Handler) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
a, ok := absAuthFrom(r)
if !ok || a.UserID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if h.deps.PlaylistStore == nil {
http.Error(w, "playlist store unavailable", http.StatusServiceUnavailable)
return
}
var body playlistBody
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if body.Name == nil || *body.Name == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
p := Playlist{
ID: ulid.Make().String(),
UserID: a.UserID,
ProfileID: a.ProfileID,
Name: *body.Name,
}
if body.Description != nil {
p.Description = *body.Description
}
if body.CoverItem != nil {
p.CoverItem = *body.CoverItem
}
if body.IsPublic != nil {
p.IsPublic = *body.IsPublic
}
if err := h.deps.PlaylistStore.CreatePlaylist(r.Context(), p); err != nil {
slog.Error("abs playlist create failed", "err", err, "user", a.UserID)
http.Error(w, "playlist persist failed", http.StatusInternalServerError)
return
}
persisted, err := h.deps.PlaylistStore.GetPlaylist(r.Context(), p.ID)
if errors.Is(err, ErrNotFound) {
persisted = p
} else if err != nil {
persisted = p
}
h.publish(a.UserID, "playlist_added", map[string]any{"id": p.ID, "name": p.Name})
writeJSON(w, http.StatusOK, h.playlistFullShape(r, persisted))
}
// playlistFullShape renders a Playlist in full-shape, hydrating items[]
// via MediaStore for audiobook items (episode items echo bare refs).
func (h *Handler) playlistFullShape(r *http.Request, p Playlist) map[string]any {
items := h.playlistItems(r, p.ID)
return playlistToABS(p, items)
}
// playlistItems resolves items in a playlist to wire-shape entries.
// Audiobook items (empty episodeId) hydrate title via MediaStore.
// Episode items are emitted as bare {libraryItemId, episodeId, position}.
func (h *Handler) playlistItems(r *http.Request, playlistID string) []map[string]any {
if h.deps.PlaylistStore == nil {
return []map[string]any{}
}
rows, err := h.deps.PlaylistStore.ListPlaylistItems(r.Context(), playlistID)
if err != nil {
slog.Warn("abs playlist list-items failed", "err", err, "playlist", playlistID)
return []map[string]any{}
}
lib := h.resolveDefaultLibrary(r.Context())
libID := audiobookLibraryID(lib)
out := make([]map[string]any, 0, len(rows))
for _, it := range rows {
entry := map[string]any{
"libraryItemId": it.LibraryItemID,
"position": it.Position,
}
if it.EpisodeID != "" {
entry["episodeId"] = it.EpisodeID
} else {
// Audiobook hydration.
if item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), it.LibraryItemID); err == nil && item != nil {
entry["libraryId"] = libID
entry["title"] = item.Title
}
}
out = append(out, entry)
}
return out
}
// playlistURLID is a tiny shim around chi.URLParam(r, "id") to read
// uniformly with the collections handler's chiURLID.
func playlistURLID(r *http.Request) string { return chi.URLParam(r, "id") }
@@ -0,0 +1,207 @@
package abs
import (
"context"
"encoding/json"
"net/http"
"sort"
"sync"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
// memPlaylistStore is an in-memory PlaylistStore for handler tests.
type memPlaylistStore struct {
mu sync.Mutex
rows map[string]Playlist // id -> row
items map[string][]PlaylistItem // playlist_id -> items
}
func newMemPlaylistStore() *memPlaylistStore {
return &memPlaylistStore{
rows: map[string]Playlist{},
items: map[string][]PlaylistItem{},
}
}
func (m *memPlaylistStore) ListUserPlaylists(_ context.Context, userID, profileID string) ([]Playlist, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]Playlist, 0)
for _, p := range m.rows {
if p.UserID == userID && p.ProfileID == profileID {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out, nil
}
func (m *memPlaylistStore) GetPlaylist(_ context.Context, id string) (Playlist, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.rows[id]
if !ok {
return Playlist{}, ErrNotFound
}
return p, nil
}
func (m *memPlaylistStore) CreatePlaylist(_ context.Context, p Playlist) error {
m.mu.Lock()
defer m.mu.Unlock()
m.rows[p.ID] = p
return nil
}
func (m *memPlaylistStore) UpdatePlaylist(_ context.Context, p Playlist) error {
m.mu.Lock()
defer m.mu.Unlock()
existing, ok := m.rows[p.ID]
if !ok {
return ErrNotFound
}
existing.Name = p.Name
existing.Description = p.Description
existing.CoverItem = p.CoverItem
existing.IsPublic = p.IsPublic
existing.UpdatedAt = time.Now()
m.rows[p.ID] = existing
return nil
}
func (m *memPlaylistStore) DeletePlaylist(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.rows, id)
delete(m.items, id)
return nil
}
func (m *memPlaylistStore) ListPlaylistItems(_ context.Context, playlistID string) ([]PlaylistItem, error) {
m.mu.Lock()
defer m.mu.Unlock()
items := m.items[playlistID]
out := make([]PlaylistItem, len(items))
copy(out, items)
sort.Slice(out, func(i, j int) bool { return out[i].Position < out[j].Position })
return out, nil
}
func (m *memPlaylistStore) AddPlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error {
m.mu.Lock()
defer m.mu.Unlock()
for _, it := range m.items[playlistID] {
if it.LibraryItemID == libraryItemID && it.EpisodeID == episodeID {
return nil // ON CONFLICT DO NOTHING
}
}
maxPos := 0
for _, it := range m.items[playlistID] {
if it.Position > maxPos {
maxPos = it.Position
}
}
m.items[playlistID] = append(m.items[playlistID], PlaylistItem{
PlaylistID: playlistID,
LibraryItemID: libraryItemID,
EpisodeID: episodeID,
Position: maxPos + 1,
AddedAt: time.Now(),
})
if p, ok := m.rows[playlistID]; ok {
p.UpdatedAt = time.Now()
m.rows[playlistID] = p
}
return nil
}
func (m *memPlaylistStore) RemovePlaylistItem(_ context.Context, playlistID, libraryItemID, episodeID string) error {
m.mu.Lock()
defer m.mu.Unlock()
items := m.items[playlistID]
out := items[:0]
for _, it := range items {
if it.LibraryItemID != libraryItemID || it.EpisodeID != episodeID {
out = append(out, it)
}
}
m.items[playlistID] = out
if p, ok := m.rows[playlistID]; ok {
p.UpdatedAt = time.Now()
m.rows[playlistID] = p
}
return nil
}
type playlistsHarness struct {
H *Handler
Play *memPlaylistStore
Pub *recordingPublisher
}
func newPlaylistsHarness(t *testing.T, knownItems ...string) *playlistsHarness {
t.Helper()
known := map[string]*models.MediaItem{}
for _, id := range knownItems {
known[id] = nil
}
pub := &recordingPublisher{}
store := newMemPlaylistStore()
h := New(Dependencies{
MediaStore: &stubMediaStore{known: known},
PlaylistStore: store,
Publisher: pub,
})
return &playlistsHarness{H: h, Play: store, Pub: pub}
}
func TestPlaylist_Create_ReturnsFullShape(t *testing.T) {
hb := newPlaylistsHarness(t)
body := []byte(`{"name":"queue","description":"d","isPublic":true}`)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, body, "1", "", hb.H.handleCreatePlaylist)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var got map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &got)
if got["name"] != "queue" {
t.Errorf("name = %v, want queue", got["name"])
}
if got["isPublic"] != true {
t.Errorf("isPublic = %v, want true", got["isPublic"])
}
items, _ := got["items"].([]any)
if items == nil {
t.Errorf("items missing on full-shape: %v", got)
}
}
func TestPlaylist_Create_NameRequired_400(t *testing.T) {
hb := newPlaylistsHarness(t)
rec := dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{}`), "1", "", hb.H.handleCreatePlaylist)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
}
func TestPlaylist_Create_FiresPlaylistAddedEvent(t *testing.T) {
hb := newPlaylistsHarness(t)
_ = dispatchABSWithParams(http.MethodPost, "/api/playlists", nil, []byte(`{"name":"queue"}`), "7", "", hb.H.handleCreatePlaylist)
evts := hb.Pub.snapshot()
if len(evts) != 1 {
t.Fatalf("events = %d, want 1", len(evts))
}
if evts[0].Event != "playlist_added" {
t.Errorf("event = %q, want playlist_added", evts[0].Event)
}
if evts[0].UserID != "7" {
t.Errorf("event userID = %q, want 7", evts[0].UserID)
}
payload, _ := evts[0].Payload.(map[string]any)
if payload["name"] != "queue" {
t.Errorf("payload name = %v, want queue", payload["name"])
}
}