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:
co-authored by
Claude Opus 4.7
parent
2be46146f2
commit
f5dd7f79ed
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user