diff --git a/internal/audiobooks/abs/playlists.go b/internal/audiobooks/abs/playlists.go new file mode 100644 index 00000000..84b50d2f --- /dev/null +++ b/internal/audiobooks/abs/playlists.go @@ -0,0 +1,67 @@ +package abs + +import ( + "context" + "time" +) + +// PlaylistStore is the narrow slice of abs_playlists + abs_playlist_items +// the playlists handlers need. Implemented by ABSPlaylistStore in +// internal/audiobooks/abs_playlist_store.go. +type PlaylistStore interface { + ListUserPlaylists(ctx context.Context, userID, profileID string) ([]Playlist, error) + GetPlaylist(ctx context.Context, id string) (Playlist, error) + CreatePlaylist(ctx context.Context, p Playlist) error + UpdatePlaylist(ctx context.Context, p Playlist) error + DeletePlaylist(ctx context.Context, id string) error + ListPlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error) + AddPlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error + RemovePlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error +} + +// Playlist is the in-memory representation of an abs_playlists row. +type Playlist struct { + ID string + UserID string + ProfileID string + Name string + Description string + CoverItem string // empty when unset + IsPublic bool + CreatedAt time.Time + UpdatedAt time.Time +} + +// PlaylistItem is the in-memory representation of an abs_playlist_items row. +type PlaylistItem struct { + PlaylistID string + LibraryItemID string + EpisodeID string // empty for audiobook items + Position int + AddedAt time.Time +} + +// playlistToABS shapes a Playlist in the ABS wire format. When items +// is nil the list-shape is emitted (no "items" key); when items is +// non-nil (possibly empty) the full-shape is emitted. +// +// coverPath is omitted when CoverItem is empty (matches continuum). +// Description is always present (round-tripped from storage). +func playlistToABS(p Playlist, items []map[string]any) map[string]any { + out := map[string]any{ + "id": p.ID, + "userId": p.UserID, + "name": p.Name, + "description": p.Description, + "isPublic": p.IsPublic, + "createdAt": p.CreatedAt.UnixMilli(), + "lastUpdate": p.UpdatedAt.UnixMilli(), + } + if p.CoverItem != "" { + out["coverPath"] = p.CoverItem + } + if items != nil { + out["items"] = items + } + return out +} diff --git a/internal/audiobooks/abs/playlists_envelope_test.go b/internal/audiobooks/abs/playlists_envelope_test.go new file mode 100644 index 00000000..44fed2f9 --- /dev/null +++ b/internal/audiobooks/abs/playlists_envelope_test.go @@ -0,0 +1,61 @@ +package abs + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +// TestPlaylistEnvelope_HasRequiredKeys asserts the eight (or nine with +// coverPath) top-level keys are present when populated. coverPath is +// emitted only when non-empty. +func TestPlaylistEnvelope_HasRequiredKeys(t *testing.T) { + now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC) + out := playlistToABS(Playlist{ + ID: "01HPL", + UserID: "1", + Name: "queue", + Description: "", + CoverItem: "01HCOVER", + IsPublic: false, + CreatedAt: now, + UpdatedAt: now, + }, []map[string]any{}) + body, _ := json.Marshal(out) + js := string(body) + for _, key := range []string{ + `"id":`, `"userId":`, `"name":`, `"description":`, + `"isPublic":`, `"coverPath":`, `"createdAt":`, `"lastUpdate":`, `"items":`, + } { + if !strings.Contains(js, key) { + t.Errorf("envelope missing %s; got %s", key, js) + } + } + if out["coverPath"] != "01HCOVER" { + t.Errorf("coverPath = %v, want 01HCOVER", out["coverPath"]) + } +} + +// TestPlaylistEnvelope_OmitsCoverPathWhenEmpty asserts cover_item="" +// produces no coverPath key (matches continuum). +func TestPlaylistEnvelope_OmitsCoverPathWhenEmpty(t *testing.T) { + out := playlistToABS(Playlist{ + ID: "01HPL", UserID: "1", Name: "x", + CreatedAt: time.Now(), UpdatedAt: time.Now(), + }, []map[string]any{}) + if _, has := out["coverPath"]; has { + t.Errorf("coverPath emitted when empty: %v", out) + } +} + +// TestPlaylistListShape_OmitsItems asserts nil items produces no items key. +func TestPlaylistListShape_OmitsItems(t *testing.T) { + out := playlistToABS(Playlist{ + ID: "01HPL", UserID: "1", Name: "x", + CreatedAt: time.Now(), UpdatedAt: time.Now(), + }, nil) + if _, has := out["items"]; has { + t.Errorf("list-shape includes items key (should be detail-only): %v", out) + } +}