Commit Graph
209 Commits
Author SHA1 Message Date
RXWatcher ace830d9e2 Merge remote-tracking branch 'origin/main' into feat/audiobooks
# Conflicts:
#	go.sum
2026-05-27 16:27:25 +02:00
RXWatcherandClaude Opus 4.7 7e6736358f fix(auth): allow NULL ip_address when client IP is unknown
Sessions are persisted with an inet column for ip_address; passing
an empty string failed the inet input parser (SQLSTATE 22P02). The
in-process ABS-compat login validates creds without a real *http.Request
to read RemoteAddr from, so the IP is genuinely unknown there. Pass
NULL instead of "" when the caller couldn't determine a client IP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:16:37 +02:00
RXWatcherandClaude Opus 4.7 dd876338e3 refactor(abs): plugin-parity /me and library wire shapes
/me returns the minimal {id, username, defaultLibraryId} envelope
emitted by continuum-plugin-audiobooks. ABS clients already have the
rich user object from /login and /authorize; /me is a session-resume
probe in real-ABS, and the previous rich envelope (mediaProgress,
librariesAccessible, permissions) could confuse clients that pattern-
match on the minimal shape.

Library map also trims folders / displayOrder / icon / settings /
createdAt / lastUpdate down to {id, name, mediaType} — real ABS
clients ignore the extra fields and emitting them risks behavior
drift from the plugin reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:16:32 +02:00
RXWatcherandClaude Opus 4.7 01fbf54e7d feat(abs): dedicated compat listener on :13378
Mounts the Audiobookshelf-compatible API on its own http.Server so
discovery probes (/ping, /healthcheck, /status, /login, /socket.io)
own the URL space at the root without colliding with silo's SPA
fallback. Mirrors the Jellyfin compat pattern at :8096.

Also adds the ABSRecommender adapter, wired into ABSHandlerDeps via
recommendations.NewRepo + catalog.DetailService. The recommender file
was previously untracked; service.go has already been referencing
ABSRecommender, so HEAD did not build without it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:16:26 +02:00
Quick 673a0e6c55 fix(metadata): accept exact cross-provider match ties 2026-05-27 09:13:30 -04:00
RXWatcherandClaude Opus 4.7 b231b27487 docs(abs): clean stale references to dropped abs_* tables
Comments referencing the now-dropped abs_user_collections,
abs_collection_items, abs_smart_collections, abs_playlists, and
abs_playlist_items tables. Code paths were updated in the prior
commits; only doc strings remained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:00:30 +02:00
RXWatcherandClaude Opus 4.7 552078683e refactor(audiobooks): ABS smart collection store queries canonical tables
Rewrites ABSSmartCollectionStore methods to read/write
user_personal_collections (collection_type='smart'). The rule DSL
goes into query_definition (formerly the abs_smart_collections.query_def
column).

color and is_pinned have no canonical analog; emitted as zero values
on every read. See spec §6 for the deferred decision on those columns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:56:13 +02:00
RXWatcherandClaude Opus 4.7 c140cb2ea6 refactor(audiobooks): ABS playlist store queries canonical tables
Rewrites ABSPlaylistStore methods to read/write user_personal_collections
(collection_type='playlist') and user_personal_collection_items
(sub_item_id replaces episode_id). DeletePlaylist runs in a transaction
since the items table has no FK cascade. Drops the coverArg helper —
the canonical schema has no cover_item column, so CoverItem is always
emitted as zero value per spec §6 deferral.

In-memory abs.Playlist / abs.PlaylistItem struct shape unchanged;
playlistToABS() emitter preserves the wire-shape contract.

Note: user_personal_collection_items PRIMARY KEY is (user_id,
collection_id, media_item_id) — sub_item_id is NOT part of it. The
old abs_playlist_items PK included episode_id, so a playlist that
referenced multiple episodes of the same library item had distinct
rows. Under the canonical PK the second insert silently collides;
prod baseline has zero playlist items so no live data is affected.
Multi-episode-of-same-book inside one playlist would require a schema
change (extend PK / add unique index) and is out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:53:21 +02:00
RXWatcherandClaude Opus 4.7 2c5d8bc290 refactor(audiobooks): ABS collection store queries canonical tables
Rewrites ABSCollectionStore's 8 methods to read/write
user_personal_collections (collection_type='manual') instead of the
dropped abs_user_collections / abs_collection_items tables. The
in-memory abs.Collection / abs.CollectionItem struct shape is
unchanged; ToABS() emitter helpers in internal/audiobooks/abs/
preserve the wire-shape contract.

Notable mappings:
- abs.Collection.IsPublic ↔ user_personal_collections.is_shared.
- profile_id is now text NOT NULL (defaults to ''), so we bind the
  empty string for primary-profile rows rather than SQL NULL.
- user_personal_collection_items.user_id is NOT NULL with no FK
  cascade from the parent — AddCollectionItem uses an INSERT ... SELECT
  to copy user_id from the parent collection, and DeleteCollection
  explicitly drops items before the parent within a single tx.
- ListCollectionItems filters sub_item_id='' to avoid leaking the
  playlist podcast-episode rows that share the items table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:49:41 +02:00
RXWatcher 01129dc383 perf(audiobooks): skip audiobook_series upsert when unchanged
Reads the current row first; skips the INSERT/UPDATE when name and
index already match. NULL = NULL handled via floatPtrEqual.
2026-05-27 09:31:06 +02:00
RXWatcher 8ff5edb318 perf(audiobooks): skip ReplacePeople when credits unchanged
Was: every scan ran DELETE+INSERT on item_people for every audiobook.
Now: compare existing vs desired credit set first; skip the write when
identical. Compares case-insensitively to absorb tag casing drift.
2026-05-27 09:29:12 +02:00
RXWatcher 4e6e2153c5 perf(audiobooks): parallelize enricher sweep
Was: serial loop over claimed batch of 50.
Now: N workers fan out over the batch (default 4, configurable via
SILO_AUDIOBOOK_ENRICH_WORKERS).

Adds a runBatch seam used by Run so tests can stub the per-item
enrich function and assert worker fan-out without hitting providers.
2026-05-27 09:24:52 +02:00
RXWatcher 221fc6ba69 feat(audiobooks): add configurable enricher worker count
Workers field + SILO_AUDIOBOOK_ENRICH_WORKERS env var override.
Used by the runBatch fan-out in the next commit.
2026-05-27 09:22:22 +02:00
RXWatcher e1d6dd71d3 refactor(audiobooks): enricher cover deps via setters
Drops imageCacher + ffmpegPath from NewEnricher and exposes setters
to mirror MetadataService / Scanner. Lets main.go install them in the
same lazy image-cacher block as the other consumers.
2026-05-27 09:18:21 +02:00
RXWatcher 35b8344c37 fix(audiobooks): run cover fallback on all enrichItem exits
Was: fallback only fired on the success path, leaving the
no-providers and no-metadata branches with a stamped last_refreshed
and no chance to extract the embedded cover.
Now: defer at the top so every return path runs the fallback. The
fallback is idempotent — no-ops when poster_path is already set.
2026-05-27 09:15:13 +02:00
RXWatcher 95f77f4f73 feat(audiobooks): add local-file cover fallback in enricher
Replaces the inline cover extraction removed from the scan path.
Pulls the embedded cover from the primary audio file via ffmpeg when
the provider chain didn't supply a poster_path.
2026-05-27 09:12:29 +02:00
RXWatcher bbc65baabc perf(audiobooks): remove inline cover extraction from scan path
Cover work moves to the enricher sweep where it can run out-of-band.
Removes one ffmpeg invocation per book from the scan hot path.
2026-05-27 09:08:57 +02:00
RXWatcher 5aa0462338 refactor(scanner): export ExtractAndUploadAudiobookCover
Required for the enricher to call it during deferred cover extraction.
Also exports FFmpegPathFromFFprobe for the same reason.
2026-05-27 09:04:04 +02:00
RXWatcher 7ea763cdb7 fix(audiobooks): warn when skip-check errors during scan
Transient DB errors from the skip-check fell through silently to the
full reconcile path. The fall-through is still correct, but operators
need visibility when it happens - a 240K-folder rescan that quietly
loses the fast path is a 12h elapsed_sec, not 12s.
2026-05-27 09:02:04 +02:00
RXWatcher e06cfc1e00 refactor(scanner): drop loadItemStatuses in favor of GetStatusByIDs
Removes the duplicate of the new ItemRepository.GetStatusByIDs query.
Three callers in scanner.go now go through the public repo method.
2026-05-27 08:51:38 +02:00
RXWatcher 40cf251e77 feat(audiobooks): skip unchanged folders in scan path
Bypasses ffprobe + DB writes for folders whose on-disk audio files
match existing media_files rows by size and mtime. Stable libraries
now rescan in minutes instead of hours.
2026-05-27 08:43:36 +02:00
RXWatcherandClaude Opus 4.7 58c7334cf5 feat(audiobooks): add unchanged-folder skip helper
Introduces audiobookDiskFile + audiobookFolderUnchanged to detect when a
folder's on-disk audio files match existing media_files rows by path,
size, and mtime. Sets up the upcoming scan-loop skip path that bypasses
ffprobe + DB writes for stable folders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 08:37:50 +02:00
Quick 101aa8c429 fix(jellycompat): stabilize signed image tags across restarts
{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}
2026-05-26 22:29:49 -04:00
Quick f930c4f96a fix(jellycompat): harden signed image tags 2026-05-26 21:50:10 -04:00
Quick 93ed484cf6 feat(jellycompat): sign image tags and accept them without session
- HMAC-sign image tags using the configured JWT secret
- Serve item/season/episode images via signed tag without requiring a session or cache hit
2026-05-26 21:34:44 -04:00
RXWatcherandClaude Opus 4.7 b71855c1fe feat(audiobooks): expand ABS API surface for mobile clients
Wires up the endpoints AudioBooth + the official audiobookshelf-app
expect but Silo's ABS-compat layer was missing, plus fixes the recently
shipped playlists/collections/series surfaces that were crashing the
mobile clients.

Browse surfaces (crash fixes):
- Hydrate playlist items with full LibraryItem (mediaType, media,
  cover) so PlaylistCover + LazyBookCard stop throwing on undefined
  fields.
- Add GET /libraries/{id}/collections paged envelope; hydrate
  collection books with full LibraryItem so the cover stack renders.
- Add GET /libraries/{id}/playlists total field so LazyBookshelf
  paginates correctly.
- Return real numUserPlaylists count from /libraries/{id} so the
  bottom-nav Playlists tab actually appears.
- Extend ListLibrarySeries with up to 4 book previews per series
  (window-ranked SQL) so LazySeriesCard renders cover stacks
  instead of name-only placeholders.
- Auto-delete a playlist when its last item is removed; fire
  playlist_removed so the client routes the user back to the list.

New mobile-blocking endpoints:
- PATCH alias on /me/progress/{id} (AudioBooth's progress writes).
- DELETE /me/progress/{id} backed by new DeleteProgress store method.
- GET /me/stats/year/{year} — year-in-review synthesised from
  AggregateStats.
- GET /ping, /healthcheck, /init, /auth-settings — unauthenticated
  server discovery on every prefix.

Ebook surface (forward-compat stubs):
- LibraryItemMedia.EbookFile omitempty field + EbookFile type.
- progressBody accepts ebookProgress + ebookLocation (silently
  ignored until ebook scanner lands).
- GET /items/{id}/ebook/{fileid} 404 stub, PATCH .../status accept.
- GET /me/ereader-devices empty list, POST
  /emails/send-ebook-to-device 503 with clear "not configured".

Podcast stubs (audiobook-only catalog v1):
- POST /podcasts/feed, POST /items/{id}/play/{episodeId},
  PATCH /me/progress/{id}/{episodeId}, GET
  /libraries/{id}/recent-episodes, GET /search/podcast.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 02:25:55 +02:00
Quick bba3177fc9 fix(metadata): break duplicate provider candidate ties
- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work
2026-05-26 20:07:43 -04:00
Quick a7f62020fd fix(auth): gate media file paths on metadata curation permission
- Allow curators (not just admins) to view media file paths and locations
- Apply library access filter to file-level access checks
2026-05-26 20:04:54 -04:00
Quick 3d791e2e9f test(auth): expand session revocation coverage 2026-05-26 19:41:55 -04:00
Quick c7d69e9ea2 fix(auth): tighten curator job response review fixes 2026-05-26 19:34:16 -04:00
Quick 21e318dc16 fix(auth): address metadata curation review issues 2026-05-26 19:27:56 -04:00
RXWatcherandClaude Opus 4.7 0a766d9bb1 fix(audiobooks): add /libraries/{id}/playlists for mobile create modal
The ABS mobile create-playlist modal loads existing playlists
BEFORE opening the form, hitting:
    GET /api/libraries/{libraryId}/playlists
and reading `data.results` (NOT `data.playlists`). It then iterates
each playlist's `items[]` to render "already in this playlist"
badges. silo's existing `/api/playlists` route emitted the wrong
envelope (`{playlists: [...]}`) and was at the wrong path; the
modal silently treated the response as empty, the user saw an
empty existing-playlist list, and depending on the device-side
UX (no "Create New" CTA when results was undefined) the create
action looked dead.

Add handleListLibraryPlaylists at `/libraries/{libraryId}/playlists`,
emit `{results: [Playlist full-shape]}` so playlist.items is
populated for the membership check. libraryId param accepted but
ignored — silo scopes playlists per (user, profile) globally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 00:48:00 +02:00
Quick 6c030d6b76 feat(api): route metadata curation by permission 2026-05-26 18:18:19 -04:00
Quick 6004dd2ddd feat(api): authorize item metadata curation 2026-05-26 18:15:05 -04:00
Quick 6f783a4a6e feat(auth): expose user permissions 2026-05-26 18:13:32 -04:00
Quick f48fd41d85 feat(auth): add assignable user permissions 2026-05-26 18:12:20 -04:00
RXWatcherandClaude Opus 4.7 1470077877 fix(audiobooks): accept items + libraryId on POST /api/playlists
The ABS mobile create-playlist modal builds the playlist + initial
members in one round-trip:
    POST /api/playlists  { items: [...], libraryId, name }
(see audiobookshelf-app components/modals/playlists/AddCreateModal.vue
submitCreatePlaylist). silo's handler only accepted
{ name, description?, cover_item?, isPublic? } — items were
silently dropped, the playlist was created empty, and the client's
.then(data) handler closed the modal but the user never saw their
selected books in the playlist (and the device-side socket event
showed an empty list).

Extend playlistBody with Items + LibraryID. After creating the
playlist row, iterate Items and call AddPlaylistItem for each
(audiobook items validated via MediaStore; episode items skip
validation per the existing accept-and-echo policy). Refresh
the playlist before returning so the client sees the populated
items[] in the response.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:59:01 +02:00
Quick 28196232c9 feat(subtitles): restore upload management 2026-05-26 17:57:26 -04:00
RXWatcherandClaude Opus 4.7 1c38248915 fix(audiobooks): emit all non-nullable LibraryItem + BookMetadata fields
Audit of the ABS Android Kotlin data classes against silo's emitted
JSON found six non-nullable LibraryItem fields silo wasn't emitting
at all (ino, path, relPath, mtimeMs, ctimeMs, birthtimeMs) plus a
non-nullable BookMetadata.explicit. jackson-module-kotlin's behaviour
around missing required parameters varies by config — some flows are
lenient enough that the library list loads with the omissions, but
the strict download path (apiHandler.getLibraryItemWithProgress)
hits a MissingKotlinParameterException and silently resolves cb(null)
which kills the AbsDownloader without a user-visible toast.

Emit safe defaults: ino = ContentID (stable item-level identifier
matching the real-ABS shape), path/relPath = "", mtime/ctime/
birthtimeMs = AddedAt, explicit = false. Costs almost nothing on the
wire and forecloses future Jackson-strictness surprises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:42:58 +02:00
RXWatcherandClaude Opus 4.7 3cf6bfe09a fix(audiobooks): emit media.tags so ABS Android downloads parse
The ABS Android client's Kotlin Book model declares
    var tags: List<String>  // non-nullable
and Jackson throws MissingKotlinParameterException when the field is
absent from /api/items/{id} responses. ApiHandler.getLibraryItem
WithProgress catches the exception and resolves cb(null), at which
point AbsDownloader silently aborts — the user sees no error, the
download just never starts.

Add Tags to LibraryItemMedia and initialise it to []string{} at
construction. Verified end-to-end: download URL the Android client
builds (/api/items/{id}/file/{ino}/download) returns HTTP 206 with
audio/mpeg bytes; the parent /api/items/{id} response now carries
"tags": [] so Jackson deserialises cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:28:13 +02:00
RXWatcherandClaude Opus 4.7 bdf1da6f2e fix(audiobooks): WS upgrade + /authorize token rotation for ABS v2.26+
Two bugs the ABS Android/iOS client surfaces against silo:

1. WebSocket upgrade returns 400. The access-log middleware wraps
   the ResponseWriter in a statusRecorder that doesn't implement
   http.Hijacker, so socket.io can't take ownership of the raw
   connection. Engine.io rejects the upgrade with
   {"code":3,"message":"Bad request"}. Fix: add Hijack + Flush
   passthroughs on statusRecorder.

2. ABS v2.26+ clients prompt "Authentication has been improved for
   security in server v2.26.0. All users are required to re-login."
   on every layout mount. The client check is:
     if (serverConfig.token === user.token || user.isOldToken)
   silo's /authorize was echoing the inbound bearer back as
   user.token — equality fires, re-login forced. Fix: mint a fresh
   access JWT in /authorize, persist its JTI, and return it as
   user.token / accessToken. Old JTI stays valid for its natural
   TTL (multi-device share-on-login). Refresh token contract
   unchanged.

Verified: curl /authorize returns user.token != original bearer;
WS upgrade probe now returns HTTP 101.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:12:18 +02:00
RXWatcherandClaude Opus 4.7 c735cd53b5 feat(audiobooks): wire Phase 1 close-out stores + mount routes
Wires RSSFeedStore into BuildABSHandler. Mounts the ten new
authenticated routes (stats x3 + author x1 + series x1 + continue x2
+ feeds-auth x3) under both /abs/api and /api inside bearerAuth.
Mounts the three public RSS routes (.xml + slug + /file/{ino}) in
the unauth public block. handleAuthorImage wired to CoverResolver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:25:55 +02:00
RXWatcherandClaude Opus 4.7 82bafb8375 feat(audiobooks): public RSS XML route + per-file stream
GET /feed/{slug}.xml (and /feed/{slug}) generates a minimal RSS 2.0
document with one <item> per media_file. GET /feed/{slug}/file/{ino}
streams the underlying file when ino belongs to the feed's
library_item_id. Both routes are unauthenticated — slug is the
capability token; closed feeds 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:03:10 +02:00
RXWatcherandClaude Opus 4.7 fa5b0c59c9 feat(audiobooks): RSS feeds — auth handlers + pgx store
POST /api/feeds/item/{itemId}/open (auto-gen or custom slug),
GET /api/feeds (owner+profile scope, only open feeds),
POST /api/feeds/{id}/close (idempotent, owner-gated).
Slug validation via ^[a-z0-9-]{4,64}$. 409 on collision via pgx
unique-violation substring detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 22:00:54 +02:00
RXWatcherandClaude Opus 4.7 ebe8ad3bd7 feat(audiobooks): continue-listening toggles
Two GET endpoints (remove-from / readd-to-continue-listening) backed
by ProgressStore.SetHideFromContinue. ListContinueListening SQL gains
a hide_from_continue = false filter so hidden items drop off the shelf
immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:57:01 +02:00
RXWatcherandClaude Opus 4.7 1b91791bf3 feat(audiobooks): author + series detail endpoints
GET /authors/{id} (people.id, kind=7 join over item_people) and
GET /series/{id} (case-insensitive series_name match, ordered by
series_index NULLS LAST). Both return entity + embedded books[].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:53:58 +02:00
RXWatcherandClaude Opus 4.7 2650eb243a feat(audiobooks): listening stats endpoints + store aggregates
Three handlers (/me/listening-stats, /me/listening-sessions,
/me/listening-sessions/{sid}). ABSPlaybackSessionStore gains
AggregateStats (totals + day/dayOfWeek/monthly buckets) and
ListClosedSessions (paginated history). Detail handler enforces
owner-scope via 404 on mismatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:50:16 +02:00
RXWatcherandClaude Opus 4.7 0ab06e5242 feat(audiobooks): wire ABSSmartCollectionStore + mount routes
Pgx-backed store + service wiring + six routes registered under both
/abs/api and /api prefixes inside the existing bearerAuth group.
JSONB column written via $9::jsonb cast for query_def.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:39:49 +02:00
RXWatcherandClaude Opus 4.7 7061110ee5 feat(audiobooks): GET /me/smart-collections/{id}/items — eval + page
Items handler evaluates the stored query_def against the audiobook
catalog. Per-user state hydrated in 2 batched calls (progress list +
bookmark counts) when caller is the owner; non-owner viewing public
sees personalized rules silently dropped. Results paginated post-eval.
siloItemToSmartcollItem adapter maps silo's MediaItem onto the
audiobook-domain Item shape; author/narrator/series/publisher/
duration_seconds left as zero-values for v1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:37:57 +02:00
RXWatcherandClaude Opus 4.7 e2670b3055 feat(audiobooks): list/get/patch/delete smart collections
Four CRUD handlers + tests. Anti-enumeration 404 on non-owner
private. List envelope is {"items": [...]}. PATCH re-validates
query_def when present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:34:21 +02:00