diff --git a/internal/audiobooks/abs/author_series_handler.go b/internal/audiobooks/abs/author_series_handler.go index cc22554c..b5b01948 100644 --- a/internal/audiobooks/abs/author_series_handler.go +++ b/internal/audiobooks/abs/author_series_handler.go @@ -32,7 +32,8 @@ func (h *Handler) handleAuthorDetail(w http.ResponseWriter, r *http.Request) { http.Error(w, "author get failed", http.StatusInternalServerError) return } - writeJSON(w, http.StatusOK, authorToABS(author)) + lib := h.resolveDefaultLibrary(r.Context(), access) + writeJSON(w, http.StatusOK, authorToABS(author, lib, h.absBaseURL(r))) } func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) { @@ -62,31 +63,97 @@ func (h *Handler) handleSeriesDetail(w http.ResponseWriter, r *http.Request) { http.Error(w, "series get failed", http.StatusInternalServerError) return } - writeJSON(w, http.StatusOK, seriesToABS(series)) + lib := h.resolveDefaultLibrary(r.Context(), access) + writeJSON(w, http.StatusOK, seriesToABS(series, lib, h.absBaseURL(r))) } -func authorToABS(a Author) map[string]any { - books := make([]map[string]any, 0, len(a.Books)) +// authorObjectABS builds the real ABS Author.toOldJSON(+numBooks) shape +// (server/models/Author.js). silo does not track asin/description/imagePath/ +// timestamps, so those are emitted as null/0 — nullable in real ABS, and a +// present key (not its value) is what keeps strict clients from crashing. +func authorObjectABS(id, name, libraryID string, numBooks int) map[string]any { + return map[string]any{ + "id": id, + "asin": nil, + "name": name, + "description": nil, + "imagePath": nil, + "libraryId": libraryID, + "addedAt": 0, + "updatedAt": 0, + "numBooks": numBooks, + } +} + +func authorToABS(a Author, lib AudiobookLibrary, baseURL string) map[string]any { + libID := audiobookLibraryID(lib) + obj := authorObjectABS(a.ID, a.Name, libID, len(a.Books)) + // Author-detail books are full minified library items (not thin stubs) so + // any strict client decodes them with its LibraryItem model. + books := make([]MinifiedLibraryItem, 0, len(a.Books)) for _, b := range a.Books { - books = append(books, map[string]any{"id": b.ContentID, "media": map[string]any{"metadata": map[string]any{"title": b.Title}}}) + books = append(books, Minify(siloItemToLibraryItem(b, lib, baseURL))) } + obj["libraryItems"] = books + return obj +} + +// seriesObjectABS builds the real ABS Series.toOldJSON shape +// (server/models/Series.js). description/timestamps are absent in silo's +// catalog → null/0. +func seriesObjectABS(id, name, libraryID string, numBooks int) map[string]any { return map[string]any{ - "id": a.ID, - "name": a.Name, - "numBooks": len(a.Books), - "books": books, + "id": id, + "name": name, + "nameIgnorePrefix": titleIgnorePrefix(name), + "description": nil, + "addedAt": 0, + "updatedAt": 0, + "libraryId": libraryID, + "numBooks": numBooks, } } -func seriesToABS(s Series) map[string]any { - books := make([]map[string]any, 0, len(s.Books)) +func seriesToABS(s Series, lib AudiobookLibrary, baseURL string) map[string]any { + libID := audiobookLibraryID(lib) + obj := seriesObjectABS(s.ID, s.Name, libID, len(s.Books)) + books := make([]MinifiedLibraryItem, 0, len(s.Books)) for _, b := range s.Books { - books = append(books, map[string]any{"id": b.ContentID, "media": map[string]any{"metadata": map[string]any{"title": b.Title}}}) + books = append(books, Minify(siloItemToLibraryItem(b, lib, baseURL))) } - return map[string]any{ - "id": s.ID, - "name": s.Name, - "numBooks": len(s.Books), - "books": books, + obj["books"] = books + return obj +} + +// seriesBookMinified builds a full real-ABS minified library item from the +// limited fields the series-LIST query carries (no full MediaItem). Every +// required minified key is present with a safe placeholder so strict clients +// (Plappa) decode the series card's books[] without crashing. +func seriesBookMinified(contentID, title, libID, baseURL string, updatedAtMs int64) MinifiedLibraryItem { + return MinifiedLibraryItem{ + ID: contentID, + Ino: contentID, + LibraryID: libID, + FolderID: VirtualFolderID, + IsFile: true, + MtimeMs: updatedAtMs, + CtimeMs: updatedAtMs, + BirthtimeMs: updatedAtMs, + AddedAt: updatedAtMs, + UpdatedAt: updatedAtMs, + MediaType: LibraryMediaType, + Media: minifiedMedia{ + ID: contentID, + Metadata: minifiedMetadata{ + Title: title, + TitleIgnorePrefix: titleIgnorePrefix(title), + Genres: []string{}, + }, + CoverPath: baseURL + "/api/items/" + contentID + "/cover", + Tags: []string{}, + NumTracks: 1, + NumAudioFiles: 1, + }, + NumFiles: 1, } } diff --git a/internal/audiobooks/abs/author_series_handler_test.go b/internal/audiobooks/abs/author_series_handler_test.go index ac36aad6..e4b7d7b6 100644 --- a/internal/audiobooks/abs/author_series_handler_test.go +++ b/internal/audiobooks/abs/author_series_handler_test.go @@ -50,10 +50,108 @@ func TestAuthor_Detail_ReturnsBooks(t *testing.T) { if got["name"] != "Brandon Sanderson" { t.Errorf("name = %v", got["name"]) } - books, _ := got["books"].([]any) - if len(books) != 2 { - t.Errorf("books len = %d, want 2", len(books)) + // Real ABS Author.toOldJSON key set (+ numBooks). + for _, k := range []string{"id", "asin", "name", "description", "imagePath", "libraryId", "addedAt", "updatedAt", "numBooks"} { + if _, ok := got[k]; !ok { + t.Errorf("author object missing key %q", k) + } } + // Author items are real-ABS minified library items under libraryItems. + items, _ := got["libraryItems"].([]any) + if len(items) != 2 { + t.Errorf("libraryItems len = %d, want 2", len(items)) + } + if len(items) > 0 { + b0, _ := items[0].(map[string]any) + if _, ok := b0["ino"]; !ok { + t.Errorf("author libraryItem missing minified key 'ino' (thin stub regression)") + } + } +} + +type libAuthorsStub struct { + noopMediaStore + authors []AuthorSummary +} + +func (s *libAuthorsStub) ListLibraryAuthors(_ context.Context, _ int64, _, _ int, _ string, _ bool, _ catalog.AccessFilter) ([]AuthorSummary, int, error) { + return s.authors, len(s.authors), nil +} + +// TestLibraryAuthors_EnvelopeBranchesOnPagination guards the real ABS +// LibraryController.getAuthors shape: bare { authors: [...] } when NOT +// paginated, paged { results, total, ... } when limit+page are present. +func TestLibraryAuthors_EnvelopeBranchesOnPagination(t *testing.T) { + media := &libAuthorsStub{authors: []AuthorSummary{ + {ID: "1", Name: "Alpha", NumBooks: 2}, + {ID: "2", Name: "Beta", NumBooks: 1}, + }} + h := New(Dependencies{MediaStore: media}) + params := map[string]string{"libraryId": VirtualLibraryID} + + // Non-paginated → { authors: [...] } + rec := dispatchABSWithParams(http.MethodGet, "/api/libraries/"+VirtualLibraryID+"/authors", params, nil, "1", "", h.handleLibraryAuthors) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + authors, ok := got["authors"].([]any) + if !ok { + t.Fatalf("non-paginated response missing 'authors' key; got keys %v", keysOf(got)) + } + if _, isPaged := got["results"]; isPaged { + t.Errorf("non-paginated response must NOT carry paged 'results'") + } + if len(authors) != 2 { + t.Errorf("authors len = %d, want 2", len(authors)) + } + if a0, _ := authors[0].(map[string]any); a0 != nil { + if _, ok := a0["asin"]; !ok { + t.Errorf("author object missing 'asin' (thin shape regression)") + } + } + + // limit present but NO page → still bare { authors: [...] }. Both limit and + // page are required to trigger the paged envelope; a limit-only request + // (e.g. Prologue's ?limit=100) must decode via `authors`, not `results`. + recLimitOnly := dispatchABSWithParams(http.MethodGet, "/api/libraries/"+VirtualLibraryID+"/authors?limit=100", params, nil, "1", "", h.handleLibraryAuthors) + if recLimitOnly.Code != http.StatusOK { + t.Fatalf("limit-only status = %d; body=%s", recLimitOnly.Code, recLimitOnly.Body.String()) + } + var gotLimitOnly map[string]any + if err := json.Unmarshal(recLimitOnly.Body.Bytes(), &gotLimitOnly); err != nil { + t.Fatalf("decode limit-only: %v", err) + } + if _, ok := gotLimitOnly["authors"].([]any); !ok { + t.Errorf("limit-only (no page) response missing bare 'authors' key; got keys %v", keysOf(gotLimitOnly)) + } + if _, isPaged := gotLimitOnly["results"]; isPaged { + t.Errorf("limit-only (no page) response must NOT carry paged 'results'") + } + + // Paginated → paged envelope + rec2 := dispatchABSWithParams(http.MethodGet, "/api/libraries/"+VirtualLibraryID+"/authors?limit=10&page=0", params, nil, "1", "", h.handleLibraryAuthors) + var got2 map[string]any + if err := json.Unmarshal(rec2.Body.Bytes(), &got2); err != nil { + t.Fatalf("decode paged: %v", err) + } + if _, ok := got2["results"]; !ok { + t.Errorf("paginated response missing 'results'; got keys %v", keysOf(got2)) + } + if _, ok := got2["total"]; !ok { + t.Errorf("paginated response missing 'total'") + } +} + +func keysOf(m map[string]any) []string { + ks := make([]string, 0, len(m)) + for k := range m { + ks = append(ks, k) + } + return ks } func TestAuthor_Detail_Unknown_404(t *testing.T) { @@ -86,10 +184,22 @@ func TestSeries_Detail_ReturnsBooks(t *testing.T) { if got["name"] != "Mistborn" { t.Errorf("name = %v", got["name"]) } + // Real ABS Series.toOldJSON key set (+ numBooks/books). + for _, k := range []string{"id", "name", "nameIgnorePrefix", "description", "addedAt", "updatedAt", "libraryId", "numBooks"} { + if _, ok := got[k]; !ok { + t.Errorf("series object missing key %q", k) + } + } books, _ := got["books"].([]any) if len(books) != 2 { t.Errorf("books len = %d, want 2", len(books)) } + if len(books) > 0 { + b0, _ := books[0].(map[string]any) + if _, ok := b0["ino"]; !ok { + t.Errorf("series book missing minified key 'ino' (thin stub regression)") + } + } } func TestSeries_Detail_Unknown_404(t *testing.T) { diff --git a/internal/audiobooks/abs/collections.go b/internal/audiobooks/abs/collections.go index c22d3d0c..d241bfeb 100644 --- a/internal/audiobooks/abs/collections.go +++ b/internal/audiobooks/abs/collections.go @@ -7,7 +7,7 @@ import ( // CollectionStore is the narrow slice of user_personal_collections // (collection_type='manual') and user_personal_collection_items -// (sub_item_id='') the collections handlers need. Implemented by +// (sub_item_id=”) the collections handlers need. Implemented by // ABSCollectionStore in internal/audiobooks/abs_collection_store.go; // post-migration-156 it reads the unified canonical tables. type CollectionStore interface { @@ -53,7 +53,7 @@ type Collection struct { // CollectionItem is the in-memory representation of a // user_personal_collection_items row scoped to a manual collection -// (sub_item_id=''). +// (sub_item_id=”). type CollectionItem struct { CollectionID string LibraryItemID string @@ -69,6 +69,7 @@ type CollectionItem struct { func collectionToABS(c Collection, books []map[string]any) map[string]any { out := map[string]any{ "id": c.ID, + "libraryId": VirtualLibraryID, // real ABS Collection.toOldJSON has libraryId; silo collections are cross-library user-personal "userId": c.UserID, "name": c.Name, "description": c.Description, diff --git a/internal/audiobooks/abs/collections_envelope_test.go b/internal/audiobooks/abs/collections_envelope_test.go index 65f8e8ae..a38b265d 100644 --- a/internal/audiobooks/abs/collections_envelope_test.go +++ b/internal/audiobooks/abs/collections_envelope_test.go @@ -25,7 +25,7 @@ func TestCollectionEnvelope_HasRequiredKeys(t *testing.T) { body, _ := json.Marshal(out) js := string(body) for _, key := range []string{ - `"id":`, `"userId":`, `"name":`, `"description":`, + `"id":`, `"libraryId":`, `"userId":`, `"name":`, `"description":`, `"isPublic":`, `"lastUpdate":`, `"createdAt":`, `"books":`, } { if !strings.Contains(js, key) { diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index ea59995d..e7912309 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -46,7 +46,9 @@ type MediaStore interface { GetAudiobooksByIDs(ctx context.Context, contentIDs []string, access catalog.AccessFilter) (map[string]*models.MediaItem, error) // ListAudiobooks returns a page of audiobooks. When libraryID is non-zero // it filters to items in that media_folder; 0 means all audiobook items. - ListAudiobooks(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter) ([]*models.MediaItem, int, error) + // filter optionally pushes an authors/series/narrators predicate into the + // query (Filter{} for none) so per-author syncs avoid a full-library scan. + ListAudiobooks(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter, filter Filter) ([]*models.MediaItem, int, error) GetMediaFiles(ctx context.Context, contentID string, access catalog.AccessFilter) ([]*models.MediaFile, error) // GetMediaFileByID fetches a single media file by its integer PK. // Used by the ABS file-streaming handler when a caller supplies a @@ -67,12 +69,14 @@ type MediaStore interface { // ListDiscover returns a randomized sampling of audiobooks for the // Home tab's discover shelf (helps new users browse the library). ListDiscover(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]*models.MediaItem, error) - // ListLibraryAuthors returns distinct authors of audiobooks in the - // library along with each author's book count. - ListLibraryAuthors(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]AuthorSummary, error) - // ListLibrarySeries returns distinct series (from audiobook_series) - // represented in the library, ordered by name. - ListLibrarySeries(ctx context.Context, libraryID int64, limit int, access catalog.AccessFilter) ([]SeriesSummary, error) + // ListLibraryAuthors returns one page of distinct audiobook authors (from a + // precomputed materialized view) plus the total author count. sortBy is one + // of "name" (default), "addedAt", or "numBooks"; limit<=0 returns all. + ListLibraryAuthors(ctx context.Context, libraryID int64, limit, offset int, sortBy string, sortDesc bool, access catalog.AccessFilter) ([]AuthorSummary, int, error) + // ListLibrarySeries returns one SQL-paginated page of distinct series (from + // audiobook_series) in the library plus the total series count. limit<=0 + // returns all. + ListLibrarySeries(ctx context.Context, libraryID int64, limit, offset int, access catalog.AccessFilter) ([]SeriesSummary, int, error) // GetAuthorByID returns the author with the given people.id plus // their audiobook list, sorted by title. Returns ErrNotFound when // no people row matches. @@ -223,10 +227,16 @@ type Dependencies struct { TokenStore TokenStore CredValidator ProfileCredentialValidator AccessResolver AccessResolver - Config ConfigProvider - Publisher EventPublisher // may be nil - Recommender Recommender // may be nil - LoginLimiter *LoginLimiter // may be nil — one is created if absent + // UsernameResolver returns the display username for an ABS principal + // (userID, profileID) without re-authenticating. Optional; GET /me falls + // back to the userID when this is nil or returns "". Login gets the + // display name from the credential validator, but /me only has the token + // claims, so it needs this to show the real username instead of the id. + UsernameResolver func(ctx context.Context, userID, profileID string) string + Config ConfigProvider + Publisher EventPublisher // may be nil + Recommender Recommender // may be nil + LoginLimiter *LoginLimiter // may be nil — one is created if absent // InstallID returns the current plugin install ID for building // host-proxy-routable URLs. Defaults to "silo.audiobooks" when nil. InstallID func() string @@ -329,13 +339,17 @@ func (h *Handler) mountRoutes(r chi.Router) { r.Get(prefix+"/status", h.handleABSStatus) } - // Stage 2: login (body credentials). - r.Post("/login", h.handleLogin) - r.Post("/abs/api/login", h.handleLogin) - // Token rotation — mobile clients call this every ~22h to avoid the - // 24h access-token interactive re-login trap. - r.Post("/auth/refresh", h.handleRefresh) - r.Post("/abs/api/auth/refresh", h.handleRefresh) + // Stage 2: login (body credentials). Real ABS serves /login at root, but + // clients differ on the prefix — some POST /api/login or /abs/api/login. + // The rest of the authenticated surface is mounted under both /api and + // /abs/api, so mount login+refresh under the same set; a client posting + // /api/login otherwise 404s and surfaces a generic "unknown error". + for _, prefix := range []string{"", "/api", "/abs/api"} { + r.Post(prefix+"/login", h.handleLogin) + // Token rotation — mobile clients call this every ~22h to avoid the + // 24h access-token interactive re-login trap. + r.Post(prefix+"/auth/refresh", h.handleRefresh) + } // Logout is mounted OUTSIDE bearerAuth so an expired-access client can // still sign out (the primary "sign out" UX moment). The handler parses // the bearer locally, revokes the JTI if parseable, and always returns @@ -414,10 +428,19 @@ func (h *Handler) mountRoutes(r chi.Router) { // PATCH /me/progress/{id}/{episodeId} — podcast episode // progress; audiobook-only catalog, so this is a stub. r.Patch(prefix+"/me/progress/{libraryItemId}/{episodeId}", h.handleSetEpisodeProgress) - // PATCH /session/{sid} — heartbeat: position + time_listening + // POST /session/{sid}/sync — real ABS heartbeat path + // (SessionController.sync). The official ABS mobile/web clients + // POST here; missing it means playback progress never syncs. + r.Post(prefix+"/session/{sid}/sync", h.handleSessionSync) + // PATCH /session/{sid} — silo-native heartbeat alias + // (kept additive for silo's own clients). r.Patch(prefix+"/session/{sid}", h.handleSessionSync) // POST /session/{sid}/close — finalise the play session r.Post(prefix+"/session/{sid}/close", h.handleSessionClose) + // POST /session/local — sync one offline-recorded session + r.Post(prefix+"/session/local", h.handleSyncLocalSession) + // POST /session/local-all — batch-sync offline-recorded sessions + r.Post(prefix+"/session/local-all", h.handleSyncLocalSessions) // Bookmarks — POST/PATCH both upsert; DELETE is idempotent. r.Post(prefix+"/me/item/{itemId}/bookmark", h.handleUpsertBookmark("bookmark_created")) r.Patch(prefix+"/me/item/{itemId}/bookmark", h.handleUpsertBookmark("bookmark_updated")) diff --git a/internal/audiobooks/abs/items_detail_test.go b/internal/audiobooks/abs/items_detail_test.go new file mode 100644 index 00000000..3a344c02 --- /dev/null +++ b/internal/audiobooks/abs/items_detail_test.go @@ -0,0 +1,87 @@ +package abs + +import ( + "encoding/json" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// TestSiloItemToLibraryItemDetail_ExpandedShape guards that GET /items/{id} +// matches real ABS LibraryItem.toOldJSONExpanded + Book.toOldJSONExpanded + +// oldMetadataToJSONExpanded: expanded outer keys, media.size + tracks, and the +// expanded metadata keys (authorName, descriptionPlain, ...). +func TestSiloItemToLibraryItemDetail_ExpandedShape(t *testing.T) { + item := &models.MediaItem{ + ContentID: "book-7", + Title: "The Test", + Overview: "
Hello world
", + People: []models.ItemPerson{ + {Person: models.Person{ID: 5, Name: "Jane Roe"}, Kind: models.PersonKindAuthor}, + {Person: models.Person{ID: 6, Name: "Ann Reader"}, Kind: models.PersonKindNarrator}, + }, + } + files := []*models.MediaFile{ + {FilePath: "/x/part1.mp3", Duration: 120, FileSize: 4096}, + } + + detail := siloItemToLibraryItemDetail(item, files, AudiobookLibrary{ID: 1, Name: "Audiobooks"}, "http://x") + body, err := json.Marshal(detail) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decode: %v", err) + } + + outer := []string{ + "id", "ino", "oldLibraryItemId", "libraryId", "folderId", "path", + "relPath", "isFile", "mtimeMs", "ctimeMs", "birthtimeMs", "addedAt", + "updatedAt", "lastScan", "scanVersion", "isMissing", "isInvalid", + "mediaType", "media", "libraryFiles", "size", + } + for _, k := range outer { + if _, ok := m[k]; !ok { + t.Errorf("expanded item missing outer key %q", k) + } + } + if lf, ok := m["libraryFiles"].([]any); !ok || len(lf) != 1 { + t.Errorf("libraryFiles = %v, want 1 entry", m["libraryFiles"]) + } + if sz, _ := m["size"].(float64); sz != 4096 { + t.Errorf("size = %v, want 4096", m["size"]) + } + + media, _ := m["media"].(map[string]any) + for _, k := range []string{"id", "libraryItemId", "metadata", "coverPath", "tags", "audioFiles", "chapters", "duration", "size", "tracks"} { + if _, ok := media[k]; !ok { + t.Errorf("expanded media missing key %q", k) + } + } + if media["id"] != "book-7" { + t.Errorf("media.id = %v, want book-7", media["id"]) + } + + meta, _ := media["metadata"].(map[string]any) + for _, k := range []string{ + "title", "titleIgnorePrefix", "subtitle", "authors", "authorName", + "authorNameLF", "narrators", "narratorName", "series", "seriesName", + "genres", "publishedYear", "publishedDate", "publisher", "description", + "descriptionPlain", "isbn", "asin", "language", "explicit", "abridged", + } { + if _, ok := meta[k]; !ok { + t.Errorf("expanded metadata missing key %q", k) + } + } + if meta["authorName"] != "Jane Roe" { + t.Errorf("authorName = %v, want Jane Roe", meta["authorName"]) + } + if meta["narratorName"] != "Ann Reader" { + t.Errorf("narratorName = %v, want Ann Reader", meta["narratorName"]) + } + // descriptionPlain strips HTML tags. + if dp, _ := meta["descriptionPlain"].(string); dp != "Hello world" { + t.Errorf("descriptionPlain = %q, want %q", dp, "Hello world") + } +} diff --git a/internal/audiobooks/abs/items_handler.go b/internal/audiobooks/abs/items_handler.go index 8608f040..6f826964 100644 --- a/internal/audiobooks/abs/items_handler.go +++ b/internal/audiobooks/abs/items_handler.go @@ -2,6 +2,7 @@ package abs import ( "context" + "encoding/json" "net/http" "github.com/go-chi/chi/v5" @@ -126,9 +127,14 @@ func (h *Handler) handleSimilarItems(w http.ResponseWriter, r *http.Request) { // handleItemsInProgress — GET /abs/api/me/items-in-progress // -// Returns the Continue Listening shelf. Queries the ProgressStore for in- -// progress rows, then hydrates each with a summary LibraryItem from the -// catalog. Items without a matching catalog entry are skipped silently. +// Matches server/controllers/MeController.js `getAllLibraryItemsInProgress`: +// the envelope is `{ libraryItems: [...] }` and each entry is the item's +// `toOldJSONMinified()` shape spread with a flat `progressLastUpdate` (ms) +// field — real ABS does NOT wrap progress in a nested `userMediaProgress` +// object for this endpoint (that shape belongs to other responses, e.g. +// item-detail). Queries the ProgressStore for in-progress rows, then +// hydrates each with a minified LibraryItem from the catalog. Items +// without a matching catalog entry are skipped silently. func (h *Handler) handleItemsInProgress(w http.ResponseWriter, r *http.Request) { a, ok := absAuthFrom(r) if !ok || a.UserID == "" { @@ -174,25 +180,21 @@ func (h *Handler) handleItemsInProgress(w http.ResponseWriter, r *http.Request) if si == nil { continue } - li := siloItemToLibraryItem(si, lib, baseURL) - items = append(items, map[string]any{ - "id": li.ID, - "libraryId": li.LibraryID, - "folderId": li.FolderID, - "mediaType": li.MediaType, - "media": li.Media, - "numTracks": li.NumTracks, - "addedAt": li.AddedAt, - "updatedAt": li.UpdatedAt, - "userMediaProgress": map[string]any{ - "id": a.UserID + "-" + p.ContentID, - "libraryItemId": p.ContentID, - "currentTime": p.CurrentSeconds, - "progress": p.ProgressPct, - "isFinished": p.IsFinished, - "lastUpdate": p.UpdatedAt.UnixMilli(), - }, - }) + mli := Minify(siloItemToLibraryItem(si, lib, baseURL)) + wire := minifiedItemToWireMap(mli) + wire["progressLastUpdate"] = p.UpdatedAt.UnixMilli() + items = append(items, wire) } writeJSON(w, http.StatusOK, map[string]any{"libraryItems": items}) } + +// minifiedItemToWireMap reuses the json tags on MinifiedLibraryItem so a +// caller can merge extra keys (e.g. progressLastUpdate) into it inside a +// heterogeneous map[string]any envelope, mirroring the spread-operator +// pattern real ABS uses (`{ ...libraryItem.toOldJSONMinified(), ... }`). +func minifiedItemToWireMap(mli MinifiedLibraryItem) map[string]any { + b, _ := json.Marshal(mli) + var m map[string]any + _ = json.Unmarshal(b, &m) + return m +} diff --git a/internal/audiobooks/abs/items_in_progress_test.go b/internal/audiobooks/abs/items_in_progress_test.go new file mode 100644 index 00000000..46e0e72a --- /dev/null +++ b/internal/audiobooks/abs/items_in_progress_test.go @@ -0,0 +1,136 @@ +package abs + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +// inProgressStubMediaStore backs the /me/items-in-progress shape tests. +type inProgressStubMediaStore struct { + noopMediaStore + libs []AudiobookLibrary + byID map[string]*models.MediaItem +} + +func (s *inProgressStubMediaStore) ListAudiobookLibraries(context.Context, catalog.AccessFilter) ([]AudiobookLibrary, error) { + return s.libs, nil +} + +func (s *inProgressStubMediaStore) GetAudiobooksByIDs(_ context.Context, ids []string, _ catalog.AccessFilter) (map[string]*models.MediaItem, error) { + out := make(map[string]*models.MediaItem, len(ids)) + for _, id := range ids { + if it, ok := s.byID[id]; ok { + out[id] = it + } + } + return out, nil +} + +// inProgressFakeProgressStore returns a fixed set of progress rows. +type inProgressFakeProgressStore struct { + fakeProgressStore + rows []ProgressRow +} + +func (f *inProgressFakeProgressStore) ListProgressForAudiobooks(context.Context, string, string, int) ([]ProgressRow, error) { + return f.rows, nil +} + +// TestItemsInProgress_EnvelopeAndItemShape asserts the response matches +// real ABS MeController.getAllLibraryItemsInProgress: envelope key +// "libraryItems", each entry is the minified library item spread with a +// flat "progressLastUpdate" field — no nested "userMediaProgress" object. +func TestItemsInProgress_EnvelopeAndItemShape(t *testing.T) { + updatedAt := time.Now() + media := &inProgressStubMediaStore{ + libs: []AudiobookLibrary{{ID: 1, Name: "Audiobooks", Type: "audiobooks"}}, + byID: map[string]*models.MediaItem{ + "book-1": {ContentID: "book-1", Title: "In Progress Book"}, + }, + } + progress := &inProgressFakeProgressStore{ + rows: []ProgressRow{ + { + UserID: "1", + ContentID: "book-1", + CurrentSeconds: 120, + ProgressPct: 0.25, + IsFinished: false, + UpdatedAt: updatedAt, + }, + }, + } + h := New(Dependencies{MediaStore: media, ProgressStore: progress}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/me/items-in-progress", nil, nil, "1", "", h.handleItemsInProgress) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + items, ok := got["libraryItems"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("libraryItems = %v, want 1 entry", got["libraryItems"]) + } + entry, ok := items[0].(map[string]any) + if !ok { + t.Fatalf("entry not an object: %v", items[0]) + } + + if entry["id"] != "book-1" { + t.Errorf("id = %v, want book-1", entry["id"]) + } + if _, hasMedia := entry["media"]; !hasMedia { + t.Errorf("entry missing minified 'media' key: %v", entry) + } + lastUpdate, ok := entry["progressLastUpdate"].(float64) + if !ok { + t.Fatalf("entry missing progressLastUpdate: %v", entry) + } + if int64(lastUpdate) != updatedAt.UnixMilli() { + t.Errorf("progressLastUpdate = %v, want %v", int64(lastUpdate), updatedAt.UnixMilli()) + } + if _, hasWrapper := entry["userMediaProgress"]; hasWrapper { + t.Errorf("entry has userMediaProgress wrapper, real ABS flattens progress instead: %v", entry) + } +} + +// TestItemsInProgress_NoProgressStore_ReturnsEmptyEnvelope covers the +// no-store-configured fallback. +func TestItemsInProgress_NoProgressStore_ReturnsEmptyEnvelope(t *testing.T) { + h := New(Dependencies{MediaStore: &inProgressStubMediaStore{}}) + rec := dispatchABSWithParams(http.MethodGet, "/api/me/items-in-progress", nil, nil, "1", "", h.handleItemsInProgress) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + items, ok := got["libraryItems"].([]any) + if !ok || len(items) != 0 { + t.Fatalf("libraryItems = %v, want empty array", got["libraryItems"]) + } +} + +// TestItemsInProgress_Unauthenticated_401 covers the auth guard: no +// ctxAuth in the request context (bearerAuth middleware never ran). +func TestItemsInProgress_Unauthenticated_401(t *testing.T) { + h := New(Dependencies{MediaStore: &inProgressStubMediaStore{}}) + req := httptest.NewRequest(http.MethodGet, "/api/me/items-in-progress", nil) + rec := httptest.NewRecorder() + h.handleItemsInProgress(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } +} diff --git a/internal/audiobooks/abs/libraries_handler.go b/internal/audiobooks/abs/libraries_handler.go index facf034a..8e15f925 100644 --- a/internal/audiobooks/abs/libraries_handler.go +++ b/internal/audiobooks/abs/libraries_handler.go @@ -44,21 +44,25 @@ func (h *Handler) handleLibraryDetail(w http.ResponseWriter, r *http.Request) { if !ok { return } - resp := map[string]any{ - "library": audiobookLibraryMap(lib), + library := audiobookLibraryMap(lib) + // Real ABS LibraryController.findOne returns the library object DIRECTLY + // when there is no ?include=filterdata; only the filterdata request wraps + // it in { filterdata, issues, numUserPlaylists, customMetadataProviders, + // library }. Returning the wrapped shape unconditionally breaks clients + // that read library fields off the top level. + if !includeHas(r.URL.Query().Get("include"), "filterdata") { + writeJSON(w, http.StatusOK, library) + return } - if includeHas(r.URL.Query().Get("include"), "filterdata") { - resp["filterdata"] = h.buildFilterData(r, lib) - resp["issues"] = 0 - // numUserPlaylists drives the bottom-nav "Playlists" tab - // visibility on the ABS mobile client (BookshelfNavBar.vue:25 - // gates the tab on `numUserPlaylists` being truthy). Comment - // in plugins/server.js:129 confirms "precise number is not - // necessary" — we just need a non-zero count when the caller - // has any playlists, so the ListUserPlaylists len suffices. - resp["numUserPlaylists"] = h.countUserPlaylists(r) - } - writeJSON(w, http.StatusOK, resp) + // numUserPlaylists drives the bottom-nav "Playlists" tab visibility on the + // ABS mobile client (BookshelfNavBar.vue gates the tab on it being truthy). + writeJSON(w, http.StatusOK, map[string]any{ + "filterdata": h.buildFilterData(r, lib), + "issues": 0, + "numUserPlaylists": h.countUserPlaylists(r), + "customMetadataProviders": []any{}, + "library": library, + }) } // countUserPlaylists returns the playlist count for the authenticated @@ -99,14 +103,14 @@ func (h *Handler) buildFilterData(r *http.Request, lib AudiobookLibrary) map[str access, _, _ := h.accessFilterFromRequest(r) authorObjs := []AuthorObj{} - if rows, err := h.deps.MediaStore.ListLibraryAuthors(ctx, lib.ID, fetchCap, access); err == nil { + if rows, _, err := h.deps.MediaStore.ListLibraryAuthors(ctx, lib.ID, fetchCap, 0, "name", false, access); err == nil { for _, a := range rows { authorObjs = append(authorObjs, AuthorObj{ID: a.ID, Name: a.Name}) } } seriesObjs := []SeriesObj{} - if rows, err := h.deps.MediaStore.ListLibrarySeries(ctx, lib.ID, fetchCap, access); err == nil { + if rows, _, err := h.deps.MediaStore.ListLibrarySeries(ctx, lib.ID, fetchCap, 0, access); err == nil { for _, s := range rows { seriesObjs = append(seriesObjs, SeriesObj{ID: s.ID, Name: s.Name}) } @@ -152,7 +156,11 @@ func (h *Handler) handleLibraryItems(w http.ResponseWriter, r *http.Request) { sortBy := q.Get("sort") sortDesc := q.Get("desc") == "1" filterBy := q.Get("filter") - minified := q.Get("minified") == "1" + // Real ABS getByFilterAndSort ALWAYS serializes list items minified + // (LibraryItem.toOldJSONMinified); the non-minified hybrid is a shape no + // real client requests. Default to minified; only an explicit minified=0 + // opts into the full shape. + minified := q.Get("minified") != "0" collapseSeries := q.Get("collapseseries") == "1" include := q.Get("include") @@ -164,18 +172,31 @@ func (h *Handler) handleLibraryItems(w http.ResponseWriter, r *http.Request) { return } - // Fetch the full visible library when filtering so local post-filter - // cannot truncate candidates before applying the predicate. - fetchLimit := limit - if hasFilter || collapseSeries || limit == 0 { - fetchLimit = 0 + // authors/series/narrators filters push down into SQL (indexed) so we never + // load + hydrate the whole library. This applies even with collapseseries=1 + // (the client's per-artist album sync): the SQL filter reduces to a handful + // of rows, then collapse + paging run in Go over that small set. Only + // progress/genre/tag/language filters still need the Go post-filter and the + // full fetch. + pushDown := hasFilter && + (filter.Kind == FilterAuthors || filter.Kind == FilterSeries || filter.Kind == FilterNarrators) + sqlFilter := Filter{} + if pushDown { + sqlFilter = filter } - fetchOffset := 0 - if !hasFilter && !collapseSeries && limit > 0 { + goFilter := hasFilter && !pushDown + + // SQL paginates only when nothing is post-processed in Go (no Go filter, no + // collapse) and the limit is positive; otherwise fetch the (now + // SQL-filtered, hence small) candidate set in full and slice in Go. + sqlPaginated := !goFilter && !collapseSeries && limit > 0 + fetchLimit, fetchOffset := 0, 0 + if sqlPaginated { + fetchLimit = limit fetchOffset = page * limit } - items, total, err := h.deps.MediaStore.ListAudiobooks(r.Context(), lib.ID, fetchLimit, fetchOffset, access) + items, total, err := h.deps.MediaStore.ListAudiobooks(r.Context(), lib.ID, fetchLimit, fetchOffset, access, sqlFilter) if err != nil { http.Error(w, "list audiobooks: "+err.Error(), http.StatusInternalServerError) return @@ -188,8 +209,8 @@ func (h *Handler) handleLibraryItems(w http.ResponseWriter, r *http.Request) { all = append(all, siloItemToLibraryItem(item, lib, baseURL)) } - // Local filter (post-fetch). - if hasFilter { + // Local filter (post-fetch) — only for filters not pushed into SQL. + if goFilter { filtered := make([]LibraryItem, 0, len(all)) for _, it := range all { if filter.Matches(it, false, false, false) { @@ -207,9 +228,9 @@ func (h *Handler) handleLibraryItems(w http.ResponseWriter, r *http.Request) { total = len(collapsed) } - // Slice for page/limit. + // Slice for page/limit. When SQL already paginated we serve the rows as-is. pageStart, pageEnd := 0, len(collapsed) - if limit > 0 && (hasFilter || collapseSeries) { + if limit > 0 && !sqlPaginated { pageStart = page * limit if pageStart > len(collapsed) { pageStart = len(collapsed) @@ -313,48 +334,40 @@ func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) { return } limit, page := readPagedQuery(r, 50) - // Fetch the full list (capped at 5000) and paginate locally so the - // envelope's total reflects real DB count, not the page slice length. - // ABS clients use total to decide whether to fetch page 2. - const fetchCap = 5000 + sortBy := r.URL.Query().Get("sort") + sortDesc := r.URL.Query().Get("desc") == "1" access, _, err := h.accessFilterFromRequest(r) if err != nil { http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden) return } - authors, err := h.deps.MediaStore.ListLibraryAuthors(r.Context(), lib.ID, fetchCap, access) + // Reads the precomputed author materialized view: indexed paginated read + + // trivial count, so large libraries aren't capped and full syncs don't blow + // the client's background-task window. limit=0 means "return all". + offset := 0 + if limit > 0 { + offset = page * limit + } + pageAuthors, total, err := h.deps.MediaStore.ListLibraryAuthors(r.Context(), lib.ID, limit, offset, sortBy, sortDesc, access) if err != nil { http.Error(w, "list authors: "+err.Error(), http.StatusInternalServerError) return } libID := audiobookLibraryID(lib) - total := len(authors) - // Local slice for the requested page. - // ABS contract: limit=0 means "return all". - var pageAuthors []AuthorSummary - if limit == 0 { - pageAuthors = authors - } else { - start := page * limit - end := start + limit - if start > total { - start = total - } - if end > total { - end = total - } - pageAuthors = authors[start:end] - } results := make([]map[string]any, 0, len(pageAuthors)) for _, a := range pageAuthors { - results = append(results, map[string]any{ - "id": a.ID, - "name": a.Name, - "numBooks": a.NumBooks, - "libraryId": libID, - }) + results = append(results, authorObjectABS(a.ID, a.Name, libID, a.NumBooks)) } - writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, "name", false, "", false, "")) + // Real ABS LibraryController.getAuthors branches on isPaginated = + // (limit present & numeric) && (page present & numeric): paged envelope + // when true, else a bare { authors: [...] }. Emitting the paged shape for + // the non-paginated request crashes clients that key on `authors`. + q := r.URL.Query() + if q.Get("limit") != "" && q.Get("page") != "" { + writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, "name", false, "", false, "")) + return + } + writeJSON(w, http.StatusOK, map[string]any{"authors": results}) } // handleLibrarySeries — GET /abs/api/libraries/{id}/series @@ -368,78 +381,56 @@ func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) { return } limit, page := readPagedQuery(r, 25) - const fetchCap = 5000 access, _, err := h.accessFilterFromRequest(r) if err != nil { http.Error(w, "resolve access: "+err.Error(), http.StatusForbidden) return } - series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, fetchCap, access) + // Paginate in SQL with a separate COUNT so large libraries aren't truncated + // at a fixed cap. limit=0 means "return all" (ABS contract). + offset := 0 + if limit > 0 { + offset = page * limit + } + pageSeries, total, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, limit, offset, access) if err != nil { http.Error(w, "list series: "+err.Error(), http.StatusInternalServerError) return } libID := audiobookLibraryID(lib) baseURL := h.absBaseURL(r) - total := len(series) - // ABS contract: limit=0 means "return all". - var pageSeries []SeriesSummary - if limit == 0 { - pageSeries = series - } else { - start := page * limit - end := start + limit - if start > total { - start = total - } - if end > total { - end = total - } - pageSeries = series[start:end] - } results := make([]map[string]any, 0, len(pageSeries)) for _, s := range pageSeries { - // books[] is what LazySeriesCard reads to populate the - // GroupCover stack. Each entry is a minified LibraryItem with - // the cover URL on media.coverPath; the mobile client's - // globals/getLibraryItemCoverSrc getter requires this field - // to render any cover image, otherwise the card falls back - // to a name-only placeholder. - books := make([]map[string]any, 0, len(s.Books)) + // books[] is what LazySeriesCard reads to populate the GroupCover + // stack. Real ABS emits FULL minified library items here; a thin stub + // crashes strict clients (Plappa) on the first missing required key. + books := make([]MinifiedLibraryItem, 0, len(s.Books)) for _, bp := range s.Books { updatedMs := int64(0) if !bp.UpdatedAt.IsZero() { updatedMs = bp.UpdatedAt.UnixMilli() } - books = append(books, map[string]any{ - "id": bp.ContentID, - "libraryId": libID, - "mediaType": LibraryMediaType, - "updatedAt": updatedMs, - "media": map[string]any{ - "coverPath": baseURL + "/api/items/" + bp.ContentID + "/cover", - "metadata": map[string]any{"title": bp.Title}, - }, - }) + books = append(books, seriesBookMinified(bp.ContentID, bp.Title, libID, baseURL, updatedMs)) } - results = append(results, map[string]any{ - "id": s.ID, - "name": s.Name, - "numBooks": s.NumBooks, - "libraryId": libID, - "addedAt": 0, - "books": books, - }) + obj := seriesObjectABS(s.ID, s.Name, libID, s.NumBooks) + obj["books"] = books + results = append(results, obj) } writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, "name", false, "", false, "")) } // handleLibrarySearch — GET /abs/api/libraries/{id}/search?q=…&limit=… -// Returns matching books grouped under "book", with empty arrays for the -// other ABS-standard buckets (podcast, series, authors, tags). Bucket -// names match continuum-plugin-audiobooks exactly: note "authors" plural, -// not "author" — ABS mobile clients key off the plural form and a -// singular bucket is silently dropped. +// +// Matches server/utils/queries/libraryItemsBookFilters.js `search()` (real +// ABS branches to the book-filter search for a non-podcast library, which +// is all Silo ever serves). That function returns exactly these keys: +// book, narrators, tags, genres, series, authors — there is NO "podcast" +// key for a book-library search (that only appears from the separate +// podcast-filter branch). Each book entry is `{ libraryItem }` — real ABS +// does not include matchKey/matchText on book entries (those only exist +// on the interactive-search HTML autocomplete, not this JSON endpoint). +// We keep an extra empty "podcast" bucket anyway since an extra key never +// crashes a strict client, only a missing one does. func (h *Handler) handleLibrarySearch(w http.ResponseWriter, r *http.Request) { lib, ok := h.resolveLibrary(w, r) if !ok { @@ -451,11 +442,13 @@ func (h *Handler) handleLibrarySearch(w http.ResponseWriter, r *http.Request) { limit = n } empty := map[string]any{ - "book": []any{}, - "podcast": []any{}, - "series": []any{}, - "authors": []any{}, - "tags": []any{}, + "book": []any{}, + "podcast": []any{}, + "narrators": []any{}, + "tags": []any{}, + "genres": []any{}, + "series": []any{}, + "authors": []any{}, } if q == "" { writeJSON(w, http.StatusOK, empty) @@ -472,16 +465,89 @@ func (h *Handler) handleLibrarySearch(w http.ResponseWriter, r *http.Request) { return } baseURL := h.absBaseURL(r) + libID := audiobookLibraryID(lib) books := make([]map[string]any, 0, len(items)) for _, it := range items { books = append(books, map[string]any{ "libraryItem": siloItemToLibraryItem(it, lib, baseURL), - "matchKey": "title", - "matchText": it.Title, }) } + + // Best-effort author/series buckets: silo has no dedicated search-scoped + // store query for these yet, so we reuse the existing aggregate listers + // (capped, same pattern as buildFilterData/handleLibraryAuthors/ + // handleLibrarySeries) and filter client-side on a case-insensitive + // substring match. narrators/tags/genres have no backing aggregation + // query at all in silo's catalog today and stay empty-but-present. + qLower := strings.ToLower(q) + const fetchCap = 5000 + + authorsOut := []any{} + if rows, _, err := h.deps.MediaStore.ListLibraryAuthors(r.Context(), lib.ID, fetchCap, 0, "name", false, access); err == nil { + for _, a := range rows { + if !strings.Contains(strings.ToLower(a.Name), qLower) { + continue + } + authorsOut = append(authorsOut, map[string]any{ + "id": a.ID, + "name": a.Name, + "numBooks": a.NumBooks, + "libraryId": libID, + }) + if len(authorsOut) >= limit { + break + } + } + } + + seriesOut := []any{} + if rows, _, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, fetchCap, 0, access); err == nil { + for _, s := range rows { + if !strings.Contains(strings.ToLower(s.Name), qLower) { + continue + } + // Real ABS wraps series search hits as { series, books } — the + // series sub-object is the plain Series.toOldJSON() shape (no + // numBooks field there; we add it anyway since an extra key is + // harmless), matched here with the same per-book thin map + // handleLibrarySeries above uses for its books[] entries. + seriesBooks := make([]map[string]any, 0, len(s.Books)) + for _, bp := range s.Books { + updatedMs := int64(0) + if !bp.UpdatedAt.IsZero() { + updatedMs = bp.UpdatedAt.UnixMilli() + } + seriesBooks = append(seriesBooks, map[string]any{ + "id": bp.ContentID, + "libraryId": libID, + "mediaType": LibraryMediaType, + "updatedAt": updatedMs, + "media": map[string]any{ + "coverPath": baseURL + "/api/items/" + bp.ContentID + "/cover", + "metadata": map[string]any{"title": bp.Title}, + }, + }) + } + seriesOut = append(seriesOut, map[string]any{ + "series": map[string]any{ + "id": s.ID, + "name": s.Name, + "numBooks": s.NumBooks, + "libraryId": libID, + "addedAt": 0, + }, + "books": seriesBooks, + }) + if len(seriesOut) >= limit { + break + } + } + } + out := empty out["book"] = books + out["authors"] = authorsOut + out["series"] = seriesOut writeJSON(w, http.StatusOK, out) } @@ -535,16 +601,23 @@ func (h *Handler) handlePersonalized(w http.ResponseWriter, r *http.Request) { } libID := audiobookLibraryID(lib) - if series, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, shelfLimit, access); err == nil && len(series) > 0 { + if series, _, err := h.deps.MediaStore.ListLibrarySeries(r.Context(), lib.ID, shelfLimit, 0, access); err == nil && len(series) > 0 { recent := make([]map[string]any, 0, len(series)) for _, s := range series { - recent = append(recent, map[string]any{ - "id": s.ID, - "name": s.Name, - "numBooks": s.NumBooks, - "libraryId": libID, - "books": []any{}, - }) + // Full real-ABS series object + minified books (same shape as + // /libraries/{id}/series) so the recent-series shelf card decodes + // identically and its cover stack has real items. + obj := seriesObjectABS(s.ID, s.Name, libID, s.NumBooks) + books := make([]MinifiedLibraryItem, 0, len(s.Books)) + for _, bp := range s.Books { + updatedMs := int64(0) + if !bp.UpdatedAt.IsZero() { + updatedMs = bp.UpdatedAt.UnixMilli() + } + books = append(books, seriesBookMinified(bp.ContentID, bp.Title, libID, baseURL, updatedMs)) + } + obj["books"] = books + recent = append(recent, obj) } shelves[3]["entities"] = recent shelves[3]["total"] = len(recent) @@ -652,22 +725,28 @@ func siloItemToLibraryItem(item *models.MediaItem, lib AudiobookLibrary, baseURL FolderID: VirtualFolderID, Path: "", RelPath: "", + IsFile: true, MtimeMs: addedAtMs, CtimeMs: addedAtMs, BirthtimeMs: addedAtMs, MediaType: LibraryMediaType, Media: LibraryItemMedia{ - Metadata: meta, - Duration: duration, - CoverPath: coverPath, - AudioFiles: []AudioTrack{}, - Tracks: []AudioTrack{}, - Chapters: []ChapterABS{}, - NumTracks: 0, // populated by item-detail handler - Tags: []string{}, + ID: item.ContentID, + LibraryItemID: item.ContentID, + Metadata: meta, + Duration: duration, + CoverPath: coverPath, + AudioFiles: []AudioTrack{}, + Tracks: []AudioTrack{}, + Chapters: []ChapterABS{}, + NumTracks: 0, // populated by item-detail handler + Tags: []string{}, }, - AddedAt: addedAtMs, - UpdatedAt: updatedAtMs, + LibraryFiles: []map[string]any{}, // populated by item-detail handler + LastScan: addedAtMs, + ScanVersion: ServerVersion, + AddedAt: addedAtMs, + UpdatedAt: updatedAtMs, } } @@ -681,6 +760,8 @@ func siloItemToLibraryItem(item *models.MediaItem, lib AudiobookLibrary, baseURL func siloItemToMetadata(item *models.MediaItem) Metadata { authors := make([]AuthorObj, 0) narrators := make([]string, 0) + authorNames := make([]string, 0) + lfNames := make([]string, 0) for _, p := range item.People { switch p.Kind { @@ -689,12 +770,15 @@ func siloItemToMetadata(item *models.MediaItem) Metadata { ID: strconv.FormatInt(p.ID, 10), Name: p.Name, }) + authorNames = append(authorNames, p.Name) + lfNames = append(lfNames, lastFirst(p.Name)) case models.PersonKindNarrator: narrators = append(narrators, p.Name) } } series := make([]SeriesObj, 0, len(item.AudiobookSeries)) + seriesName := "" for _, membership := range item.AudiobookSeries { name := strings.TrimSpace(membership.Name) if name == "" { @@ -705,6 +789,12 @@ func siloItemToMetadata(item *models.MediaItem) Metadata { obj.Sequence = strconv.FormatFloat(*membership.Index, 'f', -1, 64) } series = append(series, obj) + if seriesName == "" { + seriesName = name + if obj.Sequence != "" { + seriesName += " #" + obj.Sequence + } + } } publishedYear := "" @@ -727,15 +817,22 @@ func siloItemToMetadata(item *models.MediaItem) Metadata { } return Metadata{ - Title: item.Title, - Authors: authors, - Narrators: narrators, - Series: series, - Description: item.Overview, - PublishedYear: publishedYear, - Publisher: publisher, - Genres: genres, - Tags: tags, + Title: item.Title, + TitleIgnorePrefix: titleIgnorePrefix(item.Title), + Authors: authors, + AuthorName: strings.Join(authorNames, ", "), + AuthorNameLF: strings.Join(lfNames, ", "), + Narrators: narrators, + NarratorName: strings.Join(narrators, ", "), + Series: series, + SeriesName: seriesName, + Description: item.Overview, + DescriptionPlain: stripHTML(item.Overview), + PublishedYear: publishedYear, + Publisher: publisher, + Genres: genres, + Language: "en", + Tags: tags, } } @@ -747,12 +844,16 @@ func siloItemToLibraryItemDetail(item *models.MediaItem, files []*models.MediaFi tracks := siloFilesToAudioTracks(item.ContentID, files, baseURL, "") - // Recompute duration from files if the item's Runtime is zero. - totalDuration := base.Media.Duration + // media.duration is the summed track duration (real ABS: sum of audio file + // durations), NOT the item's Runtime — Runtime is often stale/mis-scanned + // (e.g. 222s for a 3.7h book), which desyncs the player's scrubber. Fall + // back to Runtime only when there are no tracks to sum. + totalDuration := float64(0) + for _, t := range tracks { + totalDuration += t.Duration + } if totalDuration == 0 { - for _, t := range tracks { - totalDuration += t.Duration - } + totalDuration = base.Media.Duration } // Chapters from the first file that has them. @@ -771,12 +872,34 @@ func siloItemToLibraryItemDetail(item *models.MediaItem, files []*models.MediaFi } } + // libraryFiles + summed size mirror real ABS toOldJSONExpanded. Each entry + // is the real-ABS library file shape (ino + file metadata + fileType). + nowMs := time.Now().UnixMilli() + libraryFiles := make([]map[string]any, 0, len(tracks)) + var totalSize int64 + for _, t := range tracks { + if t.Metadata != nil { + totalSize += t.Metadata.Size + } + libraryFiles = append(libraryFiles, map[string]any{ + "ino": t.Ino, + "metadata": t.Metadata, + "isSupplementary": false, + "addedAt": nowMs, + "updatedAt": nowMs, + "fileType": "audio", + }) + } + base.Media.AudioFiles = tracks base.Media.Tracks = tracks base.Media.Chapters = chapters base.Media.NumTracks = len(tracks) base.Media.Duration = totalDuration + base.Media.Size = totalSize base.NumTracks = len(tracks) + base.LibraryFiles = libraryFiles + base.Size = totalSize return base } @@ -843,6 +966,7 @@ func siloFilesToAudioTracks(contentID string, files []*models.MediaFile, baseURL TimeBase: "1/14112000", Channels: channels, ChannelLayout: channelLayout, + Chapters: []ChapterABS{}, EmbeddedCoverArt: nil, MetaTags: map[string]string{}, MimeType: mimeType, diff --git a/internal/audiobooks/abs/libraries_shape_test.go b/internal/audiobooks/abs/libraries_shape_test.go new file mode 100644 index 00000000..292faf6c --- /dev/null +++ b/internal/audiobooks/abs/libraries_shape_test.go @@ -0,0 +1,113 @@ +package abs + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +type libStub struct { + noopMediaStore + libs []AudiobookLibrary +} + +func (s *libStub) ListAudiobookLibraries(_ context.Context, _ catalog.AccessFilter) ([]AudiobookLibrary, error) { + return s.libs, nil +} + +// TestAudiobookLibraryMap_FullShape asserts the library object matches real +// ABS Library.toOldJSON (12 keys) — a strict client decodes the library model +// and crashes on any missing key. +func TestAudiobookLibraryMap_FullShape(t *testing.T) { + body, _ := json.Marshal(audiobookLibraryMap(AudiobookLibrary{ID: 18, Name: "Audiobooks"})) + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{ + "id", "name", "folders", "displayOrder", "icon", "mediaType", + "provider", "settings", "lastScan", "lastScanVersion", "createdAt", "lastUpdate", + } { + if _, ok := m[k]; !ok { + t.Errorf("library object missing key %q", k) + } + } + folders, _ := m["folders"].([]any) + if len(folders) != 1 { + t.Fatalf("folders len = %d, want 1", len(folders)) + } + f0, _ := folders[0].(map[string]any) + for _, k := range []string{"id", "fullPath", "libraryId", "addedAt"} { + if _, ok := f0[k]; !ok { + t.Errorf("folder missing key %q", k) + } + } +} + +// TestLibraryDetail_UnwrappedWithoutInclude: real ABS findOne returns the +// library object directly (not { library: ... }) when no include=filterdata. +func TestLibraryDetail_UnwrappedWithoutInclude(t *testing.T) { + media := &libStub{libs: []AudiobookLibrary{{ID: 18, Name: "Audiobooks", Type: "audiobooks"}}} + h := New(Dependencies{MediaStore: media}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/libraries/18", map[string]string{"libraryId": "18"}, nil, "1", "", h.handleLibraryDetail) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if _, wrapped := got["library"]; wrapped { + t.Errorf("response must be unwrapped (no 'library' key) without include") + } + if got["id"] != "18" { + t.Errorf("id = %v, want 18 (unwrapped library object)", got["id"]) + } + if _, ok := got["folders"]; !ok { + t.Errorf("unwrapped library missing 'folders'") + } +} + +// TestLibraryDetail_WrappedWithInclude: with include=filterdata the response +// wraps in { filterdata, issues, numUserPlaylists, customMetadataProviders, library }. +func TestLibraryDetail_WrappedWithInclude(t *testing.T) { + media := &libStub{libs: []AudiobookLibrary{{ID: 18, Name: "Audiobooks", Type: "audiobooks"}}} + h := New(Dependencies{MediaStore: media}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/libraries/18?include=filterdata", map[string]string{"libraryId": "18"}, nil, "1", "", h.handleLibraryDetail) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"filterdata", "issues", "numUserPlaylists", "customMetadataProviders", "library"} { + if _, ok := got[k]; !ok { + t.Errorf("include=filterdata response missing key %q", k) + } + } +} + +// TestLibraries_WrappedEnvelope: GET /libraries returns { libraries: [...] }. +func TestLibraries_WrappedEnvelope(t *testing.T) { + media := &libStub{libs: []AudiobookLibrary{{ID: 18, Name: "Audiobooks", Type: "audiobooks"}}} + h := New(Dependencies{MediaStore: media}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/libraries", nil, nil, "1", "", h.handleLibraries) + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + libs, ok := got["libraries"].([]any) + if !ok { + t.Fatalf("missing 'libraries' array; keys %v", keysOf(got)) + } + if len(libs) != 1 { + t.Errorf("libraries len = %d, want 1", len(libs)) + } +} diff --git a/internal/audiobooks/abs/listening_stats_handler.go b/internal/audiobooks/abs/listening_stats_handler.go index dc547d0b..665adfd4 100644 --- a/internal/audiobooks/abs/listening_stats_handler.go +++ b/internal/audiobooks/abs/listening_stats_handler.go @@ -6,6 +6,8 @@ import ( "strconv" "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/models" ) func (h *Handler) handleListeningStats(w http.ResponseWriter, r *http.Request) { @@ -27,30 +29,67 @@ func (h *Handler) handleListeningStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, statsToABS(stats)) } +// handleListeningSessions — GET /me/listening-sessions +// +// Real ABS (MeController.getListeningSessions) returns +// { total, numPages, page, itemsPerPage, sessions } — NOT the generic +// pagedEnvelope shape used by browse endpoints (results/sortBy/filterBy). +// Each entry in `sessions` must match PlaybackSession.toJSON() key-for-key +// (server/objects/PlaybackSession.js upstream) or strict decoders +// (Flutter/Swift) throw keyNotFound on the first missing field. func (h *Handler) handleListeningSessions(w http.ResponseWriter, r *http.Request) { a, ok := absAuthFrom(r) if !ok || a.UserID == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } + itemsPerPage, page := readPagedQuery(r, 10) if h.deps.PlaybackSessionStore == nil { - writeJSON(w, http.StatusOK, pagedEnvelope([]any{}, 0, 30, 0, "started_at", true, "", false, "")) + writeJSON(w, http.StatusOK, listeningSessionsEnvelope([]map[string]any{}, 0, itemsPerPage, page)) return } - limit, page := readPagedQuery(r, 30) - sessions, total, err := h.deps.PlaybackSessionStore.ListClosedSessions(r.Context(), a.UserID, a.ProfileID, limit, page*limit) + sessions, total, err := h.deps.PlaybackSessionStore.ListClosedSessions(r.Context(), a.UserID, a.ProfileID, itemsPerPage, page*itemsPerPage) if err != nil { slog.Error("abs listening sessions failed", "err", err, "user", a.UserID) http.Error(w, "sessions unavailable", http.StatusInternalServerError) return } + + // Best-effort batch hydration of mediaMetadata/displayTitle/displayAuthor + // for every session's content item. A lookup failure (deleted item, + // access revoked, store error) must never crash the response — the + // session just falls back to a placeholder mediaMetadata shape built + // from a stub item so every key strict clients expect is still present. + items := map[string]*models.MediaItem{} + if h.deps.MediaStore != nil && len(sessions) > 0 { + access, aerr := h.accessFilterForAuth(r.Context(), a) + if aerr != nil { + slog.Debug("abs listening sessions: resolve access failed", "user", a.UserID, "err", aerr) + } else { + contentIDs := make([]string, 0, len(sessions)) + for _, s := range sessions { + contentIDs = append(contentIDs, s.ContentID) + } + hydrated, herr := h.deps.MediaStore.GetAudiobooksByIDs(r.Context(), contentIDs, access) + if herr != nil { + slog.Debug("abs listening sessions: hydrate media items failed", "user", a.UserID, "err", herr) + } else { + items = hydrated + } + } + } + + baseURL := h.absBaseURL(r) out := make([]map[string]any, 0, len(sessions)) for _, s := range sessions { - out = append(out, sessionToABS(s)) + out = append(out, sessionToABS(s, items[s.ContentID], baseURL)) } - writeJSON(w, http.StatusOK, pagedEnvelope(out, total, limit, page, "started_at", true, "", false, "")) + writeJSON(w, http.StatusOK, listeningSessionsEnvelope(out, total, itemsPerPage, page)) } +// handleListeningSessionDetail — GET /me/listening-sessions/{sid} +// Returns a single PlaybackSession.toJSON()-shaped object, not the thin +// 5-field object the prior implementation emitted. func (h *Handler) handleListeningSessionDetail(w http.ResponseWriter, r *http.Request) { a, ok := absAuthFrom(r) if !ok || a.UserID == "" { @@ -67,7 +106,20 @@ func (h *Handler) handleListeningSessionDetail(w http.ResponseWriter, r *http.Re http.Error(w, "session not found", http.StatusNotFound) return } - writeJSON(w, http.StatusOK, sessionToABS(sess)) + + var item *models.MediaItem + if h.deps.MediaStore != nil { + access, aerr := h.accessFilterForAuth(r.Context(), a) + if aerr != nil { + slog.Debug("abs listening session detail: resolve access failed", "user", a.UserID, "err", aerr) + } else if fetched, ferr := h.deps.MediaStore.GetAudiobookByID(r.Context(), sess.ContentID, access); ferr != nil { + slog.Debug("abs listening session detail: hydrate media item failed", "user", a.UserID, "content_id", sess.ContentID, "err", ferr) + } else { + item = fetched + } + } + + writeJSON(w, http.StatusOK, sessionToABS(sess, item, h.absBaseURL(r))) } func statsToABS(s Stats) map[string]any { @@ -92,14 +144,103 @@ func statsToABS(s Stats) map[string]any { } } -func sessionToABS(s ABSPlaybackSession) map[string]any { +// listeningSessionsEnvelope builds the exact envelope shape +// MeController.getListeningSessions returns upstream. It intentionally does +// NOT reuse pagedEnvelope: that helper's {results,sortBy,filterBy,minified} +// shape is for browse/list endpoints, while real ABS listening-sessions +// responses only ever carry {total,numPages,page,itemsPerPage,sessions}. +func listeningSessionsEnvelope(sessions []map[string]any, total, itemsPerPage, page int) map[string]any { + numPages := 0 + if itemsPerPage > 0 { + numPages = (total + itemsPerPage - 1) / itemsPerPage + } + return map[string]any{ + "total": total, + "numPages": numPages, + "page": page, + "itemsPerPage": itemsPerPage, + "sessions": sessions, + } +} + +// sessionToABS converts a silo ABSPlaybackSession row into the exact key set +// of PlaybackSession.toJSON() upstream (server/objects/PlaybackSession.js). +// item may be nil (deleted item, access revoked, lookup error) — in that +// case a stub MediaItem is fed through the same mediaMetadata builder the +// /play endpoint uses (buildSiloPlayMediaMetadata), so every key is still +// present with empty/zero values rather than being omitted. +func sessionToABS(s ABSPlaybackSession, item *models.MediaItem, baseURL string) map[string]any { + if item == nil { + item = &models.MediaItem{ContentID: s.ContentID} + } + mediaMetadata := buildSiloPlayMediaMetadata(item) + + displayAuthor := "" + if v, ok := mediaMetadata["authorName"].(string); ok { + displayAuthor = v + } + + startedAt := s.StartedAt + updatedAt := s.LastSyncAt + if updatedAt.IsZero() { + updatedAt = startedAt + } + dateStr := "" + dayOfWeek := "" + var startedAtMs, updatedAtMs int64 + if !startedAt.IsZero() { + dateStr = startedAt.UTC().Format("2006-01-02") + dayOfWeek = startedAt.UTC().Weekday().String() + startedAtMs = startedAt.UnixMilli() + } + if !updatedAt.IsZero() { + updatedAtMs = updatedAt.UnixMilli() + } + out := map[string]any{ "id": s.ID, - "libraryItemId": s.ContentID, "userId": s.UserID, + "libraryId": VirtualLibraryID, + "libraryItemId": s.ContentID, + "bookId": s.ContentID, + "episodeId": nil, // silo is audiobook-only; podcasts are out of scope + "mediaType": LibraryMediaType, + "mediaMetadata": mediaMetadata, + // Chapters are not loaded for session list/detail responses (would + // require an extra media-files fetch per session); real ABS clients + // read chapters from the /play or /sync payload for in-player + // rendering, so an empty list here is a safe placeholder rather than + // the full per-file chapter set. + "chapters": []map[string]any{}, + "displayTitle": item.Title, + "displayAuthor": displayAuthor, + "coverPath": baseURL + "/api/items/" + s.ContentID + "/cover", + // duration: the total book duration isn't tracked on the session row + // itself; 0 is a safe placeholder (never crashes, only affects the + // progress-bar denominator on this historical-session view). + "duration": 0, + "playMethod": 0, // DIRECTPLAY + "mediaPlayer": "exo-player", + "deviceInfo": map[string]any{ + "deviceId": "unknown", + "manufacturer": "Unknown", + "model": "Unknown", + "sdkVersion": 0, + "clientVersion": "0.0.0", + }, + "serverVersion": ServerVersion, + "date": dateStr, + "dayOfWeek": dayOfWeek, "timeListening": s.TimeListeningSeconds, - "currentTime": s.CurrentPositionSeconds, + // startTime: media position when this session began. Not persisted + // separately from currentTime on ABSPlaybackSession; 0 is safe. + "startTime": 0, + "currentTime": s.CurrentPositionSeconds, + "startedAt": startedAtMs, + "updatedAt": updatedAtMs, } + // Additive extra field (not part of upstream toJSON) kept for backward + // compatibility with any existing silo-side consumers. if s.ClosedAt != nil { out["closedAt"] = s.ClosedAt.UnixMilli() } diff --git a/internal/audiobooks/abs/listening_stats_handler_test.go b/internal/audiobooks/abs/listening_stats_handler_test.go index f36e4b30..a577ce26 100644 --- a/internal/audiobooks/abs/listening_stats_handler_test.go +++ b/internal/audiobooks/abs/listening_stats_handler_test.go @@ -70,9 +70,59 @@ func TestStats_Sessions_List_Paginated(t *testing.T) { if env["total"] != float64(3) { t.Errorf("total = %v, want 3", env["total"]) } - results, _ := env["results"].([]any) - if len(results) != 3 { - t.Errorf("results len = %d, want 3", len(results)) + sessions, _ := env["sessions"].([]any) + if len(sessions) != 3 { + t.Errorf("sessions len = %d, want 3", len(sessions)) + } +} + +// TestStats_Sessions_List_EnvelopeShape asserts the response matches the +// exact envelope real audiobookshelf's MeController.getListeningSessions +// returns ({total, numPages, page, itemsPerPage, sessions}), and that each +// session object carries the PlaybackSession.toJSON() keys strict clients +// (Flutter/Swift decoders) require: mediaType, mediaMetadata, displayTitle. +func TestStats_Sessions_List_EnvelopeShape(t *testing.T) { + fake := &statsFakeStore{closed: []ABSPlaybackSession{ + {ID: "s1", UserID: "1", ContentID: "book-1", TimeListeningSeconds: 120, CurrentPositionSeconds: 45.5}, + }} + h := New(Dependencies{MediaStore: noopMediaStore{}, PlaybackSessionStore: fake}) + + rec := dispatchABSWithParams(http.MethodGet, "/api/me/listening-sessions", nil, nil, "1", "", h.handleListeningSessions) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d; body=%s", rec.Code, rec.Body.String()) + } + var env map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &env) + for _, key := range []string{"total", "numPages", "page", "itemsPerPage", "sessions"} { + if _, ok := env[key]; !ok { + t.Errorf("envelope missing key %q; body=%s", key, rec.Body.String()) + } + } + if _, ok := env["results"]; ok { + t.Errorf("envelope must not carry the pagedEnvelope 'results' key") + } + + sessions, _ := env["sessions"].([]any) + if len(sessions) != 1 { + t.Fatalf("sessions len = %d, want 1", len(sessions)) + } + sess, _ := sessions[0].(map[string]any) + if sess["mediaType"] != "book" { + t.Errorf("mediaType = %v, want book", sess["mediaType"]) + } + if _, ok := sess["mediaMetadata"].(map[string]any); !ok { + t.Errorf("mediaMetadata missing or wrong type: %v", sess["mediaMetadata"]) + } + if _, ok := sess["displayTitle"]; !ok { + t.Errorf("displayTitle missing") + } + for _, key := range []string{"id", "userId", "libraryId", "libraryItemId", "bookId", "episodeId", + "chapters", "displayAuthor", "coverPath", "duration", "playMethod", "mediaPlayer", + "deviceInfo", "serverVersion", "date", "dayOfWeek", "timeListening", "startTime", + "currentTime", "startedAt", "updatedAt"} { + if _, ok := sess[key]; !ok { + t.Errorf("session missing key %q; body=%s", key, rec.Body.String()) + } } } diff --git a/internal/audiobooks/abs/login.go b/internal/audiobooks/abs/login.go index b7a4877a..7e2950b0 100644 --- a/internal/audiobooks/abs/login.go +++ b/internal/audiobooks/abs/login.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "net/http" + "net/url" "strings" "time" @@ -54,13 +55,27 @@ func (h *Handler) handleStandaloneLogin(w http.ResponseWriter, r *http.Request) return } + // Real ABS (express body-parser + passport local) accepts BOTH JSON and + // application/x-www-form-urlencoded credential bodies; different clients + // send different encodings. Buffer the body once, try JSON, then fall back + // to form-encoded — a JSON-only parse 400s a form-encoded client, which + // the app surfaces as a generic "unknown error" on sign-in. + raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } var body struct { Username string `json:"username"` Password string `json:"password"` } - if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return + if jsonErr := json.Unmarshal(raw, &body); jsonErr != nil || strings.TrimSpace(body.Username) == "" { + if vals, formErr := url.ParseQuery(string(raw)); formErr == nil { + if u := vals.Get("username"); u != "" { + body.Username = u + body.Password = vals.Get("password") + } + } } if strings.TrimSpace(body.Username) == "" || body.Password == "" { http.Error(w, "username and password are required", http.StatusUnauthorized) @@ -163,7 +178,15 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, slog.Debug("abs completeLogin: tokens persisted", "user_id", userID, "access_jti", accessJTI, "refresh_jti", refreshJTI) - writeJSON(w, http.StatusOK, h.loginEnvelope(r, now, userID, displayName, access, refresh)) + // Real ABS delivers the refresh token in the body when the client opts in + // via x-return-tokens, otherwise as an HttpOnly refresh_token cookie. + // Mirrors server/Auth.js: setRefreshTokenCookie when !returnTokens. + returnRefreshInBody := strings.EqualFold(r.Header.Get("x-return-tokens"), "true") + if !returnRefreshInBody { + setRefreshCookie(w, r, refresh, refreshTTL) + } + + writeJSON(w, http.StatusOK, h.loginEnvelope(r, now, userID, displayName, access, refresh, returnRefreshInBody)) } // handleABSPing — GET /ping (mounted also as /healthcheck). Wire shape @@ -171,7 +194,12 @@ func (h *Handler) completeLogin(w http.ResponseWriter, r *http.Request, userID, // use this to validate the URL before showing the login form, and any // other shape causes the official mobile app to reject the server. func (h *Handler) handleABSPing(w http.ResponseWriter, _ *http.Request) { + // Real ABS /ping returns {"success": true} (server/Server.js). The ABS + // apps validate a server address by reading response.success — without it + // they report "unable to reach". The pong/server/version keys are kept as + // harmless extras for plugin-shape clients. writeJSON(w, http.StatusOK, map[string]any{ + "success": true, "server": "audiobookshelf", "version": ServerVersion, "pong": true, @@ -184,17 +212,70 @@ func (h *Handler) handleABSInit(w http.ResponseWriter, _ *http.Request) { } // handleABSStatus — GET /status. Mobile clients call this on every -// connection to confirm the server is an ABS install and pull a few -// global flags. Matches the plugin shape exactly (key order intentional). +// connection to confirm the server is an ABS install and to learn which +// auth methods to render on the login form. Mirrors real ABS Server.js +// /status: {app, serverVersion, isInit, language, authMethods, authFormData}. +// authMethods drives the login UI — omitting it can leave the app unable to +// present a usable login flow. func (h *Handler) handleABSStatus(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ - "isInit": true, - "language": "en-us", "app": "audiobookshelf", "serverVersion": ServerVersion, + "isInit": true, + "language": "en-us", + "authMethods": []string{"local"}, + "authFormData": map[string]any{}, }) } +// absUserObject builds the ABS user object shared by the login/authorize +// envelope and GET /me. It mirrors the key set of audiobookshelf +// User.toOldJSONForBrowser (server/models/User.js) so every endpoint that +// emits a user decodes with one client model — a missing key crashes strict +// clients, so we emit the full set even where silo has no analog (email is +// "", the flags are constant). +// +// `token` is the caller's current access token — real ABS's deprecated +// non-expiring `token` slot. The login/authorize caller additionally sets +// user.accessToken / user.refreshToken; GET /me never carries those. +func absUserObject(userID, displayName, token, defaultLibraryID string, now time.Time) map[string]any { + name := displayName + if name == "" { + name = userID + } + nowMs := now.UnixMilli() + return map[string]any{ + "id": userID, + "username": name, + "email": "", + "type": "user", + "defaultLibraryId": defaultLibraryID, + "librariesAccessible": []any{}, + "itemTagsAccessible": []any{}, + "itemTagsSelected": []any{}, + "mediaProgress": []any{}, + "bookmarks": []any{}, + "seriesHideFromContinueListening": []any{}, + "isOldToken": false, + "isActive": true, + "isLocked": false, + "hasOpenIDLink": false, + "token": token, + "lastSeen": nowMs, + "createdAt": nowMs, + "permissions": map[string]any{ + "download": true, + "update": true, + "delete": true, + "upload": true, + "accessAllLibraries": true, + "accessAllTags": true, + "accessExplicitContent": true, + "selectedTagsNotAccessible": false, + }, + } +} + // loginEnvelope builds the response body shared by /login and /authorize. // Both endpoints must return the identical shape so the iOS client's // resume-on-launch flow validates the same way as fresh login. @@ -209,14 +290,9 @@ func (h *Handler) loginEnvelope( r *http.Request, now time.Time, userID, displayName, accessToken, refreshToken string, + returnRefreshInBody bool, ) map[string]any { // displayName falls back to userID when the validator didn't supply one. - // ABS clients require a non-empty username on the user envelope. - name := displayName - if name == "" { - name = userID - } - libraryMaps := make([]map[string]any, 0) defaultLibraryID := VirtualLibraryID access, _, _ := h.accessFilterFromRequest(r) @@ -228,40 +304,18 @@ func (h *Handler) loginEnvelope( libraryMaps = append(libraryMaps, audiobookLibraryMap(lib)) } - nowMs := now.UnixMilli() + user := absUserObject(userID, displayName, accessToken, defaultLibraryID, now) - user := map[string]any{ - "id": userID, - "username": name, - "type": "user", - "defaultLibraryId": defaultLibraryID, - "librariesAccessible": []any{}, - "itemTagsAccessible": []any{}, - "itemTagsSelected": []any{}, - "mediaProgress": []any{}, - "bookmarks": []any{}, - "seriesHideFromContinueListening": []any{}, - "isOldToken": false, - "token": accessToken, - "lastSeen": nowMs, - "createdAt": nowMs, - "permissions": map[string]any{ - "download": true, - "update": true, - "delete": true, - "upload": true, - "accessAllLibraries": true, - "accessAllTags": true, - "accessExplicitContent": true, - "selectedTagsNotAccessible": false, - }, - } - - // x-return-tokens opt-in: when set, embed token pair on user object too - // (some clients read from the user envelope, others from the top level). - if strings.EqualFold(r.Header.Get("x-return-tokens"), "true") { - user["accessToken"] = accessToken + // Real ABS (v2.26+) ALWAYS sets user.accessToken; modern clients read it + // from exactly res.user.accessToken. The x-return-tokens header gates ONLY + // the refresh token: present in the body when the client opts in, otherwise + // null (and delivered as the refresh_token cookie by the caller). See + // audiobookshelf server/Auth.js handleLoginSuccess. + user["accessToken"] = accessToken + if returnRefreshInBody { user["refreshToken"] = refreshToken + } else { + user["refreshToken"] = nil } serverSettings := map[string]any{ @@ -300,7 +354,31 @@ func (h *Handler) loginEnvelope( "podcastEpisodeSchedule": "0 * * * *", "sortingIgnorePrefixesValue": "", "allowIframe": false, - "authActiveAuthMethods": []string{"local"}, + // Auth / rate-limit / OpenID fields from real ABS + // ServerSettings.toJSONForBrowser. OIDC-aware clients (Prologue) + // decode serverSettings into a strict model that includes these keys; + // omitting them throws keyNotFound and the whole login response fails + // to decode ("unknown error" on the login screen). Emit real ABS's + // defaults for an OIDC-disabled server — authActiveAuthMethods still + // advertises only "local", so no client tries the OpenID flow. + "rateLimitLoginRequests": 10, + "rateLimitLoginWindow": 600000, + "backupPath": "/metadata/backups", + "allowedOrigins": []string{}, + "authActiveAuthMethods": []string{"local"}, + "authLoginCustomMessage": nil, + "authOpenIDIssuerURL": nil, + "authOpenIDAuthorizationURL": nil, + "authOpenIDTokenURL": nil, + "authOpenIDUserInfoURL": nil, + "authOpenIDJwksURL": nil, + "authOpenIDLogoutURL": nil, + "authOpenIDTokenSigningAlgorithm": "RS256", + "authOpenIDButtonText": "Login with OpenID", + "authOpenIDAutoLaunch": false, + "authOpenIDAutoRegister": false, + "authOpenIDMatchExistingBy": nil, + "authOpenIDSubfolderForRedirectURLs": "", } return map[string]any{ @@ -360,7 +438,24 @@ func (h *Handler) handleABSAuthorize(w http.ResponseWriter, r *http.Request) { } } } - writeJSON(w, http.StatusOK, h.loginEnvelope(r, time.Now(), a.UserID, a.UserID, access, "")) + // /authorize never issues a refresh token (the client already holds one), + // so there is nothing to return in the body. + writeJSON(w, http.StatusOK, h.loginEnvelope(r, time.Now(), a.UserID, a.UserID, access, "", false)) +} + +// setRefreshCookie writes the ABS refresh_token cookie exactly as real ABS +// does when a client did not opt into body-token delivery. HttpOnly + Lax, +// Secure only under TLS so plain-HTTP LAN deployments still receive it. +func setRefreshCookie(w http.ResponseWriter, r *http.Request, refresh string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: refresh, + Path: "/", + HttpOnly: true, + Secure: r.TLS != nil, + SameSite: http.SameSiteLaxMode, + MaxAge: int(ttl / time.Second), + }) } // handleRefresh — POST /auth/refresh @@ -387,6 +482,16 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { refreshTok = p.RefreshToken } } + if refreshTok == "" { + // Cookie-flow clients (those that omit x-return-tokens at login) hold the + // refresh token only in the HttpOnly refresh_token cookie the server set — + // they send neither header nor body. Read it here or they get a spurious + // 400 once the access token expires. Mirrors real ABS Auth.js, which + // checks req.cookies.refresh_token. + if c, err := r.Cookie("refresh_token"); err == nil { + refreshTok = strings.TrimSpace(c.Value) + } + } if refreshTok == "" { http.Error(w, "refreshToken required", http.StatusBadRequest) return @@ -481,31 +586,50 @@ func (h *Handler) handleRefresh(w http.ResponseWriter, r *http.Request) { slog.Debug("abs refresh: rotated", "user", claims.UserID, "old_jti", claims.JTI, "new_access_jti", newAccessJTI, "new_refresh_jti", newRefreshJTI) - writeJSON(w, http.StatusOK, map[string]any{ - "user": map[string]any{ - "id": claims.UserID, - "accessToken": access, - "refreshToken": refresh, - }, - "accessToken": access, - "refreshToken": refresh, - }) + // Real ABS /auth/refresh returns the SAME payload as /login. Return the + // full envelope (not a thin token map) so a strict client can decode it + // with the same model it uses for login. The rotated refresh token goes + // in the body when the client sent x-refresh-token (mobile), otherwise as + // the refresh_token cookie. No displayName is available at refresh time — + // loginEnvelope falls back to the userID for username. + returnRefreshInBody := strings.TrimSpace(r.Header.Get("x-refresh-token")) != "" + if !returnRefreshInBody { + setRefreshCookie(w, r, refresh, refreshTTL) + } + writeJSON(w, http.StatusOK, h.loginEnvelope(r, now, claims.UserID, "", access, refresh, returnRefreshInBody)) } // handleLogout — POST /logout (and /api/logout, /abs/api/logout, /abs/api/auth/logout) // // Mounted OUTSIDE the bearerAuth group so a client whose access token has // expired — the most common "I want to sign out" moment — can still revoke -// their JTI without first re-authenticating. The handler parses the bearer -// itself, attempts JTI revoke if parseable, and ALWAYS returns 204. Mirrors -// continuum-plugin-audiobooks/internal/abs/handler.go:handleLogout. +// their JTI without first re-authenticating. // -// Logout invalidates every active ABS access/refresh token for the presented -// user profile so a signed-out client cannot silently mint a new access token -// with its saved refresh token. +// Real ABS returns HTTP 200 with { redirect_url } (null for local auth), NOT +// an empty 204 — a strict client decodes the body and would fail on 204. It +// also clears the refresh_token cookie. Token/session revocation is +// best-effort: a failure there must not turn logout into an error the client +// can't recover from. func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) { - defer w.WriteHeader(http.StatusNoContent) + h.revokeLogoutPrincipal(r) + // Clear the refresh_token cookie set at login (MaxAge<0 deletes it). + http.SetCookie(w, &http.Cookie{ + Name: "refresh_token", + Value: "", + Path: "/", + HttpOnly: true, + MaxAge: -1, + }) + + // redirect_url is null for local auth (only OpenID logout returns a URL). + writeJSON(w, http.StatusOK, map[string]any{"redirect_url": nil}) +} + +// revokeLogoutPrincipal best-effort revokes every active token for the bearer's +// principal and closes its open playback sessions. All failures are logged and +// swallowed — the caller always responds 200. +func (h *Handler) revokeLogoutPrincipal(r *http.Request) { raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if raw == "" { raw = r.URL.Query().Get("token") diff --git a/internal/audiobooks/abs/login_body_test.go b/internal/audiobooks/abs/login_body_test.go new file mode 100644 index 00000000..1511b2f5 --- /dev/null +++ b/internal/audiobooks/abs/login_body_test.go @@ -0,0 +1,65 @@ +package abs + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// recordingValidator captures the credentials handleStandaloneLogin extracted +// from the request body, and reports success so completeLogin runs. +type recordingValidator struct{ gotUser, gotPass string } + +func (v *recordingValidator) Validate(_ context.Context, u, p string) (string, string, string, error) { + v.gotUser, v.gotPass = u, p + return "1", "", "Alice", nil +} + +func newLoginBodyHandler(v *recordingValidator) *Handler { + return New(Dependencies{ + Config: &staticConfig{secret: []byte("test-secret-32-bytes-aaaaaaaaaaaaa")}, + TokenStore: newMemTokenStore(), + MediaStore: noopMediaStore{}, + CredValidator: v, + }) +} + +// TestLogin_AcceptsFormEncoded is the regression guard for the real bug: +// real ABS accepts application/x-www-form-urlencoded credentials; a JSON-only +// parse 400s a form-encoded client, surfacing as "unknown error" on sign-in. +func TestLogin_AcceptsFormEncoded(t *testing.T) { + v := &recordingValidator{} + h := newLoginBodyHandler(v) + + req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader("username=alice&password=s3cret%21")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.handleLogin(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if v.gotUser != "alice" || v.gotPass != "s3cret!" { + t.Errorf("validator got user=%q pass=%q, want alice/s3cret!", v.gotUser, v.gotPass) + } +} + +// TestLogin_AcceptsJSON confirms the JSON path still works after the change. +func TestLogin_AcceptsJSON(t *testing.T) { + v := &recordingValidator{} + h := newLoginBodyHandler(v) + + req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"username":"bob","password":"pw"}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.handleLogin(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if v.gotUser != "bob" || v.gotPass != "pw" { + t.Errorf("validator got user=%q pass=%q, want bob/pw", v.gotUser, v.gotPass) + } +} diff --git a/internal/audiobooks/abs/login_envelope_test.go b/internal/audiobooks/abs/login_envelope_test.go index d5a86d0a..9f985472 100644 --- a/internal/audiobooks/abs/login_envelope_test.go +++ b/internal/audiobooks/abs/login_envelope_test.go @@ -17,7 +17,7 @@ func TestLoginEnvelope_HasRequiredKeys(t *testing.T) { h, _, _ := newRefreshTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/login", nil) - env := h.loginEnvelope(req, time.Now(),"u1", "Display Name", "access.jwt", "refresh.jwt") + env := h.loginEnvelope(req, time.Now(), "u1", "Display Name", "access.jwt", "refresh.jwt", true) body, err := json.Marshal(env) if err != nil { t.Fatalf("marshal: %v", err) @@ -105,7 +105,7 @@ func TestLoginEnvelope_XReturnTokens_SurfacesOnUser(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/login", nil) req.Header.Set("x-return-tokens", "true") - env := h.loginEnvelope(req, time.Now(),"u1", "Display Name", "access.jwt", "refresh.jwt") + env := h.loginEnvelope(req, time.Now(), "u1", "Display Name", "access.jwt", "refresh.jwt", true) user, ok := env["user"].(map[string]any) if !ok { t.Fatalf("user is not a map: %T", env["user"]) @@ -118,16 +118,21 @@ func TestLoginEnvelope_XReturnTokens_SurfacesOnUser(t *testing.T) { } } -// TestLoginEnvelope_NoXReturnTokens_OmitsFromUser is the inverse: without the -// header, user object should NOT carry the duplicated tokens (top-level only). -func TestLoginEnvelope_NoXReturnTokens_OmitsFromUser(t *testing.T) { +// TestLoginEnvelope_NoXReturnTokens covers the default flow: real ABS ALWAYS +// puts accessToken on the user object, and sets refreshToken to null when the +// client did not opt into body delivery via x-return-tokens (the caller then +// ships the refresh token as a cookie). +func TestLoginEnvelope_NoXReturnTokens(t *testing.T) { h, _, _ := newRefreshTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/login", nil) - env := h.loginEnvelope(req, time.Now(),"u1", "Display Name", "access.jwt", "refresh.jwt") + env := h.loginEnvelope(req, time.Now(), "u1", "Display Name", "access.jwt", "refresh.jwt", false) user, _ := env["user"].(map[string]any) - if _, present := user["accessToken"]; present { - t.Errorf("user.accessToken should not be set without x-return-tokens header") + if user["accessToken"] != "access.jwt" { + t.Errorf("user.accessToken = %v, want access.jwt (must always be present)", user["accessToken"]) + } + if rt, present := user["refreshToken"]; !present || rt != nil { + t.Errorf("user.refreshToken = %v (present=%v), want nil without x-return-tokens", rt, present) } } @@ -138,10 +143,9 @@ func TestLoginEnvelope_DisplayNameFallsBackToUserID(t *testing.T) { h, _, _ := newRefreshTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/login", nil) - env := h.loginEnvelope(req, time.Now(),"user-42", "", "a", "r") + env := h.loginEnvelope(req, time.Now(), "user-42", "", "a", "r", false) user, _ := env["user"].(map[string]any) if user["username"] != "user-42" { t.Errorf("username = %v, want user-42 (fallback)", user["username"]) } } - diff --git a/internal/audiobooks/abs/login_logout_test.go b/internal/audiobooks/abs/login_logout_test.go index 41c5a955..96a867c3 100644 --- a/internal/audiobooks/abs/login_logout_test.go +++ b/internal/audiobooks/abs/login_logout_test.go @@ -2,6 +2,7 @@ package abs import ( "context" + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -39,7 +40,7 @@ func mintAndPersistAccess(t *testing.T, store *memTokenStore, cfg *staticConfig, return access } -func TestHandleLogout_RevokesJTIAndReturns204(t *testing.T) { +func TestHandleLogout_RevokesJTIAndReturns200(t *testing.T) { h, store, cfg := newLogoutTestHandler(t) jti := "logout-test-jti" access := mintAndPersistAccess(t, store, cfg, "1", jti) @@ -50,8 +51,16 @@ func TestHandleLogout_RevokesJTIAndReturns204(t *testing.T) { rec := httptest.NewRecorder() h.handleLogout(rec, req) - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + // Real ABS logout body: { redirect_url: null } for local auth. + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode: %v", err) + } + if v, ok := body["redirect_url"]; !ok || v != nil { + t.Errorf("redirect_url = %v (present=%v), want null", v, ok) } tok, _ := store.GetTokenByJTI(context.Background(), jti) if tok.RevokedAt == nil { @@ -59,35 +68,35 @@ func TestHandleLogout_RevokesJTIAndReturns204(t *testing.T) { } } -// TestHandleLogout_NoBearer_204 covers the "client called sign-out with no +// TestHandleLogout_NoBearer_200 covers the "client called sign-out with no // token" path — must still 204 (logout is idempotent / fire-and-forget). -func TestHandleLogout_NoBearer_204(t *testing.T) { +func TestHandleLogout_NoBearer_200(t *testing.T) { h, _, _ := newLogoutTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/logout", nil) rec := httptest.NewRecorder() h.handleLogout(rec, req) - if rec.Code != http.StatusNoContent { - t.Errorf("status = %d, want 204", rec.Code) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) } } -// TestHandleLogout_GarbageBearer_204 covers an unparseable token: must still +// TestHandleLogout_GarbageBearer_200 covers an unparseable token: must still // 204 (we never want sign-out to error the client out). -func TestHandleLogout_GarbageBearer_204(t *testing.T) { +func TestHandleLogout_GarbageBearer_200(t *testing.T) { h, _, _ := newLogoutTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/logout", nil) req.Header.Set("Authorization", "Bearer not.a.real.jwt") rec := httptest.NewRecorder() h.handleLogout(rec, req) - if rec.Code != http.StatusNoContent { - t.Errorf("status = %d, want 204", rec.Code) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) } } -// TestHandleLogout_WrongSignature_204 covers a token signed by a different +// TestHandleLogout_WrongSignature_200 covers a token signed by a different // secret (attacker token, restored from backup, etc.): must 204 and NOT // revoke (signature mismatch means we can't trust the JTI claim). -func TestHandleLogout_WrongSignature_204(t *testing.T) { +func TestHandleLogout_WrongSignature_200(t *testing.T) { h, store, _ := newLogoutTestHandler(t) jti := "victim-jti" _ = store.InsertToken(context.Background(), ABSToken{ID: jti, UserID: "victim", JTI: jti}) @@ -100,8 +109,8 @@ func TestHandleLogout_WrongSignature_204(t *testing.T) { req.Header.Set("Authorization", "Bearer "+bogus) rec := httptest.NewRecorder() h.handleLogout(rec, req) - if rec.Code != http.StatusNoContent { - t.Errorf("status = %d, want 204", rec.Code) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) } tok, _ := store.GetTokenByJTI(context.Background(), jti) if tok.RevokedAt != nil { @@ -121,8 +130,8 @@ func TestHandleLogout_IsIdempotent(t *testing.T) { req.Header.Set("Authorization", "Bearer "+access) rec := httptest.NewRecorder() h.handleLogout(rec, req) - if rec.Code != http.StatusNoContent { - t.Fatalf("iter %d: status = %d, want 204", i, rec.Code) + if rec.Code != http.StatusOK { + t.Fatalf("iter %d: status = %d, want 200", i, rec.Code) } } } diff --git a/internal/audiobooks/abs/login_refresh_test.go b/internal/audiobooks/abs/login_refresh_test.go index d93d852a..32e325a5 100644 --- a/internal/audiobooks/abs/login_refresh_test.go +++ b/internal/audiobooks/abs/login_refresh_test.go @@ -26,7 +26,7 @@ func (noopMediaStore) GetAudiobookByID(context.Context, string, catalog.AccessFi func (noopMediaStore) GetAudiobooksByIDs(context.Context, []string, catalog.AccessFilter) (map[string]*models.MediaItem, error) { return map[string]*models.MediaItem{}, nil } -func (noopMediaStore) ListAudiobooks(context.Context, int64, int, int, catalog.AccessFilter) ([]*models.MediaItem, int, error) { +func (noopMediaStore) ListAudiobooks(context.Context, int64, int, int, catalog.AccessFilter, Filter) ([]*models.MediaItem, int, error) { return nil, 0, nil } func (noopMediaStore) GetMediaFiles(context.Context, string, catalog.AccessFilter) ([]*models.MediaFile, error) { @@ -50,11 +50,11 @@ func (noopMediaStore) ListRecentlyAdded(context.Context, int64, int, catalog.Acc func (noopMediaStore) ListDiscover(context.Context, int64, int, catalog.AccessFilter) ([]*models.MediaItem, error) { return nil, nil } -func (noopMediaStore) ListLibraryAuthors(context.Context, int64, int, catalog.AccessFilter) ([]AuthorSummary, error) { - return nil, nil +func (noopMediaStore) ListLibraryAuthors(context.Context, int64, int, int, string, bool, catalog.AccessFilter) ([]AuthorSummary, int, error) { + return nil, 0, nil } -func (noopMediaStore) ListLibrarySeries(context.Context, int64, int, catalog.AccessFilter) ([]SeriesSummary, error) { - return nil, nil +func (noopMediaStore) ListLibrarySeries(context.Context, int64, int, int, catalog.AccessFilter) ([]SeriesSummary, int, error) { + return nil, 0, nil } func (noopMediaStore) GetAuthorByID(context.Context, string, catalog.AccessFilter) (Author, error) { return Author{}, ErrNotFound @@ -210,6 +210,70 @@ func TestHandleRefresh_BodyToken_Works(t *testing.T) { } } +// TestHandleRefresh_ReturnsFullLoginEnvelope guards that /auth/refresh returns +// the SAME payload shape as /login (real ABS behavior) — not a thin token map — +// so strict clients can decode it with their login model. +func TestHandleRefresh_ReturnsFullLoginEnvelope(t *testing.T) { + h, store, cfg := newRefreshTestHandler(t) + refresh, _ := mintAndPersistRefresh(t, store, cfg, "5") + + req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil) + req.Header.Set("x-refresh-token", refresh) + rec := httptest.NewRecorder() + h.handleRefresh(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"user", "userDefaultLibraryId", "serverSettings", "Source", "ereaderDevices"} { + if _, ok := resp[k]; !ok { + t.Errorf("refresh envelope missing top-level %q", k) + } + } + // x-refresh-token present → token in body, not a cookie. + if user, _ := resp["user"].(map[string]any); user["refreshToken"] == nil { + t.Errorf("user.refreshToken should be in body when x-refresh-token sent") + } +} + +// TestHandleRefresh_NoHeader_SetsCookie: without x-refresh-token the rotated +// refresh token is delivered as the refresh_token cookie and nulled in the body. +func TestHandleRefresh_NoHeader_SetsCookie(t *testing.T) { + h, store, cfg := newRefreshTestHandler(t) + refresh, _ := mintAndPersistRefresh(t, store, cfg, "6") + + body := bytes.NewBufferString(`{"refreshToken":"` + refresh + `"}`) + req := httptest.NewRequest(http.MethodPost, "/auth/refresh", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.handleRefresh(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var got *http.Cookie + for _, c := range rec.Result().Cookies() { + if c.Name == "refresh_token" { + got = c + } + } + if got == nil || got.Value == "" { + t.Fatalf("refresh_token cookie not set") + } + if !got.HttpOnly { + t.Errorf("refresh_token cookie should be HttpOnly") + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if user, _ := resp["user"].(map[string]any); user["refreshToken"] != nil { + t.Errorf("user.refreshToken should be null when delivered via cookie") + } +} + func TestHandleRefresh_NoToken_400(t *testing.T) { h, _, _ := newRefreshTestHandler(t) req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil) diff --git a/internal/audiobooks/abs/me_handler.go b/internal/audiobooks/abs/me_handler.go index 7cb8b92b..ff346773 100644 --- a/internal/audiobooks/abs/me_handler.go +++ b/internal/audiobooks/abs/me_handler.go @@ -4,16 +4,17 @@ import ( "net/http" "strconv" "strings" + "time" ) // handleMe — GET /abs/api/me (and /api/me) // -// Minimal {id, username, defaultLibraryId} envelope — matches the -// continuum-plugin-audiobooks shape exactly. ABS clients already -// have the full user object from /login and /authorize; /me is just -// a session-resume probe in real-ABS, so returning the rich user -// envelope here is unnecessary and can confuse clients that pattern- -// match on the minimal shape. +// Real ABS returns req.user.toOldJSONForBrowser() — the FULL user object, the +// same shape carried on the login/authorize envelope's `user`. A strict client +// decodes /me with its User model, so a thin {id,username,...} map crashes it +// on the first missing required key. Emit the shared absUserObject (minus the +// login-only accessToken/refreshToken; /me still carries the `token` slot set +// to the caller's presented bearer). func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) { a, ok := absAuthFrom(r) if !ok || a.UserID == "" { @@ -32,11 +33,16 @@ func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) { defaultLibID = audiobookLibraryID(libs[0]) } - writeJSON(w, http.StatusOK, map[string]any{ - "id": a.UserID, - "username": a.UserID, - "defaultLibraryId": defaultLibID, - }) + // Resolve the real display username; the token only carries the userID, so + // without this /me would report the numeric id as the username. + name := a.UserID + if h.deps.UsernameResolver != nil { + if resolved := h.deps.UsernameResolver(r.Context(), a.UserID, a.ProfileID); resolved != "" { + name = resolved + } + } + + writeJSON(w, http.StatusOK, absUserObject(a.UserID, name, a.Token, defaultLibID, time.Now())) } // audiobookLibraryID returns the ABS-wire library ID string for an @@ -59,9 +65,44 @@ func audiobookLibraryMap(lib AudiobookLibrary) map[string]any { if name == "" { name = VirtualLibraryName } + id := audiobookLibraryID(lib) return map[string]any{ - "id": audiobookLibraryID(lib), - "name": name, - "mediaType": LibraryMediaType, + "id": id, + "name": name, + // folders mirror real ABS LibraryFolder.toOldJSON {id,fullPath,libraryId,addedAt}. + // silo serves a single virtual folder per library. + "folders": []map[string]any{ + {"id": VirtualFolderID, "fullPath": "/" + name, "libraryId": id, "addedAt": 0}, + }, + "displayOrder": 1, + "icon": "audiobookshelf", + "mediaType": LibraryMediaType, + "provider": "audible", + "settings": audiobookLibrarySettings(), + "lastScan": nil, + "lastScanVersion": ServerVersion, + "createdAt": 0, + "lastUpdate": 0, + } +} + +// audiobookLibrarySettings emits the real ABS library `settings` object. +// It's a loose object clients read defensively; silo has no per-library +// settings storage, so these are sensible audiobook defaults. coverAspectRatio +// 1 = square (audiobook covers). +func audiobookLibrarySettings() map[string]any { + return map[string]any{ + "coverAspectRatio": 1, + "disableWatcher": true, + "skipMatchingMediaWithAsin": false, + "skipMatchingMediaWithIsbn": false, + "autoScanCronExpression": nil, + "audiobooksOnly": false, + "hideSingleBookSeries": false, + "onlyShowLaterBooksInContinueSeries": false, + "metadataPrecedence": []string{}, + "epubsAllowScriptedContent": false, + "markAsFinishedPercentComplete": nil, + "markAsFinishedTimeRemaining": 10, } } diff --git a/internal/audiobooks/abs/me_handler_test.go b/internal/audiobooks/abs/me_handler_test.go new file mode 100644 index 00000000..00285a68 --- /dev/null +++ b/internal/audiobooks/abs/me_handler_test.go @@ -0,0 +1,56 @@ +package abs + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestHandleMe_ReturnsFullUserObject guards that GET /me emits the full ABS +// user object (audiobookshelf User.toOldJSONForBrowser), not a thin +// {id,username,defaultLibraryId} map — a strict client decodes /me with its +// User model and crashes on any missing required key. +func TestHandleMe_ReturnsFullUserObject(t *testing.T) { + h := New(Dependencies{MediaStore: noopMediaStore{}}) + + req := httptest.NewRequest(http.MethodGet, "/api/me", nil) + req = req.WithContext(context.WithValue(req.Context(), ctxKey{}, ctxAuth{ + UserID: "42", + Token: "bearer.jwt", + })) + rec := httptest.NewRecorder() + h.handleMe(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var user map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &user); err != nil { + t.Fatalf("decode: %v", err) + } + + // Full toOldJSONForBrowser key set. + want := []string{ + "id", "username", "email", "type", "token", "isOldToken", + "mediaProgress", "seriesHideFromContinueListening", "bookmarks", + "isActive", "isLocked", "lastSeen", "createdAt", "permissions", + "librariesAccessible", "itemTagsSelected", "hasOpenIDLink", + } + for _, k := range want { + if _, ok := user[k]; !ok { + t.Errorf("/me user missing key %q", k) + } + } + if user["token"] != "bearer.jwt" { + t.Errorf("token = %v, want bearer.jwt (presented bearer)", user["token"]) + } + // /me must NOT carry login-only token pair. + if _, ok := user["accessToken"]; ok { + t.Errorf("/me should not carry accessToken") + } + if _, ok := user["refreshToken"]; ok { + t.Errorf("/me should not carry refreshToken") + } +} diff --git a/internal/audiobooks/abs/minified.go b/internal/audiobooks/abs/minified.go index 133d1f37..9d0cf95f 100644 --- a/internal/audiobooks/abs/minified.go +++ b/internal/audiobooks/abs/minified.go @@ -12,31 +12,69 @@ import "strings" // for a minified request works (clients ignore extra fields) but is wasteful // on the wire when a client is paging through hundreds of items. Emitting // the minified shape for a full request silently breaks detail pages. +// Keys mirror real ABS LibraryItem.toOldJSONMinified. numFiles/size are +// minified-only; libraryFiles/lastScan/scanVersion (full-only) are absent. type MinifiedLibraryItem struct { - ID string `json:"id"` - LibraryID string `json:"libraryId"` - FolderID string `json:"folderId"` - MediaType string `json:"mediaType"` - Media minifiedMedia `json:"media"` - NumTracks int `json:"numTracks,omitempty"` - AddedAt int64 `json:"addedAt"` - UpdatedAt int64 `json:"updatedAt"` + ID string `json:"id"` + Ino string `json:"ino"` + OldLibraryItemID *string `json:"oldLibraryItemId"` + LibraryID string `json:"libraryId"` + FolderID string `json:"folderId"` + Path string `json:"path"` + RelPath string `json:"relPath"` + IsFile bool `json:"isFile"` + MtimeMs int64 `json:"mtimeMs"` + CtimeMs int64 `json:"ctimeMs"` + BirthtimeMs int64 `json:"birthtimeMs"` + AddedAt int64 `json:"addedAt"` + UpdatedAt int64 `json:"updatedAt"` + IsMissing bool `json:"isMissing"` + IsInvalid bool `json:"isInvalid"` + MediaType string `json:"mediaType"` + Media minifiedMedia `json:"media"` + NumFiles int `json:"numFiles"` + Size int64 `json:"size"` + CollapsedSeries *CollapsedSeriesV1 `json:"collapsedSeries,omitempty"` } +// Keys mirror real ABS Book.toOldJSONMinified: numeric summaries instead of +// audioFiles/chapters/tracks. numTracks/numAudioFiles drive whether strict +// clients (Plappa) show the item at all — an item reporting 0 audio files is +// dropped, so they are always >= 1 in the browse projection. type minifiedMedia struct { - Metadata minifiedMetadata `json:"metadata"` - Duration float64 `json:"duration"` - CoverPath string `json:"coverPath"` + ID string `json:"id"` + Metadata minifiedMetadata `json:"metadata"` + CoverPath string `json:"coverPath"` + Tags []string `json:"tags"` + NumTracks int `json:"numTracks"` + NumAudioFiles int `json:"numAudioFiles"` + NumChapters int `json:"numChapters"` + Duration float64 `json:"duration"` + Size int64 `json:"size"` + EbookFormat *string `json:"ebookFormat"` } +// Keys mirror real ABS Book.oldMetadataToJSONMinified — flat author/series +// strings, no authors[]/series[] arrays. Nullable-in-ABS string fields are +// emitted as "" (safe: the clients decode them as String?), never dropped. type minifiedMetadata struct { - Title string `json:"title"` - AuthorName string `json:"authorName"` - AuthorNameLF string `json:"authorNameLF"` - SeriesName string `json:"seriesName,omitempty"` - SeriesSequence string `json:"seriesSequence,omitempty"` - Narrators []string `json:"narrators,omitempty"` - PublishedYear string `json:"publishedYear,omitempty"` + Title string `json:"title"` + TitleIgnorePrefix string `json:"titleIgnorePrefix"` + Subtitle string `json:"subtitle"` + AuthorName string `json:"authorName"` + AuthorNameLF string `json:"authorNameLF"` + NarratorName string `json:"narratorName"` + SeriesName string `json:"seriesName"` + Genres []string `json:"genres"` + PublishedYear string `json:"publishedYear"` + PublishedDate string `json:"publishedDate"` + Publisher string `json:"publisher"` + Description string `json:"description"` + ISBN string `json:"isbn"` + ASIN string `json:"asin"` + Language string `json:"language"` + Explicit bool `json:"explicit"` + Abridged bool `json:"abridged"` } // Minify projects a LibraryItem onto the minified shape. The original item @@ -55,33 +93,71 @@ func Minify(item LibraryItem) MinifiedLibraryItem { names = append(names, a.Name) lfNames = append(lfNames, lastFirst(a.Name)) } - seriesName, seriesSeq := "", "" + seriesName := "" if len(m.Series) > 0 { - s := m.Series[0] - seriesName = s.Name - seriesSeq = s.Sequence + seriesName = m.Series[0].Name + if seq := m.Series[0].Sequence; seq != "" { + seriesName += " #" + seq + } + } + genres := m.Genres + if genres == nil { + genres = []string{} + } + tags := item.Media.Tags + if tags == nil { + tags = []string{} + } + // Every audiobook has at least one audio file; report >= 1 so strict + // clients (Plappa) don't drop the item. Exact counts come from item-detail. + numTracks := item.Media.NumTracks + if numTracks < 1 { + numTracks = 1 } return MinifiedLibraryItem{ - ID: item.ID, - LibraryID: item.LibraryID, - FolderID: item.FolderID, - MediaType: item.MediaType, + ID: item.ID, + Ino: item.Ino, + LibraryID: item.LibraryID, + FolderID: item.FolderID, + Path: item.Path, + RelPath: item.RelPath, + IsFile: item.IsFile, + MtimeMs: item.MtimeMs, + CtimeMs: item.CtimeMs, + BirthtimeMs: item.BirthtimeMs, + AddedAt: item.AddedAt, + UpdatedAt: item.UpdatedAt, + IsMissing: item.IsMissing, + IsInvalid: item.IsInvalid, + MediaType: item.MediaType, Media: minifiedMedia{ + ID: item.Media.ID, Metadata: minifiedMetadata{ - Title: m.Title, - AuthorName: strings.Join(names, ", "), - AuthorNameLF: strings.Join(lfNames, " & "), - SeriesName: seriesName, - SeriesSequence: seriesSeq, - Narrators: m.Narrators, - PublishedYear: m.PublishedYear, + Title: m.Title, + TitleIgnorePrefix: titleIgnorePrefix(m.Title), + AuthorName: strings.Join(names, ", "), + AuthorNameLF: strings.Join(lfNames, ", "), + NarratorName: strings.Join(m.Narrators, ", "), + SeriesName: seriesName, + Genres: genres, + PublishedYear: m.PublishedYear, + Publisher: m.Publisher, + Description: m.Description, + ISBN: m.ISBN, + Language: "en", + Explicit: m.Explicit, }, - Duration: item.Media.Duration, - CoverPath: item.Media.CoverPath, + CoverPath: item.Media.CoverPath, + Tags: tags, + NumTracks: numTracks, + NumAudioFiles: numTracks, + NumChapters: len(item.Media.Chapters), + Duration: item.Media.Duration, + Size: 0, }, - NumTracks: item.NumTracks, - AddedAt: item.AddedAt, - UpdatedAt: item.UpdatedAt, + NumFiles: numTracks, + Size: 0, + CollapsedSeries: item.CollapsedSeries, } } diff --git a/internal/audiobooks/abs/minified_test.go b/internal/audiobooks/abs/minified_test.go new file mode 100644 index 00000000..048b2f22 --- /dev/null +++ b/internal/audiobooks/abs/minified_test.go @@ -0,0 +1,92 @@ +package abs + +import ( + "encoding/json" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// TestMinify_ConformsToRealABSKeys asserts the minified list item matches real +// ABS LibraryItem.toOldJSONMinified + Book.toOldJSONMinified key sets — a +// missing key crashes strict clients, and media.numTracks/numAudioFiles must +// be >= 1 or Plappa drops the item. +func TestMinify_ConformsToRealABSKeys(t *testing.T) { + item := &models.MediaItem{ + ContentID: "book-1", + Title: "Test Book", + People: []models.ItemPerson{ + {Person: models.Person{ID: 42, Name: "Stephen King"}, Kind: models.PersonKindAuthor}, + }, + } + lib := AudiobookLibrary{ID: 18, Name: "Audiobooks", Type: "audiobooks"} + + full := siloItemToLibraryItem(item, lib, "http://x") + min := Minify(full) + + body, err := json.Marshal(min) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Fatalf("decode: %v", err) + } + + outer := []string{ + "id", "ino", "oldLibraryItemId", "libraryId", "folderId", "path", + "relPath", "isFile", "mtimeMs", "ctimeMs", "birthtimeMs", "addedAt", + "updatedAt", "isMissing", "isInvalid", "mediaType", "media", "numFiles", "size", + } + for _, k := range outer { + if _, ok := m[k]; !ok { + t.Errorf("minified item missing outer key %q", k) + } + } + + media, _ := m["media"].(map[string]any) + mediaKeys := []string{ + "id", "metadata", "coverPath", "tags", "numTracks", "numAudioFiles", + "numChapters", "duration", "size", "ebookFormat", + } + for _, k := range mediaKeys { + if _, ok := media[k]; !ok { + t.Errorf("minified media missing key %q", k) + } + } + if media["id"] != "book-1" { + t.Errorf("media.id = %v, want book-1", media["id"]) + } + // Plappa guard: never advertise 0 audio files. + if n, _ := media["numTracks"].(float64); n < 1 { + t.Errorf("media.numTracks = %v, want >= 1", media["numTracks"]) + } + if n, _ := media["numAudioFiles"].(float64); n < 1 { + t.Errorf("media.numAudioFiles = %v, want >= 1", media["numAudioFiles"]) + } + + meta, _ := media["metadata"].(map[string]any) + metaKeys := []string{ + "title", "titleIgnorePrefix", "subtitle", "authorName", "authorNameLF", + "narratorName", "seriesName", "genres", "publishedYear", "publishedDate", + "publisher", "description", "isbn", "asin", "language", "explicit", "abridged", + } + for _, k := range metaKeys { + if _, ok := meta[k]; !ok { + t.Errorf("minified metadata missing key %q", k) + } + } +} + +// TestSiloItemToLibraryItem_MediaHasID guards the yaabsa BookMedia.id crash: +// the non-minified media object must carry id + libraryItemId = ContentID. +func TestSiloItemToLibraryItem_MediaHasID(t *testing.T) { + item := &models.MediaItem{ContentID: "book-9", Title: "T"} + full := siloItemToLibraryItem(item, AudiobookLibrary{ID: 1}, "http://x") + if full.Media.ID != "book-9" { + t.Errorf("media.id = %q, want book-9", full.Media.ID) + } + if full.Media.LibraryItemID != "book-9" { + t.Errorf("media.libraryItemId = %q, want book-9", full.Media.LibraryItemID) + } +} diff --git a/internal/audiobooks/abs/ping_status_test.go b/internal/audiobooks/abs/ping_status_test.go new file mode 100644 index 00000000..90fd689b --- /dev/null +++ b/internal/audiobooks/abs/ping_status_test.go @@ -0,0 +1,47 @@ +package abs + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestABSPing_HasSuccess guards the reachability probe: real ABS /ping returns +// {"success": true} and the ABS apps validate a server address by reading that +// field. Without it the app reports "unable to reach". +func TestABSPing_HasSuccess(t *testing.T) { + h := &Handler{} + rec := httptest.NewRecorder() + h.handleABSPing(rec, httptest.NewRequest(http.MethodGet, "/ping", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode: %v", err) + } + if m["success"] != true { + t.Errorf("/ping success = %v, want true", m["success"]) + } +} + +// TestABSStatus_HasAuthMethods guards that /status carries authMethods (drives +// the login form) — real ABS Server.js /status shape. +func TestABSStatus_HasAuthMethods(t *testing.T) { + h := &Handler{} + rec := httptest.NewRecorder() + h.handleABSStatus(rec, httptest.NewRequest(http.MethodGet, "/status", nil)) + var m map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &m); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"app", "serverVersion", "isInit", "language", "authMethods", "authFormData"} { + if _, ok := m[k]; !ok { + t.Errorf("/status missing key %q", k) + } + } + if methods, ok := m["authMethods"].([]any); !ok || len(methods) == 0 { + t.Errorf("authMethods = %v, want non-empty array", m["authMethods"]) + } +} diff --git a/internal/audiobooks/abs/play_response.go b/internal/audiobooks/abs/play_response.go index 2b73e3ab..77fd1133 100644 --- a/internal/audiobooks/abs/play_response.go +++ b/internal/audiobooks/abs/play_response.go @@ -254,7 +254,7 @@ func buildSiloAudioTracks( TimeBase: "1/14112000", Channels: channels, ChannelLayout: channelLayout, - Chapters: nil, + Chapters: []ChapterABS{}, EmbeddedCoverArt: nil, MetaTags: map[string]string{}, MimeType: mimeType, diff --git a/internal/audiobooks/abs/playlists.go b/internal/audiobooks/abs/playlists.go index aa0794df..c637babc 100644 --- a/internal/audiobooks/abs/playlists.go +++ b/internal/audiobooks/abs/playlists.go @@ -55,6 +55,7 @@ type PlaylistItem struct { func playlistToABS(p Playlist, items []map[string]any) map[string]any { out := map[string]any{ "id": p.ID, + "libraryId": VirtualLibraryID, // real ABS Playlist.toOldJSON has libraryId; silo playlists are cross-library user-personal "userId": p.UserID, "name": p.Name, "description": p.Description, diff --git a/internal/audiobooks/abs/playlists_envelope_test.go b/internal/audiobooks/abs/playlists_envelope_test.go index 44fed2f9..c70322f6 100644 --- a/internal/audiobooks/abs/playlists_envelope_test.go +++ b/internal/audiobooks/abs/playlists_envelope_test.go @@ -25,7 +25,7 @@ func TestPlaylistEnvelope_HasRequiredKeys(t *testing.T) { body, _ := json.Marshal(out) js := string(body) for _, key := range []string{ - `"id":`, `"userId":`, `"name":`, `"description":`, + `"id":`, `"libraryId":`, `"userId":`, `"name":`, `"description":`, `"isPublic":`, `"coverPath":`, `"createdAt":`, `"lastUpdate":`, `"items":`, } { if !strings.Contains(js, key) { diff --git a/internal/audiobooks/abs/search_shape_test.go b/internal/audiobooks/abs/search_shape_test.go new file mode 100644 index 00000000..791a71bf --- /dev/null +++ b/internal/audiobooks/abs/search_shape_test.go @@ -0,0 +1,166 @@ +package abs + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +// searchStubMediaStore backs the /libraries/{id}/search shape tests. It +// embeds noopMediaStore so only the methods the handler exercises need +// overriding. +type searchStubMediaStore struct { + noopMediaStore + libs []AudiobookLibrary + results []*models.MediaItem + authors []AuthorSummary + series []SeriesSummary +} + +func (s *searchStubMediaStore) ListAudiobookLibraries(context.Context, catalog.AccessFilter) ([]AudiobookLibrary, error) { + return s.libs, nil +} + +func (s *searchStubMediaStore) SearchAudiobooks(_ context.Context, _ int64, _ string, _ int, _ catalog.AccessFilter) ([]*models.MediaItem, error) { + return s.results, nil +} + +func (s *searchStubMediaStore) ListLibraryAuthors(context.Context, int64, int, int, string, bool, catalog.AccessFilter) ([]AuthorSummary, int, error) { + return s.authors, len(s.authors), nil +} + +func (s *searchStubMediaStore) ListLibrarySeries(context.Context, int64, int, int, catalog.AccessFilter) ([]SeriesSummary, int, error) { + return s.series, len(s.series), nil +} + +func newSearchHarness() *Handler { + store := &searchStubMediaStore{ + libs: []AudiobookLibrary{{ID: 1, Name: "Audiobooks", Type: "audiobooks"}}, + results: []*models.MediaItem{ + {ContentID: "book-1", Title: "The Search Result"}, + }, + authors: []AuthorSummary{{ID: "a1", Name: "Search Author", NumBooks: 3}}, + series: []SeriesSummary{{ + ID: "s1", Name: "Search Series", NumBooks: 2, + Books: []SeriesBookPreview{{ContentID: "book-1", Title: "The Search Result"}}, + }}, + } + return New(Dependencies{MediaStore: store}) +} + +// TestLibrarySearch_BucketKeysPresent asserts the response has every bucket +// key real ABS's libraryItemsBookFilters.search() returns (book, narrators, +// tags, genres, series, authors), plus our extra podcast bucket. A missing +// key crashes strict ABS clients that decode the whole envelope up front. +func TestLibrarySearch_BucketKeysPresent(t *testing.T) { + h := newSearchHarness() + rec := dispatchABSWithParams(http.MethodGet, "/api/libraries/1/search?q=search&limit=10", + map[string]string{"libraryId": "1"}, nil, "1", "", h.handleLibrarySearch) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"book", "podcast", "narrators", "tags", "genres", "series", "authors"} { + if _, ok := got[key]; !ok { + t.Errorf("missing bucket key %q in response: %v", key, got) + } + } +} + +// TestLibrarySearch_BookEntryHasLibraryItem asserts each "book" bucket entry +// is `{ libraryItem: