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