feat(audiobooks): add PlaylistStore interface + ABS envelope helper

Defines the storage contract and wire-shape serialiser the playlists
handlers will consume. Envelope test asserts the eight (or nine with
coverPath) top-level keys including description always round-tripped
correctly and coverPath omitted when empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-26 19:03:14 +02:00
co-authored by Claude Opus 4.7
parent 5899817623
commit 2be46146f2
2 changed files with 128 additions and 0 deletions
+67
View File
@@ -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
}
@@ -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)
}
}