From d73b1b1c6b22e61f2a41babed41c8daeb35c2be1 Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Sun, 24 May 2026 15:41:21 +0200 Subject: [PATCH] feat(audiobooks): port ABS library + items browse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 5 of the ABS-compat port. Adds: - GET /abs/api/me (and /api/me) - GET /abs/api/libraries (and /api/libraries) - GET /abs/api/libraries/{id} - GET /abs/api/libraries/{id}/items - GET /abs/api/items/{id} - GET /abs/api/items/{id}/similar - GET /abs/api/me/items-in-progress (Continue Listening) - Stub 200 endpoints for authors, series, search, personalized Extends MediaStore interface: ListAudiobookLibraries and LibraryID-filtered ListAudiobooks. Adds AudiobookLibrary struct. Also ports collapse.go (CollapseBySeries) from the plugin — identical logic, needed for collapseseries=1 query param support. Author/series detail and filter-data population stubbed (empty lists); ABS clients fall back to flat item browse when those endpoints return empty rather than crashing. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/audiobooks/abs/collapse.go | 86 +++ internal/audiobooks/abs/handler.go | 57 +- internal/audiobooks/abs/items_handler.go | 164 ++++++ internal/audiobooks/abs/libraries_handler.go | 552 +++++++++++++++++++ internal/audiobooks/abs/me_handler.go | 107 ++++ 5 files changed, 961 insertions(+), 5 deletions(-) create mode 100644 internal/audiobooks/abs/collapse.go create mode 100644 internal/audiobooks/abs/items_handler.go create mode 100644 internal/audiobooks/abs/libraries_handler.go create mode 100644 internal/audiobooks/abs/me_handler.go diff --git a/internal/audiobooks/abs/collapse.go b/internal/audiobooks/abs/collapse.go new file mode 100644 index 00000000..b6fbd19d --- /dev/null +++ b/internal/audiobooks/abs/collapse.go @@ -0,0 +1,86 @@ +package abs + +import "strings" + +// CollapseBySeries folds a flat list of LibraryItems into a deduplicated +// list where every item belonging to a series is represented by a single +// entry carrying a CollapsedSeriesV1 block listing every book in that +// series. Items with no series pass through unchanged. +// +// The representative entry for a series is the first item in source +// order — same convention real ABS uses. The series itself is keyed by +// the first series.ID on each book (real ABS books rarely belong to +// multiple series; the spec defines this as taking the first when they +// do). +// +// Stable across multiple calls: input order determines output order, so +// pagination on top of this remains deterministic. +func CollapseBySeries(items []LibraryItem) []LibraryItem { + if len(items) == 0 { + return items + } + // Index series → representative slot (+ tracked ID list). + seriesSlot := make(map[string]int, len(items)) + out := make([]LibraryItem, 0, len(items)) + + for _, it := range items { + series := primarySeries(it) + if series.ID == "" && series.Name == "" { + // Not part of a series — pass through. + out = append(out, it) + continue + } + key := seriesKey(series) + if slot, ok := seriesSlot[key]; ok { + out[slot].CollapsedSeries.NumBooks++ + out[slot].CollapsedSeries.LibraryItemIDs = append( + out[slot].CollapsedSeries.LibraryItemIDs, it.ID) + continue + } + // First sighting — clone the item and attach a fresh + // CollapsedSeriesV1 with this id as the seed. + rep := it + rep.CollapsedSeries = &CollapsedSeriesV1{ + ID: series.ID, + Name: series.Name, + NameIgnorePrefix: stripLeadingArticle(series.Name), + NumBooks: 1, + LibraryItemIDs: []string{it.ID}, + } + seriesSlot[key] = len(out) + out = append(out, rep) + } + return out +} + +// primarySeries returns the first series ref on a LibraryItem, or a +// zero-value SeriesObj when the book is unaffiliated. +func primarySeries(it LibraryItem) SeriesObj { + for _, s := range it.Media.Metadata.Series { + if s.ID != "" || s.Name != "" { + return s + } + } + return SeriesObj{} +} + +// seriesKey prefers the id (stable identifier); falls back to the name +// when the id is empty (legacy / synthesised metadata). +func seriesKey(s SeriesObj) string { + if s.ID != "" { + return "id:" + s.ID + } + return "name:" + s.Name +} + +// stripLeadingArticle produces the "ignore-prefix" sort label real ABS +// emits for series — "The Stormlight Archive" sorts under S, not T. +func stripLeadingArticle(name string) string { + lower := strings.ToLower(name) + for _, prefix := range []string{"the ", "a ", "an "} { + if strings.HasPrefix(lower, prefix) { + return strings.TrimSpace(name[len(prefix):]) + } + } + return name +} diff --git a/internal/audiobooks/abs/handler.go b/internal/audiobooks/abs/handler.go index bce56d2b..5b1a265b 100644 --- a/internal/audiobooks/abs/handler.go +++ b/internal/audiobooks/abs/handler.go @@ -25,17 +25,30 @@ import ( // Dependency interfaces // --------------------------------------------------------------------------- +// AudiobookLibrary is the narrow library view the ABS handlers expose. +// The production adapter (Stage 7) builds these from media_folders WHERE +// type = 'audiobooks'. +type AudiobookLibrary struct { + ID int64 + Name string + Type string // always "audiobooks" for this surface +} + // MediaStore is the slice of silo's catalog the ABS handler reads. // Real impl: catalog.ItemRepository + scanner.FileRepository wrapped in // a small adapter struct added in a later stage. type MediaStore interface { GetAudiobookByID(ctx context.Context, contentID string) (*models.MediaItem, error) - ListAudiobooks(ctx context.Context, limit, offset int) ([]*models.MediaItem, int, 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) ([]*models.MediaItem, int, error) GetMediaFiles(ctx context.Context, contentID string) ([]*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 // raw file ID instead of an ino. GetMediaFileByID(ctx context.Context, fileID int) (*models.MediaFile, error) + // ListAudiobookLibraries returns media_folder rows with type='audiobooks'. + ListAudiobookLibraries(ctx context.Context) ([]AudiobookLibrary, error) } // TokenStore persists and validates the ABS JWT JTIs that back the @@ -164,6 +177,14 @@ func (h *Handler) mountRoutes(r chi.Router) { r.Post("/login", h.handleLogin) r.Post("/abs/api/login", h.handleLogin) + // Unauthenticated cover + author-image routes. Real ABS serves covers + // without auth (getDoesServerImagesRequireToken returns false for our + // version), so mounting these outside bearerAuth avoids 401s. + for _, prefix := range []string{"/abs/api", "/api"} { + r.Get(prefix+"/items/{id}/cover", h.handleItemCover) + r.Get(prefix+"/authors/{id}/image", h.handleAuthorImage) + } + // Stage 3: playback session + file routes, registered under both the // legacy /abs/api prefix and the canonical /api prefix that the official // ABS mobile client builds against (no /abs prefix at server root). @@ -182,10 +203,6 @@ func (h *Handler) mountRoutes(r chi.Router) { } }) - // TODO Stage 2 (remaining): logout, refresh, authorize, me, ping, status, init - // TODO Stage 3 (remaining): library browse routes (libraries, items, item detail, cover, authors, series, search, personalized) - // TODO Stage 6: social / collection routes (bookmarks, smart-collections, collections, playlists, RSS feeds, similar) - // Stage 4: progress + session tracking — requires bearerAuth. r.Group(func(r chi.Router) { r.Use(h.bearerAuth) @@ -202,6 +219,36 @@ func (h *Handler) mountRoutes(r chi.Router) { r.Post(prefix+"/session/{sid}/close", h.handleSessionClose) } }) + + // Stage 5: browse routes (libraries, items, item detail, me, similar, + // continue-listening) + author/series/search/personalized stubs. + // Requires bearerAuth. + r.Group(func(r chi.Router) { + r.Use(h.bearerAuth) + for _, prefix := range []string{"/abs/api", "/api"} { + // Current user object. + r.Get(prefix+"/me", h.handleMe) + // Continue Listening shelf. + r.Get(prefix+"/me/items-in-progress", h.handleItemsInProgress) + // Library list + detail. + r.Get(prefix+"/libraries", h.handleLibraries) + r.Get(prefix+"/libraries/{libraryId}", h.handleLibraryDetail) + // Browse items in a library. + r.Get(prefix+"/libraries/{libraryId}/items", h.handleLibraryItems) + // Author / series / search / personalized — stubbed. + r.Get(prefix+"/libraries/{libraryId}/authors", h.handleLibraryAuthors) + r.Get(prefix+"/libraries/{libraryId}/series", h.handleLibrarySeries) + r.Get(prefix+"/libraries/{libraryId}/search", h.handleLibrarySearch) + r.Get(prefix+"/libraries/{libraryId}/personalized", h.handlePersonalized) + // Single item detail. + r.Get(prefix+"/items/{id}", h.handleItem) + // Similar items (optional Recommender; empty list when nil). + r.Get(prefix+"/items/{id}/similar", h.handleSimilarItems) + } + }) + + // TODO Stage 6: social / collection routes (bookmarks, smart-collections, + // collections, playlists, RSS feeds, author/series detail, listening stats) } // --------------------------------------------------------------------------- diff --git a/internal/audiobooks/abs/items_handler.go b/internal/audiobooks/abs/items_handler.go new file mode 100644 index 00000000..daf518fa --- /dev/null +++ b/internal/audiobooks/abs/items_handler.go @@ -0,0 +1,164 @@ +package abs + +import ( + "net/http" + + "github.com/go-chi/chi/v5" +) + +// handleItem — GET /abs/api/items/{id} (and /api/items/{id}) +// +// Returns the full ABS LibraryItem with audio track details for the given +// audiobook. The ABS mobile app fetches this when the user opens the +// item-detail page; it reads media.tracks.length to decide whether to render +// the play button and uses the track metadata for offline-download decisions. +func (h *Handler) handleItem(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + contentID := chi.URLParam(r, "id") + if contentID == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + + item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), contentID) + if err != nil || item == nil { + http.Error(w, "item not found", http.StatusNotFound) + return + } + + files, err := h.deps.MediaStore.GetMediaFiles(r.Context(), contentID) + if err != nil { + http.Error(w, "load files: "+err.Error(), http.StatusInternalServerError) + return + } + + // Resolve the library this item belongs to (first matching audiobooks lib + // or the virtual fallback). Best-effort: if the list call fails we + // proceed with a zero-ID virtual library so the item still renders. + var lib AudiobookLibrary + if libs, lerr := h.deps.MediaStore.ListAudiobookLibraries(r.Context()); lerr == nil && len(libs) > 0 { + lib = libs[0] + } else { + lib = AudiobookLibrary{ID: 0, Name: VirtualLibraryName, Type: "audiobooks"} + } + + baseURL := h.absBaseURL(r) + result := siloItemToLibraryItemDetail(item, files, lib, baseURL) + writeJSON(w, http.StatusOK, result) +} + +// handleSimilarItems — GET /abs/api/items/{id}/similar +// +// Returns a list of similar audiobooks. Uses the optional Recommender if +// wired; otherwise returns an empty list so the ABS client falls back to +// its default UI rather than crashing. +func (h *Handler) handleSimilarItems(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + contentID := chi.URLParam(r, "id") + if contentID == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + + if h.deps.Recommender == nil { + writeJSON(w, http.StatusOK, map[string]any{"libraryItems": []any{}}) + return + } + + ids, err := h.deps.Recommender.Similar(r.Context(), contentID, 10) + if err != nil || len(ids) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"libraryItems": []any{}}) + return + } + + var lib AudiobookLibrary + if libs, lerr := h.deps.MediaStore.ListAudiobookLibraries(r.Context()); lerr == nil && len(libs) > 0 { + lib = libs[0] + } else { + lib = AudiobookLibrary{ID: 0, Name: VirtualLibraryName, Type: "audiobooks"} + } + + baseURL := h.absBaseURL(r) + out := make([]LibraryItem, 0, len(ids)) + for _, id := range ids { + si, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), id) + if err != nil || si == nil { + continue + } + out = append(out, siloItemToLibraryItem(si, lib, baseURL)) + } + writeJSON(w, http.StatusOK, map[string]any{"libraryItems": out}) +} + +// 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. +func (h *Handler) handleItemsInProgress(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + if h.deps.ProgressStore == nil { + writeJSON(w, http.StatusOK, map[string]any{"libraryItems": []any{}}) + return + } + + rows, err := h.deps.ProgressStore.ListProgressForAudiobooks(r.Context(), a.UserID, a.ProfileID, 25) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var lib AudiobookLibrary + if libs, lerr := h.deps.MediaStore.ListAudiobookLibraries(r.Context()); lerr == nil && len(libs) > 0 { + lib = libs[0] + } else { + lib = AudiobookLibrary{ID: 0, Name: VirtualLibraryName, Type: "audiobooks"} + } + + baseURL := h.absBaseURL(r) + items := make([]any, 0, len(rows)) + for _, p := range rows { + if p.IsFinished || p.CurrentSeconds <= 0 { + continue + } + si, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), p.ContentID) + if err != nil || 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(), + }, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"libraryItems": items}) +} diff --git a/internal/audiobooks/abs/libraries_handler.go b/internal/audiobooks/abs/libraries_handler.go new file mode 100644 index 00000000..bae9069f --- /dev/null +++ b/internal/audiobooks/abs/libraries_handler.go @@ -0,0 +1,552 @@ +package abs + +import ( + "net/http" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// --------------------------------------------------------------------------- +// /libraries list + single-library detail +// --------------------------------------------------------------------------- + +// handleLibraries — GET /abs/api/libraries (and /api/libraries) +// +// Returns the list of audiobook media_folders. ABS clients call this to +// populate the library picker and to know which library IDs are valid. +func (h *Handler) handleLibraries(w http.ResponseWriter, r *http.Request) { + libs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context()) + if err != nil { + http.Error(w, "list libraries: "+err.Error(), http.StatusInternalServerError) + return + } + out := make([]map[string]any, 0, len(libs)) + for _, lib := range libs { + out = append(out, audiobookLibraryMap(lib)) + } + writeJSON(w, http.StatusOK, map[string]any{"libraries": out}) +} + +// handleLibraryDetail — GET /abs/api/libraries/{libraryId} +func (h *Handler) handleLibraryDetail(w http.ResponseWriter, r *http.Request) { + lib, ok := h.resolveLibrary(w, r) + if !ok { + return + } + resp := map[string]any{ + "library": audiobookLibraryMap(lib), + } + if includeHas(r.URL.Query().Get("include"), "filterdata") { + resp["filterdata"] = emptyFilterData() + resp["issues"] = 0 + resp["numUserPlaylists"] = 0 + } + writeJSON(w, http.StatusOK, resp) +} + +// emptyFilterData returns a zero-entry filter-data block. Populating authors / +// series / narrators from silo's catalog is deferred: for now ABS clients +// will show empty filter dropdowns rather than crash. +func emptyFilterData() map[string]any { + return map[string]any{ + "authors": []AuthorObj{}, + "series": []SeriesObj{}, + "narrators": []string{}, + "genres": []string{}, + "publishers": []string{}, + "languages": []string{}, + "tags": []string{}, + } +} + +// --------------------------------------------------------------------------- +// /libraries/{libraryId}/items — paginated audiobook browse +// --------------------------------------------------------------------------- + +// handleLibraryItems — GET /abs/api/libraries/{libraryId}/items +// +// Returns a paginated, optionally filtered and/or collapsed-by-series list +// of audiobook LibraryItems from silo's media_items table for the requested +// library. Supports the standard ABS query params: +// +// - limit / page — pagination +// - minified=1 — slim response (no tracks, flat author/series) +// - filter=. — local-side filter (authors, series, narrators, progress) +// - collapseseries=1 — fold books by series; returns one entry per series +// +// Note: sort pushdown is not yet implemented (returns insertion order from +// the store). Filter is applied locally after fetching. These limitations +// are consistent with the plugin at its initial launch. +func (h *Handler) handleLibraryItems(w http.ResponseWriter, r *http.Request) { + lib, ok := h.resolveLibrary(w, r) + if !ok { + return + } + + q := r.URL.Query() + limit, page := readPagedQuery(r, 30) + sortBy := q.Get("sort") + sortDesc := q.Get("desc") == "1" + filterBy := q.Get("filter") + minified := q.Get("minified") == "1" + collapseSeries := q.Get("collapseseries") == "1" + include := q.Get("include") + + filter, hasFilter := ParseFilter(filterBy) + + // Over-fetch when filtering so local post-filter has enough rows. + fetchLimit := limit + if hasFilter || limit == 0 { + fetchLimit = 5000 + } + fetchOffset := 0 + if !hasFilter && limit > 0 { + fetchOffset = page * limit + } + + items, total, err := h.deps.MediaStore.ListAudiobooks(r.Context(), lib.ID, fetchLimit, fetchOffset) + if err != nil { + http.Error(w, "list audiobooks: "+err.Error(), http.StatusInternalServerError) + return + } + + // Convert to ABS LibraryItem shape. + baseURL := h.absBaseURL(r) + all := make([]LibraryItem, 0, len(items)) + for _, item := range items { + all = append(all, siloItemToLibraryItem(item, lib, baseURL)) + } + + // Local filter (post-fetch). + if hasFilter { + filtered := make([]LibraryItem, 0, len(all)) + for _, it := range all { + if filter.Matches(it, false, false, false) { + filtered = append(filtered, it) + } + } + all = filtered + total = len(all) + } + + // Collapse-by-series before paging. + collapsed := all + if collapseSeries { + collapsed = CollapseBySeries(all) + total = len(collapsed) + } + + // Slice for page/limit. + pageStart, pageEnd := 0, len(collapsed) + if limit > 0 && (hasFilter || collapseSeries) { + pageStart = page * limit + if pageStart > len(collapsed) { + pageStart = len(collapsed) + } + pageEnd = pageStart + limit + if pageEnd > len(collapsed) { + pageEnd = len(collapsed) + } + } + pageSlice := collapsed[pageStart:pageEnd] + + // Serialise. + var results any + if minified { + mins := make([]MinifiedLibraryItem, len(pageSlice)) + for i, it := range pageSlice { + mins[i] = Minify(it) + } + results = mins + } else { + results = pageSlice + } + + writeJSON(w, http.StatusOK, pagedEnvelope(results, total, limit, page, sortBy, sortDesc, filterBy, minified, include)) +} + +// --------------------------------------------------------------------------- +// Cover and stub endpoints for authors / series / search / personalized +// --------------------------------------------------------------------------- + +// handleItemCover — GET /abs/api/items/{id}/cover (unauthenticated) +// +// Returns the audiobook's cover image. Currently redirects to silo's native +// cover endpoint; a later stage may proxy the bytes directly. +func (h *Handler) handleItemCover(w http.ResponseWriter, r *http.Request) { + contentID := chi.URLParam(r, "id") + if contentID == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + item, err := h.deps.MediaStore.GetAudiobookByID(r.Context(), contentID) + if err != nil || item == nil { + http.NotFound(w, r) + return + } + if item.PosterPath == "" { + http.NotFound(w, r) + return + } + // Redirect to silo's native image endpoint rather than proxying bytes. + http.Redirect(w, r, item.PosterPath, http.StatusFound) +} + +// handleAuthorImage — GET /abs/api/authors/{id}/image (unauthenticated) +// +// Author images are not yet stored in silo; return a clean 404 so the +// ABS client renders the placeholder. +func (h *Handler) handleAuthorImage(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) +} + +// handleLibraryAuthors — GET /abs/api/libraries/{id}/authors +// Stubbed: silo does not yet maintain a normalised author table. +func (h *Handler) handleLibraryAuthors(w http.ResponseWriter, r *http.Request) { + if _, ok := h.resolveLibrary(w, r); !ok { + return + } + writeJSON(w, http.StatusOK, map[string]any{"authors": []any{}}) +} + +// handleLibrarySeries — GET /abs/api/libraries/{id}/series +// Stubbed: silo does not yet maintain a normalised series table. +func (h *Handler) handleLibrarySeries(w http.ResponseWriter, r *http.Request) { + if _, ok := h.resolveLibrary(w, r); !ok { + return + } + writeJSON(w, http.StatusOK, map[string]any{"results": []any{}, "total": 0}) +} + +// handleLibrarySearch — GET /abs/api/libraries/{id}/search +// Stubbed: returns empty results for all categories. +func (h *Handler) handleLibrarySearch(w http.ResponseWriter, r *http.Request) { + if _, ok := h.resolveLibrary(w, r); !ok { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "book": []any{}, + "podcast": []any{}, + "author": []any{}, + "series": []any{}, + "tags": []any{}, + }) +} + +// handlePersonalized — GET /abs/api/libraries/{id}/personalized +// Stubbed: returns an empty shelf list. Continue Listening is served via +// /me/items-in-progress; this endpoint feeds the home-tab "shelves". +// A full implementation would build Recently Added, Continue Listening, +// Newest Authors, etc. from silo's catalog + progress tables. +func (h *Handler) handlePersonalized(w http.ResponseWriter, r *http.Request) { + if _, ok := h.resolveLibrary(w, r); !ok { + return + } + writeJSON(w, http.StatusOK, []any{}) +} + +// --------------------------------------------------------------------------- +// Library resolver +// --------------------------------------------------------------------------- + +// resolveLibrary looks up the library identified by the {libraryId} URL +// param, handling the virtual "silo-audiobooks" sentinel. Returns (lib, true) +// on success or writes a 404 and returns (zero, false) on failure. +func (h *Handler) resolveLibrary(w http.ResponseWriter, r *http.Request) (AudiobookLibrary, bool) { + idStr := chi.URLParam(r, "libraryId") + if idStr == "" { + idStr = chi.URLParam(r, "id") + } + + libs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context()) + if err != nil { + http.Error(w, "list libraries: "+err.Error(), http.StatusInternalServerError) + return AudiobookLibrary{}, false + } + + // Virtual sentinel → first library. + if idStr == "" || idStr == VirtualLibraryID { + if len(libs) > 0 { + return libs[0], true + } + // No libraries configured yet: return a virtual one so ABS clients + // still get a sensible (empty) browse response. + return AudiobookLibrary{ID: 0, Name: VirtualLibraryName, Type: "audiobooks"}, true + } + + n, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + http.Error(w, "library not found", http.StatusNotFound) + return AudiobookLibrary{}, false + } + for _, lib := range libs { + if lib.ID == n { + return lib, true + } + } + http.Error(w, "library not found", http.StatusNotFound) + return AudiobookLibrary{}, false +} + +// --------------------------------------------------------------------------- +// silo MediaItem → ABS LibraryItem translation +// --------------------------------------------------------------------------- + +// siloItemToLibraryItem converts a silo MediaItem (type='audiobook') into the +// ABS LibraryItem wire shape for browse-list responses (no audio tracks; only +// metadata + duration summary). File-level tracks are populated only on the +// item-detail handler (handleItem). +func siloItemToLibraryItem(item *models.MediaItem, lib AudiobookLibrary, baseURL string) LibraryItem { + meta := siloItemToMetadata(item) + libID := audiobookLibraryID(lib) + + // Duration: Runtime field on MediaItem is in minutes for video; for + // audiobooks it stores the total seconds (set by the scanner Stage 2 + // extension). Convert from the int field. + duration := float64(item.Runtime) // seconds + + coverPath := item.PosterPath + if coverPath == "" { + coverPath = baseURL + "/abs/api/items/" + item.ContentID + "/cover" + } + + addedAtMs := int64(0) + if item.AddedAt != nil { + addedAtMs = item.AddedAt.UnixMilli() + } + updatedAtMs := item.UpdatedAt.UnixMilli() + + return LibraryItem{ + ID: item.ContentID, + LibraryID: libID, + FolderID: VirtualFolderID, + MediaType: LibraryMediaType, + Media: LibraryItemMedia{ + Metadata: meta, + Duration: duration, + CoverPath: coverPath, + AudioFiles: []AudioTrack{}, + Tracks: []AudioTrack{}, + Chapters: []ChapterABS{}, + NumTracks: 0, // populated by item-detail handler + }, + AddedAt: addedAtMs, + UpdatedAt: updatedAtMs, + } +} + +// siloItemToMetadata extracts the ABS Metadata block from a silo MediaItem. +// Authors and narrators are sourced from item.People; series from Studios +// (silo stores the series name in Studios for audiobooks until a proper +// series table lands — see scanner Stage 2 notes). +func siloItemToMetadata(item *models.MediaItem) Metadata { + authors := make([]AuthorObj, 0) + narrators := make([]string, 0) + + for _, p := range item.People { + switch p.Kind { + case models.PersonKindAuthor: + authors = append(authors, AuthorObj{ + ID: strconv.FormatInt(p.ID, 10), + Name: p.Name, + }) + case models.PersonKindNarrator: + narrators = append(narrators, p.Name) + } + } + + // Series: silo's audiobook scanner stores series name in the Studios + // field until a dedicated series table is added. Derive an ID by + // slugifying the name (same convention as the plugin's translate.go). + series := make([]SeriesObj, 0, len(item.Studios)) + for _, s := range item.Studios { + s = strings.TrimSpace(s) + if s == "" { + continue + } + series = append(series, SeriesObj{ + ID: slugify(s), + Name: s, + }) + } + + publishedYear := "" + if item.Year > 0 { + publishedYear = strconv.Itoa(item.Year) + } + + genres := item.Genres + if genres == nil { + genres = []string{} + } + + return Metadata{ + Title: item.Title, + Authors: authors, + Narrators: narrators, + Series: series, + Description: item.Overview, + PublishedYear: publishedYear, + Genres: genres, + } +} + +// siloItemToLibraryItemDetail converts a silo MediaItem + its media files into +// a full ABS LibraryItem with audio track details populated. Called by +// handleItem (single-item GET). +func siloItemToLibraryItemDetail(item *models.MediaItem, files []*models.MediaFile, lib AudiobookLibrary, baseURL string) LibraryItem { + base := siloItemToLibraryItem(item, lib, baseURL) + + tracks := siloFilesToAudioTracks(item.ContentID, files, baseURL, "") + + // Recompute duration from files if the item's Runtime is zero. + totalDuration := base.Media.Duration + if totalDuration == 0 { + for _, t := range tracks { + totalDuration += t.Duration + } + } + + // Chapters from the first file that has them. + chapters := make([]ChapterABS, 0) + for _, f := range files { + if len(f.Chapters) > 0 { + for i, c := range f.Chapters { + chapters = append(chapters, ChapterABS{ + ID: i, + Start: c.StartSeconds, + End: c.EndSeconds, + Title: c.Title, + }) + } + break + } + } + + base.Media.AudioFiles = tracks + base.Media.Tracks = tracks + base.Media.Chapters = chapters + base.Media.NumTracks = len(tracks) + base.Media.Duration = totalDuration + base.NumTracks = len(tracks) + return base +} + +// siloFilesToAudioTracks converts silo MediaFile rows into ABS AudioTrack +// entries for the item-detail response. token may be empty (item-detail +// doesn't embed auth tokens; the ABS client initiates playback via /play). +func siloFilesToAudioTracks(contentID string, files []*models.MediaFile, baseURL, token string) []AudioTrack { + tracks := make([]AudioTrack, 0, len(files)) + startOffset := float64(0) + nowMs := time.Now().UnixMilli() + + for i, f := range files { + ino := trackInoFor(contentID, i) + ext := strings.ToLower(filepath.Ext(f.FilePath)) + format := strings.TrimPrefix(ext, ".") + mimeType := audioContentType(ext) + if mimeType == "" { + mimeType = "audio/mpeg" + } + filename := filepath.Base(f.FilePath) + wireIndex := i + 1 + + contentURL := baseURL + "/abs/api/items/" + contentID + "/file/" + ino + if token != "" { + contentURL += "?token=" + token + } + + duration := float64(f.Duration) + bitRate := f.Bitrate * 1000 + if bitRate == 0 { + bitRate = 128000 + } + channels := f.AudioChannels + if channels == 0 { + channels = 2 + } + channelLayout := "stereo" + if channels > 2 { + channelLayout = "surround" + } + + tracks = append(tracks, AudioTrack{ + Index: wireIndex, + Ino: ino, + Metadata: &AudioTrackMetadata{ + Filename: filename, + Ext: ext, + Path: f.FilePath, + RelPath: filename, + Size: f.FileSize, + MtimeMs: nowMs, + CtimeMs: nowMs, + BirthtimeMs: nowMs, + }, + AddedAt: nowMs, + UpdatedAt: nowMs, + ManuallyVerified: false, + Exclude: false, + Format: format, + Duration: duration, + BitRate: bitRate, + Language: nil, + Codec: f.CodecAudio, + TimeBase: "1/14112000", + Channels: channels, + ChannelLayout: channelLayout, + EmbeddedCoverArt: nil, + MetaTags: map[string]string{}, + MimeType: mimeType, + Title: filename, + StartOffset: startOffset, + ContentURL: contentURL, + }) + startOffset += duration + } + return tracks +} + +// slugify produces a stable ID-from-name, identical to the plugin's translate.go +// implementation so derived IDs round-trip consistently. +func slugify(name string) string { + var b strings.Builder + prevDash := true + for _, r := range strings.ToLower(name) { + switch { + case isLetterOrDigit(r): + b.WriteRune(r) + prevDash = false + default: + if !prevDash && b.Len() > 0 { + b.WriteRune('-') + prevDash = true + } + } + } + return strings.TrimRight(b.String(), "-") +} + +func isLetterOrDigit(r rune) bool { + return (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') +} + +// includeHas tests whether an "include" comma-separated query value contains +// the given key. +func includeHas(raw, want string) bool { + if raw == "" { + return false + } + for _, p := range strings.Split(raw, ",") { + if strings.EqualFold(strings.TrimSpace(p), want) { + return true + } + } + return false +} diff --git a/internal/audiobooks/abs/me_handler.go b/internal/audiobooks/abs/me_handler.go new file mode 100644 index 00000000..9992b3f9 --- /dev/null +++ b/internal/audiobooks/abs/me_handler.go @@ -0,0 +1,107 @@ +package abs + +import ( + "net/http" + "strconv" + "strings" +) + +// handleMe — GET /abs/api/me (and /api/me) +// +// Returns the ABS "user object" for the authenticated caller. The mobile app +// calls this on launch (after /authorize) and whenever it needs a fresh +// progress snapshot. We include all audiobook progress rows and the list of +// accessible library IDs so the home tab renders without extra calls. +func (h *Handler) handleMe(w http.ResponseWriter, r *http.Request) { + a, ok := absAuthFrom(r) + if !ok || a.UserID == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + defaultLibID := VirtualLibraryID + libs, err := h.deps.MediaStore.ListAudiobookLibraries(r.Context()) + if err == nil && len(libs) > 0 { + defaultLibID = audiobookLibraryID(libs[0]) + } + + writeJSON(w, http.StatusOK, h.buildUserObject(r, a, defaultLibID)) +} + +// buildUserObject constructs the ABS user envelope used by /me, /login, +// and /authorize. Progress and bookmarks are best-effort: errors return +// empty slices rather than failing the request. +func (h *Handler) buildUserObject(r *http.Request, a ctxAuth, defaultLibraryID string) map[string]any { + ctx := r.Context() + + // Media progress — all audiobook progress rows for this user. + progress := make([]map[string]any, 0) + if h.deps.ProgressStore != nil { + rows, _ := h.deps.ProgressStore.ListProgressForAudiobooks(ctx, a.UserID, a.ProfileID, 200) + for _, p := range rows { + progress = append(progress, progressRowToABS(p)) + } + } + + // librariesAccessible drives the mobile app's library picker. + libs, _ := h.deps.MediaStore.ListAudiobookLibraries(ctx) + libraryIDs := make([]string, 0, len(libs)) + for _, lib := range libs { + libraryIDs = append(libraryIDs, audiobookLibraryID(lib)) + } + + displayName := a.UserID // best we have without a users table join + + return map[string]any{ + "id": a.UserID, + "username": displayName, + "type": "user", + "defaultLibraryId": defaultLibraryID, + "librariesAccessible": libraryIDs, + "mediaProgress": progress, + "bookmarks": []any{}, + "isOldToken": false, + "permissions": map[string]any{ + "update": true, + "delete": true, + "download": true, + "accessExplicitContent": true, + }, + } +} + +// audiobookLibraryID returns the ABS-wire library ID string for an +// AudiobookLibrary. Numeric IDs are formatted as decimal strings; when +// the ID is 0 we fall back to the virtual constant. +func audiobookLibraryID(lib AudiobookLibrary) string { + if lib.ID > 0 { + return strconv.FormatInt(lib.ID, 10) + } + return VirtualLibraryID +} + +// audiobookLibraryMap builds the minimal ABS library object shared by +// /libraries (list) and /libraries/{id} (detail). +func audiobookLibraryMap(lib AudiobookLibrary) map[string]any { + name := strings.TrimSpace(lib.Name) + if name == "" { + name = VirtualLibraryName + } + return map[string]any{ + "id": audiobookLibraryID(lib), + "name": name, + "mediaType": LibraryMediaType, // always "book" for audiobook libraries + "folders": []map[string]any{ + { + "id": VirtualFolderID, + "fullPath": "/", + "libraryId": audiobookLibraryID(lib), + }, + }, + "displayOrder": 1, + "icon": "audiobookshelf", + "settings": map[string]any{}, + "createdAt": 0, + "lastUpdate": 0, + } +}