Compare commits

...
Author SHA1 Message Date
Claude Code 6a50097f27 fix(api): use shared UserLoader for transcription quota exemption
Adapts the rebased subtitle ASR quota feature to the group-based admin
model: quotaExempt checks admin status server-side via isAdminRequest
instead of the removed claims role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:25:44 +02:00
Claude Code 5c950beefe fix(auth): revalidate websocket viewers and enforce enabled on compat sessions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:49 +02:00
Claude Code 767a7ccf24 refactor: consolidate user loaders, group scans, and policy formatting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:49 +02:00
Claude Code 20ed9ecd05 fix(auth): prevent disabling or deleting the last enabled administrator
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 43beb4693a docs: align spec with implemented hydration and response shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 5f9cfd213b docs(jellycompat): update stale users.library_ids comment to group-derived access
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 40c4d6bcc4 chore(api): drop validation for orphaned defaults.* settings keys
Nothing reads defaults.max_playback_quality or defaults.max_profiles
anymore; new-user policy comes from groups and the only remaining
defaults.* consumer is defaults.card_overlays. Also extract role and
issuer-kind string constants flagged by goconst (presentation role
strings stay distinct from auth Permission values on purpose).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 076351f3d5 fix(web): omit group_ids until groups load so backend defaults apply
When creating a new user, omit group_ids from the request body if the
admin never touched the selector AND the groups query hasn't successfully
resolved (still loading or errored). This lets the backend's
default-group provisioning apply rather than receiving an empty array
and producing a locked-out zero-group user. When groups have loaded and
the preselection is untouched, the explicit selection is still sent.
The Groups tab now shows skeleton rows while loading and a muted notice
on error instead of a silent empty selector.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 3034db6dc8 feat(web): group membership controls on user admin pages
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 4430eda7a2 test(auth): make tampered-token test deterministic
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 373939126e fix(auth): unlimited (0) group limits win the policy union
Enforcement treats 0 as unlimited for max_streams, max_transcodes
(playback session manager), and max_profiles (profile creation), so a
plain max() across groups inverted the most-permissive union: a user in
{max_streams: 5} and {max_streams: 0} resolved to 5. Fold limits with
mergeLimit instead, seeding from the first group so an explicit 0 sticks.

Zero-group lockout stays enforced via the empty library list; MaxProfiles
is floored at 1 there because 0 would grant unlimited profile creation.
Admins keep merged group limits, matching the pre-group behavior where
admins had no stream-limit bypass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 49e14d3459 feat(web): admin groups management page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code c26835cd6a feat(web): group types and admin query hooks
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 83a9a1c102 fix(auth): qualify group columns to fix ambiguous created_at in membership joins
GroupsForUser/GroupsForUsers join user_groups, which also has a created_at
column; the unqualified column list made every group hydration (and thus every
login) fail against a real Postgres with SQLSTATE 42702. Caught by live E2E
verification; unit tests use fakes and missed it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code e40e2437ef fix(api): group member endpoints return correct errors for unknown user and group
AddMember now detects FK violations on user_id and returns ErrUnknownUser,
mapped to 404 unknown_user in writeGroupError. HandleListMembers calls
GetByID first so a nonexistent group yields 404 instead of 200 with empty
members. Adds handler tests for both cases plus a PATCH permissions
semantics table test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 5d513c2737 feat(api): admin group management endpoints
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
Claude Code 6701da1829 fix(jellycompat): load fresh user for policy; enforce download permission
Remove the session policy bridge (IsAdmin/DownloadAllowed/LibraryIDs cached
on Session): the fields were never persisted to jellycompat_sessions, so any
restart zeroed them (admins rendered as non-admin, restricted users as
unrestricted), and group edits never refreshed them. Policy is now always
read from a freshly loaded user.

- userDTO loads the user via a userLoader (hydrated effective policy) and
  fails the request on load error instead of falling back to permissive
  defaults; login, /Users/Me, /Users, /Users/{id} are cold paths.
- Rename the apiKeyUserLoader interface/Dependencies field to userLoader/
  UserLoader since it now also backs user DTOs and download enforcement.
- HandleDownload enforces the group-derived DownloadAllowed policy with a
  fresh user load, returning 403 for restricted users and failing closed on
  load errors; allowed-user behavior is unchanged (Infuse depends on it).
- Pin the EnabledFolders GUID encoding in TestUserPolicyFromEffectivePolicy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:34 +02:00
rxwatcherandClaude Code fcc782fe19 feat(jellycompat): derive UserPolicy from group-based effective policy
Replace hardcoded IsAdministrator/EnableContentDownloading/EnableAllFolders
literals in userDTO with a buildUserPolicy helper that maps models.User's
group-derived effective policy (IsAdmin, DownloadAllowed, LibraryIDs) onto
the Jellyfin UserPolicy shape. Library IDs are encoded as Jellyfin folder
GUIDs via the existing EncodeNumericID(EncodedIDLibrary, ...) helper. Session
policy fields are populated at login and API-key session synthesis so no
per-request DB round-trip is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code 18bc4b3f16 test(api): cover RequireAdmin middleware denial and stash paths
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code ac280ab7f3 feat(auth): server-side admin checks; remove role from JWT claims
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:23:06 +02:00
rxwatcherandClaude Code f499d0901b fix(auth): atomic user update and setup, default-group fallback, unknown-group mapping
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 2bf4bd127e feat(auth): users derive effective policy from groups
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code f2c9468122 refactor(auth): normalize before admin guard, dedupe group scan
In Update, call NormalizePermissions once up-front and run the
administrators admin-permission guard against the normalized slice so
whitespace-padded inputs like [" admin "] are no longer wrongly
rejected with ErrAdminPermRequired.

In GroupsForUsers, replace the hand-rolled 15-field Scan with the new
scanGroupFieldsPre helper (mirrors scanGroupFields but prepends extra
dest args before the group fields) so the scan list is no longer
duplicated in the codebase.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 4b70679815 feat(auth): group repository with guards and revision bumps
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0b7156ac0a fix(auth): restore accurate column defaults and quality flatten in migration down path
- permissions column default on down path corrected to ARRAY['marker_edit']::text[]
  (matching the schema set by 183_marker_edit_audit.sql, not the bare '{}' default)
- max_playback_quality now flattened on down path via rank-prefixed MAX + split_part,
  mirroring the most-permissive-wins logic in internal/access/quality.go
- spec doc: align default_group_slugs → users.default_group_slugs to match the
  server_settings key used in the migration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 9c0172a706 feat(auth): group-based permissions schema migration
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0733e3fc51 feat(auth): effective policy union resolver
Implements ApplyEffectivePolicy to compute a user's effective authorization
policy as the most-permissive union of their assigned groups. Handles special
cases for admin permissions, unrestricted library access, quality ceilings,
and download settings via table tests following TDD order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code ecc08b7569 feat(auth): group model, admin permission, IsAdmin derivation
- Add internal/models/group.go with Group, CreateGroupInput, UpdateGroupInput
  and built-in slug constants (administrators, users)
- Add IsAdmin bool and GroupIDs []int to models.User (derived fields, not DB columns)
- Add PermissionAdmin to assignablePermissions so groups can carry it
- Replace user.Role == "admin" checks in HasEffectivePermission / EffectivePermissions
  with user.IsAdmin (interim: scanUser/scanUsers set IsAdmin = Role == "admin")
- Update permissions_test.go: add IsAdmin: true alongside Role: "admin" in existing
  test, add TestAdminPermissionIsAssignable and TestIsAdminGrantsAllPermissions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 0638a514b7 feat(access): add MaxQuality for most-permissive quality merge 2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code e067041b05 docs: group-based permissions implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code d934bb8924 docs: fix group migration to not erase outlier restrictions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
rxwatcherandClaude Code 4d98005a23 docs: group-based tiered security model design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 01:20:54 +02:00
6f716d3404 fix(scanner): skip misplaced TV episodes in movie libraries (#90)
* fix(scanner): skip misplaced TV episodes in movie libraries

A movie-type library containing a TV show laid out as
"Show/Season NN/SxxExx.mkv" (e.g. a fan "supercuts" pack) created one
bogus movie item per episode, all titled after the season folder
("Season 01"). They surfaced on Recently Added and never matched.

Add naming.IsMisplacedSeriesFile, which detects a file with an SxxExx
name inside an explicit "Season NN"/"Specials" directory, and guard
createOrFindSkeleton so such files in a strict movie library are recorded
as a skipped root (reason series_in_movie_library) instead of becoming
items. The movie match-queue worker now dequeues files the skeleton step
deliberately skips instead of erroring on the empty content id.

The guard fires on the structural signal alone, regardless of any parsed
provider id, because a "Season NN" folder otherwise yields a bogus tmdb
id (the season number). Movies whose release filename merely contains an
SxxExx substring but live in a proper "Title (Year)/" folder are
unaffected (covered by the new test).

* fix(metadata): durably exclude misplaced-series files from movie queue

Address review findings on the misplaced-TV skip:

- The skipped file's content_id is never set, so every library sync
  re-enqueued it just for the worker to skip and dequeue it again.
  Exclude files beneath a series_in_movie_library skipped root in the
  movie match queue predicates; deleting the skipped root row makes the
  files eligible again. The worker drain remains to flush rows claimed
  before the root was recorded.
- Extract the eligibility predicate (previously duplicated verbatim in
  eight queries) into movieQueueFileEligibleCond.
- Derive skipped-root file_count from media_files under the root via a
  new UpsertObservedFile instead of hardcoding 1: a 34-episode pack now
  reports 34 instead of each per-file upsert overwriting the count.
  This also fixes the pre-existing missing_folder_ids undercount.
- Fold the two near-identical Upsert+log blocks in createOrFindSkeleton
  into a recordSkippedRoot helper, normalize libraryType once, and name
  the reason strings as constants.
- Drop a no-op filepath.Base on already-split path segments in
  IsMisplacedSeriesFile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:18:41 -04:00
7958f0bbf0 feat(nodepool): node groups, per-node caps, and local transcode fallback control (#126)
* feat(nodepool): node groups, per-node caps, and local transcode fallback control

Group co-located transcode and proxy nodes so transcoded streams are
served by a proxy on the same host/LAN instead of bouncing across the
internal network (fixes #93):

- New nodepool.Planner is the single selection entry point: it picks the
  transcode node and its group's proxy together (round-robin within the
  group), replacing the independent ProxyPool.Pick/TranscodePool.Acquire
  calls scattered across the native and jellycompat handlers, and absorbs
  the duplicated soft-affinity pick logic.
- A group is only eligible while all of its enabled members are healthy;
  ungrouped nodes keep the historical behavior.
- New per-node max_jobs cap (transcodes for transcode nodes, streams for
  proxies; NULL = unlimited), enforced via health-reported job counts
  plus short-lived reservations that expire once fresher health data
  arrives. Proxy health now reports real stream counts, including HLS
  sessions via idle-expiry tracking.
- New playback.local_transcode_fallback setting (default on) lets admins
  refuse API-server transcoding when no eligible node exists.
- Health checks now publish updated node copies under the pool lock
  instead of mutating shared structs in place, fixing a data race.
- Admin UI: group + cap fields on the node form, group/cap columns, and
  the new fallback toggle in playback settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(nodepool): proxy bandwidth measurement and egress caps

Proxy nodes now measure their stream egress (rolling 60s average over
everything under /stream) and report it via the health endpoint. A new
per-proxy max_bandwidth_kbps cap lets the planner route new streams away
from saturated proxies:

- Admission combines the measured egress with the estimated bitrate of
  the new stream (transcode target bitrate, or source bitrate for direct
  play/remux) so a stream is only admitted where it fits.
- Recently admitted streams are bridged as bandwidth reservations for the
  meter window, since the rolling average only converges on a new
  stream's rate gradually.
- A group whose proxies lack bandwidth headroom is treated as full: its
  transcode nodes are skipped, same as the job cap.
- Admin UI: per-proxy "Max Egress Bandwidth (Mbps)" field and a live
  egress column; manual health checks return the measured rate.

Active streams are never interrupted - the cap only gates new admissions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(playback): trim node-mode time-to-stream-start

Distributed playback paid several avoidable costs before the first frame
that integrated mode doesn't have. This trims the safe ones:

- Web player preconnects to the stream origin (the proxy node) as soon as
  /playback/start returns, overlapping DNS/TCP/TLS handshakes with the
  transcode dispatch instead of paying them at the first manifest fetch.
- The transcode node no longer blocks its 202 on monitoring work: the
  Redis session-track write moves off the request path, and a replaced
  session's segment directory is renamed aside and deleted in the
  background instead of synchronously (RemoveAll of a long session can
  take seconds on slow disks during quality switches).
- The proxy's node-facing HTTP client gets a tuned transport: a larger
  idle-connection pool (Go's default of 2 per host causes connection
  churn and TLS re-handshakes when many viewers stream through one
  proxy->node pair) and a response-header timeout so a hung transcode
  node can no longer hang client requests indefinitely.
- jellycompat's remote transcode dispatch gains the same 10s timeout the
  native path has had; an unreachable node previously hung the compat
  manifest request until the OS gave up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:18:18 -04:00
QuickandClaude Fable 5 52dd5dff7f perf(web): prefetch season detail and episodes from series page
Season cards now warm the react-query cache for the season's item detail
and episodes on hover/focus/touch, so navigating from a series page to a
season renders the episode grid without a request waterfall.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:06:30 -04:00
QuickandClaude Fable 5 e64b130bd3 perf(catalog): batch per-episode lookups in episodes endpoint
The item episodes endpoint issued 2-4 sequential round-trips per episode
(media files, watch progress, localization, still-image presigning),
putting season detail loads at ~500ms for typical seasons. Both the real
and synthetic season paths now share one builder that resolves each
concern in a single batched call, using the batch methods that already
existed (ListByEpisodeIDs, ListProgressByMediaItems,
PresignURLsWithExpiry) plus a new LocalizeEpisodeModels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:05:49 -04:00
QuickandClaude Fable 5 ed4cebf3ba feat(ai): per-user transcription quota for subtitle ASR jobs
Cap how many Whisper transcription jobs each user account can start per
rolling window (day/week/month), configurable from admin settings. The
player modal shows remaining usage and the server returns 429 with
details when the limit is hit.

Enforcement is atomic with the job insert (per-user advisory lock, same
pattern as media-request quotas), so concurrent requests cannot race
past the limit. Failed/cancelled jobs that never produced transcription
work are refunded. Exemption applies to the admin account's primary
profile only; other profiles on an admin account stay subject to the
quota. A partial index covers the quota count, a malformed quota
setting row degrades to "no quota" instead of blocking startup, and the
period vocabulary and admin-role predicate are each defined once
(ai.ValidQuotaPeriod, apimw.IsAdmin).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:02:42 -04:00
39ba284c9d feat(ai): shared AI core — metadata translation, Whisper ASR, per-profile language, on-view translation (#127)
* docs: design + plan for shared AI core, metadata translation, Whisper ASR

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(ai): shared LLM client, segment translator, and job runner packages

internal/ai/llm: OpenAI-compatible chat client moved out of subtitles/ai,
plus /v1/audio/transcriptions (verbose_json) for the ASR work; one shared
retry/backoff loop for both. internal/ai/translate: the batched indexed-JSON
translation protocol generalized to text segments. internal/ai/jobrunner:
dispatch/heartbeat/reaper/cancel lifecycle extracted behind a minimal store
interface, with a semaphore shareable across job services.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(subtitles): consume shared AI core

LLMTranslator becomes a thin cue<->segment adapter over aitranslate; the
service delegates dispatch/heartbeat/reaper/cancel to jobrunner; the local
OpenAI client is gone in favor of internal/ai/llm. Behavior (prompts, wire
protocol, job rows, recovery semantics) is unchanged. NewService now takes
the dispatch semaphore so all AI job services can share one bound.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(config): shared ai.* settings, metadata translation job table, localization provenance columns

ai.* connection keys (chat + optional separate ASR endpoint) load with a
fallback to the legacy subtitle_ai.* rows — those are never renamed in SQL
because encrypted values are GCM-bound to their setting key. New toggles:
subtitle_ai.transcribe_enabled, metadata_ai.enabled. Migration adds
metadata_translation_jobs, per-field provenance (provider|ai|manual) on the
localization tables, and media_folders.auto_translate_metadata.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(catalog): localization field provenance with provider/ai/manual precedence

Provider upserts keep manual values and never blank a field with an empty
incoming value; new UpsertAITranslation/UpsertAIOverview methods write AI
fields only over empty or ai-sourced values (force adds provider, never
manual) — all enforced in single-statement SQL. Serving now merges only
non-empty localized fields onto the base item, since localization rows are
legitimately partial (AI rows carry no titles/artwork).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(metadata): AI translation service, refresh auto-fallback, and admin API

internal/metadata/translation: job service over the shared AI core that
expands an item to its season/episode overviews, skips already-localized
fields (zero model calls on repeat runs), batches paragraphs through the
generic translator, and persists per batch with provenance-aware upserts.
MetadataService gains an AutoTranslator seam invoked after each refresh for
libraries with auto_translate_metadata. Admin endpoints under the metadata
curation guard: enqueue, list (poll), cancel; plus a status probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs

New WhisperTranscriber: one ffmpeg pass extracts the audio track to 10-min
16kHz mono WAV chunks (temp dir cleaned on every exit path), each chunk goes
to the OpenAI-compatible /v1/audio/transcriptions endpoint (verbose_json,
per-request timeout sized to 3x chunk duration), segment timestamps are
offset and built into wrapped cues. Chunks process playhead-first and stream
live to the requesting session. The transcript is stored as an ordinary
downloaded subtitle (provider 'transcribed'); transcribe_translate chains
the existing translator and stores the translated track as the job result.
Enqueue accepts an optional kind; status reports transcribe_enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): AI services settings, metadata translate action, library auto-translate, generate-from-audio

New AI Services admin page hosts the shared endpoint config (reads fall back
to legacy subtitle_ai.* values, writes target ai.*) and the three feature
toggles; the AI card moves out of Subtitles settings. The metadata editor
gains a Translate-with-AI panel with job polling and force/re-translate. The
library form gains the auto-translate toggle (threaded through the libraries
API). The player translate modal gains a From-audio mode that lists audio
tracks and submits transcribe / transcribe_translate jobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style: gofmt import grouping in router and translation tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(catalog): per-profile metadata language and viewer-triggered description translation

user_profiles.preferred_metadata_language threads through the access scope
into catalog serving: presentation language now resolves explicit param ->
profile preference -> library metadata language (native API and jellycompat).
ItemDetail gains pending_translation_language when the viewer's language is
missing a localized overview. New metadata_ai.on_view setting (off|button|
auto) gates POST /items/{id}/translate-description: any profile with item
access may request its language, with in-flight dedup and a 15-minute
failure cooldown so page views never hammer a broken endpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): on-view description translation with per-profile metadata language

Profile playback settings gain a Metadata language picker (library default
inherit). Detail pages: when the server reports pending_translation_language
and metadata_ai.on_view is 'auto', the description translates on view with a
pulse animation until the refetched detail comes back localized (45s
timeout); in 'button' mode a small Translate chip triggers the same flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): expose metadata_ai.on_view in AI Services settings

The on-view translation mode had no UI control, so it could only ever be
'off' — viewers got neither the auto translation nor the fallback button.
Adds the off/button/auto selector to the Features card, and the config
loader now warns and falls back to 'off' on a bad row instead of refusing
to start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): clear configuration hint when the transcription endpoint is chat-only

A blank Transcription base URL falls back to the chat endpoint; chat-only
gateways reject the multipart upload with an opaque 400 that reads like a
pipeline bug. 400/404/405 transcription failures now carry a hint to set a
Whisper-compatible endpoint in AI Services.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(subtitles): wrap ASR cue text by rune count, not bytes

Arabic/Cyrillic/Greek text is 2+ bytes per character in UTF-8, so byte-based
wrapping broke lines at roughly half the intended visual width.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(web): steer transcription base URL hint away from chat-only gateways

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ai): block chat-only gateways for transcription, add endpoint presets

llm.IsChatOnlyGateway (OpenRouter et al — no timestamped transcription API)
is enforced in three layers: the settings API rejects ai.asr_base_url values
pointing at one, the router disables ASR with a warning when the blank-URL
fallback would land on one, and llm.Transcribe refuses outright. The AI
Services page gains one-click transcription presets (Groq turbo/accurate,
OpenAI, self-hosted speaches) plus the mirrored client-side check, and the
settings API now also validates metadata_ai.on_view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(subtitles): tighten ASR subtitle sync

Three systematic timing-error sources addressed: cue offsets now use the
segment muxer's exact per-chunk start times (segment_list CSV) instead of
assuming index*chunk_seconds; the audio stream's start delay relative to the
container timeline (common in TS remuxes) is probed via ffprobe and added to
every cue; and the chunk length is now operator-tunable via
subtitle_ai.asr_chunk_seconds (60-600s, default 600) since shorter chunks
bound Whisper's within-chunk timestamp drift. Playhead-first ordering now
pivots on real chunk starts, and a beyond-end playhead starts at the final
chunk instead of restarting from zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ai): tolerate base URLs that already include the /v1 segment

Providers like DeepInfra expose their OpenAI-compatible API under a base
that contains the version segment (api.deepinfra.com/v1/openai); always
appending /v1/... mangled those. endpointURL now appends bare paths when
the base already carries /v1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): prefer self-hosted transcription in presets and hints

Preset order becomes self-hosted (recommended) -> Groq turbo -> Groq
large-v3 -> OpenAI, and the settings hint plus the job-error hint lead with
the self-hosted option. The self-hosted preset now fills the turbo CT2 model
to match the recommended speaches setup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(subtitles): request VAD and word timestamps for ASR cue accuracy

Without vad_filter, faster-whisper servers report wall-to-wall segment
times: cues linger on screen through silence (verified up to 91s) and
paragraph-length segments become single 400+ char cues. Request
vad_filter=true (skipped for hosted providers that reject non-OpenAI
fields and run VAD server-side) plus timestamp_granularities word+segment,
and rebuild cues from word timings: split at speech pauses, sentence ends,
text capacity, and a 7s max duration; cap word-less segments instead of
trusting their reported end; stretch sub-second cues to a readable minimum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:58:54 -04:00
Quick c7f65bf351 fix(audiobooks): normalize ABS session token hash 2026-06-10 11:56:59 -04:00
54b20f57d7 feat(audiobooks): player QoL — shortcuts, smart rewind, per-book speed, volume, chapter nav (#125)
Brings the web audiobook player to parity with best-in-class audiobook
apps (Audiobookshelf, Audible, BookPlayer) on the quality-of-life axis,
per docs/superpowers/plans/audiobook-player-qol.md:

- Keyboard shortcuts active on every route while the player is mounted
  (space/K, arrow skips, volume, M, N/P chapters, shift+./, speed,
  E expand, Esc collapse), inert while a video session exists.
- Configurable skip intervals with asymmetric defaults (back 10s,
  forward 30s) via a new player settings popover.
- Speed control expanded to 0.5x-3x with a 0.05 stepper, presets, and
  per-book memory (LRU-capped, device-local).
- Chapter prev/next transport buttons (prev restarts the chapter when
  more than 3s in, the universal player convention).
- Volume/mute via the shared VolumeControl with a new theme-token
  "surface" tone; persistence shared with the video player.
- Smart rewind: backs up 3-30s on resume scaled by pause length, plus a
  flat 10s on cold resume from the detail page; explicit seeks are never
  second-guessed. Toggleable in player settings.
- Now Listening time label cycles total / remaining / remaining at
  current speed.

Chapter flattening is deduplicated into lib/audiobooks/chapters.ts, the
SkipIcon is shared between both player views, and new pure modules
(smartRewind, chapter nav, prefs) come with unit tests.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:08:18 -04:00
QuickandClaude Fable 5 35a0db7d29 fix(api): decode percent-encoded provider IDs in route params
chi matches routes against the raw (escaped) request path, so
chi.URLParam returns parameters still percent-encoded when clients
escape reserved characters. The web UI sends marker provider IDs via
encodeURIComponent, so plugin-based providers like "plugin:6:introdb"
arrived as "plugin%3A6%3Aintrodb", breaking validate (400) and update
(404) for any provider ID containing a colon.

Add a shared decodedURLParam helper and use it in the marker provider,
subtitle provider, and watch provider handlers, returning 400 on
malformed escape sequences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:58:45 -04:00
Quick ef78a1c5da Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-06-10 08:43:08 -04:00
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the PR #124 review and hardening pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00
0bd4f8cb3b fix(jellycompat): restore CanDownload with a real Download route for Infuse (#123)
dd81a7ef set CanDownload=false to stop Wholphin's screensaver from
404ing on the nonexistent /Items/{id}/Download route — but the flag is
load-bearing for Infuse, which refuses Direct Play (Static=true
streaming) of items it believes it cannot download. With omitempty the
field vanished from the JSON entirely and Infuse playback broke, while
PlaybackInfo-negotiating clients were unaffected.

Resolve the underlying inconsistency instead of trading one client for
the other: implement GET/HEAD /Items/{id}/Download serving the original
file (range support, Content-Disposition, optional mediaSourceId for
multi-version items) under stream-group auth, and restore
CanDownload=true now that the route exists. Fixes Infuse playback and
keeps Wholphin's download callers working.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:09:47 -04:00
c5f21cb10d fix(jellycompat): parse repeated Fields query params (#110)
* fix(jellycompat): parse repeated Fields query params

parseItemsQuery read the Fields parameter via q.Get("Fields"), which
returns only the first value when a client sends Fields as repeated
query params (Fields=A&Fields=B&...) instead of comma-separated in a
single param (Fields=A,B,C).

The jellyfin-sdk-kotlin (used by Wholphin) sends repeated params. When
such a request listed a detail-only field like MediaSources after other
fields — e.g. the episode-playlist request
  /Shows/{id}/Episodes?Fields=PrimaryImageAspectRatio&...&Fields=MediaSources&...
silo saw only the first value (PrimaryImageAspectRatio), so
needsDetailFields stayed false, the request took the list path, and the
response came back without MediaSources. Clients then could not start
playback of the returned episodes ("no media sources").

Join all repeated Fields values before splitting on commas so field
order and delimiter style no longer matter. Comma-separated single-param
clients (e.g. VidHub) are unaffected.

* fix(jellycompat): stop advertising CanDownload and stub ThemeSongs

Wholphin (jellyfin-sdk-kotlin) audit surfaced two reachable gaps:

- mapping.go set CanDownload=true on every playable item while no
  /Items/{id}/Download route exists, sending clients that honor the flag
  (e.g. Wholphin's screensaver/slideshow) into 404s. Advertise false until
  a download route exists.
- GET /Items/{id}/ThemeSongs 404'd, so enabling theme songs in Wholphin
  silently failed on every detail page. Stub it with an empty
  ThemeMediaResult. This cannot reuse the generic item stub: the SDK
  models OwnerId as non-nullable, so the response must include it even
  when empty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(architecture): add Wholphin endpoint coverage audit

Cross-references every Jellyfin endpoint the Wholphin client can call
against the routes jellycompat serves, with gating evidence for each
missing-but-unreachable endpoint and prioritized recommendations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:42:46 -04:00
1a1282db0c build(deps): bump quic-go to 0.60.0 via webtransport-go compat shim (#121)
engine.io (via socket.io) depends on zishang520/webtransport-go v0.9.1,
which is pinned to old quic-go internals and breaks against newer
quic-go releases, blocking dependabot's quic-go bump (#74).

Add internal/compat/zishang520-webtransport-go, a shim module that
preserves the zishang520/webtransport-go API shape while delegating to
the maintained quic-go/webtransport-go v0.10.0, wired in with a go.mod
replace directive. The Dockerfiles COPY the shim before go mod download
so the replace resolves in container builds.

Also bumps the Go toolchain to 1.26.4 and golang.org/x/crypto, x/sys,
and x/image.

Closes #74.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:08:30 -04:00
79c749b9a7 Feat/continue watching card affordance (#120)
* feat(web): improve Continue Watching card affordances

Add hover/active feedback to the play button (scale, brightness,
shadow) and make the episode meta and time-left lines link to the
item page like the heading. The blank time-left placeholder stays a
plain div so card heights align without an invisible link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): cover audiobook-only continue sections using poster cards

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 20:02:18 -04:00
46540dfec3 fix(progress): track resume points independently of watched state (#117)
* fix(progress): track resume points independently of watched state

Re-watching a finished item never re-entered Continue Watching: completion
latched completed = TRUE one-way, pinned position_seconds to the duration,
and the resume query filtered on completed = FALSE — so a rewatch heartbeat
could never surface the item again (and releasing the latch would have
erased the watched state clients display).

Adopt the Jellyfin invariant instead of guard heuristics:

- Completion resets position_seconds to 0 (UpdateProgress, SetProgress,
  SetProgressAt, SetProgressIfNewer, MarkWatched, MarkProgressBatch), so
  position_seconds > 0 now means "live resume point".
- completed stays a pure one-way watched latch; rewatch heartbeats re-enter
  Continue Watching through plain GREATEST/MAX while the watched flag and
  PlayCount survive (matching Plex and Jellyfin master).
- ListProgress("in_progress") keys on position_seconds > 0 in both stores;
  the SQLite store also gains the min-resume floor the Postgres store had.
- jellycompat reports Played=true with live PositionTicks during a rewatch
  (resumePositionTicks no longer zeroes played items) — the DTO shape real
  Jellyfin emits since jellyfin/jellyfin#15762.
- Web mirrors the latch (playbackProgressCache), resumes rewatches at their
  stored position, and shows progress bars on rewatched episodes.
- ABS audiobook surfaces keep today's behavior: finished books report 100%
  via the completed flag and Continue Listening still excludes them.
- Migrations reset legacy completed rows (position pinned to duration) to
  0: a Goose migration for Postgres and a user_version-gated one-time fix
  for the per-user SQLite DBs.

Replaces the guard-based approach of #109, whose restart detection
(50% fraction + 60s time gap) could never release the latch for immediate
rewatches (blocked heartbeats refreshed updated_at, re-arming the gap) and
un-watched items on position-0 heartbeats.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(progress): address review — migration gate, one-way latch, missed writers/readers

Review fixes for the position-based watch-progress model:

- The per-user SQLite data fix is now migrateToV11 in the existing
  versioned runMigrations chain (schemaVersion 11). The previous
  standalone PRAGMA gate compared against 1, but existing DBs already
  sit at user_version 10, so the reset never ran for them — and the
  gate would have rewound the version. Fresh DBs short-circuit to the
  current version as before.
- `completed` is now one-way across every playback/sync writer:
  SetProgress (the RecordPlaybackStop path — stopping a rewatch below
  the watched threshold no longer clears the watched state),
  SetProgressAt, SetProgressIfNewer (both stores), and the history
  import upsert, which also stops pinning completed imports to
  position = duration. Mark-unwatched still releases the latch via
  ClearProgress/ClearProgressBatch.
- MarkProgressBatch regains its freshness guard: a delayed batch mark
  carrying an old timestamp can no longer zero a newer rewatch resume
  point (the position-reset now rides the original updated_at check).
- Catalog read paths align with the new in-progress definition
  (position_seconds > 0, completed-agnostic): smart-collection
  in_progress filter, progress sort ratio, episode progress CTE, and
  both next-up predicates.
- jellycompat derives PlayedPercentage and PlaybackPositionTicks from
  the same clamped position; a played item at rest reports 100 (as the
  old model did) while a rewatch reports its live fraction.
- ABS audiobook UpsertProgress stores position 0 on finish so finished
  books can't surface as phantom resume entries; re-listens still move
  position forward from 0 with the latch intact.
- The web optimistic cache zeroes the resume point on completion,
  mirroring the server invariant until the refetch lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:44:40 -04:00
162e0cc449 feat(audiobooks): redesign audiobook library around resume and series progression (#116)
* feat(audiobooks): redesign audiobook library around resume and series progression

Audiobook libraries previously reused the video-shaped library page: a
backdrop carousel hero (audiobooks have square covers and no backdrops),
movie-style default sections, and a browse grid whose primary audiobook
axes (author, narrator, series) were buried as filters.

Backend:
- New next_in_series section type: surfaces the next unstarted book, by
  series_index, in series the profile has finished a book of, ordered by
  most recent finish. Registered as a library-staple recipe.
- New GET /api/v1/catalog/audiobook-groups endpoint: grouped browse by
  author/narrator/series with book count, total duration, per-profile
  progress counts, and poster URLs for cover stacks.
- Audiobook library defaults: continue-listening is featured (renders as
  the Now Listening hero) with next-in-series directly after it. A data
  migration upgrades existing audiobook libraries, skipping layouts where
  an admin already featured a section.

Frontend:
- NowListeningHero replaces HeroBanner for audiobook libraries: resume
  deck with chapter position, hours left, ambient color from the cover,
  and one-click resume; remaining in-progress books render as the
  Continue Listening row.
- Library tab gains Books/Series/Authors/Narrators browse axes persisted
  via the type param; selecting a group drops into the Books grid with
  the matching filter applied.
- "Recommended" tab is labeled "Home" for audiobook libraries; audiobook
  continue cards use square covers and hr/min time-left formatting.
- Shared audiobook chapter/file/duration helpers extracted to
  web/src/lib/audiobooks (deduplicated from AudiobookContent).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audiobooks): address review feedback on library redesign

- Push library scoping into the next-in-series candidate SQL so finished
  series whose next book lives in another library can't consume the
  candidate limit and starve a library-scoped section (Codex P2).
- Paginate the audiobook groups fetch until the server-reported total is
  reached (500/page, 20-page bound) so client-side filtering sees the
  complete author/narrator/series list (Codex P2, CodeRabbit).
- Make the redesign migration rollback-safe: rows the Up touches carry
  config markers (featured_by_migration / seeded_by_migration) and the
  Down reverts only marked rows, leaving admin-set featured state and
  hand-created next_in_series sections alone (Codex P2, CodeRabbit).
- Gate NowListeningHero's detail-derived files/credits on the detail
  matching the deck item, so Resume can't start the new book with the
  previous book's files while keepPreviousData shows stale detail
  (Codex P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:28:53 -04:00
7f8dc95eca feat(web): make Continue Watching posters open the item page (#118)
* feat(web): make Continue Watching posters open the item page

Clicking a Continue Watching / Next Up poster previously started
playback immediately, which made it hard to reach an item's detail
page from the home screen. Posters now navigate to the item page and
playback moves to an explicit play button layered over the artwork
(hover/focus-revealed on desktop, always visible on touch, matching
the existing card menu pattern).

The card title is now its own link: for episodes it goes to the
series page via series_id, for movies to the movie page.

Also mocks the playback context in SectionRow.test.tsx, fixing two
pre-existing failures unrelated to this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): gate hidden play overlay on hover capability, not viewport width

Review feedback on the Continue Watching play button: md:opacity-0 hid
the button on touch tablets at desktop widths where no hover exists to
reveal it, and the visually hidden link still captured clicks at the
poster center on desktop. Gate the hidden state on pointer-fine instead
of md, and pair it with pointer-events-none so the hidden button cannot
intercept poster clicks; hover and keyboard focus restore both
visibility and interactivity together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:24:59 -04:00
2933e0581f fix(web): make pre-merge frontend checks pass at HEAD (prettier drift + 15 stale tests) (#119)
* style(web): run prettier over drifted files

prettier --check was failing on 19 files at HEAD. The pinned prettier
version (3.8.1) has not changed since the initial migration, so the
drift came from unformatted commits, not a toolchain bump. Formatting
changes only, no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(web): sync stale test harnesses with current components

15 tests across 6 files were failing at HEAD; the components were
committed already out of sync with their tests in the initial
migration. All fixes are in test files only:

- SectionRow / SeriesContent: mock the watch playback controller
  required by ContinueWatchingCard and MediaItemMenu, mock the new
  useSimilarItems hook, add useOptionalAuth to the auth mock, and
  wrap renders in QueryClientProvider for MediaItemMenu query hooks
- SeriesContent: read rating from item.user_rating (the useRating
  hook no longer exists)
- ProfilesSettings: add useHouseholdSessions to the profiles mock
  for the new HouseholdStreamsPanel
- ImpersonationBanner: match the redesigned banner ("Viewing as",
  sticky full-width bar instead of sidebar offset)
- EpisodeRow: watched indicator uses the text-success token now
- profile-management: use a valid DiceBear preset ref; "adventurer"
  no longer resolves and round-trips to ""

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:17:14 -04:00
074d106402 feat(jellycompat): BoxSet collections, genre-by-name, and airtight ABS media exclusion (#115)
* feat(jellycompat): exclude audiobook libraries, add BoxSets and genre-by-name

- Audiobook libraries (type 'audiobooks'/'audiobook') no longer appear in
  Views/VirtualFolders, and all browse/search/genre/detail paths are clamped
  to movie/series/episode so audiobook items cannot leak or stream through
  the Jellyfin compat surface (they are served by the ABS-compat API).
- Library collections are now exposed as Jellyfin BoxSets:
  IncludeItemTypes=BoxSet listing (optionally scoped via ParentId library),
  /Items/{id} BoxSet detail, ParentId children with curated position order
  preserved (explicit SortBy delegates to catalog ordering), poster/backdrop
  presigning, and visibility + library-access filtering.
- /Items with only unexposable IncludeItemTypes (e.g. Playlist) returns an
  empty result instead of falling through to views/browse.
- New GET /Genres/{name} endpoint resolving canonical genre casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): centralize ABS media-type exclusion, fix BoxSet edge cases from review

- Add catalog.AccessFilter.ExcludedMediaTypes, enforced by applyAccessFilter
  and threaded through Search/GetByIDsWithAccess/EnsureAccessible and
  BrowseFavorites. The compat layer stamps audiobook+podcast exclusions onto
  every resolved access filter (one wrap in withDefaults), closing the
  favorites, recommendations, and item-image leak paths that per-call-site
  guards missed.
- Treat podcast libraries like audiobook libraries: hidden from Views, items
  excluded everywhere (they're served by the ABS-compat API).
- HandleItems: BoxSet listing no longer hijacks user-state-filtered queries
  (IncludeItemTypes=BoxSet&Filters=IsFavorite returns empty again),
  IncludeItemTypes=CollectionFolder returns library views as before, and
  Ids=<boxsetId> re-hydrates the BoxSet DTO instead of falling through to
  the views response.
- BoxSet artwork is now durable: stable signed tags seeded from the artwork
  key (no churn on presign rotation) plus a collections fallback in the
  images handler, so posters survive restarts and cache expiry.
- BoxSet listing filters/sorts/pages the lightweight collection rows before
  building DTOs, so a Limit=24 page over 300 collections no longer presigns
  ~600 posters per request; collection children also page before hydrating
  user state.
- Dedupe: shared loadVisibleCollection guard, shared collection-page writer,
  single scoped-types implementation, emptyQueryResult helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): address PR review comments

- withCompatAccessExclusions merges compat exclusions with any the base
  resolver already supplies instead of conditionally skipping them.
- Explicit type filters clamp to a closed allowlist (movie/series/episode/
  season) rather than passing unknown types through to catalog queries.
- Collection artwork on the session path applies the same visibility rules
  as the BoxSet item endpoints (hidden or inaccessible-library collections
  404 instead of serving posters).
- loadVisibleCollection propagates infrastructure errors instead of masking
  transient DB failures as 404/empty; only ErrLibraryCollectionNotFound maps
  to not-found.
- ListFavorites filters ABS-surface favorites before applying the
  limit/offset window (over-fetching the raw rows) so pages don't shrink or
  shift, and presigns artwork only for the returned page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 19:16:28 -04:00
b246f271cb fix(jellycompat): hide dismissed and superseded entries from Resume (#114)
The jellycompat /UserItems/Resume endpoint served the raw in_progress
list, so Jellyfin clients showed every half-watched episode a profile
ever abandoned, including stale entries from earlier seasons of shows
the viewer had long moved past. The first-party Continue Watching row
already hid those via dismissal filtering and the superseded-episode
check, but that logic was private to internal/sections.

Extract the shared rules into internal/catalog
(ContinueWatchingProgressFilter, HomeDismissalIndex) and apply them to
the compat Resume path through a new UserDataService method,
FilterResumeProgress. The sections fetcher now delegates to the same
code, so both surfaces agree on what "still watching" means.

Resume pagination keeps advancing by raw batch counts so filtering
cannot terminate scans early, and the raw-offset fast path is limited
to StartIndex=0 for Resume because filtered lists make raw offsets
diverge from the visible list. The watched-items view ("completed"
status) stays unfiltered, and the per-series collapse deliberately
stays first-party only to preserve Jellyfin endpoint semantics.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 18:01:36 -04:00
0d0d7bab1e fix(naming): let trailing bare IMDb ids anchor group identity (#113)
* fix(naming): let trailing bare IMDb ids anchor group identity

hasStructuredIDAnchor only consulted ParseStructuredFolderIDs, so folders
tagged with an unbracketed trailing id ("Eggs Run (2021) tt8049994") still
went ambiguous on title conflicts. ParseFolderIDs now returns only explicit
evidence (structured tags plus unambiguous tt-prefixed ids), so use it for
the anchor check. Covers the last 3 ambiguous-with-IDs groups on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(naming): persist file-stem trailing IMDb ids into group identity

InferGroupIdentity anchored on file-level trailing bare IMDb ids via
hasStructuredIDAnchor but extracted file-stem IDs with
ParseStructuredFolderIDs, so the id that justified resolving a title
conflict never reached GroupIdentity/ScannedMediaGroup and downstream
matching saw a resolved group without it. Use ParseFolderIDs for the
file-stem extraction to mirror the anchor check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:12:05 -04:00
ce9830cd02 fix(jellycompat): report fileless episodes as LocationType=Virtual (#111)
* fix(jellycompat): report fileless episodes as LocationType=Virtual

Provider-metadata-only episodes (unaired/missing entries pulled from
TVDB/TMDB that have no underlying media file) were mapped with
LocationType=FileSystem and an empty MediaSources list. Jellyfin's
contract is that such items report LocationType=Virtual.

Because they were not marked Virtual, clients that build playback queues
from the episode list (Wholphin, Infuse, Findroid, ...) treated them as
playable, queued them, and failed on advance with "no media sources".
Wholphin specifically filters LocationType=Virtual out of its
auto-advance playlist, so marking these Virtual lets next-episode /
skip-outro jump cleanly to the next real episode, and the episode list
greys them out as expected.

itemFromDetailWithFields now stamps LocationType=Virtual on playable
items (movie/episode) that have zero file versions.

* fix(jellycompat): mark fileless episodes Virtual on list paths too

The Virtual fix only covered itemFromDetailWithFields, which clients reach
only when requesting detail-level Fields (MediaSources, MediaStreams, ...).
itemFromList and episodeFromUpstream still stamped LocationType=FileSystem
unconditionally, so the same fileless episode reported Virtual or FileSystem
depending on the endpoint/Fields combination used.

Centralize the decision in applyPlayableLocation (which also clears VideoType
on virtual items, matching Jellyfin) and plumb a HasMediaFiles signal into the
list paths:

- episode targets query gains an EXISTS check against media_files
- the pool-less fallback uses a new EpisodeRepository.HasFilesByIDs
- the /Shows/{id}/Episodes non-detail path reuses the already-fetched
  episode targets, so no extra query is needed

A nil signal preserves the historical FileSystem default for producers that
do not check file presence (movies, series-level lists).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:59:55 -04:00
75b476d124 fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries (#112)
* fix(naming,metadata): anchor identity on provider IDs, drop bare-numeric IDs, back off match queue retries

Three scanner/matching fixes validated against dev data:

- Group identity: explicit structured provider tags ({tmdb-...},
  [tvdbid-...]) now anchor a group's identity, so folder/file title
  conflicts (renamed releases in Radarr-tagged folders) no longer mark
  groups ambiguous and silently exclude them from matching. 3,049 of
  3,121 ambiguous groups on dev carried explicit tags.

- ParseFolderIDs: remove bare trailing numeric ID parsing entirely,
  mirroring Jellyfin's path-attribute model (bracketed key tags plus
  unambiguous tt-prefixed IMDb ids only). Titles ending in numbers
  ("District 9", "Beverly Hills 90210", "Season 01") were misparsed as
  trusted IDs, which suppresses title search and silently mismatches.
  The folderType parameter existed only to type bare numerics, so it
  is gone too.

- Match queues: replace the constant 15s/30s retry delay with shared
  exponential backoff capped at 24h. Terminal failures ("no metadata
  found from any provider") had rows at 15k+ attempts hot-looping
  every 15s on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(naming): merge trailing bare IMDb id with structured folder tags

ParseFolderIDs returned early on any structured tag, so a folder like
"Show [tvdbid-81189] tt1375666" lost the trailing IMDb id. Parse both and
merge, with an explicit structured imdb tag still taking precedence over a
trailing bare id. Matches Jellyfin, which resolves each provider key
independently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 16:38:20 -04:00
QuickandClaude Fable 5 2fe2c918f4 feat(search): add Media/Audiobooks/All scope with remembered per-user default
Search previously mixed audiobooks into movie/series results with no way to
separate them beyond single-type filters.

Backend: accept a new "video" group media scope (movies + series) anywhere a
media_scope is valid, expanded centrally via MediaScopeItemTypes into the
search item-type list, browse comma-list Type filter, and a type = ANY(...)
condition in the query executor. Register a user-scoped search.media_scope
setting (all|video|audiobook, default video).

Frontend: Media / Audiobooks / All chips on the search results page that
filter results and persist the choice as the user's default; the global
search typeahead follows the same preference. An explicit URL ?type= always
wins (with type=all as an unscoped sentinel), and the filter-bar dropdown
gains a Movies & Series option.

The API surface is additive, so Android/Apple clients are unaffected until
they adopt the new scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:46:11 -04:00
QuickandClaude Fable 5 adbdd86ac5 fix(catalog): restore search broken by unaliased COALESCE in scored CTE
The audiobook NULL-poster work wrapped nullable string columns in bare
COALESCE(...) expressions inside itemColumns/qualifiedItemColumns. Postgres
names an unaliased COALESCE output "coalesce", so the search query's scored
CTE stopped exposing poster_path etc. and every /catalog?source=query
request failed with SQLSTATE 42703, surfacing as a 500.

Alias each coalesced column back to its own name, deduplicate the three
copies of the column list into a shared itemColumnNames slice, and give the
search CTE's GROUP BY its own alias-free reference list (output aliases are
invalid in GROUP BY). Regression tests pin both invariants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 15:45:58 -04:00
QuickandClaude Fable 5 b9a9613fd0 feat(web): redesign library create/edit dialog with sectioned navigation
Replace the single scrolling library form dialog with a Plex-style
editor: a left-hand navigation rail with General, Folders, Metadata,
and Advanced panes, inline validation that jumps to the offending
section, a visual icon-card library type picker, and a sticky footer
with Cancel/Save actions.

Form state, provider-chain logic, and save mutations move into a
shared useLibraryForm hook with reusable field-group components, so
the setup wizard keeps its inline layout without duplicating logic.
LibraryPosterSection moves out of AdminLibraries.tsx into its own
component, and the Advanced toggle cards now use semantic theme
tokens instead of hardcoded white overlays that broke light themes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:51:38 -04:00
QuickandClaude Fable 5 cb6272ef25 fix(ratelimit): keep rate limit settings usable when limiter is disabled
The /admin/rate-limits/config routes were only mounted when the limiter
was running, and the limiter is only constructed at boot when
ratelimit.enabled is true. Disabling rate limiting from the UI and
restarting therefore 404'd the settings page permanently, with no way
to re-enable it without editing the database.

Mount the routes whenever the settings store exists and make the
handler tolerate a nil limiter: saves always persist, hot-reload is
skipped when nothing is running, and the PUT response reports
restart_required (enabling with no limiter built, or switching backend
on a running one). GET now exposes active/active_backend so the UI can
tell saved config apart from what the process is enforcing.

The settings page shows a persistent restart banner driven by that
server state, with the same restart flow as other settings pages via a
RestartServerButton extracted from SaveBar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:23:28 -04:00
QuickandClaude Fable 5 89f005dfa8 feat(web): regroup admin navigation and settings into intuitive sections
Reorganize the admin sidebar into Overview / Content / Automation /
Users / System so media pipeline tools no longer hide under Users and
Settings is not buried in a seven-item Server grab-bag. Group the flat
13-tab admin settings page into Server / Media / Connections / Data
sections, and split the Integrations tab into dedicated Subtitles
(search providers + AI translation) and Watch Providers (Trakt/Simkl
OAuth) pages, leaving Integrations for one-off keys like MDBList. All
routes and ?tab= ids are unchanged so deep links keep working.

Extract the shared grouped-rail markup used by the admin sidebar, admin
settings nav, and user settings nav into a SideNav component, and
remove the orphaned admin-settings PluginsSettings page (superseded by
/admin/plugins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 14:23:10 -04:00
2c714e4ef2 feat(requests): pluginize request fulfillment behind request_router.v1 (#104)
* docs: design spec for pluginizing requests fulfillment

Pluginize the requests fulfillment backend behind an agnostic
request_router.v1 capability (high seam: whole-request fulfiller).
Host keeps lifecycle/quota/policy/quality-governance and a generic
two-tier connection registry; plugins own routing+submission+status.
First plugin extracts multi-instance Sonarr/Radarr; Seerr follows in
a separate spec. Preserves autoscan reuse of arr connection rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for requests pluginization

Three-phase plan: (1) request_router.v1 SDK capability, (2) new
silo-plugin-requests-arr plugin extracting multi-instance Sonarr/Radarr,
(3) host refactor routing fulfillment through the plugin while keeping
quality governance, target records, and autoscan connection reuse host-side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db): generalize request_integrations into a two-tier connection registry

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): add generic connection fields to Integration + repo mapping

* feat(pluginhost): typed RequestRouter capability client + resolver

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin-backed RequestRouterProvider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): route fulfillment through RequestRouterProvider; host keeps quality governance

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): base auto-approve gate on router connection model

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): wire plugin-backed request router at both service sites

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(requests): remove in-host Sonarr/Radarr fulfillment code

* test(autoscan): lock request-integration reuse after connection generalization

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): plugin-driven request integration config form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): echo router connection fields in integration response

* fix(requests): retry dropped qualities, contain to one router installation, dedupe targets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): harden plugin trust boundary (validate targets, contain bad connections, media-type routing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): tighten auto-approve gate, restore default/4k validation, propagate config-encode error, drop itoa wrapper, test status/options translation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(requests): resolve integrations/settings/secrets once per reconcile cycle

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): dedupe config helpers, preserve zero profile id, stabilize installation default, drop redundant options write

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for schema-driven plugin config form

Extends AdminFormDescriptor into a full form-description language (dynamic
options, multi-select, conditional visibility, sections, validation) + a
plugin Validate RPC, rendered by one reusable SchemaForm engine. Retires the
bespoke arr connection form and integrationOptionsFromRouter so any
request_router backend renders its config UI from manifest data with zero
host changes. Addresses code-review finding #9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for schema-driven plugin config form

Six phases: SDK AdminFormDescriptor extensions + Validate RPC; reusable
SchemaForm renderer (refactor PluginConfigForm onto it); host Validate
plumbing + generic options + legacy-column derivation + retire
integrationOptionsFromRouter; requests admin page swap to SchemaForm with
per-plugin grouping; arr manifest enrichment + Validate impl; verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): extend plugin admin-form TS types (sections, conditions, validation, multi-select)

* feat(web): schema-form pure utils (show_when, validation, value coercion)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm renderer (controls, sections, show_when, dynamic options, errors)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): render PluginConfigForm via the shared SchemaForm engine

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): RequestRouter Validate client + provider seam

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): plugin Validate on save, generic options, derive legacy columns from plugin_config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): generic options response + 400 field_errors on plugin validation failure

* feat(web): generic request-integration options type + surface validation field_errors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): render request connections via SchemaForm; per-plugin grouping; retire bespoke arr form

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): silent connection-options probe with inline failure status (no toast spam)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): serialize admin_form sections/show_when/dynamic_options/validation to the client

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): drop show_when-hidden fields from buildSchemaValues payload

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass requester user id as int64 (no truncation)

* refactor(requests): drop legacy arr columns; plugin_config is sole source of truth

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): backfill api key in plugin validate; centralize validation 400; drop duplicate host cross-field check; guard admin-form serializer

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): refuse stored api key reuse when base_url changes (security hardening)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): SchemaForm regex-guard, default_value, type-driven coercion, validity callback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): connection-options latest-wins + narrowed deps + clear stale errors; auto-select; type-driven persist; reuse types

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for silo-plugin-requests-seerr (request_router.v1 backend)

* docs: implementation plan for silo-plugin-requests-seerr

* docs(spec): FindExistingRequest uses /api/v1/request (carries request id)

* docs(spec): seerr hardening — id-recovery, 404 terminal, media-status, sort pin, single missing-tmdb message

* docs: design spec for shared plugin-platform SDK helpers (code-review #10)

* docs: plan for plugin-platform SDK helpers (#10) + spec fix (inline broker wiring, no import cycle)

* docs: design spec for typed 4K quality-tier signal (code-review #9)

* docs: implementation plan for typed 4K quality-tier signal (#9)

* feat(requests): stamp is4k per quality (host owns the 4K-tier fact)

* fix(requests): store capability sub-id, not the type, in request_integrations

request_integrations.capability_id carried the capability TYPE
("request_router.v1") instead of the capability sub-id ("arr"/"seerr").
The host resolves a router plugin via
requireCapability("request_router.v1", id), which keys on (type, id), so
storing the type resolved to no capability: every save/options/fulfill
500'd ("Request operation failed" / "no fulfillment backend configured")
in ~1ms, before the arr/Seerr API was ever contacted. The path was
internally split-brained (the fulfillment filter matched the type while
the dispatcher needed the sub-id), so it never worked end-to-end; the
unit tests hid it behind a fake provider that skips requireCapability.

Align capability_id with the scan_source/metadata convention (sub-id):
- validateInstance: require a non-empty sub-id; drop the default-to-type
  and the "!= request_router.v1" reject.
- resolveRouterConnections / integrationConfigured / unbound-guidance:
  match on a non-empty capability, not type equality.
- repository: persist capability_id verbatim (never default to the type).
- web AdminRequests: send the selected plugin's capability.id in both the
  options probe and the save payload (was a hardcoded type constant).
- migration 20260608131649: backfill capability_id from each bound
  installation's request_router.v1 capability and drop the column's
  misleading default. Unbound legacy rows are left for admin re-save.

Tests: validateInstance now requires the sub-id, and the selected sub-id
must reach the plugin Validate RPC (fakeRouterProvider records it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): polish request connection cards (grouped toggles + option loading states)

The schema-driven connection cards rendered each boolean as its own
bordered, double-labeled box and showed dynamic SELECTs (root folder,
quality profile, tags) as empty controls with a single "Loading options…"
line while the host probed the service.

- Toggles render as a cohesive settings list: consecutive switches collapse
  into one bordered, divided container; each row is toggle-first with the
  label + description hugging beside it (no stranded whitespace between a
  short label and its switch). Honors show_when, so conditional toggles
  still group correctly.
- Dynamic SELECT/MULTI_SELECT fields show a per-field spinner + shimmer
  skeleton while options load, and only when there's nothing to show yet —
  a background re-probe never flashes over the operator's current value.
- Sections get a softer surface and clearer titles; the card's enable
  switch is labeled Enabled/Disabled; the options-load failure is a proper
  inline alert with retry guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): treat "Any"/no-cap playback ceiling as 4K-allowed

allowedQualities decided whether to also request 2160p with
`CompareQuality(ceiling, PlaybackQuality4K) >= 0`. But an "Any" max
playback quality resolves to an empty ceiling ("no cap"), and in
qualityRank "" is the LOWEST rank (0) — so CompareQuality("", "2160p")
returns -1 and 4K was dropped. A requester with unlimited playback quality
only got a 1080p request, never the 4K one.

Use access.QualityAllowed(PlaybackQuality4K, ceiling), which already
encodes "empty ceiling == no cap == allows everything". Now:
- "" / "Any"  -> 1080p + 2160p
- "2160p"     -> 1080p + 2160p
- "1080p"     -> 1080p only
- resolver error still fails safe to the HD ceiling.

Tests: add an "any/no-cap ceiling adds 2160p" case; the unknown-quality,
status-coercion, dedup, and per-quality-idempotency submit tests now pin
an explicit HD ceiling (they relied on the old empty-default == HD-only
behavior and were not about 4K entitlement).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for collapsible Library + anime gate/nesting (request card UI, Spec A)

Spec A of two for the request connection card UX: Library section becomes
collapsible/collapsed (auto-expanding on validation errors) and the anime
override fields move into a single gated section below Library instead of
popping out as a detached sibling card. Single-default enforcement is Spec B.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for collapsible Library + anime gate/nesting (Spec A)

Task-by-task TDD plan: SchemaForm auto-expand-on-error + nested-field
affordance (silo-server), arr manifest regroup (collapsible Library, anime
gate section), then build/deploy/reinstall + manual verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): auto-expand collapsible schema sections that have validation errors

SchemaFormSection now accepts a forceOpen prop; when any field in the section
has a mergedError (client validation or server error), the section expands
automatically so required-field setup can never be hidden behind a collapsed
accordion. The operator's manual toggle is preserved via a nullable userOpen
state that only takes effect when forceOpen is false.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): indent show_when-revealed schema fields to read as nested

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for schema-driven single-default exclusivity enforcement (Spec B)

At most one connection per service_kind may be the HD default (is_default) or
4K default (is_default_4k). Generic exclusivity: a new AdminFormField
exclusive_group_field declares the rule, the plugin Validate enforces it
against host-supplied siblings (config only, no creds), and the admin UI
auto-clears conflicts as you toggle. Host stays plugin-agnostic. Forward-only;
no migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-default exclusivity enforcement (Spec B)

Five TDD tasks across 3 repos: SDK proto (siblings + exclusive_group_field)
+ buf regen; arr Validate cross-sibling + manifest; host gathers siblings
(config-only) into Validate; frontend generic mutual-exclusion helper; then
re-vendor/rebuild/redeploy + plugininstall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(requests): pass sibling connections to plugin Validate for cross-connection rules

Adds siblings []ResolvedRouterConnection to RequestRouterProvider.Validate so
the plugin can enforce cross-connection invariants (e.g. one default per
service_kind) without the host resolving sibling credentials. The new
siblingConnections helper gathers other connections on the same installation,
carrying only ID + PluginConfig. Vendor updated to the Task 1 SDK version that
carries ValidateRequest.Siblings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): auto-clear mutually-exclusive defaults across request connection cards

Adds generic applyExclusivity helper and wires it into updateCardConfig so
turning on a field with exclusive_group_field proactively clears the same
field on sibling cards sharing the same group value, matching server-side
enforcement with a proactive UX.

* docs: design spec for single-flighting plugin client launch (cold-start herd fix)

Concurrent ensureClient calls for a cold installation each spawn a redundant
plugin process (Host.Start releases its lock during launch). Wrap ensureClient
in a per-installation singleflight.Group so concurrent first-use collapses to
one launch. Host-only fix; surfaced while testing the request-router feature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for single-flighting plugin client launch

TDD: concurrency tests (herd collapses to one launch, warm-cache reuse,
distinct installations stay parallel, failed launch propagates) + the
singleflight wrapper around ensureClient; then rebuild/redeploy + verify.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): single-flight ensureClient to prevent cold-start launch herd

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(requests): harden capability containment + dedupe eligibility; UI/migration cleanups

Addresses /code-review high findings on the previously-unreviewed commits:
- resolveRouterConnections contains fulfillment to the first chosen
  (installation, capability) and locks only after a connection's key resolves,
  so a plugin exposing >1 request_router capability never mixes connections and
  a skipped bad-key connection never pins the capability (+ test).
- extract eligibleRouterConnection, shared by resolveRouterConnections and
  integrationConfigured so the auto-approval gate and fulfillment filter can't
  drift.
- SchemaForm: shared FieldDescription helper (field/switch/section); key switch
  groups by position so a show_when reveal doesn't remount the group (focus loss).
- migration backfill uses a deterministic correlated subquery instead of a join
  cross-product when an installation exposes multiple request_router capabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for opt-in Seerr per-user requester mapping

Per-connection requester_mode (admin default | mapped). In mapped mode the host
pushes the requester email/username into the Fulfill descriptor and the seerr
plugin resolves/creates the matching Seerr user by email with operator-chosen
default permissions, attributing the request (and gating Seerr-side approval via
the auto-approve permission). Spans SDK (descriptor fields), host (extend
UserIdentityLookup with email + a requester resolver), and the seerr plugin
(Seerr user API + mapping). Fallback to admin on any failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for Seerr per-user requester mapping

Five TDD tasks across 3 repos: SDK descriptor fields (requester_email/username);
host resolves identity (UserIdentityLookup+email, RequesterIdentityResolver,
populate descriptor at both Fulfill sites); seerr config+user API (find/create
by email, exported PermissionBits); seerr Fulfill mapping + admin_form; then
re-vendor/rebuild/redeploy + plugininstall (installation 6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: make Seerr unmapped-requester behavior a toggle (admin fallback | fail request)

Per user feedback: require_mapped_user switch (default off = admin fallback,
on = fail the request). Updates spec + plan Tasks 3/4 (config field, Fulfill
honoring the toggle via a mapFailed signal, a new test, and the manifest switch).

* feat(requests): resolve requester email/username into the Fulfill descriptor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: design spec for simplified Seerr mapped-user permissions

Reduce the 5 permission toggles to two (request_4k_all + auto_approve);
1080p always granted; remove manage_requests; 4K eligibility per-user from the
request's qualities (host-decided, same as arr) with a blanket override toggle.
Seerr-plugin-only; permission-only override (host still gates 4K requests).

* docs: implementation plan for simplified Seerr mapped-user permissions

Two tasks (seerr-plugin-only): replace the 5 perm toggles with request_4k_all +
auto_approve (1080p always; 4K from request qualities via userPermissions;
remove PermManageRequests/PermissionBits; manifest + json_schema), then rebuild
+ reinstall (installation 6). No host/SDK change.

* docs: design spec for host rebase onto main + #95 credential-model adoption

Per-commit rebase of our 68 request-router commits onto the force-pushed
origin/main (drops 188 patch-equivalent). At the credential-path conflicts, adopt
#95's inline secret.Cipher model: keep our plugin columns + #95's encrypt/decrypt
in repository.go; drop our SecretResolver and read in.APIKeyRef directly in
service.go; wire NewRepository(pool, dataCipher). #39-area conflicts take ours
(our pluginization supersedes it). Security review + SECRET_KEY deploy note.

* docs: implementation plan for host rebase + #95 credential adoption

Four tasks: (1) guided per-commit rebase onto origin/main, take-ours on
credential files so it builds; (2) TDD integration commit adopting #95's
secret.Cipher (encrypt/decrypt in repository.go, drop SecretResolver, read
APIKeyRef directly, wire NewRepository(pool, cipher)); (3) security review;
(4) pin published SDK v0.6.0, push fork, open host PR with SECRET_KEY deploy note.

* chore(rebase): restore scan-source service methods + temp requests-repo arity

Post-rebase conflict fixups: take-ours on internal/plugins/service.go dropped
origin's ScanSourceClientByPluginID (independent upstream capability) — restored.
mediarequests.NewRepository temporarily 1-arg to match our pre-#95 repo; Task 2
restores the cipher arg when adopting #95's at-rest credential model.

* feat(requests): adopt at-rest credential cipher (#95) for plugin api keys; drop SecretResolver

* build: pin published silo-plugin-sdk v0.6.0 (drop local replace)

* test(requests): guard at-rest cipher round-trip + empty-key auto-approval (code-review)

Max-effort code review of the #95 credential integration. Fixes the actionable
findings:
- TestEncryptAPIKeyRoundTripAndAAD: pins encryptAPIKey<->DecryptIfEncrypted
  inversion, the id-bound apiKeyAAD == secret.RowAAD(...) match (so #95's backfill
  rows decrypt), the blank-key "" sentinel, and row-bound AAD — the security-
  critical invariants had no automated guard (no DB harness for scanIntegration).
- TestCreateRequestAutoApprovalEmptyKeyTreatedAsUnconfigured: pins that a keyless
  connection reads as unconfigured (request stays pending, never submitted), so
  integrationConfigured and resolveRouterConnections can't drift.
- Fix stale fulfillContext comment (referenced a resolved-API-key cache removed
  with SecretResolver).

Assessed-not-changed (documented): decrypt-error-fails-closed and failed-backfill
behaviors are origin/main #95 design we adopt; nil-cipher is unreachable in prod
and matches the codebase-wide no-guard pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build: drop stale machine-local SDK replace comment from go.mod

The replace directive was already removed when v0.6.0 was pinned (3410df7);
this leftover comment falsely claimed a local replace still existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(web): prettier-format schema-form utils to 100-col width

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: drop internal superpowers specs/plans from PR

These design specs and implementation plans are internal development
artifacts; keep them out of the upstream PR diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(metadata): exclude providers from content levels they don't declare

ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): isolate singleflight launch from leader ctx cancellation

The deduped ensureClient launch ran doEnsureClient under the leader caller's
ctx, so if that caller's request was canceled/timed out mid-launch the shared
plugin start was torn down and the error propagated to every waiter. Run the
launch under context.WithoutCancel so a single caller cannot cancel work the
other waiters depend on (values preserved for tracing/auth). (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): nil-guard request-router wiring

RequestRouterClient dereferenced a.Svc unconditionally and AttachRequestRouter
called SetRouterProvider even with nil deps, so a build without the plugin
service would panic instead of degrading. Guard both: the adapter returns a
controlled error and AttachRequestRouter no-ops, leaving fulfillment to fail
with the existing "no backend configured" path. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): correct value coercion + track capability sub-id in request form

- schemaForm: Boolean("false") was true; parse string booleans explicitly.
  array:num now coerces decimals ("1.5"), array:int stays integer-only.
- AdminRequests: track capability_id alongside installation_id (composite
  <Select> value) so a multi-capability installation resolves the exact
  backend; reset pluginConfig when the selected plugin changes so plugin A's
  keys never reach plugin B's options probe/save. (CodeRabbit)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(requests): address request-router review findings

* fix(requests): handle router review edge cases

* fix(web): resolve schema form build casing

* fix(requests): skip unconfigured 4k fulfillment targets

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-09 13:00:48 -04:00
66a4146fc2 fix(metadata): exclude providers from content levels they don't declare (#106)
ResolveChain falls back to every enabled metadata provider when a library
+ content-level has no enabled chain entry. That fallback was media-type
blind: a provider declaring default_priority only for an unrelated level
(e.g. an audiobook provider declaring {"audiobook": N}) was kept in the
list (merely sorted last) and invoked for video content levels.

In production this made silo.audiobook-metadata hammer external audiobook
APIs with anime/movie/series titles every scheduled enrichment pass
(MatchWorker, 30s) for the season/episode levels that had no enabled chain
entry. Disabling the chain entries did not help because the fallback never
consults them; only disabling the installation removed it from the global
set.

Treat a non-empty default_priority map as the provider enumerating the
content levels it supports: in resolveEnabledProvidersByPriority, exclude
providers whose declared map omits the requested level instead of ranking
them last. Providers that declare no default_priority make no claim and
stay eligible everywhere (legacy behavior).

Fixes #105

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:31:52 -04:00
Quick b7439884ec fix(tasks): skip idle audiobook maintenance 2026-06-08 22:46:44 -04:00
QuickandGitHub 7c64c06e3d Redirect Jellyfin image routes to upstream URLs (#101)
* Redirect Jellyfin image routes to upstream URLs

* fix(jellycompat): proxy images for incompatible clients
2026-06-08 22:36:22 -04:00
QuickandGitHub 2fa30379ad fix(jellycompat): include Wholphin display preference dimensions (#102) 2026-06-08 22:36:07 -04:00
5855cebe75 fix(audiobooks): complete playback and catalog parity (#96)
* fix(audiobooks): complete playback and catalog parity

* fix(web): allow podcast continue targets

* fix(audiobooks): use folder sidecar covers during scan

* fix(audiobooks): read nullable poster paths during cover scan

* feat(home): split continue listening sections

* fix(catalog): coalesce nullable media artwork fields

* feat(playback): surface audiobook sessions in admin activity

* fix(audiobooks): address playback review feedback

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:55:35 -04:00
cf4e080bf4 fix(jellycompat): Wholphin (jellyfin-sdk-kotlin) playback & genre compatibility (#100)
* fix(jellycompat): match MediaSourceId across UUID formats (compact vs dashed)

* fix(jellycompat): honor ImageTypes=Backdrop as a filter on /Items

Wholphin genre cards request /Items?imageTypes=Backdrop&limit=1&sortBy=Random
and assume every returned item has a backdrop. Silo ignored ImageTypes, so a
random pick could lack a backdrop (BackdropImageTags: null), crashing Wholphin.

Push the filter down to the catalog browse SQL
(NULLIF(BTRIM(backdrop_path),'') IS NOT NULL) so random/limited selections only
ever consider backdrop-having items; empty genres correctly return [].

* fix(jellycompat): case-insensitive PlaySessionId + api_key in stream auth

Wholphin's jellyfin-sdk-kotlin builds its own direct-play URL
(/Videos/{id}/stream?static=true&playSessionId=...&mediaSourceId=...) with a
lowercase 'playSessionId', no api_key, and no auth header (ExoPlayer's data
source drops it). PlaybackSessionAuth read 'PlaySessionId'/'PlaySessionID'
case-sensitively, so the fallback never matched -> 401 on every direct-play
stream -> forced (often failing) transcode fallback. Resolve PlaySessionId via
newCaseInsensitiveQuery, and likewise accept case-variant api_key in
ExtractToken.

* fix(jellycompat): support Wholphin season item queries

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:05:33 -04:00
339ed20074 fix(audiobooks): improve poster enrichment throughput (#94)
* fix(audiobooks): improve poster enrichment throughput

* fix(audiobooks): enable ABS compat by default

* chore(audiobooks): remove stale feature flag setting

* fix(audiobooks): validate scoped scan roots

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
2026-06-08 18:17:08 -04:00
Quick 9a240edb17 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-06-08 15:27:13 -04:00
9e29e7b330 feat(security): encrypt server-owned credentials at rest (#45) (#95)
* feat(security): encrypt server-owned credentials at rest

Introduce AES-256-GCM at-rest encryption (HKDF-derived from a required
SECRET_KEY) for server-owned credentials, with row-bound AAD, a versioned
enc:v1: envelope, and an idempotent startup backfill.

- internal/secret: cipher + RowAAD/SettingsAAD + the startup backfill engine.
- SECRET_KEY required at bootstrap; cipher threaded as an explicit dependency.
- server_settings: EncryptedSettingsRepo decorator over the audited
  SensitiveSettingKeys (also drives admin redaction); the config watcher and
  watch-sync settings reads decrypt too.
- Arr keys inline-encrypted; the ambiguous SecretResolver indirection removed
  from requests/autoscan.
- Per-table columns encrypted: subtitles, watch-sync, webhook-sync (not
  webhook_secret), history-import, and the jellycompat session's bridged Silo
  access/refresh tokens.
- Startup backfill (resolve-then-encrypt for arr refs) is best-effort and
  primary-node gated.

Equality-looked-up secrets and plugin_runtime_configs.config_value are out of
scope (need hashing / cross-repo design) — see
docs/architecture/secret-encryption.md.

Refs #45

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(compose): require SECRET_KEY in docker-compose

The server now fatals without SECRET_KEY, so the integrated service (and the
commented distributed proxy/transcode examples) pass it through with a
fail-fast guard matching the existing MEDIA_ROOT pattern. Distributed worker
nodes must use the SAME key as the primary to decrypt shared data.
Generate with: openssl rand -base64 48.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): encrypt history import session credentials

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:25:48 -04:00
d3626fca00 feat(jellycompat): add /Users endpoint and unified sa_ admin-key auth (#91)
* fix(api): allow HEAD on /api/v1/direct-download

Firefox (and some download managers) issue a HEAD request before
starting a download. The route only registered GET, so HEAD returned
405 Method Not Allowed and the browser aborted the download.

Mirrors the pattern already used by /stream/{session_id}, which
registers both GET and HEAD on the same handler. ServeDirect is built
on http.ServeContent / ServeFile, which natively handle HEAD by
writing headers without a body, so no handler changes are needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: design jellyfin autoscan scan compatibility

* refactor(scan): extract scan trigger resolver

* refactor(api): share scan target resolution

* feat(jellycompat): accept admin api keys for autoscan

* feat(jellycompat): add autoscan media update route

* docs: document jellyfin autoscan setup

* fix(jellycompat): harden autoscan auth and batch scan enqueue

- Reject nil API keys and bound last-used update with a 5s timeout
- Stop leaking internal queue errors in autoscan responses
- Batch scan enqueues via new CreateBatch and reuse folder list across path resolves

* chore: add planning docs and requests updates

- Add plans for date-named episodes and Jellyfin autoscan compat
- Update requests handlers, service, and UI hooks
- Remove Makefile.local.example

* refactor(scantrigger): drop redundant Target.LibraryID field

- Read library ID from Target.Folder.ID everywhere
- Guard scan queue enqueue against nil Folder
- Simplify admin API key auth error plumbing

* fix(catalog): gate search overview-only matches behind title FTS

- Always apply stats CTE + CROSS JOIN so single-word queries no longer flood results with description-only hits
- Require overview_rank >= 0.15 for overview-only fallback rows
- Switch title gate from contiguous LIKE to title_rank > 0 so reordered-token title matches aren't demoted

* ci(docker): build image on push to main via self-hosted runner

- Trigger Docker image builds on pushes to main instead of nightly cron
- Run on self-hosted Linux runner
- Drop the `nightly` tag

* docs(specs): add design for TMDB-backed request section in search

Adds the design for surfacing requestable TMDB results inside the main
catalog search (Cmd+K dialog and full results page) as a clearly
delimited "Request to Add" section that never blocks or displaces
library results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(specs): address Codex adversarial review for search request section

Splits discovery eligibility from submission eligibility so blocked
and quota-exhausted viewers still see the requestable section with
disabled per-row CTAs, matching the documented behavior. Documents
the required extensions to useRequestSearch — signal forwarding,
viewer-identity-keyed cache, and invalidation on auth/profile/
settings/limit changes — so the planned 5-minute staleTime is
safe and cancellation works as described.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plans): add implementation plan for search request section

Twelve TDD tasks covering: api() signal contract test, useCanRequest
hook, viewer-keyed requestKeys.search, useRequestSearch extension
(signal + viewer key + 5min staleTime + enabled override), invalidation
cascade tests, RequestPosterCard optional onRequest, RequestToAddSection
component (dialog + grid variants), GlobalSearch and Catalog wiring
with empty-state suppression for the library-0/TMDB-pending edge case,
final lint/test pass, and manual smoke. Notes a single deviation from
the spec: submitDisabledReason is null in the initial implementation,
with per-row request data driving disabled UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(plans): address Codex adversarial review for search request section

Fixes the high-severity finding that RequestToAddSection's internal
useRequestSearch call was not gated on discoveryEnabled, allowing
/api/v1/requests/search and TMDB lookups to fire for users without
request access. The plan now (1) passes { enabled: discoveryEnabled }
to the section's hook, (2) gates the parent mount in GlobalSearch and
Catalog on canRequest.discoveryEnabled as defense in depth, and (3)
adds tests asserting both the enabled forwarding and the no-mount
behavior when discovery is disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(api): pin AbortSignal forwarding contract on api()

* feat(hooks): add useCanRequest gating hook for discovery eligibility

* refactor(keys): add viewerKey to requestKeys.search

* feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime

* test(requests): document viewer-keyed cache isolation and invalidation cascade

* feat(request-card): make onRequest optional on discover variant

* feat(search): add RequestToAddSection dialog variant

* feat(search): add RequestToAddSection grid variant for Catalog page

* feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression

* feat(catalog): render RequestToAddSection grid with empty-state suppression

* chore(web): format request search section

* test(web): avoid unsupported Array.at in search request tests

* commit message

{"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"}

* fix(jellycompat): tolerate autoscan sidecar updates

* fix(webhooksync): skip events for unmapped external users

- Require explicit profile mapping instead of falling back to the default profile
- Update settings UI copy to reflect that unmapped users are ignored

* refactor(admin): show per-section loading and error states

- Replace page-level loading gate with skeletons per section on dashboard and stats
- Surface query errors inline instead of blocking the whole page
- Disable "Scan All Libraries" when no libraries are configured

* fix(search): address request search review feedback

* feat(subtitles): restore upload management

* feat(auth): add assignable user permissions

* feat(auth): expose user permissions

* feat(api): authorize item metadata curation

* feat(api): route metadata curation by permission

* feat(web): add permission helpers

* feat(web): assign metadata curation permission

* fix(web): keep device profile hooks unconditional

* feat(web): show metadata tools to curators

* fix(auth): address metadata curation review issues

* fix(auth): tighten curator job response review fixes

* docs: add metadata curation permission plan

* test(auth): expand session revocation coverage

* docs: add PageBack component design spec

* 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

* 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

* feat(ui): add shared PageBack component for consistent back navigation

Replace the eight inconsistent back affordances across user-facing pages
with a single absolute-positioned chevron pill, so the control lives in
the same screen position regardless of title length or hero content.

DetailBreadcrumb keeps its textual hierarchy path but no longer owns the
back chevron; PageBack does. DetailHero gains a topNav slot consumed by
Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop
their bespoke back buttons and add PageBack inside a relative wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): add floating variant to PageBack for sticky nav

- Add `floating` prop to pin PageBack to viewport on lg+ screens
- Switch styling from glass-subtle to glass with shadow for better contrast
- Use floating variant on SettingsLayout

* fix(ui): make PageBack destinations deterministic

* feat(episode-carousel): highlight currently viewed episode

- Add "Now Viewing" badge with pulsing indicator on current episode
- Replace border with primary-color ring for current episode card
- Set aria-current="page" on links to the current episode

* 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

* fix(jellycompat): harden signed image tags

* 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"}

* fix(metadata): accept exact cross-provider match ties

* Optimize episode added_at sorting

* fix(libraryingest): treat drainer shutdown cancel as clean stop

TV/series full scans (libraries with new or updated items) were recorded as
"cancelled" with an empty error message and never completed matching.

When the file-walk finishes, the ingest executor waits out a settle window and
then calls stopDrainers() to shut down the concurrent match goroutines. That
cancels the drainer context while a ProcessBatchByFolderAndPathPrefix call may
still be in flight. The drainer treated the resulting context.Canceled as a
fatal error: it pushed the error to drainerErrCh and called cancel() on the
whole scan context, so scanqueue.process() mapped it to cancelRun().

Large/slow libraries (many series, slow provider lookups) keep a batch in
flight continuously, so stopDrainers() almost always landed mid-call and the
scan was cancelled; small/fast libraries were usually idle at that instant and
completed normally.

Treat a cancelled drainer context as a deliberate shutdown: return cleanly
without escalating. Genuine external cancellation still reaches the run via the
main goroutine's scanCtx checks, so real cancels are not swallowed.

Adds a regression test (settle window made injectable) that fails against the
old handler with 'concurrent match scope ...: context canceled' and passes
with the fix.

* perf(catalog): add episode browse index fast path

* fix(catalog): address episode catalog review feedback

* Fix settle-window drainer cancellation in library ingest

* fix(catalog): support relative date filters

* fix(collections): cap smart collection results

* fix(library): preserve episode browse url

* fix(catalog): use season posters for episode cards

* feat(sections): show episode context in cards

* feat(calendar): show local episode airtimes

* Enable DRI passthrough in docker compose

Co-authored-by: Codex <noreply@openai.com>

* feat(ui): paginate the Ambiguous Roots table

Match the sibling tables on the Admin Libraries page (Troubleshooting/unmatched):
use the existing usePagination hook + PaginationBar (10/page, auto-hidden when
<=10 rows), render pag.rows, and reset to page 0 when the library selector or
search filter changes.

* feat(admin): enlarge match-candidate posters + hover-to-enlarge

Unmatched-item match dialog rendered candidate posters at 44x64px, too small
to identify a film. Bump to 64x96 (2:3) and add a portaled Tooltip hover preview
(192x288) using the existing poster image, so operators can tell candidates apart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): search unmatched items across the whole table, not just the page

The unmatched-items search filtered only the current page's rows client-side.
Push the query server-side: HandleListUnmatchedItems takes an optional 'q' param
and filters title/library/type/status with parameterized ILIKE across all rows,
paginating the filtered set. Frontend hook takes a debounced search, resets to
page 1 on change, keeps the section mounted while searching. Also fixes stale
test mocks that returned the pre-pagination array shape instead of {items,total}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(admin): prettier-format match dialog; reset unmatched page in onChange

Run prettier over the Tooltip-wrapped poster JSX, and reset the unmatched-items
page in the search input's onChange rather than a useEffect (avoids the
react-hooks/set-state-in-effect warning / cascading renders).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(admin): wire QueryClientProvider + missing hook mocks so the suite runs

The AdminLibraries test file failed all 6 tests with 'No QueryClient set' on
this branch and at the parent commit -- pre-existing infrastructure gap. With
that fixed, several hooks that the page imports (useCancelLibraryScans,
useLibraryRoots, useUpsertLibraryRootOverride, useDeleteLibraryRootOverride,
useActiveScans) and the UNMATCHED_PAGE_SIZE constant also needed mocking. One
stale assertion on the renamed 'Root path' header is updated; the deeper
troubleshooting test, which mocked useSkippedLibraryRoots but the section was
refactored to useLibraryRoots(_, 'ambiguous'), is skipped with a TODO -- a real
rewrite is needed and is out of scope for this MR.

5 of 6 tests now run and pass; the 6th is properly flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): search all unmatched item library memberships

* fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles

These tokens survived the provider-tag regex ([\w]+ doesn't match braces),
polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so
they could not score-match. Broaden the regex to drop {...} and empty tokens.

* fix(naming): numeric-only titles are not bare provider IDs

'86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate
so the correct title match was rejected. Require a letter in the name before
treating a trailing number as a bare id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(naming): document bare-id trade-off; cover CJK title + movies numeric

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(matcher): auto-accept a year-corroborated single distinct show

A search that resolves to one distinct show (one candidate, or the same
title+year returned once per source as unmerged TVDB/TMDB rows) whose year
matches the parsed year is now auto-accepted via the existing top-ranked
candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15
thresholds are unchanged; this only adds a year-gated acceptance for
effectively-unique results (recovers lone-correct-result items like 1201 (1993)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(matcher): tolerate concurrent-merge ErrItemNotFound in series episode-link ensure

A scan drainer and the background MatchWorker can process the same folder
concurrently. When a provider-ID merge moves a series' episodes to the survivor
and deletes the source, an in-flight ensureSeriesEpisodeLinks(sourceID) hits
catalog.ErrItemNotFound and was failing the whole scan. The episodes are already
reattached, so this is benign: log and continue (matching the lenient call sites)
instead of failing. Genuine errors still abort.

* diag(matcher): debug-log per-candidate match scores

Adds a DEBUG-gated log in selectInitialMatchCandidate printing each scored
candidate (title/year/type/sources/provider_ids/score) against the hint, so
operators can see why an item did or didn't auto-match. Zero-cost when debug
logging is off; no change to matching behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(matcher): resolve cross-source ties by library provider priority

Accept a year-corroborated single distinct show when the TOP tie-group (within
15 pts of best) is one show across providers, ignoring low-score noise below it,
and pick the winner by the library's metadata-provider chain order (providerPriority,
highest-first; falls back to top-scored). Recovers items like '100 Days Wild'
that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(matcher): accept cross-source-corroborated ties without a hint year

When the top tie-group is one distinct show returned by 2+ distinct providers
(candidatesAreSingleDistinctShow already verifies matching title+year), accept it
even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie
McDowd'). Multi-source agreement substitutes for the year guard; lone single-source
no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged.

* diag(matcher): debug-log provider search query + per-provider result counts

Adds DEBUG logs in the ModeInitialMatch search path: each provider's result
count for the query, and the assembled raw/candidate totals. Lets us see when a
provider search returns zero ('no metadata found') vs a scoring/tie issue.
Zero behavior change.

* fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...})

Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g.
'17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern
needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing
tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match
path instead of falling to title+year scoring.

* feat(metadata): match sole exact-title candidate despite year off by <=2

Folder years routinely differ from provider release years by a year or two
(festival vs wide release, regional dates), zeroing the year bonus and leaving
a lone exact-title candidate at 63-68 — just under the single-candidate >=70
gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add
title corroboration to the existing lone-result rule: a sole distinct show whose
normalized title exactly matches and whose year is within +/-2 is accepted. The
55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs
Puppy!). No 55/70/15 threshold change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(lint): gofmt single-space alignment in root_inference.go var block

When inferProviderTagRe was broadened to handle unsubstituted Sonarr token
placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers
single-space rather than column-aligned spacing across the var block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(metadata): tighten matcher and bare IMDb parsing

* docs: add resilient library deletion design spec

Batched, deadlock-retrying rewrite of delete_library to replace the
single multi-minute transaction that deadlocks on large libraries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: add implementation plan for resilient library deletion

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(catalog): add deadlock-retry helper for batched deletes

* test(catalog): clarify cancel-path expectation in retry test

* feat(catalog): add deleteInBatches loop helper

* refactor(catalog): make image-dir helpers querier-agnostic

Add rowQuerier interface satisfied by both *pgxpool.Pool and pgx.Tx.
Split collectImageDirs into collectRawImageDirs (raw collection) and a
thin wrapper that filters via filterUnreferencedImageDirs. Both helpers
now accept rowQuerier so a later task can call them from pool-level
batch deletes without an open transaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(catalog): delete libraries in deadlock-retrying batches

Replaces the single multi-minute delete transaction with phased, batched
autocommit deletes (orphan items, media files, memberships, folder row),
each retried on deadlock. Holds only short locks, survives concurrent
writers, and is resumable on failure.

* refactor(catalog): wrap orphan-batch iteration error

* fix(catalog): clamp still/poster/logo backdrops to largest cached variant

Episode stills used as backdrops only exist at w500/w300 in the cache, so
requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath
+ imageTypeFromCachedPath and route featured (w1920) and Continue Watching /
Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their
type's largest cached variant while real backdrops keep the requested width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* perf(startup): defer non-critical init off the HTTP listener path

Collect catalog-size-dependent seeding (metadata match queues, legacy
series-group cleanup) and the watch-provider scrobble sweep into a
backgroundInit slice that runs sequentially in a background goroutine after
the server is ready, instead of blocking startup before the listener accepts
connections. Steps log failures and stop early on shutdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(migrations): make air_timezone column add idempotent

Use ADD COLUMN IF NOT EXISTS so re-running 162 on a database that already has
the column is a no-op.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* build: stamp git revision via Makefile ldflags

`make build` did not inject buildinfo's `revisionOverride`/`dirtyOverride`
ldflags (the Dockerfile already does), so binaries built via make report
their version as "unavailable" in the admin Build panel whenever Go's VCS
metadata isn't embedded. Mirror the Dockerfile by computing the git
revision + dirty state and passing them through `-ldflags -X`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): accessibility & UX fixes from visual QA pass

Accessibility (WCAG AA):
- Lighten the Standard-theme `--muted-foreground` (#6e6e78 -> #9696a0,
  ~3.4:1 -> >=5.3:1) and darken the light-theme equivalent so secondary
  text meets 1.4.3 contrast app-wide; the opt-in High Contrast mode is no
  longer the only conformant path.
- Give icon-only controls accessible names (4.1.2): the password show/hide
  toggle (also drop tabIndex={-1} so it's keyboard reachable), and the
  Edit/Delete/health/copy/refresh actions across the Users, Libraries,
  Nodes, API Keys, Catalog Maintenance and Job History admin tables.
- Fix the Switch off-state (invisible track -> visible border + fill) and
  the PlaybackSettings SettingRow label association (the <label htmlFor>
  pointed at a wrapping <div>; the id now lands on the Switch/SelectTrigger).
- Login: wrap the card in <main> and add an <h1>; Profiles: add an
  accessible PIN-protected label and a corner lock badge.
- Player + catalog: role="status" on the initial loading overlay; scope the
  catalog count ("0 in library" for search) and announce it via aria-live;
  trim the verbose poster-link name to the title.

UX / consistency:
- Emphasize overdue scheduled tasks (warning colour + icon + word, not
  colour alone).
- Per-source catalog subtitles instead of one shared string.
- Add a Reconnect affordance when the admin log stream drops (it does not
  auto-retry).
- Page titles for Watch Party + all admin sub-pages (incl. plugins); admin
  heading capitalisation normalised to Title Case.
- Show "dev build" instead of "unavailable" when no build revision is
  stamped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): re-check orphan status when deleting library items

Orphan detection moved outside the media_items delete in the batched
library-delete rewrite, opening a TOCTOU race: a concurrent scan/import
could attach one of the collected content IDs to another library between
collectOrphanBatch and the delete, after which the unconditional
`DELETE FROM media_items WHERE content_id = ANY($1)` would still remove the
shared row and cascade away the newly-added membership — dropping the item
from the other library. Re-check the orphan invariant inside the delete
(NOT EXISTS a membership in another folder) and count rows actually deleted.

Addresses PR #21 review (P1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(metadata): persist a cleared air_timezone instead of skipping it

Clearing a previously-set air timezone sent JSON null, which decodes to a
nil *string that UpdateMetadata treats as "skip this column", so the old
value remained. The dialog now sends "" (accepted by ValidateAirTimezone),
and UpdateMetadata maps air_timezone through NULLIF so an empty value
persists as SQL NULL (matching the nullable column) rather than "".

Addresses PR #21 review (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(startup): sweep open scrobbles before accepting playback

The open-scrobble sweep was queued in the deferred background-init list,
which runs concurrently with the HTTP listener; a resume immediately after
restart could start new scrobbles before the previous process's open
sessions were stopped, leaving overlapping/stale scrobbles on remote
providers. Run the sweep synchronously before the listener starts, bounded
by a 30s timeout so an unreachable provider can't hang startup (the heavier
non-critical init stays deferred).

Addresses PR #21 review (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): scan air_timezone in paginated item queries

scanItemsWithTotal was not updated for the new air_timezone column, yet the
shared column lists it reads (itemColumns, qualifiedListItemColumns) include
it. Search and BrowseFavorites build their SELECTs from those lists with
COUNT(*) OVER (), so each row carried one more column than the scan had
destinations and every call failed at scan time with a pgx mismatch. Add the
missing &item.AirTimezone target between AirTime and ShowStatus.

Found during PR #21 review (critical: Search/Favorites runtime regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* revert(startup): keep open-scrobble sweep deferred for fast startup

Reverts 62563f5c. Running the sweep synchronously before the listener could
add up to 30s to restart-before-playback when a watch provider is
unreachable, which regresses the deliberate startup-deferral from a1a6c6cf.
Prefer the fast-startup behavior and accept the small window where a resume
immediately after restart may create a duplicate scrobble; the sweep returns
to the deferred background-init list. (Panic-safety for that list is added in
a follow-up commit.)

Per PR #21 review decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(startup): recover from panics in deferred background init

The deferred background-init steps run in a detached goroutine after the
HTTP listener is already accepting connections. An unrecovered panic in any
step (queue seeding, legacy cleanup, scrobble sweep) would crash the entire
live server. Wrap each step in a recover that logs the panic with a stack
and continues to the next step.

Found during PR #21 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(auth): make usernames and emails case-insensitive

Login identifiers were compared case-sensitively, so "John" and "john"
were distinct accounts and a user could not log in unless they matched the
exact casing used at registration.

Convert users.username and users.email to the citext type (migration 165).
citext compares case-insensitively while preserving the originally stored
casing for display, so the existing unique constraints become
case-insensitive and `WHERE username = $1` / `email = $1` lookups match
regardless of case with no change to the query code itself.

Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved),
applied at the repository chokepoints (Create, Update, GetByUsername,
GetByEmail) and before validation in the create paths, so surrounding
whitespace no longer defeats matching or creates lookalike accounts.

Verified non-destructively against the dev DB: mixed-case lookups resolve
to the same row, case-variant inserts are rejected by the unique
constraint, and the down migration cleanly reverts to text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(sections): add trending_discover home section

A library-agnostic home section that surfaces external global trending
(TMDB or Trakt, admin-selectable) mixing movies + series, matched to
titles in the viewer's enabled libraries. TMDB uses /trending/all/{window}
(natively mixed); Trakt merges trending movies + shows. Fetched live with
a 1h in-process cache, so no background job or stored collection — and no
per-library duplication.

Appears in the admin section gallery via its recipe presets (TMDB Trending
Today/This Week, Trakt Trending); featured -> hero via the existing flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add trending_discover persistent snapshot design

Replace the in-process 1h trending cache with a background-refreshed,
persisted snapshot for reliability under upstream failure and sync-run
observability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add trending_discover persistent snapshot implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sections): tidy trending_discover fetch and cache helpers

Extract newTrendingEntry, reuse orderMediaItems, and collapse concurrent
cache-miss loads with singleflight. Baseline for the persistent snapshot work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sections): add trending_discover_snapshots table

* feat(sections): add trending snapshot model and repository

* feat(sections): list enabled trending_discover section configs

* feat(sections): add trending refresher with persisted snapshots

* feat(tasks): add refresh_trending_discover task

* refactor(sections): read trending_discover from persisted snapshot

* feat: wire trending refresh task and snapshot reader

* chore(sections): satisfy lint (wrap trakt errors, lift source/window constants)

* fix(migrations): renumber trending_discover_snapshots 166 -> 167

The shared dev DB already recorded version 166 (166_trending_blend_collection_type
from another branch), so the integer-version migration runner silently skipped our
166 and the table was never created — the trending section errored out empty.
167 is the next free version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sections): harden trending refresher per PR review

- Interleave Trakt movies/shows by rank so the mixed row shows both types
  instead of burying all series past the display limit.
- Treat any Trakt sub-fetch failure as fatal (errors.Join) so a partial
  result never overwrites the last-good snapshot with a media type missing.
- Skip non-title entries (TMDB trending/all returns media_type "person") in
  both ID batching and ordering so they can't match an unrelated library title.
- Guard the refresh task against a nil refresher.
- Tests: person skip, Trakt interleave, Trakt partial-failure preserves
  last-good, snapshot read error propagation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): project air_timezone in episode catalog subquery

episodeCatalogSelectBody is the derived "mi" relation that episode catalog
hydration and preview read qualifiedListItemColumns("mi") from. The local
episode airtimes feature (808f265f) added air_timezone to the shared column
lists but not to this hand-written subquery, so the outer projection
referenced mi.air_timezone, which the subquery never exposed.

Postgres returns SQLSTATE 42703 (undefined_column), which is not one of the
codes episodeCatalogEntriesUnavailable treats as "fast path unavailable" (it
only catches 42P01/42883), so episode catalog requests failed with HTTP 500
instead of degrading. movie and series scopes query media_items directly, so
the column is present there and only episode scope broke.

Add si.air_timezone to the subquery, and add a regression test asserting that
episodeCatalogSelectBody exposes every column qualifiedListItemColumns reads
off mi, so future additions to the shared column lists cannot silently drift
from the episode read model again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(calendar): order events by viewer-local wall-clock time

The local-airtime change re-sorted calendar events in Go using air_at,
the absolute UTC instant, which is nil whenever air_timezone is unset.
Since air_timezone is only inferred for a few networks/countries, most
events fell through to the alphabetical title tiebreak while still
displaying their raw air_time, so each day appeared scrambled.

Sort each local day by the wall-clock time the viewer actually sees,
mirroring the client: zoned events convert air_at into the viewer
timezone, unzoned events use the raw air_time, and date-only entries
(no air_time) sort last. The timezone reasoning lives in the new
catalog.CalendarEventLocalTime helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(calendar): add presets design spec and implementation plan

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): generalize personal filter to an id-set restriction

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): resolve presets to id-sets and overlay watched status

Also drops the now-unused Filter/UserID/ProfileID fields from the
blendUpcomingIntoDiscoverRows CalendarFilter literal in recommendations.go,
which only wants an unrestricted windowed query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): wire popular and trending sources into calendar handler

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): add watched field to CalendarEvent type

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): preset selector with responsive pills, persistence, empty-state nudge

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): dim and check-mark already-watched event cards

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(calendar): hide server-wide Popular preset in web UI for now

Popular reflects server-wide watch counts, which are sparse on a
low-traffic server. Hidden from the selector, URL allowlist, and
empty-state nudge; backend filter and the CalendarFilter type are
left intact so re-enabling is a one-line change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(calendar): simplify preset handler and reuse storage util

- Extract hardcoded trending snapshot source/window to named constants.
- Collapse the three identical personal-preset nil-checks into one case.
- Persist the selected preset through the shared storage util (try/catch
  wrapped) instead of raw localStorage with manual SSR guards.
- Derive KNOWN_FILTERS from PRESET_OPTIONS so the lists can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(jellycompat): add /Users endpoint and unified sa_ admin-key auth

A user could not connect Tunarr to the Jellyfin-compat API. Tunarr's media-source
health check probes both /Users/Me and /Users; for API-key connections (its
recommended method) there is no "me", so it relies on GET /Users, which we did
not implement. Adding it alone was insufficient: a Silo sa_ admin key only
authorized two autoscan routes, so an API-key source would pass the ping but 401
on every browse/stream call.

This adds GET /Users and unifies auth so an sa_ admin key authorizes the same
browse/stream routes a session token does — matching Jellyfin, where an API key
authorizes every endpoint:

- GET /Users returns the caller's own user as a single-element list
  (current-profile-only; Silo is multi-account, so listing all users would leak
  across households). Behavior verified against real Jellyfin 10.11.8.
- An sa_ admin key synthesizes a compat session bound to the account's primary
  profile, injected into request context so existing handlers work unchanged.
  Applied to the browse group (RequireSessionOrAPIKeySession) and the stream
  group (PlaybackSessionAuth); /Library/VirtualFolders keeps its admin-bool path.
- The key + owning user are re-validated on every request (revocation is
  immediate); only the primary-profile lookup is cached. HLS follow-ups that
  carry only PlaySessionId resolve the negotiated session's sa_ CompatToken.

Validated end-to-end on dev: connect -> list user -> libraries -> browse ->
PlaybackInfo -> HLS stream, including PlaySessionId-only follow-ups.

Security notes: an admin key acts as the primary (parent) profile, so it bypasses
child-profile parental/PIN restrictions and can mutate the primary profile's
watch state — acceptable for an admin-trust key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Silo Server Migration <noreply@silo-server.invalid>
Co-authored-by: zZebrahz <zzebrahz@gmail.com>
Co-authored-by: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
2026-06-08 14:25:10 -04:00
QuickandClaude Opus 4.8 ed8f63c886 docs(plans): add credential encryption at-rest plan (#45)
Plan for issue #45: a reusable internal/secret AES-256-GCM package keyed by a required SECRET_KEY (HKDF), inline-encrypted api_key_ref columns that replace the SecretResolver indirection, an EncryptedSettings decorator for sensitive server_settings, and an idempotent startup backfill with resolve-then-encrypt for legacy arr references. Plan doc only; no code changes.

Refs #45

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:29:34 -04:00
d3v1l1989andGitHub 65ea9b651c fix(web): drop backdrop-blur from Pill card overlay preset (#89)
The Pill overlay preset was the only one of the five to carry
`backdrop-blur-sm` (backdrop-filter: blur(4px)) on its badge class.
Each enabled badge renders its own element, so a typical library grid
produces hundreds of backdrop-filter layers the browser must re-sample
and blur on every repaint — including every scroll frame, since the
poster art behind each badge moves while scrolling. This caused severe
scroll/interaction jank, most visibly on Chrome on Windows.

The blur is also effectively invisible: the Pill background is already
rgba(20,20,30,0.7) (70% opaque), so removing it has no meaningful visual
impact while eliminating the per-frame GPU cost. The other four presets
are unaffected.
2026-06-08 09:57:47 -04:00
Quick 3704b262d6 Merge branch 'main' of https://github.com/Silo-Server/silo-server
# Conflicts:
#	cmd/silo/main.go
#	go.sum
#	internal/api/handlers/admin.go
#	internal/api/handlers/admin_jobs.go
#	internal/api/handlers/admin_jobs_test.go
#	internal/api/handlers/admin_test.go
#	internal/api/handlers/subtitle_search.go
#	internal/api/router.go
#	internal/auth/permissions.go
#	internal/auth/permissions_test.go
#	internal/auth/repository.go
#	internal/catalog/folder_delete_test.go
#	internal/catalog/folder_repo.go
#	internal/catalog/window_count_test.go
#	internal/metadata/worker.go
#	internal/requests/service.go
#	internal/scanner/file_repo.go
#	internal/scanqueue/repository.go
#	internal/scanqueue/service.go
#	internal/scantrigger/scantrigger.go
#	internal/scantrigger/scantrigger_test.go
#	internal/sections/defaults.go
#	internal/sections/defaults_test.go
#	web/src/api/client.ts
#	web/src/components/ContinueWatchingCard.tsx
#	web/src/components/ItemCard.tsx
#	web/src/lib/documentTitle.ts
#	web/src/lib/permissions.ts
#	web/src/pages/AdminDashboard.tsx
#	web/src/pages/AdminLibraries.test.tsx
#	web/src/pages/AdminLibraries.tsx
#	web/src/pages/AdminTasks.tsx
#	web/src/pages/AdminUserDetail.tsx
#	web/src/pages/AdminUsers.tsx
#	web/src/pages/ItemDetail/EpisodeContent.tsx
#	web/src/pages/ItemDetail/MovieContent.tsx
#	web/src/pages/ItemDetail/components/ActionBar.tsx
#	web/src/pages/LibraryBrowse.tsx
#	web/src/pages/LibraryPage.tsx
#	web/src/pages/RequestDetail.tsx
2026-06-08 09:38:21 -04:00
QuickandGitHub f5b3cba3cb fix(catalog): use semi-joins for discovery library scope (#88)
* fix(catalog): use semi-joins for discovery library scope

* fix(catalog): require membership for deny-only discovery
2026-06-08 09:33:55 -04:00
zZebrahzandGitHub 96bae2f346 fix(library): clean orphaned provisional items (#76)
* fix(library): clean orphaned provisional items

* test(metadata): use item not found sentinel in fake repo

* fix(library): preserve abs references during orphan cleanup

* fix(library): avoid dropped abs cleanup tables

* fix(library): harden provisional orphan cleanup

* fix(library): preserve home dismissal series orphans

* fix(library): Delete matched items created after the first orphan sweep
2026-06-07 21:32:40 -04:00
978e1b4954 feat(playback): NVENC support for transcoding (#79)
* feat(playback): NVENC support for transcoding

* fix(playback): probe nvenc before auto-selecting

* fix(playback): use safe NVENC smoke probe dimensions

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-07 21:27:56 -04:00
zZebrahzandGitHub c4e67effc3 fix(admin): correct dashboard library counts and scans (#78)
* fix(admin): correct dashboard library counts and scans

* fix(admin): handle mixed library dashboard counts

* fix(admin): refine dashboard count affordances
2026-06-07 19:59:10 -04:00
6b1554a86c Fix scoped catalog refresh invalidation (#51)
* Fix scoped catalog refresh invalidation

* Remove duplicate catalog refetch

* fix(web): keep library lists fresh on scoped catalog events

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-07 18:15:34 -04:00
Quick 480ca44306 fix(recommendations): improve taste seed cold start ranking
Closes #66
2026-06-07 17:42:52 -04:00
d3v1l1989andGitHub ecd85f9ca1 fix(jellycompat): enforce account-level library restrictions (#72)
* fix(jellycompat): enforce account-level library restrictions

The compat AccessFilterFn re-implemented a subset of the access policy
and never loaded users.library_ids, so admin-assigned account library
restrictions did not apply to the Jellyfin-compat API: restricted users
could list, browse, search, and play items from every enabled library.

Replace the hand-rolled closure with the shared access.Resolver via a
new jellycompat.NewScopeAccessFilter adapter so account restrictions,
profile restrictions, user-disabled libraries, and rating/quality
ceilings apply exactly as on the native API. PIN verification is
skipped because compat login already verifies profile PINs.

Also: fail closed (empty allowlist) when scope resolution errors
instead of returning an unrestricted filter; treat an empty allowlist
as restricted-to-nothing in ListUserLibraries instead of unrestricted;
and hide user-disabled libraries from the compat views list.

* test(jellycompat): cover library list filtering via folder source stub

Extract a folderListSource interface for the two FolderRepository
methods directContentService uses (matching the existing browseSource/
itemAccessSource pattern) so ListUserLibraries is unit-testable, and
add coverage for the restricted-allowlist path and the user-disabled
library filtering in the views list.
2026-06-07 17:19:40 -04:00
QuickandGitHub f9bb94a299 [codex] Fix autoscan plugin bindings and poll status (#75)
* fix(autoscan): bind sources by plugin id

* fix(autoscan): skip overlapping source polls
2026-06-07 17:18:55 -04:00
eb6024573e feat(audiobooks): make audiobook libraries first-class catalog items (#73)
* docs(audiobooks): design spec for plugin absorption

Plan to absorb silo-plugin-audiobooks into silo-server as a first-party
feature. Audiobooks land in silo's existing SPA; ABS clients connect
directly. Hard constraints: reuse existing tables (media_items,
media_files, user_watch_progress, user_playback_sessions, people,
item_people, library_collections); only two new tables (abs_sessions,
podcast_feeds) and at most one column add (media_libraries.kind);
silo's main :8080 listener handles ABS Socket.io natively. Out of
scope: audiobook requests flow, smart collections, share links,
external recommender, custom metadata providers, separate audiobook
SPA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 1 (discovery + schema)

First of six sub-plans for the absorption. Six tasks: a discovery
audit that resolves the spec's Risk questions, four idempotent SQL
migrations (abs_sessions, podcast_feeds, media_libraries.kind,
audiobooks.enabled feature flag), and an empty-but-compiling
internal/audiobooks package scaffolded into cmd/silo. Lands as a
strict no-op for users (feature flag defaults to false).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): discovery findings for absorption sub-plan 1

Locks schema/code decisions for migrations 139-142 and downstream
sub-plans. Resolves open Risk questions from the absorption design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 139 add abs_sessions table

Parallel of jellycompat_sessions for Audiobookshelf-compatible clients.
Lets ABS mobile/desktop apps maintain a device-bound session that
silo's audiobooks/abs handlers will validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): match codebase conventions in migration 139

Lowercases type keywords in the abs_sessions CREATE TABLE body to
match neighboring migrations, fixes the client_version column
alignment, and replaces the misleading "parallel to
jellycompat_sessions" header comment with a more accurate
description of the table's role.

Cosmetic only — the running schema is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 140 add podcast_feeds table

Side table on media_items for RSS-subscribed podcasts. Holds feed URL,
ETag/Last-Modified for conditional fetches, last-refresh timestamp, and
the per-feed refresh interval consumed by the upcoming
podcastfeed.Refresher scheduled task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): uppercase PRIMARY KEY in migration 140

Aligns with the codebase convention (type keywords lowercase,
constraint keywords uppercase) established in migration 139's
post-style-fix form. Cosmetic only — running schema is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(audiobooks): migration 141 no-op for media_folders.type

Sub-plan 1 originally reserved migration 141 to add a 'kind' column to
media_libraries discriminating audiobook/podcast libraries. Discovery
audit (sub-plan 1 Task 1) found that the actual table is media_folders
and it already has a type text NOT NULL column with no CHECK constraint
or enum, so 'audiobooks' and 'podcasts' can be added as future values
without DDL.

Landing this migration as a documented no-op preserves the version
numbering audit trail and pins the decision in git history. The
matching down migration is also a no-op.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): migration 142 add audiobooks.enabled flag

Server-settings row that gates the absorbed audiobooks feature.
Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent
sub-plans branch on this flag and operators flip it to 'true' at
cutover.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): scaffold internal/audiobooks package

Empty-but-compiling Service that reads the audiobooks.enabled feature
flag from server_settings. Wired into cmd/silo so the package is
referenced from the binary; no routes mounted, no scheduled tasks
registered, no DB writes. Subsequent sub-plans hang scanner branches,
ABS handlers, Socket.io, podcast refresher, and SPA pages off this
Service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(audiobooks): cosmetic cleanups in scaffolded package

Two pre-emptive cleanups flagged by code review before sub-plan 2
copies the patterns:

  1. Sort the internal/audiobooks import after internal/adminjob in
     cmd/silo/main.go (alphabetical).
  2. Drop the redundant "audiobooks: " prefix from the Enabled() error
     wrap; matches how every other top-level service package
     (watchstate, scanqueue, metadata, etc.) formats errors.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 2 (scanner)

Second of six sub-plans. 10 tasks: PersonKind constants for Author and
Narrator, audio-extension recognizer, library-type helpers, a
walkLogicalTree refactor (movieLibrary bool -> typed walkMode), chapter
extraction via ffprobe, single-file and multi-file audiobook parsers,
scanner write path producing media_items.type='audiobook', and a
filesystem podcast parser (RSS deferred to sub-plan 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): add Author and Narrator PersonKind constants

Discovery audit confirmed item_people.kind is unconstrained smallint
with values 1-6 in use. Reserve 7 = Author, 8 = Narrator for audiobook
people-links written by the upcoming scanner branches.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): add audio-extension recognizer for scanner

Mirrors the existing videoExtensions/SupportsVideoFile pair. Used by
upcoming audiobook and podcast scanner branches to filter directory
walks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): library-type recognizers for scanner dispatch

isAudiobookLibraryType and isPodcastLibraryType match singular and
plural forms case-insensitively, mirroring isMovieLibraryType. Used by
upcoming scanner walk branches (Task 4) that filter audio files into
audiobook and podcast libraries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(scanner): replace movieLibrary bool with typed walkMode

Lets walkLogicalTree dispatch on multiple library shapes (video, movie,
audiobook, podcast) without proliferating boolean flags. Behavior for
existing video and movie libraries is unchanged; audiobook and podcast
modes will be consumed by the upcoming audiobook.go and podcast.go
parsers in later tasks of this sub-plan.

walkModeFor() derives the mode from a media_folders.type string;
unknown types default to walkModeVideo to preserve prior behavior for
any caller still passing a raw type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): expose ffprobe format tags on ProbeData

The audiobook scanner needs format-level tags (title, artist, album,
date) for media_items metadata; ffprobe already parses them in
ffprobeFormat.Tags but ProbeData previously discarded them. Add
FormatTags map[string]string to ProbeData, populate it in
convertProbeData via a new normalizeFormatTags helper that lowercases
keys and trims values.

Adds a fixture audiobook .m4b with embedded chapters (Intro/Outro) and
format tags, and a test that verifies ProbeFile() returns both
correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): parser for single-file audiobook folders

parseAudiobookFolder reads tags + chapters via the existing ProbeFile
(now that Task 5 exposes FormatTags on ProbeData) and produces a
parsedAudiobook struct. Title falls back from "title" tag to "album";
author from "artist" -> "album_artist" -> "composer"; series from
"album" -> "series" -> "mvnm" (Movement Name, used by some MP4 tools).
Year parsed from "date" or "year" tags, tolerating ISO dates and
parenthesized forms.

Single-file case only; multi-file folders (one audio file per chapter)
return a placeholder error and arrive in Task 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): multi-file audiobook folder support

Folders containing N audio files (one per chapter/part) get one
parsedAudiobookFile per file; each file's chapter list is synthesized
as a single chapter with title = filename stem. Title/author/series/
year come from the first file's tags.

Also drops the duplicate pickFirstNonEmpty helper added in Task 6 in
favor of the existing firstNonEmpty already in probe.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): scanner write path produces audiobook media_items

ScanAudiobookFolder walks an audiobooks-typed media folder and treats
each immediate subdirectory as one audiobook. For each parsed audiobook
it upserts:
  - one media_items row with type='audiobook'
  - one media_files row per audio file (with chapters JSONB)
  - author/narrator links in item_people (kind=7, kind=8)

Adds itemRepo and personRepo to the Scanner struct, wired from
fileRepo.Pool() in NewScanner — no constructor signature change needed.

ScanFolder dispatches to this path when folder.Type='audiobooks',
bypassing the per-file movie/TV pipeline because audiobooks are
folder-scoped entities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): filesystem podcast scanner

ScanPodcastFolder walks a podcasts-typed media folder, treating each
subdirectory as a podcast show and each audio file inside as an
episode. Writes media_items.type='podcast' + episodes rows + media_files
rows. RSS-subscribed feeds (podcast_feeds table) arrive in sub-plan 5;
this task covers filesystem-only ingestion.

ScanFolder dispatches to this path when folder.Type='podcasts'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audiobooks): implementation plan sub-plan 5 (podcasts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): expose audiobooks/podcasts library types in admin UI

Adds 'Audiobooks' and 'Podcasts' options to the library-type dropdown
in the admin libraries page so operators can flag a folder as an
audiobook or podcast library. Extends contentLevelsForType() so the
admin UI's downstream filtering treats those types correctly
(audiobook -> ['audiobook'], podcasts -> ['podcast',
'podcast_episode']).

Backend scanner branches for these types were already wired in
sub-plan 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 139_abs_sessions to 147 for origin/main merge

origin/main adds 139_media_requests at the same number our local
audiobook branch had used for abs_sessions. Renumber ours to 147 to
free up 139 for the upstream migration. The schema_versions row is
updated in lockstep on the running database so the migrator sees the
abs_sessions migration as already applied at its new version.

Migrations 140-146 (podcast feeds, media_folders kind noop, audiobook
feature flag, abs playback sessions, podcast episode guid, audiobook
series, audiobook title cleanup) stay where they are — they don't
collide with anything on origin/main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 140_podcast_feeds to 157 for origin/main merge

origin/main added 140_user_permissions at the same version this branch
had used for podcast_feeds. Renumber ours to 157 (next free above the
collections-unify migration at 156) so 140 is free for the upstream
migration. schema_versions on the running database is updated in lockstep
so the migrator sees podcast_feeds as already applied at its new version.

Same pattern as d59c1cb (renumber 139_abs_sessions to 147 for the prior
main merge). Pending migrations after this rename: 132 (downloaded
subtitles admin index, main), 140 (user_permissions, main), and 156
(unify_user_collections, this branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 141_media_folders_kind_noop to 159 for origin/main merge

Same shape as eb8f67d (the 140→157 renumber from the previous main
merge). origin/main added 141_episode_title_sort_index at the same
version this branch had used for media_folders_kind_noop. Renumber
ours to 159 (next free above the audiobook_series truncate at 158) so
141 is open for the upstream migration. schema_versions on the
running database is updated in lockstep so the migrator sees
media_folders_kind_noop as already applied at its new version.

Pending migrations on silo-prod after this rename: 141
(episode_title_sort_index, main) and any other newer ones from main
that the branch hasn't picked up yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(migrations): renumber 142_audiobooks_feature_flag to 160 for origin/main merge

Companion to 3c6f062's 141 renumber — origin/main also added
142_episode_catalog_entries (alongside 141_episode_title_sort_index)
at a version this branch had used for the audiobooks feature flag.
Renumber ours to 160 so 142 is open for the upstream migration;
schema_versions on silo-prod is updated in lockstep so the migrator
sees audiobooks_feature_flag as already applied at its new version.

This was the only remaining collision (verified by checking for
duplicate version prefixes across migrations/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address foundation review comments

* fix(audiobooks): tighten scanner identity handling

* fix(audiobooks): propagate scanner cancellation

* chore(audiobooks): adopt goose migration layout

* docs(audiobooks): implementation plan sub-plan 3 (API + frontend MVP)

Third of six sub-plans. 9 tasks: three REST endpoints (list/detail/
progress), TanStack Query hooks + types, three React pages
(Library/Detail/Player), and navigation integration. Scoped to MVP —
author/series indices, smart collections, share links, and other
nice-to-haves from the spec are deferred. Streaming reuses silo's
existing /api/v1/stream/{session_id}; no new transcode code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): list endpoint at GET /api/v1/audiobooks

Paginated list of media_items with type='audiobook' scoped to the
caller's accessible libraries via the existing access filter.
Mirrors silo's existing list-style handlers for movies and series.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}

Returns the media_items row, its media_files (with chapters JSONB),
author/narrator extracted from item_people (kinds 7/8), and the
caller's per-profile listening progress from user_watch_progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress

UPSERTs user_watch_progress for the caller's (user_id, profile_id,
content_id). Body carries position_seconds; clients are expected to
post every 5-10s during playback plus on pause/seek (matching silo's
existing video progress cadence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): frontend types and TanStack Query hooks

TypeScript types match the JSON shapes from the new
/api/v1/audiobooks endpoints (list, detail, progress). Three hooks:
useAudiobookLibrary (list), useAudiobook (detail), and
useReportAudiobookProgress (mutation that invalidates the detail
query on success so progress updates reflect immediately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): library grid page at /audiobooks

Renders a paginated grid of audiobook cards using the
useAudiobookLibrary hook. Each card links to /audiobooks/book/{id}.
Cards show poster, title, and year; falls back to a "No cover"
placeholder when the audiobook has no poster_url. Empty state hints
to operators that they need to set a library's type to 'audiobooks'.

Routes themselves are wired in Task 8 (navigation integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): detail page with chapter list

Renders cover, title, author, narrator, year, and overview alongside a
chapter list. Clicking a chapter opens an inline sticky
AudiobookPlayer at that chapter's start. A "Resume" button restarts
playback at the saved progress position if present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): HTML5 audio player with chapter navigation

Single-file audiobook playback for MVP. Multi-file queuing arrives in
a follow-up. Streams via the existing /api/v1/direct-download GET
endpoint. Position is reported to /api/v1/audiobooks/{id}/progress
every 10s while playing plus on pause/seek/end. Skip-30s, playback
rate select, chapter list panel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audiobooks): wire navigation and routes

Adds an Audiobooks entry to the sidebar and registers the two new
routes (/audiobooks for the library grid, /audiobooks/book/:id for
detail). The player renders inline inside the detail page; no
dedicated player route is required for MVP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audiobooks): address native API review comments

* feat(audiobooks): add ABS compatibility and polish

* fix(audiobooks): stabilize ABS playback progress reporting

* fix(audiobooks): clean up ABS branch review fixes

* chore(audiobooks): adopt goose layout for ABS migrations

* fix(audiobooks): align player seek bar props

* feat(audiobooks): make libraries first-class catalog items

* feat(admin): add server restart endpoint

* fix(audiobooks): address review comment findings

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:57:05 -04:00
zZebrahzandGitHub c469550fa0 fix(libraries): collapse admin library diagnostics (#61)
* Fix troubleshooting visibility on admin libraries

* Remove metadata matcher badge from admin libraries

* test(libraries): cover hidden matcher queue badge
2026-06-07 12:51:45 -04:00
d3v1l1989andGitHub 3353456d46 fix(jellycompat): aggregate series watch state from episode progress (#70)
A series never has a user_watch_progress row of its own, so series items
always rendered Played:false with no UnplayedItemCount — every show looked
unwatched in compat clients' library views, and the IsPlayed browse filter
treated all series as unplayed. Roll episode progress up to series rows in
the browse/search/detail paths, mirroring applySeasonUserData and the
native API's rollup semantics (WatchedCount/UnplayedCount/InProgressCount).
Progress lookups are chunked at 500 ids (per-user stores may be
SQLite-backed) and the rollup is capped at 250 series per page so
max-limit browses of series-only libraries stay bounded.
2026-06-07 11:42:44 -04:00
d3v1l1989andGitHub 826f0d0b9a fix(jellycompat): serve real MediaSources on Resume/NextUp pages (#69)
Infuse and SenPlayer build Continue Watching rows from the listing's
MediaSources and discard responses that carry only the detail-field stub,
leaving the row empty. Keep the progress scan list-level (the per-entry
GetItemDetail fanout removed in the error-report-2026-05-08 §6 fix stays
removed), but re-map the returned page — bounded by the request limit,
capped at 100 — through the detail path when the client requests
detail-level Fields, matching HandleLatest's existing pattern. The stub
remains as the fallback past the cap and is made honest: playability
flags set, collections serialized empty instead of null.
2026-06-07 11:10:15 -04:00
0b3723c7f2 fix(jellycompat): enforce allow_4k_transcode (#65)
* fix(jellycompat): enforce allow_4k_transcode in playback negotiation and transcode start

The compat layer never read the setting, so clients that couldn't direct
play a 4K file got a full video encode regardless. Suppress
SupportsTranscoding for 4K sources during negotiation and guard the local
and remote transcode starts; audio-only transcodes (video copy) stay
allowed, matching the native handler.

Fixes #64

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(jellycompat): cover settings read error in 4K transcode default-deny

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:47:59 -04:00
QuickandGitHub cdb5b70a8d Merge pull request #25 from Silo-Server/feat/calendar-presets
feat(calendar): personalized presets (Following default) with watched overlay
2026-05-29 14:57:57 -04:00
QuickandClaude Opus 4.8 4fea42488a refactor(calendar): simplify preset handler and reuse storage util
- Extract hardcoded trending snapshot source/window to named constants.
- Collapse the three identical personal-preset nil-checks into one case.
- Persist the selected preset through the shared storage util (try/catch
  wrapped) instead of raw localStorage with manual SSR guards.
- Derive KNOWN_FILTERS from PRESET_OPTIONS so the lists can't drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:56:16 -04:00
QuickandClaude Opus 4.8 dc194c4e54 feat(calendar): hide server-wide Popular preset in web UI for now
Popular reflects server-wide watch counts, which are sparse on a
low-traffic server. Hidden from the selector, URL allowlist, and
empty-state nudge; backend filter and the CalendarFilter type are
left intact so re-enabling is a one-line change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:24:18 -04:00
QuickandClaude Opus 4.8 93b835e1fd feat(calendar): dim and check-mark already-watched event cards
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:51 -04:00
QuickandClaude Opus 4.8 6cebbb3cd9 feat(calendar): preset selector with responsive pills, persistence, empty-state nudge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:51 -04:00
QuickandClaude Opus 4.8 f859cbfd7a feat(calendar): add watched field to CalendarEvent type
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 14:13:51 -04:00
QuickandClaude Opus 4.8 e9847a4424 feat(calendar): wire popular and trending sources into calendar handler
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:56:14 -04:00
QuickandClaude Opus 4.8 6560dfc470 feat(calendar): resolve presets to id-sets and overlay watched status
Also drops the now-unused Filter/UserID/ProfileID fields from the
blendUpcomingIntoDiscoverRows CalendarFilter literal in recommendations.go,
which only wants an unrestricted windowed query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:55:39 -04:00
QuickandClaude Opus 4.8 134ebf399e feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:50:48 -04:00
QuickandClaude Opus 4.8 a119b60ca6 feat(calendar): generalize personal filter to an id-set restriction
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:49:34 -04:00
QuickandClaude Opus 4.8 1f729c4764 docs(calendar): add presets design spec and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:47:45 -04:00
QuickandClaude Opus 4.8 cade7f37aa fix(calendar): order events by viewer-local wall-clock time
The local-airtime change re-sorted calendar events in Go using air_at,
the absolute UTC instant, which is nil whenever air_timezone is unset.
Since air_timezone is only inferred for a few networks/countries, most
events fell through to the alphabetical title tiebreak while still
displaying their raw air_time, so each day appeared scrambled.

Sort each local day by the wall-clock time the viewer actually sees,
mirroring the client: zoned events convert air_at into the viewer
timezone, unzoned events use the raw air_time, and date-only entries
(no air_time) sort last. The timezone reasoning lives in the new
catalog.CalendarEventLocalTime helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:16:18 -04:00
QuickandGitHub 57bcae38e9 Merge pull request #24 from Silo-Server/claude/amazing-thompson-b2843d
fix(catalog): project air_timezone in episode catalog subquery
2026-05-29 11:55:56 -04:00
QuickandClaude Opus 4.8 aa1a0bd38b fix(catalog): project air_timezone in episode catalog subquery
episodeCatalogSelectBody is the derived "mi" relation that episode catalog
hydration and preview read qualifiedListItemColumns("mi") from. The local
episode airtimes feature (808f265f) added air_timezone to the shared column
lists but not to this hand-written subquery, so the outer projection
referenced mi.air_timezone, which the subquery never exposed.

Postgres returns SQLSTATE 42703 (undefined_column), which is not one of the
codes episodeCatalogEntriesUnavailable treats as "fast path unavailable" (it
only catches 42P01/42883), so episode catalog requests failed with HTTP 500
instead of degrading. movie and series scopes query media_items directly, so
the column is present there and only episode scope broke.

Add si.air_timezone to the subquery, and add a regression test asserting that
episodeCatalogSelectBody exposes every column qualifiedListItemColumns reads
off mi, so future additions to the shared column lists cannot silently drift
from the episode read model again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:53:32 -04:00
QuickandGitHub 3c338e855a Merge pull request #23 from Silo-Server/feat/trending-section
feat(sections): persist trending_discover via background-refreshed snapshot
2026-05-29 11:35:05 -04:00
QuickandClaude Opus 4.8 f105361658 fix(sections): harden trending refresher per PR review
- Interleave Trakt movies/shows by rank so the mixed row shows both types
  instead of burying all series past the display limit.
- Treat any Trakt sub-fetch failure as fatal (errors.Join) so a partial
  result never overwrites the last-good snapshot with a media type missing.
- Skip non-title entries (TMDB trending/all returns media_type "person") in
  both ID batching and ordering so they can't match an unrelated library title.
- Guard the refresh task against a nil refresher.
- Tests: person skip, Trakt interleave, Trakt partial-failure preserves
  last-good, snapshot read error propagation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:28:59 -04:00
QuickandClaude Opus 4.8 c98f828c25 fix(migrations): renumber trending_discover_snapshots 166 -> 167
The shared dev DB already recorded version 166 (166_trending_blend_collection_type
from another branch), so the integer-version migration runner silently skipped our
166 and the table was never created — the trending section errored out empty.
167 is the next free version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:57:57 -04:00
Quick 4455c2cd40 chore(sections): satisfy lint (wrap trakt errors, lift source/window constants) 2026-05-29 10:51:54 -04:00
Quick 9d03ad0b37 feat: wire trending refresh task and snapshot reader 2026-05-29 10:49:01 -04:00
Quick 38723c8b81 refactor(sections): read trending_discover from persisted snapshot 2026-05-29 10:46:01 -04:00
Quick 3c3ef82ae8 feat(tasks): add refresh_trending_discover task 2026-05-29 10:44:15 -04:00
Quick ff5f399e2c feat(sections): add trending refresher with persisted snapshots 2026-05-29 10:43:54 -04:00
Quick 7d70bf0cf4 feat(sections): list enabled trending_discover section configs 2026-05-29 10:42:09 -04:00
Quick 94a588c7a3 feat(sections): add trending snapshot model and repository 2026-05-29 10:41:47 -04:00
Quick ca4cd230a4 feat(sections): add trending_discover_snapshots table 2026-05-29 10:40:58 -04:00
QuickandClaude Opus 4.8 1a74bdbb6a refactor(sections): tidy trending_discover fetch and cache helpers
Extract newTrendingEntry, reuse orderMediaItems, and collapse concurrent
cache-miss loads with singleflight. Baseline for the persistent snapshot work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:40:13 -04:00
QuickandClaude Opus 4.8 cc5fee0956 docs: add trending_discover persistent snapshot implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:37:27 -04:00
QuickandClaude Opus 4.8 7992470cc3 docs: add trending_discover persistent snapshot design
Replace the in-process 1h trending cache with a background-refreshed,
persisted snapshot for reliability under upstream failure and sync-run
observability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 10:25:31 -04:00
QuickandClaude Opus 4.8 ba4fd9d9fd feat(sections): add trending_discover home section
A library-agnostic home section that surfaces external global trending
(TMDB or Trakt, admin-selectable) mixing movies + series, matched to
titles in the viewer's enabled libraries. TMDB uses /trending/all/{window}
(natively mixed); Trakt merges trending movies + shows. Fetched live with
a 1h in-process cache, so no background job or stored collection — and no
per-library duplication.

Appears in the admin section gallery via its recipe presets (TMDB Trending
Today/This Week, Trakt Trending); featured -> hero via the existing flag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 09:25:17 -04:00
QuickandGitHub c216899c2f Merge pull request #22 from Silo-Server/feat/case-insensitive-usernames
feat(auth): make usernames and emails case-insensitive
2026-05-28 21:46:07 -04:00
QuickandClaude Opus 4.7 c61381dc79 feat(auth): make usernames and emails case-insensitive
Login identifiers were compared case-sensitively, so "John" and "john"
were distinct accounts and a user could not log in unless they matched the
exact casing used at registration.

Convert users.username and users.email to the citext type (migration 165).
citext compares case-insensitively while preserving the originally stored
casing for display, so the existing unique constraints become
case-insensitive and `WHERE username = $1` / `email = $1` lookups match
regardless of case with no change to the query code itself.

Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved),
applied at the repository chokepoints (Create, Update, GetByUsername,
GetByEmail) and before validation in the create paths, so surrounding
whitespace no longer defeats matching or creates lookalike accounts.

Verified non-destructively against the dev DB: mixed-case lookups resolve
to the same row, case-variant inserts are rejected by the unique
constraint, and the down migration cleanly reverts to text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:44:51 -04:00
QuickandGitHub 834f3e5b84 Merge pull request #21 from Silo-Server/fix/web-a11y-visual-qa
fix(web): accessibility & UX fixes from visual QA pass
2026-05-28 21:09:43 -04:00
QuickandClaude Opus 4.8 9ac181dbf4 fix(startup): recover from panics in deferred background init
The deferred background-init steps run in a detached goroutine after the
HTTP listener is already accepting connections. An unrecovered panic in any
step (queue seeding, legacy cleanup, scrobble sweep) would crash the entire
live server. Wrap each step in a recover that logs the panic with a stack
and continues to the next step.

Found during PR #21 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:39:08 -04:00
QuickandClaude Opus 4.8 d34e292e54 revert(startup): keep open-scrobble sweep deferred for fast startup
Reverts 62563f5c. Running the sweep synchronously before the listener could
add up to 30s to restart-before-playback when a watch provider is
unreachable, which regresses the deliberate startup-deferral from a1a6c6cf.
Prefer the fast-startup behavior and accept the small window where a resume
immediately after restart may create a duplicate scrobble; the sweep returns
to the deferred background-init list. (Panic-safety for that list is added in
a follow-up commit.)

Per PR #21 review decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:37:33 -04:00
QuickandClaude Opus 4.8 b10ae3eabc fix(catalog): scan air_timezone in paginated item queries
scanItemsWithTotal was not updated for the new air_timezone column, yet the
shared column lists it reads (itemColumns, qualifiedListItemColumns) include
it. Search and BrowseFavorites build their SELECTs from those lists with
COUNT(*) OVER (), so each row carried one more column than the scan had
destinations and every call failed at scan time with a pgx mismatch. Add the
missing &item.AirTimezone target between AirTime and ShowStatus.

Found during PR #21 review (critical: Search/Favorites runtime regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:37:18 -04:00
QuickandClaude Opus 4.8 62563f5c80 fix(startup): sweep open scrobbles before accepting playback
The open-scrobble sweep was queued in the deferred background-init list,
which runs concurrently with the HTTP listener; a resume immediately after
restart could start new scrobbles before the previous process's open
sessions were stopped, leaving overlapping/stale scrobbles on remote
providers. Run the sweep synchronously before the listener starts, bounded
by a 30s timeout so an unreachable provider can't hang startup (the heavier
non-critical init stays deferred).

Addresses PR #21 review (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:26:15 -04:00
QuickandClaude Opus 4.8 9fceae63fc fix(metadata): persist a cleared air_timezone instead of skipping it
Clearing a previously-set air timezone sent JSON null, which decodes to a
nil *string that UpdateMetadata treats as "skip this column", so the old
value remained. The dialog now sends "" (accepted by ValidateAirTimezone),
and UpdateMetadata maps air_timezone through NULLIF so an empty value
persists as SQL NULL (matching the nullable column) rather than "".

Addresses PR #21 review (P2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:26:15 -04:00
QuickandClaude Opus 4.8 a4c394b827 fix(catalog): re-check orphan status when deleting library items
Orphan detection moved outside the media_items delete in the batched
library-delete rewrite, opening a TOCTOU race: a concurrent scan/import
could attach one of the collected content IDs to another library between
collectOrphanBatch and the delete, after which the unconditional
`DELETE FROM media_items WHERE content_id = ANY($1)` would still remove the
shared row and cascade away the newly-added membership — dropping the item
from the other library. Re-check the orphan invariant inside the delete
(NOT EXISTS a membership in another folder) and count rows actually deleted.

Addresses PR #21 review (P1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 20:26:15 -04:00
QuickandClaude Opus 4.8 234869d41e fix(web): accessibility & UX fixes from visual QA pass
Accessibility (WCAG AA):
- Lighten the Standard-theme `--muted-foreground` (#6e6e78 -> #9696a0,
  ~3.4:1 -> >=5.3:1) and darken the light-theme equivalent so secondary
  text meets 1.4.3 contrast app-wide; the opt-in High Contrast mode is no
  longer the only conformant path.
- Give icon-only controls accessible names (4.1.2): the password show/hide
  toggle (also drop tabIndex={-1} so it's keyboard reachable), and the
  Edit/Delete/health/copy/refresh actions across the Users, Libraries,
  Nodes, API Keys, Catalog Maintenance and Job History admin tables.
- Fix the Switch off-state (invisible track -> visible border + fill) and
  the PlaybackSettings SettingRow label association (the <label htmlFor>
  pointed at a wrapping <div>; the id now lands on the Switch/SelectTrigger).
- Login: wrap the card in <main> and add an <h1>; Profiles: add an
  accessible PIN-protected label and a corner lock badge.
- Player + catalog: role="status" on the initial loading overlay; scope the
  catalog count ("0 in library" for search) and announce it via aria-live;
  trim the verbose poster-link name to the title.

UX / consistency:
- Emphasize overdue scheduled tasks (warning colour + icon + word, not
  colour alone).
- Per-source catalog subtitles instead of one shared string.
- Add a Reconnect affordance when the admin log stream drops (it does not
  auto-retry).
- Page titles for Watch Party + all admin sub-pages (incl. plugins); admin
  heading capitalisation normalised to Title Case.
- Show "dev build" instead of "unavailable" when no build revision is
  stamped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:47:11 -04:00
QuickandClaude Opus 4.8 c30b38d220 build: stamp git revision via Makefile ldflags
`make build` did not inject buildinfo's `revisionOverride`/`dirtyOverride`
ldflags (the Dockerfile already does), so binaries built via make report
their version as "unavailable" in the admin Build panel whenever Go's VCS
metadata isn't embedded. Mirror the Dockerfile by computing the git
revision + dirty state and passing them through `-ldflags -X`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:46:56 -04:00
QuickandClaude Opus 4.7 ee3a6d1776 fix(migrations): make air_timezone column add idempotent
Use ADD COLUMN IF NOT EXISTS so re-running 162 on a database that already has
the column is a no-op.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:11 -04:00
QuickandClaude Opus 4.7 a1a6c6cfac perf(startup): defer non-critical init off the HTTP listener path
Collect catalog-size-dependent seeding (metadata match queues, legacy
series-group cleanup) and the watch-provider scrobble sweep into a
backgroundInit slice that runs sequentially in a background goroutine after
the server is ready, instead of blocking startup before the listener accepts
connections. Steps log failures and stop early on shutdown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:11 -04:00
QuickandClaude Opus 4.7 772569cefe fix(catalog): clamp still/poster/logo backdrops to largest cached variant
Episode stills used as backdrops only exist at w500/w300 in the cache, so
requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath
+ imageTypeFromCachedPath and route featured (w1920) and Continue Watching /
Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their
type's largest cached variant while real backdrops keep the requested width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:11 -04:00
Quick 0d651cc947 refactor(catalog): wrap orphan-batch iteration error 2026-05-28 17:02:53 -04:00
Quick c1f12d1b1b fix(catalog): delete libraries in deadlock-retrying batches
Replaces the single multi-minute delete transaction with phased, batched
autocommit deletes (orphan items, media files, memberships, folder row),
each retried on deadlock. Holds only short locks, survives concurrent
writers, and is resumable on failure.
2026-05-28 17:02:53 -04:00
QuickandClaude Sonnet 4.6 d4520677cc refactor(catalog): make image-dir helpers querier-agnostic
Add rowQuerier interface satisfied by both *pgxpool.Pool and pgx.Tx.
Split collectImageDirs into collectRawImageDirs (raw collection) and a
thin wrapper that filters via filterUnreferencedImageDirs. Both helpers
now accept rowQuerier so a later task can call them from pool-level
batch deletes without an open transaction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 17:02:53 -04:00
Quick 518cc7eac7 feat(catalog): add deleteInBatches loop helper 2026-05-28 17:02:53 -04:00
Quick fdfa1c16b8 test(catalog): clarify cancel-path expectation in retry test 2026-05-28 17:02:53 -04:00
Quick 5a366ec42b feat(catalog): add deadlock-retry helper for batched deletes 2026-05-28 17:02:53 -04:00
QuickandClaude Opus 4.7 be06b32c82 docs: add implementation plan for resilient library deletion
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:02:53 -04:00
QuickandClaude Opus 4.7 3f1254d374 docs: add resilient library deletion design spec
Batched, deadlock-retrying rewrite of delete_library to replace the
single multi-minute transaction that deadlocks on large libraries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:02:53 -04:00
Quick c19f0d6c6f Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-28 13:11:49 -04:00
QuickandGitHub 481a577937 Merge pull request #19 from fluxis/mr/a-matcher-reliability
fix(metadata): matcher reliability — Sonarr token cleanup, bare-bracket IMDb IDs, year-corroboration, provider-priority tie resolution
2026-05-28 13:11:43 -04:00
Quick 9f62c6977c fix(metadata): tighten matcher and bare IMDb parsing 2026-05-28 13:09:56 -04:00
Silo Server DeveloperandClaude Opus 4.7 311fbd05dd chore(lint): gofmt single-space alignment in root_inference.go var block
When inferProviderTagRe was broadened to handle unsubstituted Sonarr token
placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers
single-space rather than column-aligned spacing across the var block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server DeveloperandClaude Opus 4.7 fa839b6e37 feat(metadata): match sole exact-title candidate despite year off by <=2
Folder years routinely differ from provider release years by a year or two
(festival vs wide release, regional dates), zeroing the year bonus and leaving
a lone exact-title candidate at 63-68 — just under the single-candidate >=70
gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add
title corroboration to the existing lone-result rule: a sole distinct show whose
normalized title exactly matches and whose year is within +/-2 is accepted. The
55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs
Puppy!). No 55/70/15 threshold change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server Developer edb7051508 fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...})
Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g.
'17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern
needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing
tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match
path instead of falling to title+year scoring.
2026-05-28 17:58:06 +02:00
Silo Server Developer 7406023c93 diag(matcher): debug-log provider search query + per-provider result counts
Adds DEBUG logs in the ModeInitialMatch search path: each provider's result
count for the query, and the assembled raw/candidate totals. Lets us see when a
provider search returns zero ('no metadata found') vs a scoring/tie issue.
Zero behavior change.
2026-05-28 17:58:06 +02:00
Silo Server Developer f4ea8a6d75 feat(matcher): accept cross-source-corroborated ties without a hint year
When the top tie-group is one distinct show returned by 2+ distinct providers
(candidatesAreSingleDistinctShow already verifies matching title+year), accept it
even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie
McDowd'). Multi-source agreement substitutes for the year guard; lone single-source
no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged.
2026-05-28 17:58:06 +02:00
Silo Server DeveloperandClaude Opus 4.7 db34df0a35 feat(matcher): resolve cross-source ties by library provider priority
Accept a year-corroborated single distinct show when the TOP tie-group (within
15 pts of best) is one show across providers, ignoring low-score noise below it,
and pick the winner by the library's metadata-provider chain order (providerPriority,
highest-first; falls back to top-scored). Recovers items like '100 Days Wild'
that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server DeveloperandClaude Opus 4.7 f4ba5faddc diag(matcher): debug-log per-candidate match scores
Adds a DEBUG-gated log in selectInitialMatchCandidate printing each scored
candidate (title/year/type/sources/provider_ids/score) against the hint, so
operators can see why an item did or didn't auto-match. Zero-cost when debug
logging is off; no change to matching behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:58:06 +02:00
Silo Server Developer aab0a804b9 fix(matcher): tolerate concurrent-merge ErrItemNotFound in series episode-link ensure
A scan drainer and the background MatchWorker can process the same folder
concurrently. When a provider-ID merge moves a series' episodes to the survivor
and deletes the source, an in-flight ensureSeriesEpisodeLinks(sourceID) hits
catalog.ErrItemNotFound and was failing the whole scan. The episodes are already
reattached, so this is benign: log and continue (matching the lenient call sites)
instead of failing. Genuine errors still abort.
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 aff528358e test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 8ff06ed008 feat(matcher): auto-accept a year-corroborated single distinct show
A search that resolves to one distinct show (one candidate, or the same
title+year returned once per source as unmerged TVDB/TMDB rows) whose year
matches the parsed year is now auto-accepted via the existing top-ranked
candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15
thresholds are unchanged; this only adds a year-gated acceptance for
effectively-unique results (recovers lone-correct-result items like 1201 (1993)).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 a6da4a4a98 test(naming): document bare-id trade-off; cover CJK title + movies numeric
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server DeveloperandClaude Opus 4.7 cc7cd5cd39 fix(naming): numeric-only titles are not bare provider IDs
'86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate
so the correct title match was rejected. Require a letter in the name before
treating a trailing number as a bare id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:45:38 +02:00
Silo Server Developer e074925327 fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles
These tokens survived the provider-tag regex ([\w]+ doesn't match braces),
polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so
they could not score-match. Broaden the regex to drop {...} and empty tokens.
2026-05-28 17:45:38 +02:00
Quick 30b9194e43 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-28 11:41:12 -04:00
QuickandGitHub 7ce164fba9 Merge pull request #20 from fluxis/mr/b-admin-ux
feat(admin): admin libraries UX polish — full-table unmatched search, larger match-candidate posters with hover preview, paginated Ambiguous Roots
2026-05-28 11:41:03 -04:00
Quick b26e8b753c fix(admin): search all unmatched item library memberships 2026-05-28 11:31:50 -04:00
Silo Server DeveloperandClaude Opus 4.7 8fee2370f8 test(admin): wire QueryClientProvider + missing hook mocks so the suite runs
The AdminLibraries test file failed all 6 tests with 'No QueryClient set' on
this branch and at the parent commit -- pre-existing infrastructure gap. With
that fixed, several hooks that the page imports (useCancelLibraryScans,
useLibraryRoots, useUpsertLibraryRootOverride, useDeleteLibraryRootOverride,
useActiveScans) and the UNMATCHED_PAGE_SIZE constant also needed mocking. One
stale assertion on the renamed 'Root path' header is updated; the deeper
troubleshooting test, which mocked useSkippedLibraryRoots but the section was
refactored to useLibraryRoots(_, 'ambiguous'), is skipped with a TODO -- a real
rewrite is needed and is out of scope for this MR.

5 of 6 tests now run and pass; the 6th is properly flagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:25:36 +02:00
Silo Server DeveloperandClaude Opus 4.7 387a79962f style(admin): prettier-format match dialog; reset unmatched page in onChange
Run prettier over the Tooltip-wrapped poster JSX, and reset the unmatched-items
page in the search input's onChange rather than a useEffect (avoids the
react-hooks/set-state-in-effect warning / cascading renders).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:25:36 +02:00
Silo Server DeveloperandClaude Opus 4.7 c99406e360 fix(admin): search unmatched items across the whole table, not just the page
The unmatched-items search filtered only the current page's rows client-side.
Push the query server-side: HandleListUnmatchedItems takes an optional 'q' param
and filters title/library/type/status with parameterized ILIKE across all rows,
paginating the filtered set. Frontend hook takes a debounced search, resets to
page 1 on change, keeps the section mounted while searching. Also fixes stale
test mocks that returned the pre-pagination array shape instead of {items,total}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:25:36 +02:00
Silo Server DeveloperandClaude Opus 4.7 690db33147 feat(admin): enlarge match-candidate posters + hover-to-enlarge
Unmatched-item match dialog rendered candidate posters at 44x64px, too small
to identify a film. Bump to 64x96 (2:3) and add a portaled Tooltip hover preview
(192x288) using the existing poster image, so operators can tell candidates apart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 16:59:48 +02:00
Silo Server Developer 1d20deb51f feat(ui): paginate the Ambiguous Roots table
Match the sibling tables on the Admin Libraries page (Troubleshooting/unmatched):
use the existing usePagination hook + PaginationBar (10/page, auto-hidden when
<=10 rows), render pag.rows, and reset to page 0 when the library selector or
search filter changes.
2026-05-28 16:59:48 +02:00
QuickandCodex 1054b96e46 Enable DRI passthrough in docker compose
Co-authored-by: Codex <noreply@openai.com>
2026-05-27 21:40:07 -04:00
Quick 808f265fab feat(calendar): show local episode airtimes 2026-05-27 21:32:18 -04:00
QuickandGitHub 0f3f548952 Merge pull request #18 from Silo-Server/codex/episode-section-labels
feat(sections): show episode context in cards
2026-05-27 15:49:35 -04:00
Quick e4814dea76 feat(sections): show episode context in cards 2026-05-27 15:48:00 -04:00
Quick 87986d88d0 fix(catalog): use season posters for episode cards 2026-05-27 14:54:58 -04:00
Quick f08ae2572d fix(library): preserve episode browse url 2026-05-27 14:54:51 -04:00
Quick d27b177817 fix(collections): cap smart collection results 2026-05-27 14:27:14 -04:00
Quick 0b01c2c413 fix(catalog): support relative date filters 2026-05-27 13:59:29 -04:00
QuickandGitHub bfe73a4dc9 Merge pull request #15 from Silo-Server/fix/tv-scan-settle-window-cancel
fix(libraryingest): TV full scans cancelled at settle-window boundary
2026-05-27 13:44:57 -04:00
Quick 01b0926748 Fix settle-window drainer cancellation in library ingest 2026-05-27 13:37:33 -04:00
QuickandGitHub d9e6059079 Merge pull request #16 from Silo-Server/codex/episode-catalog-performance
perf(catalog): add episode browse index fast path
2026-05-27 12:52:48 -04:00
Quick b4d4ad218a fix(catalog): address episode catalog review feedback 2026-05-27 12:48:59 -04:00
Quick 3a23798094 perf(catalog): add episode browse index fast path 2026-05-27 11:57:49 -04:00
CoffeeKnyte a3397be489 fix(libraryingest): treat drainer shutdown cancel as clean stop
TV/series full scans (libraries with new or updated items) were recorded as
"cancelled" with an empty error message and never completed matching.

When the file-walk finishes, the ingest executor waits out a settle window and
then calls stopDrainers() to shut down the concurrent match goroutines. That
cancels the drainer context while a ProcessBatchByFolderAndPathPrefix call may
still be in flight. The drainer treated the resulting context.Canceled as a
fatal error: it pushed the error to drainerErrCh and called cancel() on the
whole scan context, so scanqueue.process() mapped it to cancelRun().

Large/slow libraries (many series, slow provider lookups) keep a batch in
flight continuously, so stopDrainers() almost always landed mid-call and the
scan was cancelled; small/fast libraries were usually idle at that instant and
completed normally.

Treat a cancelled drainer context as a deliberate shutdown: return cleanly
without escalating. Genuine external cancellation still reaches the run via the
main goroutine's scanCtx checks, so real cancels are not swallowed.

Adds a regression test (settle window made injectable) that fails against the
old handler with 'concurrent match scope ...: context canceled' and passes
with the fix.
2026-05-27 15:46:24 +00:00
Quick a720467d3b Optimize episode added_at sorting 2026-05-27 10:11:58 -04:00
Quick cba9e7a65a fix(metadata): accept exact cross-provider match ties 2026-05-27 09:13:30 -04:00
QuickandGitHub e8dda26b01 Merge pull request #14 from Silo-Server/t3code/116da365
feat(jellycompat): sign image tags and accept them without session
2026-05-26 22:31:23 -04:00
Quick 578100f5ef 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 deb8305a1d fix(jellycompat): harden signed image tags 2026-05-26 21:50:10 -04:00
Quick 0788e7c211 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
QuickandGitHub 74dd8bc6fc Merge pull request #13 from Silo-Server/t3code/2f7d918d
feat(episode-carousel): highlight currently viewed episode
2026-05-26 21:29:37 -04:00
QuickandGitHub 3875e36ecf Merge pull request #12 from Silo-Server/t3code/5155230b
feat(ui): shared PageBack component for consistent back navigation
2026-05-26 21:29:27 -04:00
Quick 7641c016d8 feat(episode-carousel): highlight currently viewed episode
- Add "Now Viewing" badge with pulsing indicator on current episode
- Replace border with primary-color ring for current episode card
- Set aria-current="page" on links to the current episode
2026-05-26 21:29:11 -04:00
Quick 8f580a420c fix(ui): make PageBack destinations deterministic 2026-05-26 21:18:39 -04:00
Quick 65fe0a7f51 feat(ui): add floating variant to PageBack for sticky nav
- Add `floating` prop to pin PageBack to viewport on lg+ screens
- Switch styling from glass-subtle to glass with shadow for better contrast
- Use floating variant on SettingsLayout
2026-05-26 20:51:52 -04:00
QuickandClaude Opus 4.7 932f3c3908 feat(ui): add shared PageBack component for consistent back navigation
Replace the eight inconsistent back affordances across user-facing pages
with a single absolute-positioned chevron pill, so the control lives in
the same screen position regardless of title length or hero content.

DetailBreadcrumb keeps its textual hierarchy path but no longer owns the
back chevron; PageBack does. DetailHero gains a topNav slot consumed by
Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop
their bespoke back buttons and add PageBack inside a relative wrapper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:18:05 -04:00
Quick ee54942b01 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 3c28e9b7f0 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-26 20:05:38 -04:00
QuickandGitHub ea13ecb998 Merge pull request #11 from Silo-Server/t3code/eb3dedf1
feat(auth): add metadata curation permission
2026-05-26 20:05:29 -04:00
Quick c152298ad3 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 0b6070c6e1 docs: add PageBack component design spec 2026-05-26 19:56:45 -04:00
Quick 9f48ae2d8b test(auth): expand session revocation coverage 2026-05-26 19:41:55 -04:00
Quick 353a7fd61d docs: add metadata curation permission plan 2026-05-26 19:40:00 -04:00
Quick 2e550c3d35 fix(auth): tighten curator job response review fixes 2026-05-26 19:34:16 -04:00
Quick 60959f5812 fix(auth): address metadata curation review issues 2026-05-26 19:27:56 -04:00
Quick ed8b992c80 feat(web): show metadata tools to curators 2026-05-26 18:24:39 -04:00
Quick b784f86249 fix(web): keep device profile hooks unconditional 2026-05-26 18:24:13 -04:00
Quick d637ae7dbf feat(web): assign metadata curation permission 2026-05-26 18:21:16 -04:00
Quick 1a72e59254 feat(web): add permission helpers 2026-05-26 18:18:55 -04:00
Quick 99f10fb2d4 feat(api): route metadata curation by permission 2026-05-26 18:18:19 -04:00
Quick 5ba7ecdca7 feat(api): authorize item metadata curation 2026-05-26 18:15:05 -04:00
Quick c95c40c3fe feat(auth): expose user permissions 2026-05-26 18:13:32 -04:00
Quick 24d48c5e6e feat(auth): add assignable user permissions 2026-05-26 18:12:20 -04:00
QuickandGitHub e2e2b632ff Merge pull request #10 from Silo-Server/codex/restore-subtitle-uploads
feat(subtitles): restore upload management
2026-05-26 18:02:27 -04:00
Quick 2728d2f50a feat(subtitles): restore upload management 2026-05-26 17:57:26 -04:00
QuickandGitHub f969da014d Merge pull request #9 from Silo-Server/t3code/search-tmdb-fallback-results
feat(search): add TMDB-backed Request to Add section to search surfaces
2026-05-26 15:17:34 -04:00
Quick 107a0d3c85 fix(search): address request search review feedback 2026-05-26 15:11:10 -04:00
Quick 44feb2f08a refactor(admin): show per-section loading and error states
- Replace page-level loading gate with skeletons per section on dashboard and stats
- Surface query errors inline instead of blocking the whole page
- Disable "Scan All Libraries" when no libraries are configured
2026-05-26 14:33:25 -04:00
Silo Server Migration 86afa748de Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-05-26 08:35:16 -04:00
Silo Server Migration ef1c6accb6 fix(webhooksync): skip events for unmapped external users
- Require explicit profile mapping instead of falling back to the default profile
- Update settings UI copy to reflect that unmapped users are ignored
2026-05-26 08:34:17 -04:00
QuickandGitHub 291e7a382b Merge pull request #8 from Silo-Server/t3code/jellyfin-autoscan-scan-compat
feat(jellycompat): support Autoscan via Jellyfin media-update endpoint
2026-05-26 08:33:48 -04:00
zZebrahz fd39824c0e fix(jellycompat): tolerate autoscan sidecar updates 2026-05-25 19:37:25 -07:00
Silo Server Migration 77c8309a4c commit message
{"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"}
2026-05-25 22:27:46 -04:00
Silo Server Migration ac1e0ad6a3 test(web): avoid unsupported Array.at in search request tests 2026-05-25 18:22:27 -04:00
Silo Server Migration 7926d25c79 chore(web): format request search section 2026-05-25 18:20:39 -04:00
Silo Server Migration 09b97c0d32 feat(catalog): render RequestToAddSection grid with empty-state suppression 2026-05-25 18:19:42 -04:00
Silo Server Migration bcc28795e4 feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression 2026-05-25 18:17:31 -04:00
Silo Server Migration c5e2770bab feat(search): add RequestToAddSection grid variant for Catalog page 2026-05-25 18:15:52 -04:00
Silo Server Migration eda8b7938d feat(search): add RequestToAddSection dialog variant 2026-05-25 18:15:10 -04:00
Silo Server Migration b3cdf438f9 feat(request-card): make onRequest optional on discover variant 2026-05-25 18:13:31 -04:00
Silo Server Migration 4d51608118 test(requests): document viewer-keyed cache isolation and invalidation cascade 2026-05-25 18:12:45 -04:00
Silo Server Migration e97286da01 feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime 2026-05-25 18:12:19 -04:00
Silo Server Migration 4f0ecbe3c0 refactor(keys): add viewerKey to requestKeys.search 2026-05-25 18:11:11 -04:00
Silo Server Migration 5fef177e36 feat(hooks): add useCanRequest gating hook for discovery eligibility 2026-05-25 18:10:58 -04:00
Silo Server Migration e1f711dea7 test(api): pin AbortSignal forwarding contract on api() 2026-05-25 18:10:06 -04:00
Silo Server MigrationandClaude Opus 4.7 08ba09c955 docs(plans): address Codex adversarial review for search request section
Fixes the high-severity finding that RequestToAddSection's internal
useRequestSearch call was not gated on discoveryEnabled, allowing
/api/v1/requests/search and TMDB lookups to fire for users without
request access. The plan now (1) passes { enabled: discoveryEnabled }
to the section's hook, (2) gates the parent mount in GlobalSearch and
Catalog on canRequest.discoveryEnabled as defense in depth, and (3)
adds tests asserting both the enabled forwarding and the no-mount
behavior when discovery is disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:02:58 -04:00
Silo Server MigrationandClaude Opus 4.7 f2a6b1081e docs(plans): add implementation plan for search request section
Twelve TDD tasks covering: api() signal contract test, useCanRequest
hook, viewer-keyed requestKeys.search, useRequestSearch extension
(signal + viewer key + 5min staleTime + enabled override), invalidation
cascade tests, RequestPosterCard optional onRequest, RequestToAddSection
component (dialog + grid variants), GlobalSearch and Catalog wiring
with empty-state suppression for the library-0/TMDB-pending edge case,
final lint/test pass, and manual smoke. Notes a single deviation from
the spec: submitDisabledReason is null in the initial implementation,
with per-row request data driving disabled UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:35:10 -04:00
Silo Server MigrationandClaude Opus 4.7 9fc70631a8 docs(specs): address Codex adversarial review for search request section
Splits discovery eligibility from submission eligibility so blocked
and quota-exhausted viewers still see the requestable section with
disabled per-row CTAs, matching the documented behavior. Documents
the required extensions to useRequestSearch — signal forwarding,
viewer-identity-keyed cache, and invalidation on auth/profile/
settings/limit changes — so the planned 5-minute staleTime is
safe and cancellation works as described.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:19:37 -04:00
Silo Server MigrationandClaude Opus 4.7 c205da8b2b docs(specs): add design for TMDB-backed request section in search
Adds the design for surfacing requestable TMDB results inside the main
catalog search (Cmd+K dialog and full results page) as a clearly
delimited "Request to Add" section that never blocks or displaces
library results.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:53:03 -04:00
Silo Server Migration 8f818ac0c9 ci(docker): build image on push to main via self-hosted runner
- Trigger Docker image builds on pushes to main instead of nightly cron
- Run on self-hosted Linux runner
- Drop the `nightly` tag
2026-05-25 13:03:56 -04:00
Silo Server Migration a639cf60de fix(catalog): gate search overview-only matches behind title FTS
- Always apply stats CTE + CROSS JOIN so single-word queries no longer flood results with description-only hits
- Require overview_rank >= 0.15 for overview-only fallback rows
- Switch title gate from contiguous LIKE to title_rank > 0 so reordered-token title matches aren't demoted
2026-05-25 12:49:38 -04:00
Silo Server Migration b2fae0aa70 refactor(scantrigger): drop redundant Target.LibraryID field
- Read library ID from Target.Folder.ID everywhere
- Guard scan queue enqueue against nil Folder
- Simplify admin API key auth error plumbing
2026-05-25 12:11:56 -04:00
Silo Server Migration c3cf485cd9 chore: add planning docs and requests updates
- Add plans for date-named episodes and Jellyfin autoscan compat
- Update requests handlers, service, and UI hooks
- Remove Makefile.local.example
2026-05-25 12:07:50 -04:00
Silo Server Migration 4a5ca0b9eb fix(jellycompat): harden autoscan auth and batch scan enqueue
- Reject nil API keys and bound last-used update with a 5s timeout
- Stop leaking internal queue errors in autoscan responses
- Batch scan enqueues via new CreateBatch and reuse folder list across path resolves
2026-05-25 11:39:35 -04:00
Silo Server Migration 03f97e63d6 docs: document jellyfin autoscan setup 2026-05-25 11:18:02 -04:00
Silo Server Migration f73febbd55 feat(jellycompat): add autoscan media update route 2026-05-25 11:18:02 -04:00
Silo Server Migration 6ceb75e717 feat(jellycompat): accept admin api keys for autoscan 2026-05-25 11:18:02 -04:00
Silo Server Migration bf0aefa050 refactor(api): share scan target resolution 2026-05-25 11:18:02 -04:00
Silo Server Migration 4f46580210 refactor(scan): extract scan trigger resolver 2026-05-25 11:18:02 -04:00
Silo Server Migration 77510cd12a docs: design jellyfin autoscan scan compatibility 2026-05-25 11:18:02 -04:00
QuickandGitHub a75d232ceb Merge pull request #7 from Silo-Server/t3code/discover-studios-networks-genres-clean
feat(requests): add media request system
2026-05-25 10:56:57 -04:00
QuickandGitHub dae57776fc Merge pull request #5 from Silo-Server/fix/direct-download-head
fix(api): allow HEAD on /api/v1/direct-download
2026-05-24 11:58:26 -04:00
rxwatcherandClaude Opus 4.7 e4485c9eb6 fix(api): allow HEAD on /api/v1/direct-download
Firefox (and some download managers) issue a HEAD request before
starting a download. The route only registered GET, so HEAD returned
405 Method Not Allowed and the browser aborted the download.

Mirrors the pattern already used by /stream/{session_id}, which
registers both GET and HEAD on the same handler. ServeDirect is built
on http.ServeContent / ServeFile, which natively handle HEAD by
writing headers without a body, so no handler changes are needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:57:42 +02:00
1051 changed files with 127152 additions and 10567 deletions
+19
View File
@@ -1,6 +1,12 @@
# Silo Docker deployment
# For the default docker compose setup, MEDIA_ROOT is the main value you need to set.
# Optional: layer compose files through env. Leave unset for CPU-only defaults.
# Linux/macOS example for enabling NVIDIA override:
# COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
# Windows example:
# COMPOSE_FILE=docker-compose.yml;docker-compose.nvidia.yml
# Required for docker compose: host path to your media library.
# Example: MEDIA_ROOT=/srv/media
MEDIA_ROOT=/srv/media
@@ -40,6 +46,19 @@ POSTGRES_SHM_SIZE=8gb
# PROXY_PORT=8083
# TRANSCODE_PORT=8082
# Optional NVIDIA override controls (used by docker-compose.nvidia.yml).
# NVIDIA_GPU_COUNT=1
# REQUIRED: master key for at-rest credential encryption. Silo refuses to start
# without it. Integration API keys, S3 keys, and other server-owned secrets are
# encrypted under a key derived from this value, so it must be at least 32
# characters and kept secret. Generate one with:
# openssl rand -base64 48
# Back it up SEPARATELY from your database dumps (treat it like a CA private
# key): losing it makes every encrypted secret unrecoverable. See
# docs/architecture/secret-encryption.md.
# SECRET_KEY=replace-with-output-of-openssl-rand-base64-48
# Run from source / advanced overrides
# Only DATABASE_URL is required when running Silo outside the default docker compose stack.
# DATABASE_URL=postgres://silo:password@localhost:5432/silo
+3 -1
View File
@@ -3,6 +3,7 @@ FROM node:22-slim AS frontend
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
COPY web/vendor/foliate-js ./vendor/foliate-js
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
@@ -22,6 +23,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download
@@ -54,7 +56,7 @@ RUN apt-get update && \
RUN mkdir -p /tmp/silo-transcode
COPY --from=build /silo /usr/local/bin/silo
COPY third_party/jellyfin-web/ /srv/jellyfin-web/
EXPOSE 8080 8096
EXPOSE 8080 8096 13378
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT:-8080}/api/v1/health || exit 1
ENTRYPOINT ["silo"]
+2
View File
@@ -5,6 +5,7 @@ FROM node:22-slim AS frontend
RUN corepack enable && corepack prepare pnpm@10.32.1 --activate
WORKDIR /app/web
COPY web/package.json web/pnpm-lock.yaml ./
COPY web/vendor/foliate-js ./vendor/foliate-js
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
COPY web/ .
@@ -19,6 +20,7 @@ ENV GONOSUMDB=github.com/Silo-Server/*
RUN apt-get update && apt-get install -y --no-install-recommends libvips-dev && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY go.mod go.sum ./
COPY internal/compat/zishang520-webtransport-go/ internal/compat/zishang520-webtransport-go/
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg/mod \
go mod download
+23
View File
@@ -34,6 +34,29 @@ The easiest way to run Silo is with Docker Compose. The default stack assumes yo
If you already have PostgreSQL and Redis available, omit those bundled service examples from compose and point Silo at your existing `DATABASE_URL` and `REDIS_URL` instead.
### Optional NVIDIA/NVENC
GPU support is kept out of the default compose file so hosts without NVIDIA drivers work unchanged.
Install the NVIDIA Container Toolkit and use a Docker Compose version with GPU reservation support before enabling this override.
Use the optional override file when you want NVENC:
```sh
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d
```
If you want this controlled from `.env`, set `COMPOSE_FILE`:
```dotenv
COMPOSE_FILE=docker-compose.yml:docker-compose.nvidia.yml
NVIDIA_GPU_COUNT=1
```
Windows uses `;` instead of `:` between compose files.
Then `docker compose up -d` will include the NVIDIA override automatically.
4. **Configure through the admin UI**
Add libraries, users, metadata providers, and playback settings from the web interface.
+254 -63
View File
@@ -4,7 +4,6 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io/fs"
@@ -18,9 +17,12 @@ import (
"sort"
"strconv"
"strings"
"sync/atomic"
"syscall"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
"github.com/hashicorp/go-hclog"
"github.com/jackc/pgx/v5/pgxpool"
@@ -34,6 +36,8 @@ import (
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/api/handlers"
"github.com/Silo-Server/silo-server/internal/audiobooks"
"github.com/Silo-Server/silo-server/internal/audiobooks/podcastfeed"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/autoscan"
"github.com/Silo-Server/silo-server/internal/cache"
@@ -43,6 +47,7 @@ import (
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/database"
"github.com/Silo-Server/silo-server/internal/ebooks"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/imagecache"
@@ -68,11 +73,10 @@ import (
"github.com/Silo-Server/silo-server/internal/ratelimit"
"github.com/Silo-Server/silo-server/internal/recommendations"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
"github.com/Silo-Server/silo-server/internal/requests/radarr"
"github.com/Silo-Server/silo-server/internal/requests/sonarr"
"github.com/Silo-Server/silo-server/internal/s3client"
"github.com/Silo-Server/silo-server/internal/scanner"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/sections"
"github.com/Silo-Server/silo-server/internal/server"
"github.com/Silo-Server/silo-server/internal/subtitles"
@@ -137,7 +141,7 @@ func mustGetSetting(store interface {
func configureOperationalLogging(
ctx context.Context,
pool *pgxpool.Pool,
settingsRepo *catalog.ServerSettingsRepo,
settingsRepo catalog.SettingsStore,
redisCfg config.RedisConfig,
logStreamHub *logstream.Hub,
baseHandler slog.Handler,
@@ -248,6 +252,38 @@ func maybeApplyPostgresTuning(ctx context.Context, pool *pgxpool.Pool, appMaxCon
}
}
// runCredentialBackfills sweeps any plaintext server-owned credential to
// ciphertext on the primary (migration-running) node. All passes are
// best-effort: a failed row leaves the prior plaintext (no new exposure) and
// still reads via the read-path pass-through, so a backfill error must never
// block boot. The sensitive-settings pass runs first so the arr
// resolve-then-encrypt pass sees consistent referenced settings.
func runCredentialBackfills(ctx context.Context, pool *pgxpool.Pool, cipher *secret.Cipher, settings *catalog.EncryptedSettingsRepo) {
settingsN, err := settings.BackfillSensitiveSettings(ctx)
if err != nil {
slog.Error("secret backfill: sensitive settings", "error", err)
}
columnsN, err := secret.BackfillColumns(ctx, pool, cipher, secret.ColumnBackfillTargets())
if err != nil {
slog.Error("secret backfill: credential columns", "error", err)
}
historyServersN, err := historyimport.NewRepository(pool, cipher).BackfillSessionServerSecrets(ctx)
if err != nil {
slog.Error("secret backfill: history import session server credentials", "error", err)
}
// The arr resolver is the encrypting settings decorator: it decrypts a
// sensitive target (e.g. requests.radarr.api_key) or passes through a
// plaintext custom key, exactly replicating the deleted resolveAPIKey.
arrN, err := secret.BackfillReferencedColumns(ctx, pool, cipher, settings.Get, secret.ArrKeyBackfillTargets())
if err != nil {
slog.Error("secret backfill: arr api keys", "error", err)
}
if total := settingsN + columnsN + historyServersN + arrN; total > 0 {
slog.Info("secret backfill: encrypted plaintext credentials at rest",
"settings", settingsN, "columns", columnsN, "history_session_servers", historyServersN, "arr_keys", arrN, "total", total)
}
}
func main() {
envFile := flag.String("env", ".env", "path to .env bootstrap file")
migrateOnly := flag.Bool("migrate-only", false, "apply database migrations and exit")
@@ -262,6 +298,15 @@ func main() {
log.Fatalf("bootstrap: %v", err)
}
// Construct the at-rest credential cipher from SECRET_KEY immediately after
// bootstrap, before any settings repo is built. It is threaded explicitly as
// a dependency into every repo that stores a server-owned secret — never a
// package-level global.
dataCipher, err := secret.New(bc.SecretKey)
if err != nil {
log.Fatalf("secret cipher: %v", err)
}
// Step 2: Connect to PostgreSQL (bootstrap pool with default max connections)
bootstrapDBCfg := config.DatabaseConfig{URL: bc.DatabaseURL, MaxConnections: 20}
pool, err := database.NewPool(ctx, bootstrapDBCfg)
@@ -310,7 +355,11 @@ func main() {
// Run migrations only for integrated/api modes. Proxy and transcode nodes
// should never alter the schema — they may scale independently and would
// race or apply migrations before the primary node is deliberately upgraded.
if bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == "" {
// The same gate decides whether this node runs the credential-encryption
// backfills: only the primary (migration-running) node sweeps plaintext to
// ciphertext; secondary nodes read whatever the primary encrypted.
isPrimaryNode := bc.Mode == "integrated" || bc.Mode == "api" || bc.Mode == ""
if isPrimaryNode {
migCtx, migCancel := context.WithTimeout(ctx, 5*time.Minute)
if migErr := database.RunMigrations(migCtx, pool, migrations.FS, "sql"); migErr != nil {
migCancel()
@@ -320,8 +369,14 @@ func main() {
slog.Info("database migrations applied")
}
// Step 3: Load settings from DB
settingsRepo := catalog.NewServerSettingsRepo(pool)
// Step 3: Load settings from DB. settingsRepo is the encrypting decorator so
// every consumer (config.LoadFromDB, admin, ABS, watchers) transparently sees
// plaintext while sensitive keys rest as ciphertext. The settings backfill
// (run after migrations, before this GetAll) is wired further below.
settingsRepo := catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
if isPrimaryNode {
runCredentialBackfills(ctx, pool, dataCipher, settingsRepo)
}
settings, err := settingsRepo.GetAll(ctx)
if err != nil {
log.Fatalf("loading settings: %v", err)
@@ -392,7 +447,9 @@ func main() {
log.Fatalf("recreating pool with configured max_connections: %v", err)
}
}
settingsRepo = catalog.NewServerSettingsRepo(pool)
// Re-wrap with the encrypting decorator so the recreated pool's settings repo
// still encrypts/decrypts — no raw settings repo may escape into later wiring.
settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)
nodeID := resolveNodeIdentity()
// Step 9: Validate
@@ -421,6 +478,8 @@ func main() {
appCtx, appCancel := context.WithCancel(ctx)
defer appCancel()
restartReqCh := make(chan struct{}, 1)
var restartRequested atomic.Bool
eventBus := cache.NewEventBus(cfg.Redis.URL)
logStreamHub := logstream.NewHub(nodeID, eventBus)
@@ -454,7 +513,7 @@ func main() {
DatabaseURL: cfg.Database.URL,
JFListen: cfg.JellyfinCompat.Listen,
}
watcher := nodeconfig.NewWatcher(pool, eventBus, bootstrap)
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
if err := watcher.Start(appCtx); err != nil {
slog.Error("config watcher start failed", "error", err)
os.Exit(1)
@@ -513,6 +572,7 @@ func main() {
BootstrapSensitiveValues: bootstrapSensitiveValues,
AppContext: appCtx,
DB: pool,
SecretCipher: dataCipher,
EventBus: eventBus,
LogStreamHub: logStreamHub,
RealtimeHub: realtimeHub,
@@ -521,6 +581,19 @@ func main() {
OpsLogRepo: opsRepo,
FFmpegLogSink: playback.NewSlogFFmpegLogSink(slog.Default(), nodeID),
PublicURL: os.Getenv("SILO_PUBLIC_URL"),
RequestServerRestart: func(context.Context) error {
if !restartRequested.CompareAndSwap(false, true) {
return handlers.ErrServerRestartAlreadyRequested
}
restartReqCh <- struct{}{}
return nil
},
}
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
if err != nil {
slog.Warn("Audiobookshelf compatibility disabled; failed to read setting", "err", err)
absCompatEnabled = false
}
adminJobCancelRegistry := adminjob.NewCancelRegistry()
deps.AdminJobCancelRegistry = adminJobCancelRegistry
@@ -576,7 +649,7 @@ func main() {
log.Fatalf("register watch provider: %v", err)
}
watchProviderService = watchsync.NewService(
watchsync.NewPostgresRepository(deps.DB),
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
watchProviderRegistry,
)
deps.WatchProviderService = watchProviderService
@@ -597,6 +670,7 @@ func main() {
deps.ProxyPool = proxyPool
deps.TranscodePool = transcodePool
deps.NodePlanner = nodepool.NewPlanner(proxyPool, transcodePool)
healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo)
healthChecker.Start(appCtx)
@@ -868,6 +942,8 @@ func main() {
var groupClaimRepo *catalog.GroupClaimRepository
var seasonRepo *catalog.SeasonRepository
var episodeRepo *catalog.EpisodeRepository
var audiobookEnricher *audiobooks.Enricher
var ebookEnricher *ebooks.Enricher
if needsWorkers && deps.DB != nil && deps.FileRepo != nil {
chainRepo := metadata.NewChainRepository(deps.DB)
skippedRootRepo = metadata.NewSkippedRootRepository(deps.DB)
@@ -938,6 +1014,26 @@ func main() {
personRefreshService = metadata.NewPersonRefreshService(deps.DB, pluginResolver, personRepo)
personRefreshService.SetImageResolver(imageResolver)
// Wire the audiobook enricher. It uses the same plugin resolver and chain
// repo as the movie/TV pipeline, but resolves providers at
// content_level='audiobook' and sweeps items directly rather than via a queue.
audiobookEnricher = audiobooks.NewEnricher(
deps.DB,
chainRepo,
pluginResolver,
itemRepo,
personRepo,
providerIDRepo,
)
ebookEnricher = ebooks.NewEnricher(
deps.DB,
chainRepo,
pluginResolver,
itemRepo,
personRepo,
providerIDRepo,
)
// Always wire the image resolver so plugin-prefixed URLs (e.g.
// metadb://) can be resolved to presigned HTTP URLs in API responses.
metadataService.SetImageResolver(imageResolver)
@@ -948,10 +1044,20 @@ func main() {
imageCacher := imagecache.New(deps.S3Public)
metadataService.SetImageCacher(imageCacher)
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
if deps.Scanner != nil {
deps.Scanner.SetImageCacher(imageCacher)
}
if cfg.Metadata.CacheImages {
personRefreshService.SetImageCacher(imageCacher)
slog.Info("metadata image caching enabled")
}
if audiobookEnricher != nil {
audiobookEnricher.SetImageCacher(imageCacher)
audiobookEnricher.SetFFmpegPath(scanner.FFmpegPathFromFFprobe(scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)))
}
if ebookEnricher != nil {
ebookEnricher.SetImageCacher(imageCacher)
}
}
matchWorker = metadata.NewMatchWorker(metadataService, deps.FileRepo, cfg.Matcher.Workers, cfg.Matcher.BatchSize, 30*time.Second)
@@ -1141,7 +1247,7 @@ func main() {
deps.UserStoreProvider = userStoreProvider
}
if watchProviderService != nil {
historyRepo := historyimport.NewRepository(deps.DB)
historyRepo := historyimport.NewRepository(deps.DB, deps.SecretCipher)
historyIdentity := watchstate.NewStableIdentityResolver(itemRepo, episodeRepo, catalog.NewProviderIDRepository(deps.DB))
watchProviderService.
WithMatcher(historyimport.NewMatcher(historyRepo)).
@@ -1404,6 +1510,7 @@ func main() {
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil {
taskMgr.Register(tasks.NewScanLibrariesTask(deps.FolderRepo, deps.LibraryScanQueue, deps.EventBus))
}
taskMgr.Register(tasks.NewCleanupOrphanedMediaItemsTask(catalog.NewOrphanedProvisionalCleaner(deps.DB)))
if deps.IntroAnalyzer != nil {
taskMgr.Register(tasks.NewDetectIntroMarkersTask(deps.IntroAnalyzer, settingsRepo))
}
@@ -1439,15 +1546,15 @@ func main() {
taskMgr.Register(tasks.NewSyncWatchProvidersTask(watchProviderService))
}
requestReconcileSvc := mediarequests.NewService(
mediarequests.NewRepository(deps.DB),
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
nil,
mediarequests.NewCatalogPresence(
catalog.NewItemRepository(deps.DB),
catalog.NewProviderIDRepository(deps.DB),
),
)
requestReconcileSvc.SetSecretResolver(settingsRepo)
requestReconcileSvc.SetFulfillmentAdapters(radarr.NewClient(nil), sonarr.NewClient(nil))
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
api.AttachRequestRouter(requestReconcileSvc, pluginService)
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
auth.NewUserRepository(deps.DB),
@@ -1458,13 +1565,15 @@ func main() {
}
taskMgr.Register(tasks.NewReconcileRequestsTask(requestReconcileSvc, 100))
if deps.FolderRepo != nil && deps.LibraryScanQueue != nil && pluginService != nil && pluginInstallationStore != nil {
autoscanRepo := autoscan.NewRepository(deps.DB)
autoscanRepo := autoscan.NewRepository(deps.DB, deps.SecretCipher)
if err := autoscanRepo.MarkInterruptedEvents(appCtx); err != nil {
slog.Warn("autoscan: failed to mark interrupted polls", "err", err)
}
autoscanSvc := api.BuildAutoscanService(
autoscanRepo,
pluginService,
pluginInstallationStore,
mediarequests.NewRepository(deps.DB),
settingsRepo,
mediarequests.NewRepository(deps.DB, deps.SecretCipher),
deps.FolderRepo,
deps.LibraryScanQueue,
deps.RedisClient,
@@ -1487,6 +1596,13 @@ func main() {
historyReconciler := watchstate.NewHistoryReconciler(deps.DB, historyResolver)
taskMgr.Register(tasks.NewRepairProviderIDIntegrityTask(metadata.NewProviderIDIntegrityRepairer(deps.DB), historyReconciler))
taskMgr.Register(tasks.NewReconcileWatchHistoryTask(historyReconciler))
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastfeed.New(), podcastfeed.NewDBStore(deps.DB)))
if audiobookEnricher != nil {
taskMgr.Register(tasks.NewSyncAudiobookMetadataTask(audiobookEnricher))
}
if ebookEnricher != nil {
taskMgr.Register(tasks.NewSyncEbookMetadataTask(ebookEnricher))
}
if pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && pluginService != nil {
pluginTasks, err := plugins.NewTaskRegistryWithTypedResolver(pluginInstallationStore, pluginRuntimeConfigStore, pluginService).Tasks(appCtx)
if err != nil {
@@ -1503,6 +1619,57 @@ func main() {
slog.Info("task manager started")
}
// Build the ABS-compatible REST + Socket.io handler when a DB pool is
// available. Routes are mounted at the root level by NewRouter (not under
// /api/v1/) so ABS clients resolve /login, /api/*, /abs/api/*, and
// /abs/socket.io/* without path prefix hacks.
if absCompatEnabled && deps.DB != nil {
absUserRepo := auth.NewUserRepository(deps.DB)
absSessionRepo := auth.NewSessionRepository(deps.DB)
absJWTService := auth.NewJWTService(
cfg.Auth.JWTSecret,
cfg.Auth.AccessTokenExpiry,
cfg.Auth.RefreshTokenExpiry,
)
absAuthSvc := auth.NewService(
auth.NewLocalProvider(absUserRepo, absSessionRepo),
absJWTService,
absSessionRepo,
absUserRepo,
nil, // invite codes: not needed for ABS compat
nil, // settings: not needed here
nil, // user store: not needed here
)
absItemRepo := catalog.NewItemRepository(deps.DB)
absEpisodeRepo := catalog.NewEpisodeRepository(deps.DB)
absSeasonRepo := catalog.NewSeasonRepository(deps.DB)
absPersonRepo := catalog.NewPersonRepository(deps.DB)
var absFileFetcher catalog.FileVersionFetcher
if deps.FileRepo != nil {
absFileFetcher = deps.FileRepo
}
absDetailSvc := catalog.NewDetailService(absItemRepo, absEpisodeRepo, absSeasonRepo, absPersonRepo, absFileFetcher)
if deps.ImageResolver != nil {
absDetailSvc.SetImageResolver(deps.ImageResolver)
}
absHDeps := audiobooks.ABSHandlerDeps{
Pool: deps.DB,
Items: absItemRepo,
Files: deps.FileRepo,
Settings: settingsRepo,
Auth: &audiobooks.SiloCredValidator{
Auth: absAuthSvc,
Pool: deps.DB,
},
AccessResolver: audiobooks.NewABSAccessResolver(absUserRepo, userStoreProvider),
Recs: recommendations.NewRepo(deps.DB),
Detail: absDetailSvc,
}
absH := audiobooksService.BuildABSHandler(absHDeps)
deps.ABSHandler = absH
}
_ = audiobooksService
if deps.DB != nil && pluginInstallationStore != nil && pluginRuntimeConfigStore != nil && deps.PluginService != nil {
userRepo := auth.NewUserRepository(deps.DB)
sessionRepo := auth.NewSessionRepository(deps.DB)
@@ -1624,6 +1791,11 @@ func main() {
metricsMux := http.NewServeMux()
metricsMux.Handle("/metrics", promhttp.Handler())
metricsMux.Handle("/api/", router)
// ABS-compat is NOT mounted on the main listener — see the "ABS compat
// listener" block below. It binds its own port so the discovery probes
// (/ping, /healthcheck, /status, /init, /login, /socket.io) own the URL
// space without collision with silo's SPA fallback. Mirrors how the
// Jellyfin compat server is set up at :8096.
metricsMux.Handle("/", server.FrontendHandler())
// Step 9: Start background workers (if needed).
@@ -1724,9 +1896,9 @@ func main() {
compatDeps := jellycompat.Dependencies{
Config: cfg,
DB: deps.DB,
SecretCipher: dataCipher,
ClientIPResolver: ipResolver,
ProxyPool: deps.ProxyPool,
TranscodePool: deps.TranscodePool,
NodePlanner: deps.NodePlanner,
JWTSecret: cfg.Auth.JWTSecret,
RecWorker: recWorker,
}
@@ -1777,12 +1949,12 @@ func main() {
compatDeps.FileResolver = deps.FileRepo
}
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB)
compatDeps.SubtitleRepo = subtitles.NewPgRepository(deps.DB, deps.SecretCipher)
// Construct auth service for jellycompat login.
userRepo := auth.NewUserRepository(deps.DB)
compatDeps.APIKeyValidator = auth.NewAPIKeyRepository(deps.DB)
compatDeps.APIKeyUserLoader = userRepo
compatDeps.UserLoader = userRepo
compatDeps.ScanQueue = deps.LibraryScanQueue
sessionRepo := auth.NewSessionRepository(deps.DB)
jwtService := auth.NewJWTService(
@@ -1793,47 +1965,17 @@ func main() {
provider := auth.NewLocalProvider(userRepo, sessionRepo)
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
// Access filter resolver for profile-scoped library access.
compatDeps.AccessFilterFn = func(ctx context.Context, userID int, profileID string) catalog.AccessFilter {
if userStoreProvider == nil {
return catalog.AccessFilter{}
}
store, err := userStoreProvider.ForUser(ctx, userID)
if err != nil {
return catalog.AccessFilter{}
}
profile, err := store.GetProfile(ctx, profileID)
if err != nil || profile == nil {
return catalog.AccessFilter{}
}
filter := catalog.AccessFilter{
MaxContentRating: profile.MaxContentRating,
}
if profile.LibraryRestrictionsEnabled && len(profile.AllowedLibraryIDs) > 0 {
filter.AllowedLibraryIDs = profile.AllowedLibraryIDs
}
// Apply user-disabled library IDs.
if raw, err := store.GetSetting(ctx, "disabled_library_ids"); err == nil && raw != "" {
var disabled []int
if json.Unmarshal([]byte(raw), &disabled) == nil && len(disabled) > 0 {
if filter.AllowedLibraryIDs != nil {
filtered := make([]int, 0, len(filter.AllowedLibraryIDs))
disSet := make(map[int]struct{}, len(disabled))
for _, id := range disabled {
disSet[id] = struct{}{}
}
for _, id := range filter.AllowedLibraryIDs {
if _, ok := disSet[id]; !ok {
filtered = append(filtered, id)
}
}
filter.AllowedLibraryIDs = filtered
} else {
filter.DisabledLibraryIDs = disabled
}
}
}
return filter
// Access filter resolver for viewer-scoped library access.
// Backed by the shared access.Resolver so account-level library
// restrictions (users.library_ids), profile restrictions,
// user-disabled libraries, and rating/quality ceilings apply to
// the compat API exactly as they do to the native API.
if userStoreProvider != nil {
compatDeps.AccessFilterFn = jellycompat.NewScopeAccessFilter(access.NewResolver(
userRepo,
userStoreProvider,
nil, // profile tokens unused: compat login already verifies PINs
))
}
}
@@ -1846,6 +1988,28 @@ func main() {
compatSrv.IdleTimeout = 120 * time.Second
}
// ABS-compat listener — dedicated http.Server bound to its own port
// (default :13378) that hosts the Audiobookshelf-compatible API.
// Mirrors the Jellyfin compat layout above. The ABS handler mounts
// onto a fresh chi router here so /ping, /healthcheck, /status, /login,
// /socket.io, etc. own the URL space at the root — no SPA fallback,
// no collision with silo's /api/v1.
var absSrv *http.Server
if (mode == "integrated" || mode == "api") && deps.ABSHandler != nil && cfg.AudiobookshelfCompat.Listen != "" {
absRouter := chi.NewRouter()
absRouter.Use(chimiddleware.Recoverer)
absRouter.Use(chimiddleware.Compress(5))
deps.ABSHandler.Mount(absRouter)
absSrv = &http.Server{
Addr: cfg.AudiobookshelfCompat.Listen,
Handler: absRouter,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 0,
IdleTimeout: 120 * time.Second,
}
}
// Run non-critical startup work in the background so it doesn't delay the
// HTTP listener from accepting connections. Steps run sequentially and stop
// early if the app context is cancelled (shutdown).
@@ -1870,7 +2034,7 @@ func main() {
}()
}
errCh := make(chan error, 2)
errCh := make(chan error, 3)
go func() {
slog.Info("HTTP server listening", "addr", cfg.Server.Listen)
if listenErr := srv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
@@ -1885,6 +2049,14 @@ func main() {
}
}()
}
if absSrv != nil {
go func() {
slog.Info("ABS compat server listening", "addr", absSrv.Addr)
if listenErr := absSrv.ListenAndServe(); listenErr != nil && listenErr != http.ErrServerClosed {
errCh <- fmt.Errorf("abs compat server error: %w", listenErr)
}
}()
}
// Step 11: Wait for termination signal.
sigCh := make(chan os.Signal, 1)
@@ -1895,6 +2067,9 @@ func main() {
case sig := <-sigCh:
appCancel()
slog.Info("received signal, shutting down", "signal", sig)
case <-restartReqCh:
appCancel()
slog.Info("server restart requested, shutting down")
case serverErr := <-errCh:
appCancel()
slog.Error("server error, shutting down", "error", serverErr)
@@ -1914,6 +2089,11 @@ func main() {
slog.Error("jellyfin compat shutdown error", "error", shutdownErr)
}
}
if absSrv != nil {
if shutdownErr := absSrv.Shutdown(shutdownCtx); shutdownErr != nil {
slog.Error("abs compat shutdown error", "error", shutdownErr)
}
}
// 2. Clean up stale sessions.
if sessionCleaner != nil {
@@ -2264,3 +2444,14 @@ func mapFolderTypeToMediaType(t string) string {
return "mixed"
}
}
// audiobooksSettingsAdapter bridges catalog.ServerSettingsRepo (which
// exposes Get) to the audiobooks.SettingsReader interface (which
// requires GetString). The two signatures are identical modulo name.
type audiobooksSettingsAdapter struct {
repo catalog.SettingsStore
}
func (a *audiobooksSettingsAdapter) GetString(ctx context.Context, key string) (string, error) {
return a.repo.Get(ctx, key)
}
+20
View File
@@ -0,0 +1,20 @@
services:
silo:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: ${NVIDIA_GPU_COUNT:-1}
capabilities: [gpu]
# Optional distributed-mode example. Most single-host installs should
# just use the integrated service above.
# silo-transcode:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: ${NVIDIA_GPU_COUNT:-1}
# capabilities: [gpu]
+12
View File
@@ -36,6 +36,11 @@ services:
restart: unless-stopped
environment:
MODE: integrated
# Master key for at-rest credential encryption. Required — the server
# refuses to start without it. Generate with: openssl rand -base64 48
# Back it up SEPARATELY from your database dumps; losing it makes encrypted
# secrets unrecoverable. See docs/architecture/secret-encryption.md.
SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
REDIS_URL: redis://redis:6379
SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
@@ -43,11 +48,14 @@ services:
ports:
- "${PORT:-8090}:8080"
- "${JF_PORT:-8096}:8096"
- "${ABS_PORT:-13378}:13378"
volumes:
- ${MEDIA_ROOT:?Set MEDIA_ROOT in .env to the host media path}:${MEDIA_CONTAINER_ROOT:-/mnt/media}:ro
- ${MEDIA_BOOKS_ROOT:-${MEDIA_ROOT}}:${MEDIA_BOOKS_CONTAINER_ROOT:-${MEDIA_CONTAINER_ROOT:-/mnt/media}/books}:ro
- ${SILO_DATA_ROOT:-/opt/silo}/plugins:/var/lib/silo/plugins
- ${SILO_DATA_ROOT:-/opt/silo}/transcode:/tmp/silo-transcode
- ${SILO_DATA_ROOT:-/opt/silo}/catalog-seeds:/catalog-seeds:ro
- ${SILO_DATA_ROOT:-/opt/silo}/audiobook-covers:/var/lib/silo/audiobook-covers
- /proc/meminfo:/host/proc/meminfo:ro
depends_on:
postgres:
@@ -68,6 +76,8 @@ services:
# profiles: [proxy]
# environment:
# MODE: proxy
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
# REDIS_URL: redis://redis:6379
# PORT: "8080"
@@ -92,6 +102,8 @@ services:
# profiles: [transcode]
# environment:
# MODE: transcode
# # Must be the SAME SECRET_KEY as the primary node (shared encrypted data).
# SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env — generate one with openssl rand -base64 48}
# DATABASE_URL: postgres://${POSTGRES_USER:-silo}:${POSTGRES_PASSWORD:-silo}@postgres:5432/${POSTGRES_DB:-silo}?sslmode=disable
# REDIS_URL: redis://redis:6379
# SILO_PLUGIN_CACHE_DIR: /var/lib/silo/plugins
+142
View File
@@ -0,0 +1,142 @@
# At-rest credential encryption
Silo encrypts third-party integration credentials and other server-owned secrets
at rest. Sonarr/Radarr API keys, S3 keys, watch-sync OAuth tokens, the
Audiobookshelf-compat signing key, and the sensitive `server_settings` entries
are stored as ciphertext in PostgreSQL and decrypted only in memory. The key that
protects them lives **outside** the database, so a full DB dump or compromise
does not expose the secrets.
## How it works
- **Cipher:** AES-256-GCM with a random 12-byte nonce per value. Implemented in
`internal/secret`.
- **Key derivation:** the data key is HKDF-SHA256–derived from the `SECRET_KEY`
environment variable with a versioned domain label. `SECRET_KEY` is the
encryption root; it is never stored in the database.
- **Envelope:** every ciphertext is stored as `enc:v1:<base64url(nonce‖sealed)>`.
The `enc:v1:` prefix is the version marker (see *Key rotation* below).
- **Row binding (AAD):** each ciphertext is GCM-bound to its logical row via
additional-authenticated data — `table:column:<pk>` for per-row columns,
`server_settings:<key>` for settings. A DB-write attacker therefore cannot move
a credential blob from one row/column to another: decrypting under a different
binding fails authentication.
- **Read path:** an empty value stays empty; a non-`enc:v1:` value is treated as
legacy plaintext and passed through unchanged (so a not-yet-migrated row keeps
working); an `enc:v1:` value is decrypted and **any** failure (wrong key,
tampering, truncation) is surfaced as an error — it is never silently used as a
credential.
## SECRET_KEY
`SECRET_KEY` is **required**. The server refuses to start without it (it fatals
in `config.LoadBootstrap`), and it must be at least 32 characters.
Generate one with:
```bash
openssl rand -base64 48
```
Set it in the deployment environment (or in `.env` for source/dev runs, which
`godotenv` loads at startup). See `.env.example`.
### Back it up — separately from the database
Treat `SECRET_KEY` like a CA private key:
- **Store it outside your database backups.** A DB dump contains only ciphertext;
the value of at-rest encryption is entirely lost if the key travels with the
dump.
- Keep it in a secrets manager / sealed secret / password manager, not in the
repo or in the same bucket as your Postgres backups.
- Rotating the *deployment* (new host, restored DB) requires the **same**
`SECRET_KEY`. Restoring a database backup onto a node with a different
`SECRET_KEY` makes every encrypted secret unreadable.
### Key loss
If `SECRET_KEY` is lost, the encrypted secrets are **unrecoverable** — that is the
point of keeping the key out of the database. Recovery means re-entering the
affected credentials:
- Re-enter Sonarr/Radarr and Autoscan API keys, S3 keys, watch-sync connections,
history-import tokens, and subtitle provider credentials.
- `auth.jwt_secret` becomes unreadable, so all existing sessions are invalid and
users must log in again (a new secret is generated on next boot if the row is
cleared — see below).
## Rollout (automatic backfill)
On the first boot after deploying this change, the primary (migration-running)
node runs an idempotent, best-effort startup backfill that encrypts any
remaining plaintext in place:
1. sensitive `server_settings` keys,
2. the per-table credential columns (subtitles, watch-sync, webhook-sync,
history-import, including temporary server-list credentials stored in
session JSON),
3. the two arr `api_key_ref` columns — these are **resolved-then-encrypted**: a
legacy row that held a `server_settings` reference (e.g.
`requests.radarr.api_key`) is collapsed to the real credential before being
encrypted.
The backfill is safe to run repeatedly: already-encrypted values are skipped, and
a per-row guard makes concurrent multi-node boots converge without
double-encrypting. A failed row is logged and left as plaintext (no new exposure)
rather than blocking boot. Proxy/transcode nodes skip the backfill; they read
whatever the primary encrypted. No manual steps are required.
## Rollback / downgrade hazard
Downgrading to a binary that predates this change is **not** safe while secrets
are encrypted, because the old binary has no read path: it would read
`enc:v1:auth.jwt_secret` as a literal JWT secret (invalidating all sessions) and
read `enc:v1:`-prefixed integration keys as garbage credentials.
To downgrade safely you must first return the affected values to plaintext, for
example:
- Decrypt the sensitive `server_settings` back to plaintext (run a one-off that
reads each via the encrypting repo and writes the plaintext via the raw repo),
**or**
- At minimum, clear `auth.jwt_secret` so the older binary regenerates a fresh
plaintext one (this still logs everyone out), and re-enter any integration
credentials as plaintext.
There is no automatic "decrypt everything" downgrade path in this release; plan
downgrades accordingly.
## Key rotation (future)
The `enc:v1:` envelope and the HKDF domain label are versioned. A future rotation
would introduce `enc:v2:` with a new derived key (and `Decrypt` already dispatches
on the version, returning an explicit error for an unknown version). Rotation is
out of scope for this release; the versioning exists so it can be added without a
data migration of the envelope format.
## Scope and known gaps
Covered: arr (Requests + Autoscan) API keys, S3 keys, all sensitive
`server_settings`, watch-sync tokens, webhook-sync `access_token`,
history-import admin/session tokens and temporary server-list credentials,
subtitle provider `api_key`/`password`,
the Jellyfin-compat session's bridged Silo access/refresh tokens
(`jellycompat_sessions.streamapp_access_token` / `streamapp_refresh_token`), and
the ABS signing key.
Deliberately **not** encrypted (tracked as follow-ups):
- **Equality-looked-up secrets** — these are matched by exact value
(`WHERE … = $1`), and AES-GCM is randomized, so encrypting them would break the
lookup. They need a deterministic **blind-index hash** column instead:
`api_keys.api_key`, `webhook_sync_connections.webhook_secret`,
`jellycompat_sessions.token`, and `watch_together_rooms.join_token`.
- **Plugin runtime config** — `plugin_runtime_configs.config_value` is opaque
plugin-defined JSONB whose secret fields are manifest-declared and whose runtime
lives in a separate repo; encrypting it correctly needs a coordinated,
manifest-aware design.
Excluded (not a gap): `plex_sync_connections.*` is a dead table (zero Go
references); `oauth_completion.token_ciphertext` is already AES-GCM;
`users.password_hash` and the `*_hash` columns are already hashed.
@@ -0,0 +1,98 @@
# Wholphin → jellycompat Endpoint Coverage Report
Date: 2026-06-09
Scope: every Jellyfin server endpoint the Wholphin Android TV client (jellyfin-sdk-kotlin based) can call, cross-referenced against the routes registered in `internal/jellycompat/router.go`. Wholphin source analyzed from a local clone of `damontecres/Wholphin` (`app/src/main`). Commands assume the repository root is the cwd.
Wholphin's Seerr integration (`/api/v1/...` discover/request endpoints) targets a separate Seerr server, not Jellyfin, and is out of scope here.
## Methodology
1. Grepped Wholphin for every `*Api.method()` SDK call (`itemsApi`, `playStateApi`, etc.), websocket subscriptions, and raw URL construction; mapped each to its HTTP endpoint per the Jellyfin OpenAPI spec.
2. Inventoried every route registered in `internal/jellycompat/router.go`, including path normalization (`path_normalization.go` handles casing and `/emby`//`jellyfin` prefixes).
3. For each endpoint Wholphin uses but silo does not serve, checked whether a server-side gate (user policy flag, DTO field) prevents the client from ever calling it.
## Summary
- **Covered (full):** 22 endpoint families — everything on the core path: auth, browse, item detail, episodes/next-up, resume, playback info, direct/HLS streaming, subtitles, progress reporting, favorites/played, images, genres, persons, suggestions, media segments (skip intro), display preferences.
- **Covered (partial/stub):** 4 — websocket (keep-alive only, no remote control), `/Studios`, `/Users/Public`, `/QuickConnect/Enabled` (deliberate `false`).
- **Missing but safely gated:** 8 — the client never calls them because we don't advertise the capability (delete, subtitle download, trickplay, local trailers, additional parts, quick connect auth, live TV, playlists).
- **Missing and reachable:** 4 — theme songs, universal audio stream, item download, client log upload. These return 404 when the client calls them. None block video playback.
**Bottom line: the entire core video path is covered.** The reachable gaps are quality-of-life features (theme songs, photo screensaver, debug log upload) plus one advertised-but-broken inconsistency (`CanDownload=true` with no `/Items/{id}/Download` route).
## Coverage matrix
### Fully covered
| Wholphin call | Endpoint | silo handler |
|---|---|---|
| `userApi.authenticateUserByName` | `POST /Users/AuthenticateByName` | `handlers_auth.go` |
| `userApi.getCurrentUser` | `GET /Users/Me` | `handlers_auth.go` |
| `userApi.getUserById` | `GET /Users/{id}` | `handlers_auth.go` |
| `systemApi.getPublicSystemInfo` | `GET /System/Info/Public` | `handlers_system.go` |
| `sessionApi.postCapabilities` | `POST /Sessions/Capabilities` | `handlers_playback.go` |
| `itemsApi.getItems` | `GET /Items` | `handlers_items.go` |
| `itemsApi.getResumeItems` | `GET /UserItems/Resume` (+ legacy `/Users/{id}/Items/Resume`) | `handlers_items.go` |
| `userLibraryApi.getItem` | `GET /Items/{id}` (+ legacy user-scoped form) | `handlers_items.go` |
| `tvShowsApi.getEpisodes` | `GET /Shows/{id}/Episodes` | `handlers_items.go` |
| `tvShowsApi.getNextUp` | `GET /Shows/NextUp` | `handlers_items.go` |
| `suggestionsApi.getSuggestions` | `GET /Items/Suggestions` | `handlers_items.go` |
| `genresApi.getGenres` | `GET /Genres` | `handlers_items.go` |
| `personsApi.getPersons` | `GET /Persons` | `handlers_persons.go` (full when PersonRepo is wired; empty stub otherwise) |
| `searchApi.getSearchHints` | `GET /Search/Hints` | `handlers_items.go` (Wholphin's call is currently commented out upstream) |
| `mediaInfoApi.getPostedPlaybackInfo` | `POST /Items/{id}/PlaybackInfo` | `handlers_playback.go` |
| `videosApi.getVideoStreamUrl` | `GET/HEAD /Videos/{id}/stream(.{container})` | `streams.go` |
| (transcode path from PlaybackInfo) | `GET /Videos/{id}/master.m3u8` + HLS playlist/segments | `streams.go` |
| (subtitle delivery URLs) | `GET /Videos/{id}/{source}/Subtitles/{idx}/stream.{fmt}` | `streams.go` |
| `playStateApi.reportPlaybackStart/Progress/Stopped` | `POST /Sessions/Playing[/Progress|/Stopped]` | `streams.go` |
| `playStateApi.markPlayedItem/markUnplayedItem` | `POST/DELETE /UserPlayedItems/{id}` (+ legacy) | `handlers_userdata.go` |
| `userLibraryApi.markFavoriteItem/unmarkFavoriteItem` | `POST/DELETE /UserFavoriteItems/{id}` (+ legacy) | `handlers_userdata.go` |
| `mediaSegmentsApi.getItemSegments` | `GET /MediaSegments/{id}` | `handlers_items.go` (skip intro/credits) |
| `displayPreferencesApi.updateDisplayPreferences` | `GET/POST /DisplayPreferences/{id}` | `handlers_displayprefs.go` |
| `imageApi.getItemImageUrl` | `GET /Items/{id}/Images/{type}[/{index}]` | `handlers_images.go` |
| `imageApi.getUserImageUrl` | `GET /Users/{id}/Images/Primary` | `handlers_images.go` (placeholder avatar) |
### Partial / stub (graceful degradation)
| Endpoint | Status | Client impact |
|---|---|---|
| `GET /socket` (websocket) | Accepts upgrade, answers KeepAlive only (`handlers_websocket.go`) | Wholphin subscribes to `GeneralCommandMessage` and `PlaystateMessage` for server-initiated remote control. Connection stays healthy; remote-control commands are simply never delivered. No errors, feature inert. |
| `GET /Studios` | Empty-array stub | Studio browse rows render empty. |
| `GET /Users/Public` | Empty-array stub | No user tiles on the login screen; manual login works. |
| `GET /QuickConnect/Enabled` | Hardcoded `false` | Deliberate: Wholphin hides Quick Connect UI, so the unimplemented `POST /QuickConnect/Authorize` and `POST /Users/AuthenticateWithQuickConnect` are never called. |
### Missing but safely gated (client never calls them)
| Endpoint | Why it's unreachable |
|---|---|
| `DELETE /Items/{id}` | We return `CanDelete=false` per item (`mapping.go`) and `EnableContentDeletion=false` in user policy (`handlers_auth.go`), so Wholphin hides delete UI. |
| `POST /Items/{id}/RemoteSearch/Subtitles/{subtitleId}` | `EnableSubtitleManagement=false` in user policy hides subtitle search/download. |
| `GET /Videos/{id}/Trickplay/...` | We never emit trickplay metadata on item DTOs, so the scrubber never requests tiles. |
| `GET /Items/{id}/LocalTrailers` | Wholphin gates on `localTrailerCount > 0` (`TrailerService.kt`); we never set `LocalTrailerCount`. |
| `GET /Videos/{id}/AdditionalParts` | Gated on `partCount > 1` (`PlaylistCreator.kt`); we never set `PartCount`. |
| Quick Connect auth endpoints | Gated by `/QuickConnect/Enabled` = false. |
| Live TV suite (`/LiveTv/Channels`, `/Programs`, `/Recordings`, timers) | We expose no live-TV views or channels, so the feature never activates. |
| Playlists (`GET/POST/DELETE /Playlists/{id}/Items`) | We expose no playlist items in the catalog, so playlist screens are unreachable. Becomes a real gap the day silo grows playlists. |
### Missing and reachable (return 404 today)
| Endpoint | Wholphin caller | Impact | Severity |
|---|---|---|---|
| `GET /Items/{id}/ThemeSongs` | `ThemeSongPlayer.kt` | ~~404 → feature silently dead.~~ **Resolved 2026-06-09:** now stubbed with an empty `ThemeMediaResult` (including the `OwnerId` field jellyfin-sdk-kotlin requires). | Resolved |
| `GET /Audio/{id}/universal` | `ThemeSongPlayer.kt`, `MusicService.kt` | Audio streaming for theme songs and music playback. No music libraries are exposed today, but this is the second half of the theme-song path and the blocker for any future audio support in jellycompat. | Low (today) |
| `GET /Items/{id}/Download` | `SlideshowViewModel.kt`, `ScreensaverService.kt` | ~~Inconsistency: `CanDownload=true` while the route 404s.~~ **Resolved 2026-06-09:** a real `/Items/{id}/Download` route now serves the original file (`streams.go`), and `CanDownload=true` again. The earlier `CanDownload=false` workaround broke Infuse, which requires the flag for Direct Play. | Resolved |
| `POST /ClientLog/Document` | `MediaReportService.kt`, `DebugPage.kt` | "Upload logs to server" debug action fails. | Low |
| `GET /Audio/{id}/Lyrics` | `NowPlayingViewModel.kt` | Music-only; unreachable until audio libraries exist. | Low |
## Recommendations (priority order)
1. **Merge the repeated-`Fields` fix** (PR #110). `parseItemsQuery` still reads `q.Get("Fields")` (`internal/jellycompat/query.go`), which truncates jellyfin-sdk-kotlin's repeated query params and breaks Wholphin episode auto-advance. This is the only *covered* endpoint with a known correctness bug for this client. Follow up by sweeping the remaining single-value reads (`Ids`, `GenreIds`, `PersonIds`, `Filters`, `SortBy`, `SortOrder`) to `q.Values`, matching how `IncludeItemTypes`/`MediaTypes`/`ImageTypes` are already handled.
2. ~~Resolve the `CanDownload` inconsistency~~ — **done 2026-06-09**: real `/Items/{id}/Download` route added; `CanDownload` stays `true` (Infuse requires it for Direct Play).
3. ~~Stub `GET /Items/{id}/ThemeSongs`~~ — **done 2026-06-09**: empty `ThemeMediaResult` stub registered.
4. **Websocket server-push** (remote control: server-initiated pause/stop, display messages) — tracked in issue #122.
5. **Optional, later:** `POST /ClientLog/Document` accept-and-discard stub and real `/Studios` data. Playlists, lyrics, and universal audio only matter once silo exposes those content types through jellycompat.
## Notes
- Path normalization (`path_normalization.go`) already handles Wholphin's casing and any `/emby`//`jellyfin` prefixes, so none of the covered routes are at risk from URL-shape differences.
- Unmatched jellycompat paths fall through to chi's default 404 with no body; jellyfin-sdk-kotlin surfaces these as `InvalidStatusException`, which Wholphin generally catches per-feature.
+82
View File
@@ -0,0 +1,82 @@
# PR #124 (ebooks) — Review & Hardening Pass
Date: 2026-06-10
Scope: full review of the ebook feature branch (`work/ebooks-reader-base`) followed by
fixes for every finding. Two review rounds were performed (a full-PR review, then an
adversarial edge-case/security pass over both the original code and the first round of
fixes), with all changes verified against the full Go test suite, the full web test
suite, production frontend build, ESLint/Prettier, and `make verify-local-paths`.
Commands assume the repository root is the cwd.
## Security fixes
- **Stored XSS via book content (high):** book sections render in same-origin blob
iframes with `allow-scripts` (WebKit requirement). A Content-Security-Policy is now
served with all SPA HTML responses (`internal/server/frontend.go`) — blob/srcdoc
documents inherit it, so `script-src 'self' 'wasm-unsafe-eval'` blocks script
execution from book content on all browsers. Threat model documented on the policy
constant. `X-Content-Type-Options: nosniff` added to frontend, jellycompat, and
ebook file responses.
- Ebook file serving can no longer fall through to `application/octet-stream` for an
admitted file (extension/container whitelist and MIME resolution share one resolver),
closing a browser-sniff escape combined with the `?token=` query-auth fallback.
- External links in books are intercepted app-side: http(s) only, opened with
`noopener,noreferrer` (blocks reverse tabnabbing and `javascript:` URLs).
- Request size caps (`http.MaxBytesReader`, 413) on progress/config/annotation writes;
`Content-Disposition` built with `mime.FormatMediaType` (RFC 5987, no header
injection); annotation PATCH is atomic (`SELECT ... FOR UPDATE`) with
presence-aware field semantics and invariant re-validation.
- Verified safe under adversarial review: no IDOR (all reader-state SQL scoped to
user+profile+content), no path traversal, range serving via stdlib, progress input
validation. Accepted residual: CSS-only loads inside book iframes can leak the
book's own content but cannot reach tokens, DOM, or navigation.
## Reliability fixes
- **Scanner missing-file reconciliation (critical class):** ebook scans now mark
missing files like video/audio, with real walk-failure tracking (failed roots are
excluded from deletion), symlinked-root support via the shared logical walker, and
the empty-root cleanup allowance — an unmounted share can no longer wipe a library.
- **Enrichment:** provider errors record failures (dedicated `ebook_enrichment_state`
backoff table, capped retries) instead of permanently stamping items done;
unconfigured chains and scan-window races skip without stamping. No longer shares
`media_items.refresh_failures` with the metadata refresh-debt system.
- Ebook items are created `pending` and promoted to `matched` on enrichment (backfill
migration included), making curated-metadata protection real: matched items keep
provider titles/people/series on re-scan (fill-empty only).
- PDF metadata: head+tail window scan (non-linearized PDFs keep the Info dict at the
end), delimiter-aware key matching, head-wins merge. `.md` dropped as an ebook
format; plain `.fb2` reads capped like `.fbz`.
- FK-cascade indexes added for the reader-state tables.
## Consistency & correctness fixes
- Hidden-history (`user_history_hidden_items`) gating applied to every ebook progress
surface (watched/in-progress filters, all sort plans, Continue Reading, sort
metrics, `Played` state, recommendation signals) with the exact video semantics.
- `GetItemWatchers` counts distinct watchers (no binge inflation) and the
`minWatchers` floor counts distinct accounts, not profiles; ebook reading now feeds
implicit taste signals and taste-seed candidates; Continue Reading pages past
dismissals and dedupes across pages.
- The 0.9 finished threshold is centralized (`models.EbookFinishedProgressThreshold`).
- Reader frontend: open-flow race teardown (no wrong-file progress saves), ordered
progress writes with `pagehide`/`visibilitychange` flush, settings no longer
clobbered by late server config, TTS stop actually stops, Media Session cleanup,
512 MiB download guard, fraction bookmarks navigable.
## Feature completion
- Native read-state endpoints: `POST/DELETE /watched/{id}` and `/history/remove`
accept ebook content IDs (mark read = progress 1.0 preserving location; mark unread
mirrors video unwatch and clears position; history removal hides without losing
position). Web UI exposes Mark Read/Unread on the item page and card menus, with
Continue Reading dismiss copy and dismissal-path ID encoding fixed.
## Client follow-ups (Android / Apple)
- Ebook leaf user data encodes the reading ratio as `position_seconds` (0..1) with
`duration_seconds = 1`; do not render absolute times for ebooks.
- `/watched/{id}` returns `{type: "ebook", affected_count: 1, played: bool}` and the
existing watched SSE event fires.
- Reading progress is deliberately per-book (cross-format): one position per content
ID.
@@ -0,0 +1,138 @@
# ABS Wire-Shape Verification (post collections-unify cutover)
Breadcrumbs for the next person debugging an ABS endpoint wire-shape issue
after the canonical-tables cutover (migration 156 + commits `0dc830e`,
`8c7fe1b`, `b64ce17`).
The Go in-memory structs (`abs.Collection`, `abs.Playlist`,
`abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem`) have **no
`json:"..."` struct tags**. The JSON wire contract is defined entirely by
the `*ToABS()` map-builder helpers in `internal/audiobooks/abs/`. As long as
the store layer populates the struct fields with the same values, the wire
shape is preserved. The rewrites in `0dc830e`, `8c7fe1b`, `b64ce17` did NOT
modify the emitters — only the SQL-backed store implementations.
## Envelope tests — which one guards which endpoint
Run with `go test ./internal/audiobooks/abs/ -run Envelope -v -count=1`.
| Test file | Functions | Guards |
|---|---|---|
| `collections_envelope_test.go` | `TestCollectionEnvelope_HasRequiredKeys`, `TestCollectionListShape_OmitsBooks` | `collectionToABS` keys; list-shape (no `books`) vs detail-shape (with `books`) for `GET /api/collections`, `GET /api/collections/{id}`, `GET /api/libraries/{id}/collections`, and all POST/PATCH/DELETE collection endpoints |
| `playlists_envelope_test.go` | `TestPlaylistEnvelope_HasRequiredKeys`, `TestPlaylistEnvelope_OmitsCoverPathWhenEmpty`, `TestPlaylistListShape_OmitsItems` | `playlistToABS` keys; list-shape (no `items`) vs detail-shape (with `items`); `coverPath` is omitted when `CoverItem == ""` — covers `GET /api/playlists`, `GET /api/playlists/{id}`, `GET /api/libraries/{id}/playlists`, batch and item-add/remove endpoints |
| `smart_collections_envelope_test.go` | `TestSmartCollectionEnvelope_HasRequiredKeys`, `TestSmartCollectionEnvelope_EmptyQueryDef` | `smartCollectionToABS` keys; `queryDef` decoded from raw JSONB bytes into nested object, empty bytes → `{}` — covers `GET /api/me/smart-collections`, `GET /api/me/smart-collections/{id}`, POST/PATCH equivalents |
| `bookmarks_envelope_test.go` | `TestBookmarkEnvelope_HasRequiredKeys` | Bookmarks emitter (separate from this cutover, not affected by migration 156) |
| `login_envelope_test.go` | `TestLoginEnvelope_HasRequiredKeys` and three xReturnTokens / displayName variants | Login envelope (not affected by migration 156) |
In addition, handler-level round-trip tests live in
`playlists_handler_test.go` and `bookmarks_handler_test.go`. There is NO
snapshot/goldenfile harness in the repo today — these envelope tests are
the primary regression guard.
## Manual live-DB diff procedure
For a pre/post-deploy wire-shape verification against a live silo, see the
plan's Task 5 "manual verification" section at
`docs/superpowers/plans/2026-05-27-collections-unify-3-abs-adapters.md`
(§ `Task 5: Wire-shape regression test`). Summary:
1. Pre-cutover, seed one of each (collection, playlist with item,
smart collection) via the old `abs_*` tables, then capture each list
endpoint's response to `/tmp/wire_before_<kind>.json` using a curl
against the running silo with a valid ABS bearer token (HS256 JWT —
minted by the login flow, NOT the raw `abs_sessions.token` value).
2. Apply migration 156. Seed equivalent rows in `user_personal_collections`
with the same IDs and content. Capture again to
`/tmp/wire_after_<kind>.json`.
3. `diff /tmp/wire_before_<kind>.json /tmp/wire_after_<kind>.json` for each
`kind in {collections,playlists,smart_collections}`. Expected: empty
diff.
This is an MR-description-level manual step, not a committed test.
## Intentionally-zero fields after the rewrite
These wire keys are still emitted, but the store always populates the
in-memory field with the zero value because the canonical
`user_personal_collections` schema has no analog column (per spec §6 of the
collections-unify plan). They are NOT bugs — do not "fix" them by reaching
for some other column.
| In-memory field | Wire key | Zero value | Spec ref | Disposition |
|---|---|---|---|---|
| `abs.Playlist.CoverItem` | `coverPath` | `""` (key omitted entirely when empty — see `playlistToABS`) | spec §6.1 | Dropped. PATCH `coverPath` body field is silently ignored by the store. Cover regeneration from first-item poster is the chosen long-term path. |
| `abs.SmartCollection.Color` | `color` | `""` (key always emitted as empty string) | spec §6.3 | Deferred. No column on `user_personal_collections`. Wire key stays present for client compatibility. |
| `abs.SmartCollection.IsPinned` | `isPinned` | `false` (key always emitted) | spec §6.2 | Deferred. Same rationale. |
If you're adding a "Pin this smart collection" feature later, the column
needs to land in a new migration on `user_personal_collections` first;
don't try to thread it through some adjacent column.
## Canonical mapping — struct field → source column
The full pre-cutover wire contract was captured in a working note that does
not persist (`/tmp/abs_wire_contract.md`). The essentials are reproduced
here so the next maintainer doesn't have to re-derive them.
All three struct families now read from `user_personal_collections`
(and `user_personal_collection_items` for collections + playlists),
discriminated by `collection_type IN ('manual','playlist','smart')`.
### `abs.Collection` (`collection_type = 'manual'`)
| Field | Source column |
|---|---|
| ID | `user_personal_collections.id` |
| UserID | `user_personal_collections.user_id::text` (column is `integer`) |
| ProfileID | `user_personal_collections.profile_id` |
| Name | `user_personal_collections.name` |
| Description | `user_personal_collections.description` |
| IsPublic | `user_personal_collections.is_shared` |
| CreatedAt | `user_personal_collections.created_at` |
| UpdatedAt | `user_personal_collections.updated_at` |
`abs.CollectionItem` reads `user_personal_collection_items` with
`sub_item_id = ''` filter (the manual-collection sentinel established in
migration 156 step 1). LibraryItemID ← `media_item_id`. ORDER BY
`added_at ASC`.
### `abs.Playlist` (`collection_type = 'playlist'`)
Same column mapping as `abs.Collection` (modulo `collection_type` filter)
EXCEPT `CoverItem` which is always `""` — see "Intentionally-zero fields"
above.
`abs.PlaylistItem` reads `user_personal_collection_items` with NO
`sub_item_id` filter (playlists can carry episode entries). Mapping:
LibraryItemID ← `media_item_id`, EpisodeID ← `sub_item_id`,
Position ← `position`. ORDER BY `position ASC, added_at ASC`.
### `abs.SmartCollection` (`collection_type = 'smart'`)
Same column mapping as `abs.Collection` EXCEPT:
- `Color`, `IsPinned` → always zero (see above).
- `QueryDef` ← `user_personal_collections.query_definition` (JSONB → `[]byte`
round-trip; column is `NOT NULL DEFAULT '{}'::jsonb` per migration 016).
No items table — smart-collection membership is evaluated at request time
via the `smartcoll` package.
### Wire-shape quirks worth remembering
- Collection/Playlist emit `lastUpdate` (NOT `updatedAt`). SmartCollection
emits `updatedAt`. Cross-struct inconsistency, carry forward verbatim.
- All timestamps are `UnixMilli()` int64, NOT RFC3339 strings.
- `ProfileID` is carried in memory but NEVER emitted on the wire — it's
scope/auth only.
- The list vs detail shape distinction is implicit: list responses pass
`nil` for the items/books slice; the emitter then omits the key
entirely. Clients differentiate on key presence.
## Verification status (2026-05-27)
- All 13 envelope tests pass (run: `go test ./internal/audiobooks/abs/
-run Envelope -v -count=1`).
- Full audiobooks test suite passes (`go test ./internal/audiobooks/...
-short -count=1 -timeout 120s`).
- Live-DB diff was NOT executed in CI — see manual procedure above.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,916 @@
# Audiobooks Absorption — Sub-plan 1: Discovery + Schema
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Resolve open data-model questions from the audiobooks spec, then land the foundational schema (two new tables, optional one column add, one feature-flag setting) and a compiling-but-empty `internal/audiobooks/` package scaffold. No user-visible behavior change after this sub-plan; silo continues to operate exactly as it does today.
**Architecture:** Six tasks total. Task 1 audits silo-server's existing schema/code and writes a findings doc that locks the remaining decisions. Tasks 2–5 land additive SQL migrations (idempotent up + symmetric down). Task 6 creates a Go package skeleton with one no-op service constructor wired into the dependency-injection point in `cmd/silo`, which proves the new package compiles inside the rest of the binary without changing any runtime behavior.
**Tech Stack:** Go 1.26 + pgx; PostgreSQL 18 (pgvector image); plain `.up.sql` / `.down.sql` migrations applied at startup by `internal/database/migrate.go`. Frontend out of scope for this sub-plan.
**Source spec:** `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
---
## File Structure
| Path | Created/Modified | Purpose |
|---|---|---|
| `docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md` | Create | Findings doc produced by Task 1. Locks the remaining schema/code decisions. |
| `migrations/147_abs_sessions.up.sql` | Create | New table parallel to `jellycompat_sessions`. |
| `migrations/147_abs_sessions.down.sql` | Create | Drop the table. |
| `migrations/157_podcast_feeds.up.sql` | Create | New table for RSS-subscribed podcast metadata. |
| `migrations/157_podcast_feeds.down.sql` | Create | Drop the table. |
| `migrations/159_media_folders_kind_noop.up.sql` | Create (conditional — see Task 4 step 1) | Document `media_folders.type='audiobooks'`; no column change needed. |
| `migrations/159_media_folders_kind_noop.down.sql` | Create (same condition) | No-op rollback. |
| `migrations/160_audiobooks_feature_flag.up.sql` | Create | Insert `audiobooks.enabled='false'` into `server_settings`. |
| `migrations/160_audiobooks_feature_flag.down.sql` | Create | Delete the setting row. |
| `internal/audiobooks/doc.go` | Create | Package comment + intent. |
| `internal/audiobooks/service.go` | Create | Empty `Service` struct, `New` constructor, `Enabled()` reader. |
| `internal/audiobooks/service_test.go` | Create | One unit test proving the package compiles and `Enabled()` returns the setting value. |
| `cmd/silo/main.go` | Modify | Construct `audiobooks.New(...)` once during startup so the scaffold is referenced. No routes are mounted, no tasks registered. |
Migrations and the scaffold compile and run independently from the audiobooks ports that arrive in later sub-plans.
---
## Task 1: Discovery Audit
Audits silo-server's existing schema and code to resolve the spec's open
Risk questions. Produces one findings document and one commit. No code
changes elsewhere.
**Files:**
- Create: `docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md`
### Step 1.1: Create the findings doc skeleton
- [ ] **Create** `docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md` with this exact content:
```markdown
# Audiobooks Absorption — Discovery Findings
Produced by sub-plan 1, Task 1. Locks data-model and integration
decisions for migrations 139–142 and downstream sub-plans.
## D1 — Next migration number
(Filled in step 1.2)
## D2 — `media_libraries` kind/type column
(Filled in step 1.3)
## D3 — `media_files.chapters` JSONB shape
(Filled in step 1.4)
## D4 — `user_watch_progress` scoping (profile vs user)
(Filled in step 1.5)
## D5 — `user_playback_sessions` audiobook fit
(Filled in step 1.6)
## D6 — `people` / `item_people` role conventions
(Filled in step 1.7)
## D7 — Catalog FTS handling of `type='audiobook'`
(Filled in step 1.8)
## D8 — First-party scheduled-task registration
(Filled in step 1.9)
```
### Step 1.2: D1 — Migration number
- [ ] **Run:** `ls migrations/ | grep -E '^[0-9]+_' | sort -t_ -k1 -n | tail -3`
Expected output is three filenames whose numeric prefixes are consecutive.
Whatever the highest prefix is, the next available number is **(highest + 1)**.
- [ ] **Append to the findings doc** under the D1 heading:
```text
Highest existing migration: <NN>_<name>.up.sql
Next available number: <NN+1>
Sub-plan 1 will use migrations <NN+1> through <NN+4>.
```
(Replace the placeholders with the observed values.) This locks the
numbering used by Tasks 2–5. If the next number is not 139, update the
filenames in Tasks 2–5 accordingly when you reach them.
### Step 1.3: D2 — `media_libraries` kind/type column
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.media_libraries"`
Inspect the column list. Look for any column whose name or comment
suggests a per-library *kind* / *type* / *category* discriminator
(candidate names: `kind`, `library_type`, `category`, `media_type`,
`content_type`).
- [ ] **Append to the findings doc** under the D2 heading exactly one of:
- `No existing kind/type column on media_libraries. Task 4 adds 'kind' as planned.`
- `Existing column '<NAME>' (<TYPE>) discriminates library content. Task 4 becomes a no-op migration; the audiobook scanner branch will read '<NAME>' instead of 'kind'.`
### Step 1.4: D3 — `media_files.chapters` JSONB shape
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT chapters FROM media_files WHERE chapters IS NOT NULL AND jsonb_array_length(chapters) > 0 LIMIT 1;"`
This returns one sample chapter array used by silo's existing video
chapter feature (added in migration 066).
- [ ] **Append to the findings doc** under the D3 heading:
```text
Sample chapter JSON (live data):
<paste the JSON exactly as returned, no edits>
Sub-plan 2 (scanner) MUST emit objects with the same keys when writing
audiobook chapters so the existing player and serialization code accept
them without changes.
```
### Step 1.5: D4 — `user_watch_progress` scoping
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.user_watch_progress"`
- [ ] **Append to the findings doc** under the D4 heading exactly one of:
- `user_watch_progress is profile-scoped: column '<profile_id>' (FK to user_profiles). Audiobook progress slots in directly.`
- `user_watch_progress is user-scoped only (no profile column). Audiobook progress will share user state across all household profiles unless Sub-plan 3 adds a profile_id column. RAISE THIS AS A RISK in sub-plan 2 brainstorming.`
### Step 1.6: D5 — `user_playback_sessions` fit
- [ ] **Run:** `sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.user_playback_sessions"`
- [ ] **Append to the findings doc** under the D5 heading:
```text
Column list:
<paste the \d output verbatim>
Columns required by audiobook sessions: media_item_id (or equivalent),
profile/user FK, started_at, current_position_seconds (or equivalent),
status. Mark any required column as MISSING and surface in sub-plan 3.
```
### Step 1.7: D6 — `people` / `item_people` role conventions
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.item_people"
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT DISTINCT role FROM item_people ORDER BY role;"
```
- [ ] **Append to the findings doc** under the D6 heading:
```text
item_people.role storage: <TEXT | enum named '<name>' | CHECK constraint>
Existing role values in use: <comma-separated list from the SELECT>
Sub-plan 2 will UPSERT 'author' and 'narrator' into item_people for
audiobook items. If the role column is constrained by an enum or CHECK,
Sub-plan 2 must add 'author' and 'narrator' to the constraint as part of
its first migration.
```
### Step 1.8: D7 — FTS handling of new `type` values
- [ ] **Run:**
```bash
grep -lE "type\s*=\s*'(movie|series|episode)'" migrations/*.up.sql | head -5
```
These migrations build/maintain silo's title search. Open each one and
scan for whether the type filter is hardcoded (`type IN ('movie','series')`)
or generic (no type filter, indexes everything).
- [ ] **Append to the findings doc** under the D7 heading:
```text
Indexes / generated columns that filter by media_items.type:
<bulleted list of (migration_file, what it filters)>
Verdict: audiobooks WILL / WILL NOT be FTS-searchable out of the box.
If WILL NOT: Sub-plan 3 must include an extra migration that extends the
type filter to include 'audiobook' and 'podcast'.
```
### Step 1.9: D8 — Scheduled-task registration
- [ ] **Run:**
```bash
grep -rEn "RegisterTask|TaskManager|RegisterScheduled|task\.Register" cmd/silo/main.go internal --include='*.go' | head -10
```
- [ ] **Append to the findings doc** under the D8 heading:
```text
First-party scheduled tasks register at: <file:line>
Registration call shape: <one-line copy of the call as used by an
existing first-party task such as smart-count refresh>
Sub-plan 5 (podcasts) will register podcastfeed.Refresher at the same
call site using the same pattern.
```
### Step 1.10: Commit
- [ ] **Run:**
```bash
git add docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md
git commit -m "$(cat <<'EOF'
docs(audiobooks): discovery findings for absorption sub-plan 1
Locks schema/code decisions for migrations 139-142 and downstream
sub-plans. Resolves open Risk questions from the absorption design spec.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Migration 147 — `abs_sessions`
Parallel of `jellycompat_sessions` for Audiobookshelf clients. Lets ABS
mobile/desktop apps reconnect without re-authenticating against silo's
main `auth_sessions`.
**Files:**
- Create: `migrations/147_abs_sessions.up.sql`
- Create: `migrations/147_abs_sessions.down.sql`
> **Note:** If Task 1, Step 1.2 found the next number is not 139, rename
> both filenames to match. All other tasks downstream of 139 shift by the
> same delta.
### Step 2.1: Write the failing assertion
- [ ] **Run** (this should error — the table does not exist yet):
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.abs_sessions"
```
Expected: `Did not find any relation named "public.abs_sessions".`
### Step 2.2: Create the up migration
- [ ] **Create** `migrations/147_abs_sessions.up.sql` with:
```sql
-- Audiobookshelf-compatible client sessions. Parallel to
-- jellycompat_sessions: each row identifies an ABS mobile/desktop
-- client by its device + token so it can reconnect without
-- re-authenticating against silo's main auth_sessions.
CREATE TABLE IF NOT EXISTS public.abs_sessions (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
device_id TEXT NOT NULL,
device_name TEXT,
client_name TEXT,
client_version TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
last_seen_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_abs_sessions_token_hash
ON public.abs_sessions (token_hash);
CREATE INDEX IF NOT EXISTS idx_abs_sessions_user_device
ON public.abs_sessions (user_id, device_id);
CREATE INDEX IF NOT EXISTS idx_abs_sessions_last_seen
ON public.abs_sessions (last_seen_at);
```
### Step 2.3: Create the down migration
- [ ] **Create** `migrations/147_abs_sessions.down.sql` with:
```sql
DROP INDEX IF EXISTS public.idx_abs_sessions_last_seen;
DROP INDEX IF EXISTS public.idx_abs_sessions_user_device;
DROP INDEX IF EXISTS public.idx_abs_sessions_token_hash;
DROP TABLE IF EXISTS public.abs_sessions;
```
### Step 2.4: Apply the migration by restarting silo
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy:**
```bash
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
```
- [ ] **Verify silo applied the migration:**
```bash
sudo docker logs --since 2m silo-prod-silo-1 2>&1 | grep -i 'database migrations applied'
```
Expected: one line containing `database migrations applied`.
### Step 2.5: Verify the table exists and is usable
- [ ] **Run** (each command should now succeed):
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.abs_sessions"
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 147;"
```
Expected: `\d` prints the full column list; the `SELECT` returns one row with `version=147`.
- [ ] **Run** a sample insert/delete to prove constraints work:
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
INSERT INTO abs_sessions (user_id, token_hash, device_id, device_name, client_name, client_version)
VALUES ((SELECT id FROM users ORDER BY id LIMIT 1), 'test-token-hash-001', 'test-device-001', 'Test Device', 'AbsTestClient', '1.0.0');
DELETE FROM abs_sessions WHERE token_hash = 'test-token-hash-001';
"
```
Expected: `INSERT 0 1` then `DELETE 1`. (At least one user must exist.)
### Step 2.6: Commit
- [ ] **Run:**
```bash
git add migrations/147_abs_sessions.up.sql migrations/147_abs_sessions.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 147 add abs_sessions table
Parallel of jellycompat_sessions for Audiobookshelf-compatible clients.
Lets ABS mobile/desktop apps maintain a device-bound session that
silo's audiobooks/abs handlers will validate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Migration 157 — `podcast_feeds`
One row per subscribed podcast. The podcast itself lives in `media_items`
(`type='podcast'`); this side table carries the RSS-specific metadata the
feed refresher needs.
**Files:**
- Create: `migrations/157_podcast_feeds.up.sql`
- Create: `migrations/157_podcast_feeds.down.sql`
### Step 3.1: Write the failing assertion
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.podcast_feeds"
```
Expected: `Did not find any relation named "public.podcast_feeds".`
### Step 3.2: Create the up migration
- [ ] **Create** `migrations/157_podcast_feeds.up.sql` with:
```sql
-- RSS-feed metadata for subscribed podcasts. One row per podcast
-- media_items row; the feed refresher polls feed_url every
-- refresh_interval_seconds and upserts new episodes into the existing
-- episodes table.
CREATE TABLE IF NOT EXISTS public.podcast_feeds (
media_item_id TEXT PRIMARY KEY
REFERENCES public.media_items(content_id) ON DELETE CASCADE,
feed_url TEXT NOT NULL,
etag TEXT,
last_modified TEXT,
last_refreshed_at TIMESTAMP WITH TIME ZONE,
last_refresh_error TEXT,
refresh_interval_seconds INTEGER NOT NULL DEFAULT 600,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_podcast_feeds_feed_url
ON public.podcast_feeds (feed_url);
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_due_for_refresh
ON public.podcast_feeds (last_refreshed_at);
```
> **Note:** `media_items.content_id` is TEXT (silo uses snowflake-style
> string IDs, not BIGSERIAL — confirmed by the FK targets in other
> migrations). If Task 1 Step 1.4 sampled a numeric `content_id`,
> change the column type to match. Otherwise leave as TEXT.
### Step 3.3: Create the down migration
- [ ] **Create** `migrations/157_podcast_feeds.down.sql` with:
```sql
DROP INDEX IF EXISTS public.idx_podcast_feeds_due_for_refresh;
DROP INDEX IF EXISTS public.idx_podcast_feeds_feed_url;
DROP TABLE IF EXISTS public.podcast_feeds;
```
### Step 3.4: Apply the migration
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy:**
```bash
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
```
### Step 3.5: Verify
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "\d public.podcast_feeds"
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT version FROM schema_versions WHERE version = 157;"
```
Expected: column list printed; one row with `version=157`.
### Step 3.6: Commit
- [ ] **Run:**
```bash
git add migrations/157_podcast_feeds.up.sql migrations/157_podcast_feeds.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 157 add podcast_feeds table
Side table on media_items for RSS-subscribed podcasts. Holds feed URL,
ETag/Last-Modified for conditional fetches, last-refresh timestamp, and
the per-feed refresh interval consumed by the upcoming
podcastfeed.Refresher scheduled task.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Migration 159 — `media_libraries.kind` audiobook value (no-op)
Adds a discriminator column so the scanner can branch on
`audiobooks`/`podcasts` libraries without filename heuristics.
> **CONDITIONAL:** If Task 1 Step 1.3 (D2) recorded *"Existing column
> '<NAME>' discriminates library content"*, swap this whole task for a
> documented no-op: create both migration files as no-op SQL (`-- intentionally empty: existing column '<NAME>' covers this need`)
> and adjust downstream sub-plans to read `'<NAME>'` instead of `kind`.
> Otherwise, proceed with the steps below.
**Files:**
- Create: `migrations/159_media_folders_kind_noop.up.sql`
- Create: `migrations/159_media_folders_kind_noop.down.sql`
### Step 4.1: Write the failing assertion
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "SELECT kind FROM media_libraries LIMIT 1;"
```
Expected: `column "kind" does not exist`.
### Step 4.2: Create the up migration
- [ ] **Create** `migrations/159_media_folders_kind_noop.up.sql` with:
```sql
-- Per-library content discriminator. Lets the scanner choose the right
-- parser (movie/tv extensions vs audiobook/podcast extensions) without
-- filename heuristics. Existing rows default to 'movies' to preserve
-- current behavior; operators set 'audiobooks' or 'podcasts' on the
-- libraries they want the new flavor for.
ALTER TABLE public.media_libraries
ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'movies';
ALTER TABLE public.media_libraries
ADD CONSTRAINT media_libraries_kind_check
CHECK (kind IN ('movies', 'tv', 'audiobooks', 'podcasts'));
CREATE INDEX IF NOT EXISTS idx_media_libraries_kind
ON public.media_libraries (kind);
```
### Step 4.3: Create the down migration
- [ ] **Create** `migrations/159_media_folders_kind_noop.down.sql` with:
```sql
DROP INDEX IF EXISTS public.idx_media_libraries_kind;
ALTER TABLE public.media_libraries
DROP CONSTRAINT IF EXISTS media_libraries_kind_check;
ALTER TABLE public.media_libraries
DROP COLUMN IF EXISTS kind;
```
### Step 4.4: Apply the migration
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy** (same command as Task 2 Step 2.4).
### Step 4.5: Verify
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
SELECT id, name, kind FROM media_libraries ORDER BY id;
"
```
Expected: every existing library row has `kind='movies'`.
- [ ] **Run** to confirm the CHECK rejects bad values:
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
INSERT INTO media_libraries (id, name, kind) VALUES (-9999, 'bad-test', 'garbage');
"
```
Expected: `ERROR: new row for relation "media_libraries" violates check constraint "media_libraries_kind_check"`.
- [ ] **Run** to clean up any test row that snuck through (no-op if the previous step rejected as expected):
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "DELETE FROM media_libraries WHERE id = -9999;"
```
### Step 4.6: Commit
- [ ] **Run:**
```bash
git add migrations/159_media_folders_kind_noop.up.sql migrations/159_media_folders_kind_noop.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 159 document audiobooks media folder type
Per-library discriminator that lets the scanner pick the right parser
('movies', 'tv', 'audiobooks', 'podcasts') instead of inferring from
filename. Existing libraries default to 'movies'; operators set the new
values on the libraries they want indexed with audiobook/podcast logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Migration 160 — `audiobooks.enabled` feature flag
Adds a `server_settings` row so subsequent sub-plans can branch on
"audiobooks compiled in but turned off" vs "fully live". Default is
`false` so this sub-plan's landing is a strict no-op for users.
**Files:**
- Create: `migrations/160_audiobooks_feature_flag.up.sql`
- Create: `migrations/160_audiobooks_feature_flag.down.sql`
### Step 5.1: Write the failing assertion
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
SELECT value FROM server_settings WHERE key = 'audiobooks.enabled';
"
```
Expected: zero rows.
### Step 5.2: Create the up migration
- [ ] **Create** `migrations/160_audiobooks_feature_flag.up.sql` with:
```sql
-- Master kill-switch for the absorbed audiobooks feature. Defaults to
-- 'false' so landing migrations 147/157/159/160 is a no-op for users; operators
-- flip this to 'true' at cutover.
INSERT INTO server_settings (key, value) VALUES ('audiobooks.enabled', 'false')
ON CONFLICT (key) DO NOTHING;
```
### Step 5.3: Create the down migration
- [ ] **Create** `migrations/160_audiobooks_feature_flag.down.sql` with:
```sql
DELETE FROM server_settings WHERE key = 'audiobooks.enabled';
```
### Step 5.4: Apply and verify
- [ ] **Run:** `sudo docker compose -p silo-prod restart silo`
- [ ] **Wait until healthy** (same command).
- [ ] **Run:**
```bash
sudo docker exec silo-prod-postgres-1 psql -U silo -d silo -c "
SELECT key, value FROM server_settings WHERE key = 'audiobooks.enabled';
"
```
Expected: one row, `value='false'`.
### Step 5.5: Commit
- [ ] **Run:**
```bash
git add migrations/160_audiobooks_feature_flag.up.sql migrations/160_audiobooks_feature_flag.down.sql
git commit -m "$(cat <<'EOF'
feat(audiobooks): migration 160 add audiobooks.enabled flag
Server-settings row that gates the absorbed audiobooks feature.
Defaults to 'false' so sub-plan 1 lands as a strict no-op; subsequent
sub-plans branch on this flag and operators flip it to 'true' at
cutover.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Scaffold `internal/audiobooks/` package
Creates an empty but compiling Go package, wires its constructor into
`cmd/silo/main.go` so the package is referenced from the binary, and
ships a unit test that proves the `Enabled()` reader returns the flag
value seeded by migration 160.
This task proves the package compiles inside the rest of silo without
changing any runtime behavior. No routes are mounted. No scheduled
tasks are registered. No DB writes happen.
**Files:**
- Create: `internal/audiobooks/doc.go`
- Create: `internal/audiobooks/service.go`
- Create: `internal/audiobooks/service_test.go`
- Modify: `cmd/silo/main.go` (one new constructor call near the other service constructions)
### Step 6.1: Write the failing test
- [ ] **Create** `internal/audiobooks/service_test.go` with:
```go
package audiobooks
import (
"context"
"errors"
"testing"
)
type fakeSettingsReader struct {
value string
err error
}
func (f *fakeSettingsReader) GetString(_ context.Context, key string) (string, error) {
if key != "audiobooks.enabled" {
return "", errors.New("unexpected key: " + key)
}
return f.value, f.err
}
func TestServiceEnabledReadsFlag(t *testing.T) {
cases := []struct {
name string
stored string
want bool
}{
{"flag true", "true", true},
{"flag false", "false", false},
{"flag empty defaults false", "", false},
{"flag garbage defaults false", "yes-please", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
svc := New(&fakeSettingsReader{value: tc.stored})
got, err := svc.Enabled(context.Background())
if err != nil {
t.Fatalf("Enabled returned error: %v", err)
}
if got != tc.want {
t.Fatalf("Enabled = %v, want %v", got, tc.want)
}
})
}
}
func TestServiceEnabledPropagatesError(t *testing.T) {
wantErr := errors.New("db down")
svc := New(&fakeSettingsReader{err: wantErr})
_, err := svc.Enabled(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Enabled error = %v, want %v wrapped", err, wantErr)
}
}
```
### Step 6.2: Run the test to verify it fails
- [ ] **Run:** `go test ./internal/audiobooks/...`
Expected: build failure with messages like `undefined: New` and
`package audiobooks not found`.
### Step 6.3: Write the package doc
- [ ] **Create** `internal/audiobooks/doc.go` with:
```go
// Package audiobooks owns silo's first-party audiobook + podcast feature,
// absorbed from the historical silo-plugin-audiobooks. Sub-plan 1 lands
// only the package scaffold and the kill-switch reader; ABS-compat REST,
// Socket.io, scanner branches, podcast feed refresh, and the silo SPA
// pages arrive in later sub-plans.
//
// See docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md
// for the design and docs/superpowers/plans/2026-05-24-audiobooks-*.md
// for the staged implementation plans.
package audiobooks
```
### Step 6.4: Write the service
- [ ] **Create** `internal/audiobooks/service.go` with:
```go
package audiobooks
import (
"context"
"fmt"
)
// SettingsReader is the minimal slice of the server-settings store that
// the audiobooks service needs. The production implementation is
// internal/serversettings.Store (or whatever silo names that helper at
// wiring time); tests pass a fake.
type SettingsReader interface {
GetString(ctx context.Context, key string) (string, error)
}
// Service is the audiobooks feature's top-level orchestrator. Sub-plan 1
// exposes only Enabled(); subsequent sub-plans hang additional methods
// off Service as new capabilities (scanner branches, ABS handlers, etc.)
// come online.
type Service struct {
settings SettingsReader
}
// New constructs a Service. The constructor takes the dependencies it
// will actually use; current sub-plan needs only the settings reader.
func New(settings SettingsReader) *Service {
return &Service{settings: settings}
}
// Enabled reports whether the audiobooks feature flag (set by
// 160_audiobooks_feature_flag and toggled by operators) is currently true.
// Any value other than the literal string "true" reads as false; this matches how silo
// treats other boolean server_settings rows.
func (s *Service) Enabled(ctx context.Context) (bool, error) {
if s == nil || s.settings == nil {
return false, nil
}
value, err := s.settings.GetString(ctx, "audiobooks.enabled")
if err != nil {
return false, fmt.Errorf("audiobooks: read audiobooks.enabled: %w", err)
}
return value == "true", nil
}
```
### Step 6.5: Run the test to verify it passes
- [ ] **Run:** `go test ./internal/audiobooks/...`
Expected: `ok github.com/Silo-Server/silo-server/internal/audiobooks ...`
### Step 6.6: Wire the constructor into `cmd/silo/main.go`
The audit in Task 1 Step 1.9 located the function where first-party
services are constructed at startup. Open `cmd/silo/main.go`, find the
block where other internal services (e.g. metadata, downloads, catalog)
are constructed and pass a settings store to.
- [ ] **Run** to find the right insertion point:
```bash
grep -nE 'serversettings|server_settings|settingsStore|metadata\.NewService|downloads\.NewService' cmd/silo/main.go | head -10
```
Note the file:line of the existing service construction block.
- [ ] **Modify** `cmd/silo/main.go`: in the service-construction block (immediately after another similar `service := pkg.New(...)` line), add:
```go
audiobooksService := audiobooks.New(settingsStore)
_ = audiobooksService // referenced by sub-plan 2 onward; no behavior in sub-plan 1
```
Where `settingsStore` is whatever the existing services pass in for
the server-settings reader. The `_ = audiobooksService` line keeps Go
from rejecting an unused variable; it gets removed in sub-plan 2 when
the variable is actually used.
- [ ] **Add** the import to the import block at the top of `cmd/silo/main.go`:
```go
"github.com/Silo-Server/silo-server/internal/audiobooks"
```
### Step 6.7: Verify everything still compiles and tests pass
- [ ] **Run:** `go build ./...`
Expected: exit 0, no output.
- [ ] **Run:** `go test ./internal/audiobooks/... ./cmd/...`
Expected: all passes.
- [ ] **Run:** `make lint`
Expected: no new lint findings introduced by this task.
### Step 6.8: Smoke-test the running binary
- [ ] **Run:** `sudo docker compose -p silo-prod up -d --force-recreate silo`
- [ ] **Wait until healthy** (same command as Task 2 Step 2.4).
- [ ] **Run:**
```bash
curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8090/api/v1/health
curl -sS -o /dev/null -w "HTTP %{http_code}\n" http://localhost:8090/
```
Expected: same status codes silo returned before this sub-plan
started (typically `200` for `/`, `404` for `/api/v1/health` since
that path was 404 in the baseline logs). No new errors in `docker
logs silo-prod-silo-1`.
### Step 6.9: Commit
- [ ] **Run:**
```bash
git add internal/audiobooks/doc.go internal/audiobooks/service.go internal/audiobooks/service_test.go cmd/silo/main.go
git commit -m "$(cat <<'EOF'
feat(audiobooks): scaffold internal/audiobooks package
Empty-but-compiling Service that reads the audiobooks.enabled feature
flag from server_settings. Wired into cmd/silo so the package is
referenced from the binary; no routes mounted, no scheduled tasks
registered, no DB writes. Subsequent sub-plans hang scanner branches,
ABS handlers, Socket.io, podcast refresher, and SPA pages off this
Service.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Self-review (done by the planner before handoff)
**Spec coverage:**
| Spec section | Task(s) that cover it |
|---|---|
| Data model: reused tables | Task 1 (verification only) |
| Data model: `abs_sessions` (migration 147) | Task 2 |
| Data model: `podcast_feeds` (migration 157) | Task 3 |
| Data model: `media_folders.type` audiobook value (migration 159 no-op) | Task 4 |
| Data model: data migration = none | n/a — confirmed in spec |
| Feature flag (`audiobooks.enabled`) | Task 5 |
| Architecture: `internal/audiobooks/` package | Task 6 |
| Risks: `media_libraries` column check | Task 1, Step 1.3 |
| Risks: `media_files.chapters` shape | Task 1, Step 1.4 |
| Risks: progress profile-scoping | Task 1, Step 1.5 |
| Risks: FTS handling of `audiobook` | Task 1, Step 1.8 |
| Risks: `people` role conventions | Task 1, Step 1.7 |
Out of sub-plan 1 scope (deferred to later sub-plans):
ABS REST port, Socket.io port, scanner branches, podcast refresher,
silo SPA pages, plugin retirement, cutover. Each is its own sub-plan
(2–6).
**Placeholder scan:** None. Every step has the actual command, the
actual SQL, or the actual Go to write. Conditional language is confined
to Task 4 (which is explicit about its condition and what to do if it
fires).
**Type consistency:** The fake `SettingsReader` interface in Task 6's
test matches the production interface defined in `service.go`. Method
name `GetString`, signature `(ctx context.Context, key string) (string, error)` — consistent across both files.
**Note on migration numbering:** Tasks 2–5 originally used numbers `139..142`
based on a snapshot showing `138_search_number_word_normalization.up.sql` as
the highest existing migration. The landed implementation was renumbered to
`147_abs_sessions`, `157_podcast_feeds`, `159_media_folders_kind_noop`, and
`160_audiobooks_feature_flag`.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,326 @@
# Audiobooks Absorption — Sub-plan 3: Silo-native API + Frontend (MVP)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Land the minimum viable silo-native audiobooks UI: backend REST endpoints + React pages so a user can browse audiobook libraries scanned by sub-plan 2, see book details with chapters, play with progress saved. Author/series index pages, smart collections, share links, and other features from the spec's "nice-to-haves" list are deferred to a future sub-plan if the demand surfaces.
**Architecture:** Three new Go handler files under `internal/api/handlers/audiobooks_*.go` for list/detail/progress. New `web/src/pages/audiobooks/` directory with three pages: Library, Detail, and an inline player. Hooks under `web/src/hooks/audiobooks/`. The streaming path reuses silo's existing `/api/v1/stream/{session_id}` endpoint — no new transcode code.
**Tech stack:** Go + chi router + pgx for backend; React 19 + TanStack Query + radix-ui + tailwind for frontend.
**Source spec:** `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
**Predecessor:** `docs/superpowers/plans/2026-05-24-audiobooks-absorption-2-scanner.md` (scanner produces `media_items.type='audiobook'`)
---
## File Structure
| Path | C/M | Purpose |
|---|---|---|
| `internal/api/handlers/audiobooks_list.go` | C | `HandleListAudiobooks` — GET /api/v1/audiobooks (paginated, filterable by library) |
| `internal/api/handlers/audiobooks_detail.go` | C | `HandleGetAudiobook` — GET /api/v1/audiobooks/{id} (returns item + chapters + author/narrator + listening progress) |
| `internal/api/handlers/audiobooks_progress.go` | C | `HandleReportAudiobookProgress` — POST /api/v1/audiobooks/{id}/progress |
| `internal/api/router.go` | M | Mount three new routes under `/api/v1/audiobooks/*` |
| `web/src/hooks/audiobooks/useAudiobookLibrary.ts` | C | TanStack Query hook for list |
| `web/src/hooks/audiobooks/useAudiobook.ts` | C | Detail + progress hooks |
| `web/src/pages/audiobooks/AudiobookLibrary.tsx` | C | Grid page (poster + title + author) |
| `web/src/pages/audiobooks/AudiobookDetail.tsx` | C | Detail page (cover, metadata, chapter list, play button) |
| `web/src/pages/audiobooks/AudiobookPlayer.tsx` | C | HTML5 `<audio>` player with chapter nav + progress reporting |
| `web/src/App.tsx` or routing entry | M | Add the three new routes |
| `web/src/components/Sidebar.tsx` (or equivalent) | M | Add "Audiobooks" navigation link when at least one audiobook library exists |
---
## Task 1: Backend — list audiobooks
GET /api/v1/audiobooks?library_id=N&limit=...&offset=... → paginated audiobook list scoped to libraries the user has access to.
**Files:** Create `internal/api/handlers/audiobooks_list.go`, modify `internal/api/router.go`.
### 1.1 Test
Add `internal/api/handlers/audiobooks_list_test.go` with a test that:
- Inserts two `media_items` rows (one `type='audiobook'`, one `type='movie'`)
- Calls the handler with a fake request
- Asserts only the audiobook is returned
If no test harness exists for handler-level tests in `internal/api/handlers/`, search neighbors (`grep -lE 'func.*Handler.*Test' internal/api/handlers/*_test.go`) for the established pattern. If integration testing requires a real DB and no harness exists, write the handler logic + a simple compile-time test only; defer the integration test (DONE_WITH_CONCERNS).
### 1.2 Handler
```go
package handlers
import (
"encoding/json"
"net/http"
"strconv"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type AudiobookHandler struct {
Items *catalog.ItemRepository
}
func (h *AudiobookHandler) HandleListAudiobooks(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 || limit > 200 {
limit = 50
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
filter := apimw.AccessFilterFor(r)
items, total, err := h.Items.Search(r.Context(), "", []string{"audiobook"}, limit, offset, filter)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "list audiobooks failed")
return
}
resp := struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}{
Items: itemSummariesAsMaps(items),
Total: total,
Limit: limit,
Offset: offset,
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
func itemSummariesAsMaps(items []*models.MediaItem) []map[string]any {
out := make([]map[string]any, 0, len(items))
for _, it := range items {
out = append(out, map[string]any{
"content_id": it.ContentID,
"title": it.Title,
"year": it.Year,
"poster_url": it.PosterPath, // silo's poster column; UI prefixes with media-token base URL
})
}
return out
}
```
Verify `catalog.ItemRepository.Search` matches the signature above by reading `internal/catalog/item_repo.go:739`. Adjust if needed.
### 1.3 Route + commit
In `internal/api/router.go`, find where other v1 routes are mounted and add:
```go
r.Get("/audiobooks", audiobookHandler.HandleListAudiobooks)
```
Wire the handler construction near other handler constructors. The handler needs `catalog.ItemRepository` — already available in the router's deps.
Commit: `feat(audiobooks): list endpoint at GET /api/v1/audiobooks`.
---
## Task 2: Backend — audiobook detail
GET /api/v1/audiobooks/{id} → media_item + media_files (with chapters) + author/narrator from item_people + per-profile listening progress.
### 2.1 Handler
Create `internal/api/handlers/audiobooks_detail.go`. The handler queries:
1. `media_items WHERE content_id = $1 AND type = 'audiobook'`
2. `media_files WHERE content_id = $1` (returns paths + chapters JSONB)
3. `item_people JOIN people ON ... WHERE content_id = $1 AND kind IN (7, 8)`
4. `user_watch_progress WHERE content_id = $1 AND profile_id = $current`
Reuse existing repo methods where they exist. If not, add inline SQL.
Response shape:
```json
{
"audiobook": { "content_id": "...", "title": "...", "year": 2024, "overview": "...", "poster_url": "..." },
"author": "Test Author",
"narrator": "Test Narrator",
"files": [
{ "id": 123, "path": "/...", "duration_seconds": 3600, "chapters": [ {"index": 0, "title": "Intro", "start_seconds": 0, "end_seconds": 27.9} ] }
],
"progress": { "position_seconds": 1842.5, "updated_at": "2026-05-24T..." }
}
```
### 2.2 Route + commit
In `router.go`: `r.Get("/audiobooks/{id}", audiobookHandler.HandleGetAudiobook)`.
Commit: `feat(audiobooks): detail endpoint at GET /api/v1/audiobooks/{id}`.
---
## Task 3: Backend — progress endpoint
POST /api/v1/audiobooks/{id}/progress with body `{"position_seconds": 1842.5, "media_file_id": 123}`. UPSERTs `user_watch_progress`.
### 3.1 Handler
Create `internal/api/handlers/audiobooks_progress.go`. Body is JSON; handler parses, validates `position_seconds >= 0`, then upserts into `user_watch_progress` keyed on `(user_id, profile_id, media_item_id)`.
Reuse silo's existing progress-write helper if one exists (grep `INSERT INTO user_watch_progress` or `UpsertWatchProgress`). If not, write inline SQL using the existing DB pool from handler deps.
### 3.2 Route + commit
`r.Post("/audiobooks/{id}/progress", audiobookHandler.HandleReportAudiobookProgress)`.
Commit: `feat(audiobooks): progress endpoint at POST /api/v1/audiobooks/{id}/progress`.
---
## Task 4: Frontend — TanStack Query hooks + types
**Files:**
- Create: `web/src/lib/audiobooks/types.ts`
- Create: `web/src/hooks/audiobooks/useAudiobookLibrary.ts`
- Create: `web/src/hooks/audiobooks/useAudiobook.ts`
- Create: `web/src/hooks/audiobooks/useReportAudiobookProgress.ts`
### 4.1 Types
```ts
// web/src/lib/audiobooks/types.ts
export interface AudiobookSummary {
content_id: string;
title: string;
year: number;
poster_url: string | null;
}
export interface AudiobookListResponse {
items: AudiobookSummary[];
total: number;
limit: number;
offset: number;
}
export interface AudiobookChapter {
index: number;
title: string;
start_seconds: number;
end_seconds: number;
}
export interface AudiobookFile {
id: number;
path: string;
duration_seconds: number;
chapters: AudiobookChapter[];
}
export interface AudiobookProgress {
position_seconds: number;
updated_at: string;
}
export interface AudiobookDetail {
audiobook: { content_id: string; title: string; year: number; overview: string; poster_url: string | null };
author: string;
narrator: string;
files: AudiobookFile[];
progress: AudiobookProgress | null;
}
```
### 4.2 Hooks
Use TanStack Query patterns silo already uses (grep `useQuery` for examples). Cache keys: `["audiobooks", "library", libraryId]` and `["audiobook", id]`. Progress mutation invalidates the detail query on success.
### 4.3 Commit
Commit: `feat(audiobooks): frontend types and TanStack Query hooks`.
---
## Task 5: Frontend — Audiobook Library page
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookLibrary.tsx`
Grid of audiobook cards (poster + title + author). Use silo's existing card / grid components — grep `web/src/components/` for `MediaCard` or similar. Infinite scroll via `useInfiniteQuery` would be ideal but a simple paginated load-more is fine for MVP.
Route URL: `/audiobooks` (or `/audiobooks/library/:id` if there are multiple libraries). For MVP, single library route `/audiobooks` is fine.
Commit: `feat(audiobooks): library grid page`.
---
## Task 6: Frontend — Audiobook Detail page
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookDetail.tsx`
Layout: cover image (poster) + metadata (title, author, year, overview) + chapter list + play button. Chapter list is collapsible; clicking a chapter starts playback at that chapter's start_seconds.
Route URL: `/audiobooks/book/:id`.
Commit: `feat(audiobooks): detail page with chapter list`.
---
## Task 7: Frontend — Audiobook Player
**Files:**
- Create: `web/src/pages/audiobooks/AudiobookPlayer.tsx` (or `web/src/player/AudiobookPlayer.tsx`)
HTML5 `<audio>` element. The stream URL for an audiobook file is silo's existing `/api/v1/stream/{session_id}` — call POST /api/v1/playback/session to start a session (existing silo endpoint), then point the `<audio src>` at the stream URL. Position updates throttled to every 5-10 seconds + on pause/seek.
Controls: play/pause, skip-30s-forward, skip-30s-back, playback rate select (0.5×, 0.75×, 1×, 1.25×, 1.5×, 2×, 3×), chapter list panel.
If silo's existing stream-session-start endpoint shape isn't obvious, grep for `playback/session` and copy the pattern from the video player.
Commit: `feat(audiobooks): HTML5 audio player with chapter navigation`.
---
## Task 8: Navigation + routing integration
**Files:**
- Modify: `web/src/App.tsx` (or wherever routes are registered) — add three new routes
- Modify: `web/src/components/Sidebar.tsx` (or equivalent) — add "Audiobooks" nav link
The sidebar link should only show when at least one audiobook library exists. Use a `useAudiobookLibrary` query with a short staleTime to gate visibility, OR a separate `useLibrariesOfKind("audiobooks")` hook.
Commit: `feat(audiobooks): wire navigation and routes`.
---
## Task 9: Build, lint, smoke
```bash
cd /opt/silo-server
go build ./...
go test ./internal/api/handlers/audiobooks_*_test.go ./internal/scanner/... ./internal/models/... ./internal/audiobooks/...
go vet ./...
cd web && pnpm run lint && pnpm run format:check && cd ..
make build
```
Rebuild silo image + force-recreate container:
```bash
sudo docker build -t silo:latest /opt/silo-server
sudo docker compose -p silo-prod up -d --force-recreate silo
until [ "$(sudo docker inspect -f '{{.State.Health.Status}}' silo-prod-silo-1 2>/dev/null)" = "healthy" ]; do sleep 2; done; echo healthy
curl -sS -o /dev/null -w "GET /api/v1/audiobooks -> %{http_code}\n" http://localhost:8090/api/v1/audiobooks
```
If any sweep changes (gofmt, prettier) were made, commit them with `chore(audiobooks): sweep formatting`.
---
## Risks
- Frontend tasks depend on silo's existing component shapes. Subagents will need to grep `web/src/components/` for the right patterns. If they invent ad-hoc components, ask them to use existing ones.
- The progress endpoint UPSERT shape depends on `user_watch_progress` PK shape — which discovery D4 confirmed is `(user_id, profile_id, content_id)`. Confirm and use exactly that ON CONFLICT target.
- Audiobook libraries may not yet exist on the test stack. To smoke-test, an operator must manually update a `media_folders.type` to `'audiobooks'` and trigger a rescan after the sub-plan 2 scanner branch lands.
@@ -0,0 +1,148 @@
# Audiobooks Absorption — Sub-plan 4: ABS-compat REST + Socket.io
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Port the audiobookshelf-compatible REST + Socket.io surface from `silo-plugin-audiobooks/internal/abs/` and `silo-plugin-audiobooks/internal/abssocket/` into `internal/audiobooks/abs/` and `internal/audiobooks/abssocket/`. **Preserve the plugin's file layout** but **rewrite every SQL query** against silo's existing tables (`media_items`, `media_files`, `episodes`, `user_watch_progress`, `user_playback_sessions`, `abs_sessions`, `people`, `item_people`). No new tables.
**Architecture:** Stage-based port. Support files first, then auth, then file serving, then progress, then author/series, then Socket.io, then router wiring. Out-of-scope features (bookmarks, smart collections, share links, playlists, podcasts/RSS in this sub-plan) are dropped — files containing them are not copied, or are copied with the unsupported handlers stubbed to 501 Not Implemented.
**Source plugin:** `/opt/silo_plugins/silo-plugin-audiobooks/internal/{abs,abssocket}/`
---
## Stage 1: Support files
**Files to copy and adapt:**
- `mount.go` — package mount + route registration shape
- `handler.go` — top-level handler type + dependency wiring
- `filter.go` — generic helpers
- `access_log.go` — request-log helper
- `minified.go` — generic helpers (likely small)
**Adapt steps:**
1. Replace import paths: `github.com/RXWatcher/silo-plugin-audiobooks/...` → `github.com/Silo-Server/silo-server/internal/audiobooks/...`
2. Replace plugin SDK references with direct silo equivalents:
- Plugin's `*pgx.Pool` field on Handler → silo's `*pgxpool.Pool` (same type via different import alias)
- Plugin's `Logger` → silo's `log/slog` package-level functions
- Plugin's config types → struct fields on `audiobooks.Service` (extended)
3. Remove any code that talks to the plugin SDK runtime (plugin proxy callbacks etc.)
4. Get `go build ./internal/audiobooks/abs/...` to compile cleanly even if no routes are wired yet.
Commit: `feat(audiobooks): port ABS handler scaffolding (mount/handler/filter)`.
---
## Stage 2: Auth + JWT
**Files:**
- `jwt.go` — token generation + validation
- `login_internal_test.go` (keep as test) + `login_ratelimit.go`
- Whatever the actual `login.go` file is called (look for the login handler — likely inside `handler.go` or a dedicated file)
**Adapt:**
- The plugin's login handler posts credentials to silo's `/api/v1/auth/login` over HTTP. **In silo's in-process port, replace this HTTP round-trip with a direct call to silo's auth service** (find via `grep -nE 'auth.*Authenticate|auth.*Login' /opt/silo-server/internal/auth/*.go`). The plugin's network call is no longer needed because the code is now in-process.
- ABS sessions are stored in `abs_sessions` (migration 139 from sub-plan 1).
- JWT signing secret: store in `server_settings` under key `audiobooks.abs.jwt_secret` (auto-generated on first request if missing).
Commit: `feat(audiobooks): port ABS auth (JWT + login bridge)`.
---
## Stage 3: File serving + play session
**Files:**
- `file_handler.go` — handles ABS GET requests for audio file streams
- `play_response.go` — issues a play session to the client (URL + token)
**Adapt:**
- Plugin's `bookref` lookup table (linking ABS-style IDs to backend file IDs) → use silo's `media_files.id` directly. ABS-side item ID = silo's `content_id`; ABS-side file ID = silo's `media_files.id`.
- The stream URL ABS clients expect must point back at silo. Issue them a `/api/v1/direct-download?file_id={id}` URL signed with silo's existing media-token signer (grep `mediatoken` in `internal/`).
Commit: `feat(audiobooks): port ABS file handler + play session`.
---
## Stage 4: Progress tracking
**Files:**
- `progress_internal_test.go` (test, port)
- Whatever file owns the `/api/me/progress` ABS endpoint family — look in `handler.go` or grep ABS progress route paths
- `continue_listening.go`
**Adapt:**
- All progress reads/writes hit silo's `user_watch_progress` keyed on `(user_id, profile_id, content_id)`. Sub-plan 3's progress handler did this already — copy the patterns.
Commit: `feat(audiobooks): port ABS progress + continue listening`.
---
## Stage 5: Author / Series / Browse
**Files:**
- `author_series_handler.go`
- `collapse.go`
**Adapt:**
- Author rows come from silo's `people` + `item_people` (kind=7).
- Series grouping: if silo doesn't have an explicit "audiobook series" concept, use `media_items.title` prefix matching (e.g., books named "Foundation #1", "Foundation #2") for MVP. Document the heuristic.
Commit: `feat(audiobooks): port ABS author/series browse`.
---
## Stage 6: Socket.io
**Files:**
- `abssocket/server.go` + `abssocket/server_test.go`
**Adapt:**
- Reuse silo's `gorilla/websocket` upgrader. The plugin's standalone-listener escape hatch is no longer needed — mount under `/abs/socket.io/` on silo's main listener.
- Redis adapter behavior preserved if `REDIS_URL` is set (silo always has one in the docker setup).
Commit: `feat(audiobooks): port ABS Socket.io endpoint`.
---
## Stage 7: Router wiring
**Files:**
- Modify `internal/api/router.go` — mount `audiobookABSHandler` under `/abs/*` and the small set of `/api/*` legacy paths ABS clients hardcode
- Modify `internal/audiobooks/service.go` — add `ABSHandler` field, wire it from `audiobooks.New(...)`
- Modify `cmd/silo/main.go` — construct the ABS handler and pass it into the audiobooks service
**Routes to expose** (the minimum ABS-required set):
- `POST /abs/login` (login flow)
- `GET /abs/api/me` (profile)
- `GET /abs/api/libraries` (library list)
- `GET /abs/api/libraries/{id}/items` (browse)
- `GET /abs/api/items/{id}` (item detail)
- `GET /abs/api/items/{id}/file/{ino}` (stream)
- `POST /abs/api/me/progress/{libraryItemId}` (progress)
- `GET /abs/socket.io/` (Socket.io)
For ABS-legacy hard-coded paths that don't fit under `/abs/`: register them explicitly with the ABS auth middleware so they don't collide with silo's existing `/api/v1/*`.
Build + smoke:
```bash
sudo docker build -t silo:latest /opt/silo-server
sudo docker compose -p silo-prod up -d --force-recreate silo
curl -sS -o /dev/null -w "POST /abs/login -> %{http_code}\n" -X POST -H 'Content-Type: application/json' -d '{}' http://localhost:8090/abs/login
```
Expected: 400 (bad request body) or 401 (validation failed), NOT 404.
Commit: `feat(audiobooks): wire ABS routes into main router`.
---
## Stage 8: Build + smoke + lint sweep
Single final task. Same shape as sub-plan 2 Task 10.
---
## Risks
- The plugin's code expects a specific database schema. Some plugin queries may have no clean silo-table equivalent (e.g., the plugin's `bookref` mapping table). In that case, stub the handler to return 501 + log + flag in PR notes — don't add new tables.
- ABS clients have undocumented expectations (CORS headers, specific error shapes, etc.). Smoke testing against a real ABS client is the only way to find these. Defer to sub-plan 6's manual verification.
- Each stage commits independently and produces partial functionality (e.g., after stage 2 you can log in but can't list libraries). That's intentional — clients won't connect end-to-end until stage 7.
@@ -0,0 +1,35 @@
# Audiobooks Absorption — Sub-plan 5: Podcasts (RSS feed refresher)
**Goal:** Port the plugin's `podcastfeed` package as a first-party scheduled task in silo, so RSS-subscribed podcasts get their episode lists kept up to date.
**Source plugin:** `../silo-plugin-audiobooks/internal/podcastfeed/refresher.go` (353 LOC) + `refresher_test.go` (295 LOC)
## Tasks
### Task 1: Port the refresher package
- Copy `refresher.go` + `refresher_test.go` from plugin to `internal/audiobooks/podcastfeed/`
- Update imports (drop plugin SDK; use silo's `pgxpool` + `log/slog`)
- Rewrite SQL queries against silo's `podcast_feeds` table (created in sub-plan 1 migration 140), `media_items` (type='podcast'), and `episodes`
- Drop the plugin's `presentation_libraries` reference; subscriptions are tracked by `podcast_feeds.media_item_id`
### Task 2: Wire as scheduled task
- Discovery D8 documented silo's task registration pattern: `taskMgr.Register(tasks.NewXxxTask(...))` in `cmd/silo/main.go`
- Add `tasks.NewSyncPodcastFeedsTask(refresher)` and register
- Default interval: 10 minutes (matches plugin)
### Task 3: ABS podcast endpoints (optional)
The plugin's `abs/podcast.go` + `abs/rss_feed_handler.go` + `abs/podcast_handler.go` expose:
- `GET /abs/api/libraries/{id}/podcasts/{podcastId}/episodes/{episodeId}`
- `POST /abs/api/libraries/{id}/podcasts` (add subscription)
- `GET /abs/api/podcast/feeds` (admin RSS feed browse)
If reach is required for ABS clients, port these. Otherwise stub 501 and ship. For initial release, stub.
### Task 4: Build + smoke
- Verify scheduled task registers on startup (`docker logs` shows the task)
- Verify nothing else breaks
## Risks
- Plugin's RSS parser may depend on third-party packages not in silo's go.mod. Check go.mod after porting and add if needed.
- `episodes` table in silo has a different shape than the plugin assumed. Re-read the plugin's INSERT statements and adapt.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,187 @@
# Audiobook-native catalog filter fields
**Status:** Draft — not yet approved or executed.
**Context.** The audiobooks library page currently inherits the catalog's
movie/TV filter set. We've already (a) wired `libraryType="audiobook[s]"`
into the sort-relevance scope so video-only sorts (resolution, IMDb/RT
ratings, content rating) drop out, and (b) gated the obviously-irrelevant
video-only filter sections (Director, Writer, Producer, Studio, Network,
Video Quality) in `CollectionGuidedRulesEditor`. What remains is
**affirmative** support for audiobook-native filter dimensions: **author**,
**narrator**, **series**.
Commands assume the repository root is the cwd.
## Goal
Surface three new filter dimensions on the audiobook library page:
1. **Author** — `item_people.kind = PersonKindAuthor` (7).
2. **Narrator** — `item_people.kind = PersonKindNarrator` (8).
3. **Series** — `audiobook_series.series_name` (free-text or distinct list).
The user should be able to:
- See per-dimension counts/options in the filter sheet (populated from the
current scope, like Genres/Studios already do).
- Add a rule via the guided editor that filters items by exact author /
narrator / series.
## Non-goals
- Series **ordering** (we already have `audiobook_series.series_index`).
Sort by series is a separate piece of work; this plan focuses on
filtering only.
- Author/narrator typeahead UI improvements beyond the existing
`PersonSearchSelect` (reuse it with kind-scoped lookups).
## Backend
### `internal/catalog/catalog_resolver.go`
`CatalogFiltersResult` currently has `Genres`, `Studios`, `Networks`,
`Countries`, `OriginalLanguages`, `ContentRatings`, `Resolutions`,
`AudioLanguages`, `SubtitleLanguages`. Add:
```go
Authors []string `json:"authors,omitempty"`
Narrators []string `json:"narrators,omitempty"`
Series []string `json:"series,omitempty"`
```
In `listFiltersForSource`, add three parallel facet queries:
- `listDistinctPeopleByKind(ctx, scope, models.PersonKindAuthor)` — joins
`item_people` + `people` on the result set defined by the current
request scope (library_ids, media_scope=audiobook, etc.) and returns
distinct `people.name`.
- Same for `PersonKindNarrator`.
- `listDistinctAudiobookSeriesNames(ctx, scope)` — distinct
`audiobook_series.series_name` for the scope, joined via
`audiobook_series.content_id = media_items.content_id`.
All three need to respect the access filter the caller passes in (mirror
how Genres/Studios already gate by access).
### `internal/api/handlers/catalog.go`
Extend `catalogFiltersResponse` and `HandleGetCatalogFilters` to emit the
new fields. Mirror the pattern used for the existing ones — no flag
gating, audiobook filters should always be included when present.
### `internal/catalog/query_builder.go`
Three new field names: `author`, `narrator`, `series`. The first two
reuse `buildPersonClause` with the appropriate `PersonKind`. `series`
needs a small new clause:
```go
case "series":
// EXISTS (SELECT 1 FROM audiobook_series s
// WHERE s.content_id = media_items.content_id
// AND lower(s.series_name) = lower($N))
```
Register the three field names in `catalogQueryRuleFields` so the parser
accepts them. None of them are personalized — they don't go into
`catalogPersonalRuleFields`.
### Tests
- `internal/catalog/catalog_resolver_test.go` — extend with audiobook
scope: assert authors/narrators/series come back, scoped to libraries
the test user has access to.
- `internal/catalog/query_builder_test.go` — add cases for `author`,
`narrator`, `series` rules; verify the emitted SQL clauses match the
expected shape.
## Frontend
### `web/src/api/types.ts`
Extend `CatalogFiltersResponse` (and any `ItemFiltersResponse` base, if
shared) with optional `authors?: string[]`, `narrators?: string[]`,
`series?: string[]`.
Extend `QueryRule['field']` to include `'author' | 'narrator' | 'series'`
if it's a literal union (or no-op if it's `string`).
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
Add to `GuidedFormState`:
- `author: string`
- `narrator: string`
- `series: string`
Update `queryDefinitionToGuidedState` / `guidedStateToQueryDefinition` to
round-trip the three rules (mirror how `actor` / `director` are wired).
In the audiobook-only render path (already gated via `isAudiobookLibrary`),
add a new section *above* the country row:
```tsx
<div className="grid gap-4 md:grid-cols-2">
<Author picker /> {/* PersonSearchSelect with kind="author" */}
<Narrator picker /> {/* PersonSearchSelect with kind="narrator" */}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Series picker /> {/* SearchableSelect over filters.series */}
</div>
```
`PersonSearchSelect` likely needs a new `kind` prop so it can scope its
backend lookup to authors or narrators. If today it queries all kinds,
we'll add a query param + handler-side filter.
### `ActiveFilterBadges` + `catalogFilterBadges`
Add badge rendering for the three new fields so they appear in the
selected-filters chip row above the editor.
### Tests
- `web/src/components/collections/CollectionGuidedRulesEditor.test.tsx`
— render with `libraryType="audiobooks"` and a filters payload
containing authors/narrators/series; assert all three sections appear
and update the query definition correctly.
## Migration / data
No schema changes required. `PersonKindAuthor=7` and `PersonKindNarrator=8`
already exist in `internal/models/media.go`. `audiobook_series` already
holds series names (migration 145). The scanner is already writing both.
## Risk
- **Filter facet performance.** Three new distinct-value queries per
`/catalog/filters` request. The existing facet queries use the same
`facetFetcher` infrastructure, so they'll inherit the same query-time
budget. For an audiobook library on the order of a few thousand items
this should be fine; if it's not, we can cache author/narrator lookups
more aggressively (they change rarely).
- **PersonSearchSelect kind scoping.** If the component today emits a
single combined kind=any search, we need to add kind-filtered variants
without breaking the existing actor/director/writer/producer pickers.
- **Series name normalization.** Backfill at migration 145 used regex
parsing; some items may have inconsistent casing or whitespace.
Distinct facet query should `TRIM(series_name)` and present `LOWER`
for matching; otherwise duplicates show up in the picker.
## Rollout
This is gated entirely behind `libraryType === "audiobook[s]"` on the
frontend, and the new filter fields are additive on the backend. No
feature flag needed. Ship in two PRs if reviewer prefers:
1. **Backend** — filter response fields + query-builder clauses + tests.
2. **Frontend** — types + GuidedFormState + editor sections + tests.
Or a single PR if the diff stays reviewable (~600 lines including tests).
## Verification
- Add new tests as above; `make lint` and `cd web && pnpm vitest run`
should pass.
- Manually: open the audiobook library filter sheet, pick an author and
a narrator, confirm the result set narrows. Pick a series, confirm
same.
@@ -0,0 +1,123 @@
# `audiobook_series` data cleanup
**Status:** Draft. Findings + options; no implementation yet.
## Problem
Migration `145_audiobook_series.up.sql` (which created the
`audiobook_series` table) shipped with a regex backfill that
over-matched. On this server's 220k-book audiobook library it produced
**162,322 distinct series names**, of which **141,591 (87%) are
singletons** — books that are the only "member" of their named series.
Spot checks make the failure mode obvious: rows like
- `series_name = "Computer Programming: This Book Includes: SQL, …"` for a standalone book whose subtitle happened to contain `"#2022 Version"`
- `series_name = "Reparación de crédito [Credit Repair]"` (the title verbatim)
- `series_name = "Cómo Atraer a las Mujeres … [How to Attract Women …]"` (the title verbatim)
The regex (`'^.+[^\s-]\s+\d+(?:\.\d+)?\s*-\s*.+$'`) treats any numeric
chapter / edition / part token followed by ` - ` as series-N-of-book
syntax. The real series (Star Wars, Warhammer 40k, Animorphs, Redwall,
*In Death*, etc.) live in the 733-row "large series" bucket; the
9,075 pairs and 10,923 small clusters mix real and false-positive.
The cap added in `b95494c` keeps the user-facing dropdown manageable
(top 1000 alphabetically), but the underlying data is still polluted —
sorting by series, the Series filter dropdown, and any future
"recommended" surfacing of series will all surface noise until the
backfill is repaired.
Commands assume the repository root is the cwd.
## Options
### A — delete singletons (destructive)
```sql
DELETE FROM audiobook_series
WHERE series_name IN (
SELECT series_name FROM audiobook_series GROUP BY series_name HAVING COUNT(*) = 1
);
```
- **Pros:** simple, removes the bulk of the noise in one shot, no
schema change.
- **Cons:** also deletes legitimate "first book of a not-yet-complete
series" rows. Reversal requires a re-scan.
### B — filter singletons at query time, leave data alone
In `listDistinctAudiobookSeriesWithSource`, group by series name and
`HAVING COUNT(*) >= 2`.
- **Pros:** non-destructive; the table keeps full information for
detail-page lookups and series-detail surfaces.
- **Cons:** hides legitimate one-book-only series from the filter
dropdown. Series-detail and sort behaviour unchanged (still surface
singletons). The 11.7 MB payload was already capped to 1000 in
`b95494c`, so the size win from this option is small.
### C — mark backfill provenance, delete only backfilled singletons
Add a `source` column to `audiobook_series` (`'scanner' | 'backfill'`)
in a new migration. Migration 145's backfill rows get `source =
'backfill'`; scanner-written rows get `'scanner'`. Then delete only the
backfilled singletons.
- **Pros:** distinguishes legitimate from spurious without guessing.
- **Cons:** retroactive — we'd have to assume all current rows are
backfill (which they mostly aren't anymore — the scanner has been
writing too). Without a marker added at backfill time, can't tell
what's what.
### D — wipe + re-scan (clean slate)
```sql
TRUNCATE audiobook_series;
```
Then trigger a full library re-scan so the scanner writes only the
real series_name values it extracts from tags.
- **Pros:** the cleanest end state; the scanner is authoritative.
- **Cons:** long re-scan window (the user already noticed 219k books
is slow to scan); any books not currently scannable lose their
series info entirely. Destructive.
## Recommendation
**Option B as a quick win** — non-destructive, ships as a one-line SQL
change in the facet helper, makes the Series filter dropdown useful
immediately. The dropdown stops showing 87% noise.
**Option D as the eventual fix** — once the scanner has been verified
to write series_name authoritatively (including the series_index from
real tags), `TRUNCATE audiobook_series` + re-scan is the right end
state. Need to confirm scanner behaviour before pulling the trigger.
Option A is too blunt (drops real first-books-of-series). Option C is
over-engineered without a marker added at backfill time.
## What this plan does not cover
- Whether the scanner currently writes `audiobook_series` rows
authoritatively for every audiobook it touches, or only when tags
contain explicit series metadata. Need to check
`internal/scanner/audiobook.go` (or wherever the audiobook_series
upsert lives) before proposing option D as the long-term path.
- Whether *Search Series* (the user-facing series-detail page, if
any) would also benefit from the same cleanup.
- Sort-by-series on the library page — even with option B in place,
the sort still groups by every series_name including singletons,
so books in fake "series" appear under their fake series name.
Acceptable for now; revisit alongside option D.
## Verification (once an option is picked)
- Re-run the data sanity query in this doc's intro and confirm the
singleton count dropped where expected.
- Open the Series filter dropdown on the audiobook library page and
confirm only multi-book series show up.
- `go test ./internal/catalog/...` + `cd web && pnpm vitest run` still
pass.
@@ -0,0 +1,143 @@
# Server-side typeahead for catalog facets
**Status:** Draft. Architecture sketch; no implementation yet.
## Why
`b95494c` capped `/api/v1/catalog/filters` facet responses to 1000
distinct values each, which fixed an 11.7 MB payload on the audiobook
library (88k authors, 92k narrators, 161k series). The cap is a
stopgap — a user looking for an author past the first 1000
alphabetically can't find them through the dropdown.
Parallel context: a separate investigation (transcript in the
2026-05-27 sessions) compared how `librarymanagerre`, `booklore-ng`,
and `audiobookshelf` paginate large libraries. The cursor +
virtual-scroll camp (librarymanagerre, booklore-ng's
`@tanstack/react-virtual`) scales to 250k+ items by never sending the
client a full list; the eager-shelves camp (audiobookshelf web +
mobile) renders one DOM div per item and falls over above ~30–50k.
The 11.7 MB facet issue here is the same shape on a different surface
— "too much data shipped to the client at once."
The cleanest fix is **server-side typeahead**: the client sends a
search prefix as the user types, the server returns the top N matches
for that prefix.
Commands assume the repository root is the cwd.
## Surface
### `/api/v1/catalog/filters/search`
New endpoint, scoped per facet, query parameters mirror
`/api/v1/catalog/filters` (`source`, `library_id`, etc.) plus:
- `facet=author|narrator|series|studio|network|country|genre|original_language|content_rating`
- `q=<prefix>` — the user's typed prefix (1-64 chars, trimmed)
- `limit=<N>` — capped at e.g. 50
Response:
```json
{ "matches": ["Brandon Sanderson", "Brandon Sanderson & Steven Erikson", ...], "has_more": false }
```
Returns up to `limit` matches sorted by:
1. exact-prefix match first (`q` matches the start of the value)
2. then case-insensitive substring match
3. then alphabetical
`has_more` is true when the underlying result set was truncated.
### `/api/v1/catalog/filters` keeps the current shape
No change to the existing endpoint — it still returns the capped top
1000 per facet for the initial dropdown render. The typeahead surface
takes over once the user starts typing.
## Backend changes
### `internal/catalog/catalog_resolver.go`
New method `SearchFacetWithOptions(ctx, req, access, facet, prefix,
limit)` that:
1. Resolves the same access/scope as `ListFiltersWithOptions`.
2. Dispatches to a facet-specific helper based on `facet`.
3. Returns the matches + has_more flag.
Add to `facetFetcher` interface:
```go
SearchPeopleByKind(ctx, kind models.PersonKind, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
SearchAudiobookSeries(ctx, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
SearchDistinctArrayColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
SearchDistinctScalarColumn(ctx, column string, filters BrowseFilters, baseRelation, mediaScope, prefix string, limit int) ([]string, bool, error)
```
Each helper runs the existing facet SQL with an added
`WHERE LOWER(<value>) LIKE LOWER($N || '%')` (prefix match) and
`LIMIT N+1` (so we can detect has_more by checking if the result set
exceeds N).
### `internal/api/handlers/catalog.go`
New handler `HandleCatalogFacetSearch` mounted at
`/api/v1/catalog/filters/search`.
## Frontend changes
### `web/src/components/ui/searchable-select.tsx` (or a new variant)
`SearchableSelect` today does client-side filtering over the full
`options` array. Replace it for the high-cardinality facets with a
debounced server search:
- Type `<= 100ms` of inactivity → fire `/catalog/filters/search?facet=author&q=...`
- Reset focus + cancel in-flight when the user keeps typing
- Show the first 50 matches; "show more" disabled (the user types more
to narrow further)
The existing `SearchableSelect` stays for low-cardinality facets
(genres, content_ratings, etc.) where the initial 1000 is plenty.
### `web/src/components/collections/CollectionGuidedRulesEditor.tsx`
Author / Narrator / Series sections switch to the new typeahead-backed
select. Studio / Network / Country can stay on `SearchableSelect`
(low-cardinality) or migrate later for consistency.
## Tests
- Backend: search SQL emits the LIKE prefix + LIMIT N+1, has_more
semantics, access filter still gates the result set.
- Frontend: typeahead component debounces, cancels stale requests,
renders empty / loading / no-results states.
## Migration
None — additive endpoint. Frontend can be rolled out incrementally:
audiobook-only sections first (since they're the worst offenders),
others later.
## What this plan does not cover
- The audiobookshelf-app pagination question from the separate
transcript. That's a different surface (the ABS-compat
`/abs/api/libraries/{id}/items` endpoint on `:13378`), not the silo
native catalog. Same architectural family but the patch lives in
`internal/audiobooks/abs/libraries_handler.go`, not catalog.
- A general client-side virtual scroll for the library grid itself.
That's the cousin problem ("250k books rendered as 125k shelf
divs"); cross-applies but is its own work.
## Risk / rollout
- New endpoint is opt-in for the frontend — existing callers keep
using the bulk `/filters` response.
- Typeahead latency budget: each keystroke fires one DB query against
the same indexes the bulk facets already use. Should be fast
(`item_people(content_id, kind)` covers people lookups,
`audiobook_series(content_id)` covers series, and a `LOWER()`
functional index already exists on
`audiobook_series_name_lower`). Verify with EXPLAIN before shipping.
@@ -0,0 +1,314 @@
# Collections Unification — Sub-project 1: Schema Migration
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land migration 156 — extend `user_personal_collections` to admit `'playlist'` and `'smart'` types, add nullable `sub_item_id` to its items table, add `media_types` filter to `page_sections`, move the single existing `abs_playlists` row into the canonical store, and drop the five `abs_*` collection tables.
**Architecture:** Pure schema migration + one-row data move. No application code changes. The follow-up sub-projects 2–4 wire silo to the new schema; this one is just the storage foundation. Hard cutover — the `abs_*` tables are near-empty (1 playlist row, 0 of everything else) so rollback risk is minimal.
**Tech Stack:** PostgreSQL 18, raw SQL migrations under `migrations/`, no Go code in this sub-project.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.1, §4.4, §5.
---
## File map
**Create:**
- `migrations/156_unify_user_collections.up.sql`
- `migrations/156_unify_user_collections.down.sql`
**No Go code modified in this sub-project.** Application code keeps querying the (now-dropped) `abs_*` tables until sub-projects 3 and 4 land, which means **silo will break in dev/staging until sub-project 3 ships.** Land this sub-project and sub-project 3 together (single MR) if a production deploy is imminent. Otherwise it's safe to ship in isolation on a feature branch that hasn't been merged yet.
---
## Task 1: Up-migration body
**Files:**
- Create: `migrations/156_unify_user_collections.up.sql`
- [ ] **Step 1: Write the migration file**
```sql
-- Unify user-owned lists into user_personal_collections.
--
-- See docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md
-- for design rationale.
-- 1. Sub-item granularity column on the canonical items table.
-- Empty string for whole-item entries; populated for podcast-episode
-- playlist entries (sub_item_id == abs_playlist_items.episode_id).
ALTER TABLE user_personal_collection_items
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
-- 2. Widen the collection_type domain. The pre-existing CHECK (if any)
-- only admits 'manual' and 'synced'.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
-- 3. Move the existing abs_playlists row(s) into the canonical store.
-- is_public maps to is_shared; profile_id (uuid) is stringified.
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id)
SELECT
id,
user_id,
COALESCE(profile_id::text, ''),
name,
description,
'playlist',
is_public,
created_at,
updated_at,
COALESCE(profile_id::text, '')
FROM abs_playlists;
INSERT INTO user_personal_collection_items
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
SELECT
p.user_id,
i.playlist_id,
i.library_item_id,
i.episode_id,
i.position,
i.added_at
FROM abs_playlist_items i
JOIN abs_playlists p ON p.id = i.playlist_id;
-- 4. Drop the abs_* collection tables. abs_playlist_items has a FK to
-- abs_playlists, so the order matters.
DROP TABLE abs_playlist_items;
DROP TABLE abs_playlists;
DROP TABLE abs_collection_items;
DROP TABLE abs_user_collections;
DROP TABLE abs_smart_collections;
-- 5. Media-type filter on page_sections. Default preserves current
-- behavior (existing rails surface movies+series only).
ALTER TABLE page_sections
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
```
- [ ] **Step 2: Verify the file compiles as valid SQL**
The repo embeds migrations via `migrations/embed.go`. The migration runner will syntax-check on load.
Run: `go build ./...`
Expected: clean (the embed package compiles fine even if a new file is added).
- [ ] **Step 3: Commit (up only — down comes next task)**
Don't commit yet. Commit happens after Task 2's down-migration so the up/down pair lands atomically.
---
## Task 2: Down-migration body
**Files:**
- Create: `migrations/156_unify_user_collections.down.sql`
The down migration is intentionally lossy in reverse: rolling back loses any `'playlist'`/`'smart'` collections that were created after the up. With the up bringing in 1 row, lossy reverse is acceptable.
- [ ] **Step 1: Find the abs_* CREATE TABLE statements to inline**
Read these files and copy their `CREATE TABLE` bodies (and `CREATE INDEX` statements; **omit** any `DROP TABLE IF EXISTS` boilerplate at the top — the down migration creates from clean state):
```bash
cat migrations/149_abs_user_collections.up.sql
cat migrations/150_abs_collection_items.up.sql
cat migrations/151_abs_playlists.up.sql
cat migrations/152_abs_playlist_items.up.sql
cat migrations/153_abs_smart_collections.up.sql
```
- [ ] **Step 2: Write the down migration**
```sql
-- Reverse migration 156. Lossy in reverse — playlist/smart rows
-- created after the up migration are deleted, not migrated back.
-- 1. Remove the page_sections column.
ALTER TABLE page_sections DROP COLUMN media_types;
-- 2. Recreate the abs_* tables empty. Schemas are inlined from the
-- original up migrations 149–153 — keep identical (column types,
-- constraint names, index names) so any tool keyed off those names
-- sees the same shape.
--
-- BEGIN inlined from migrations/149_abs_user_collections.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
-- END inlined
-- BEGIN inlined from migrations/150_abs_collection_items.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
-- END inlined
-- BEGIN inlined from migrations/151_abs_playlists.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
-- END inlined
-- BEGIN inlined from migrations/152_abs_playlist_items.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX + FK statements>
-- END inlined
-- BEGIN inlined from migrations/153_abs_smart_collections.up.sql
-- <PASTE EXACT CREATE TABLE + CREATE INDEX statements>
-- END inlined
-- 3. Remove rows we promoted from abs_playlists during the up.
DELETE FROM user_personal_collection_items
WHERE collection_id IN (
SELECT id FROM user_personal_collections
WHERE collection_type IN ('playlist', 'smart')
);
DELETE FROM user_personal_collections
WHERE collection_type IN ('playlist', 'smart');
-- 4. Restore the narrow CHECK constraint.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced'));
-- 5. Drop the sub_item_id column.
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
```
Replace each `<PASTE EXACT ... statements>` block with the actual SQL from the corresponding `migrations/14X_abs_*.up.sql` file. Do NOT skip any constraint or index — the round-trip test below verifies parity.
- [ ] **Step 3: Verify SQL syntactic validity**
Run: `go build ./...`
Expected: clean.
- [ ] **Step 4: Commit the up+down pair**
```bash
git add migrations/156_unify_user_collections.up.sql migrations/156_unify_user_collections.down.sql
git commit -m "feat(migrations): 156 unify user collections
Adds sub_item_id to user_personal_collection_items, widens
collection_type to include 'playlist' and 'smart', adds media_types
filter to page_sections, moves the existing abs_playlists row into
the canonical store, and drops the abs_* collection tables.
Lays the storage foundation for sub-projects 2-4 to wire the
application against."
```
---
## Task 3: Migration round-trip test
**Files:**
- Create: `migrations/156_unify_user_collections_test.go` (only if a similar pattern exists in the repo)
The repo doesn't have a standard "migration round-trip test" harness today (verify with `find . -name "migration*_test.go" -o -name "*round_trip*_test.go" | head`). If no harness exists, this task is **best-effort manual verification**:
- [ ] **Step 1: Verify a harness pattern exists, or skip to manual verification**
Run:
```bash
find . -name "migration*_test.go" -not -path "*/node_modules/*" 2>/dev/null | head
grep -rln "migrate.Up\|migrate.Down\|migrate.Steps" --include="*_test.go" 2>/dev/null | head
```
If no harness emerges, fall through to Step 2 (manual). Otherwise add a Go test following the existing harness pattern that:
1. Migrates to 155.
2. Inserts a fake `abs_playlists` row (id `test-pl-1`, user 1, name `test`).
3. Inserts a fake `abs_playlist_items` row referencing it.
4. Migrates up to 156.
5. Asserts `user_personal_collections WHERE collection_type='playlist' AND id='test-pl-1'` exists and `user_personal_collection_items WHERE collection_id='test-pl-1'` exists.
6. Migrates down to 155.
7. Asserts the `abs_playlists` table exists and is empty (the row is GONE — down is lossy by design).
- [ ] **Step 2: Manual verification against a throwaway DB**
If no harness, do the following manually once and capture the output in the MR description:
```bash
# 1. Spin up an empty postgres
docker run --rm -d --name pgcheck -e POSTGRES_PASSWORD=x -p 5443:5432 pgvector/pgvector:pg18
sleep 5
# 2. Apply migrations up to 155 (use the existing migrate binary or schema dump)
# Adapt this to whatever the repo uses to run migrations against an arbitrary DB.
# 3. Insert a fake abs_playlists + items row.
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
INSERT INTO abs_playlists(id, user_id, name) VALUES ('test-pl-1', 1, 'test');
INSERT INTO abs_playlist_items(playlist_id, library_item_id, position) VALUES ('test-pl-1', 'foo', 0);
"
# 4. Apply migration 156.
# 5. Verify the row landed in user_personal_collections.
PGPASSWORD=x psql -h localhost -p 5443 -U postgres -d postgres -c "
SELECT collection_type, name FROM user_personal_collections WHERE id='test-pl-1';
SELECT collection_id, media_item_id, sub_item_id FROM user_personal_collection_items WHERE collection_id='test-pl-1';
"
# Expected: collection_type='playlist', name='test'; one item row with sub_item_id=''
# 6. Apply down migration.
# 7. Verify abs_playlists exists (empty) and user_personal_collections has no playlist rows.
# Cleanup
docker rm -f pgcheck
```
- [ ] **Step 3: If you added a Go test, commit it**
```bash
git add migrations/156_unify_user_collections_test.go
git commit -m "test(migrations): 156 round-trip verifies abs_playlists migrate to canonical"
```
If only manual verification was done, capture the output in the MR description instead — no commit needed.
---
## Verification (after merge)
1. **Dev DB** — run `make dev-backend` against a fresh DB. Migration 156 applies cleanly. `\d user_personal_collection_items` shows the `sub_item_id` column. `\d page_sections` shows `media_types`.
2. **Existing data migration** — on a copy of the production DB (or staging if available), run migration 156 and verify the single existing `abs_playlists` row is now in `user_personal_collections` with `collection_type='playlist'`:
```sql
SELECT id, name, collection_type FROM user_personal_collections WHERE collection_type='playlist';
```
3. **abs_* tables gone** — confirm:
```sql
SELECT table_name FROM information_schema.tables WHERE table_schema='public' AND table_name LIKE 'abs_%collection%';
-- Should return 0 rows.
```
`abs_sessions`, `abs_bookmarks`, `abs_playback_sessions`, `abs_rss_feeds` remain — those aren't in scope here.
4. **Silo binary builds against the new schema** — `go build ./...` is green even though the ABS store adapters still reference the dropped tables. They reference them via SQL strings, so compile-time is fine; runtime will fail until sub-project 3 rewrites them. **Do NOT** deploy without sub-project 3, or stash this migration behind a feature flag.
---
## Self-Review
**Spec coverage:**
- `sub_item_id` column ✓ (Task 1)
- `collection_type` widened to playlist/smart ✓ (Task 1)
- Move existing abs_playlists row ✓ (Task 1)
- Drop abs_* tables ✓ (Task 1)
- `page_sections.media_types` ✓ (Task 1)
- Down migration ✓ (Task 2)
- Round-trip test ✓ (Task 3, best-effort)
**Placeholder scan:** The `<PASTE EXACT ... statements>` blocks in Task 2 Step 2 are template placeholders that the implementer must fill in by copying from the existing migration files. Each is bounded by explicit BEGIN/END comment markers naming the source file. The instructions in Step 1 say to read those files first; Step 2's block then becomes literal SQL. Not a "TBD" in the bad sense — it's a deliberate copy-from-source step.
**Type consistency:** `sub_item_id` (text, NOT NULL DEFAULT '') consistent in up + down + verification queries. `collection_type` enum values consistent across the spec, up, down, and CHECK constraint.
**Risk:** All schema-level, no application changes. The biggest risk is forgetting to ship sub-project 3 in the same release — runtime will start erroring on ABS endpoints the moment migration 156 applies, because the adapters still query the dropped tables.
@@ -0,0 +1,274 @@
# Collections Unification — Sub-project 2: Smartcoll Engine Lift
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Move the smart-collection rule engine from `internal/audiobooks/smartcoll/` to `internal/smartcoll/` so all media types (movie, series, audiobook) share one evaluator. Pure code move + import update; no logic changes.
**Architecture:** The 4-file package (~1060 LOC) currently lives in `internal/audiobooks/smartcoll/`. We relocate it verbatim and rewire its imports. The existing in-place audiobook caller (`internal/audiobooks/abs_smart_collection_store.go`) updates its import path. Sub-projects 3 and 4 will later call the engine from new sites (ABS adapter rewrites, section recipes); those plans cover their own wiring.
**Tech Stack:** Go. Standard `gopls`-style refactor: move, fix imports, run tests.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.2, §4.6.
**Predecessor sub-project:** None — this lands independent of sub-project 1. Order doesn't matter.
---
## File map
**Move (4 files):**
- `internal/audiobooks/smartcoll/evaluator.go` → `internal/smartcoll/evaluator.go`
- `internal/audiobooks/smartcoll/evaluator_test.go` → `internal/smartcoll/evaluator_test.go`
- `internal/audiobooks/smartcoll/query.go` → `internal/smartcoll/query.go`
- `internal/audiobooks/smartcoll/query_test.go` → `internal/smartcoll/query_test.go`
**Modify (import path):**
- Every file that imports `github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll` — change to `github.com/Silo-Server/silo-server/internal/smartcoll`.
**Add (new test, cross-type coverage):**
- `internal/smartcoll/cross_type_test.go` — three tests verifying audiobook-specific rules no-op against non-audiobook items.
---
## Task 1: Move the package directory
**Files:** see "Move" list above.
- [ ] **Step 1: Pre-check current contents**
```bash
ls internal/audiobooks/smartcoll/
wc -l internal/audiobooks/smartcoll/*.go
```
Expected: 4 files (evaluator.go, evaluator_test.go, query.go, query_test.go). If the contents differ, pause and report — the plan assumes this exact set.
- [ ] **Step 2: Verify nothing outside the package references it (yet) besides the known caller**
```bash
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" .
```
Expected: `internal/audiobooks/abs_smart_collection_store.go` and possibly its test, plus the package's own test files. If you find any other importer, pause and add it to the import-update list in Task 2.
- [ ] **Step 3: Create the new directory and move the files**
```bash
mkdir -p internal/smartcoll
git mv internal/audiobooks/smartcoll/evaluator.go internal/smartcoll/evaluator.go
git mv internal/audiobooks/smartcoll/evaluator_test.go internal/smartcoll/evaluator_test.go
git mv internal/audiobooks/smartcoll/query.go internal/smartcoll/query.go
git mv internal/audiobooks/smartcoll/query_test.go internal/smartcoll/query_test.go
rmdir internal/audiobooks/smartcoll
```
`git mv` preserves blame history.
- [ ] **Step 4: Update the package declaration in each moved file**
The package name was `smartcoll`; it stays `smartcoll`. Open each moved file and **verify** the `package smartcoll` line is unchanged. If any file declares `package audiobooks_smartcoll` or similar, fix it to `package smartcoll`.
```bash
head -1 internal/smartcoll/*.go
```
Expected: every line reads `package smartcoll`. If any differ, fix with sed:
```bash
sed -i '1s/^package .*/package smartcoll/' internal/smartcoll/<filename>.go
```
- [ ] **Step 5: Build — this WILL fail until Task 2 runs**
```bash
go build ./...
```
Expected: compilation error in `internal/audiobooks/abs_smart_collection_store.go` because its import path now points at a non-existent directory. That's expected — Task 2 fixes it. **Do not commit yet.**
---
## Task 2: Update all imports
**Files:**
- Modify: every file from Task 1 Step 2's grep result.
- [ ] **Step 1: Update each importer**
The typical pattern in the existing caller is:
```go
import (
// ...
"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"
)
```
Becomes:
```go
import (
// ...
"github.com/Silo-Server/silo-server/internal/smartcoll"
)
```
For each file in the Task 1 Step 2 grep result, run:
```bash
sed -i 's|"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"|"github.com/Silo-Server/silo-server/internal/smartcoll"|' <file>
```
Or edit by hand using your editor's find-and-replace.
- [ ] **Step 2: Verify no stale import remains**
```bash
grep -rln '"github.com/Silo-Server/silo-server/internal/audiobooks/smartcoll"' --include="*.go" . || echo "clean"
```
Expected: `clean` (no matches).
- [ ] **Step 3: Build + test**
```bash
go build ./...
go test ./internal/smartcoll/ ./internal/audiobooks/... -short -timeout 60s
go vet ./internal/smartcoll/ ./internal/audiobooks/...
```
Expected: all green. The moved tests still pass because the engine logic is unchanged; the package path changed but the test code itself is package-local.
- [ ] **Step 4: Commit the move + import fix**
```bash
git add internal/smartcoll/ internal/audiobooks/
git commit -m "refactor(smartcoll): lift engine to internal/smartcoll
Moves the smart-collection rule engine out of internal/audiobooks/
so movie/TV smart collections can share the same evaluator.
No logic changes. Tests move with their package and continue to pass."
```
`git log --follow internal/smartcoll/evaluator.go` should show the prior history at the old path.
---
## Task 3: Cross-type smoke tests
**Files:**
- Create: `internal/smartcoll/cross_type_test.go`
The existing tests cover audiobook-specific rule evaluation. Sub-project 4 will start asking the engine to evaluate against movies + TV, so we add three small tests that nail down the no-op contract for audiobook-specific predicates against non-audiobook items.
- [ ] **Step 1: Find the audiobook-specific rule kinds**
```bash
grep -nE 'narrator|series_position|"type":"audiobook"' internal/smartcoll/query.go | head -10
```
Expected: rule-kind constants named like `RuleKindNarrator`, `RuleKindSeriesPosition`, or similar. Note the exact names — the test below references them. **If the rule kinds are not actually present (rule registration happens elsewhere), pause and report — the spec assumed they exist.**
- [ ] **Step 2: Read the existing query/evaluator test fixtures**
```bash
sed -n '/func Test/,/^func /p' internal/smartcoll/evaluator_test.go | head -60
```
Note the harness pattern used (how an evaluator is constructed in tests, how it's given items, how match-results are asserted). Mirror that pattern in the new tests.
- [ ] **Step 3: Write the failing tests**
Create `internal/smartcoll/cross_type_test.go`:
```go
package smartcoll
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
// TestNarratorRuleNoopOnMovie verifies that an audiobook-specific narrator
// rule treats movie items as a no-op (matches everything, since the rule
// can't apply). This is the cross-type contract that lets the engine
// evaluate against mixed-type item sets without errors.
func TestNarratorRuleNoopOnMovie(t *testing.T) {
// Adapt to the actual evaluator-construction API discovered in Step 2.
// Expected: build an evaluator with a narrator-name predicate, evaluate
// it against a movie item, assert no error AND the item is matched
// (or excluded — pick whichever the existing audiobook tests treat as
// "no-op" and document it).
t.Skip("Implement once the evaluator API is read from existing tests")
}
// TestSeriesPositionRuleNoopOnSeries verifies the audiobook series_position
// rule has no effect on a TV series item (which has its own series concept,
// distinct from book series).
func TestSeriesPositionRuleNoopOnSeries(t *testing.T) {
t.Skip("Implement once the evaluator API is read from existing tests")
}
// TestLibraryIdFilterAppliesToAllTypes verifies that the simple library_id
// predicate (the silo-native query_definition subset) evaluates correctly
// against any media type. This locks in compat with existing
// user_personal_collections rows after migration 156.
func TestLibraryIdFilterAppliesToAllTypes(t *testing.T) {
t.Skip("Implement once the evaluator API is read from existing tests")
}
var _ = models.MediaItem{} // import-stability marker; remove once tests are real
```
- [ ] **Step 4: Fill in the test bodies**
Replace each `t.Skip(...)` with a real test using the harness pattern from the existing tests. The exact code depends on the evaluator's public surface (constructor, evaluate method, predicate registration). **Read `internal/smartcoll/evaluator.go` and the existing test file once before writing the bodies** — don't guess at the API.
If the evaluator's no-op behavior for type-mismatched rules turns out to be "exclude the item" rather than "match the item", adjust the assertions to reflect that — but document the chosen semantic in a comment above each test so future readers know which it is.
- [ ] **Step 5: Run the new tests**
```bash
go test ./internal/smartcoll/ -run "Cross|Noop|LibraryIdFilter" -v
```
Expected: all three PASS, and the existing tests in the package continue to pass.
- [ ] **Step 6: Commit**
```bash
git add internal/smartcoll/cross_type_test.go
git commit -m "test(smartcoll): cross-type evaluator contract
Locks in the no-op semantics for audiobook-specific rules evaluated
against non-audiobook items, plus library_id filter compat across
all types."
```
---
## Verification (after merge)
1. `git log --follow internal/smartcoll/evaluator.go` shows the prior history at `internal/audiobooks/smartcoll/evaluator.go` — confirms blame preserved.
2. `go test ./internal/smartcoll/ -v` runs both the moved tests and the three new cross-type tests, all passing.
3. `grep -rln 'internal/audiobooks/smartcoll' --include="*.go" .` returns empty.
4. No production behavior change. Smart collections in the ABS API still resolve via the engine; the engine just lives at a new path.
---
## Self-Review
**Spec coverage:**
- Move package to `internal/smartcoll/` ✓ (Task 1)
- Update import paths ✓ (Task 2)
- Cross-type evaluator tests ✓ (Task 3)
**Placeholder scan:** Task 3's `t.Skip(...)` lines are explicit failing-test stubs that Step 4 fills in. The instructions in Step 4 say to read the existing tests first rather than guess. Not a TBD — it's a "read-existing, then write" workflow with explicit guidance.
**Type consistency:** Package name `smartcoll` consistent. Import path `github.com/Silo-Server/silo-server/internal/smartcoll` consistent. Rule-kind names depend on what Step 1 of Task 3 discovers — the plan flags this explicitly.
**Risk:** Pure refactor. The chance of breakage is low (Go's import system catches missed renames at compile time). The cross-type tests are new functionality, but they assert behavior that already exists in the engine (no-op on type-mismatch) so they should pass without code changes.
@@ -0,0 +1,515 @@
# Collections Unification — Sub-project 3: ABS Store Adapter Rewrites
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rewrite the three ABS store adapters (`abs_collection_store.go`, `abs_playlist_store.go`, `abs_smart_collection_store.go`) to query the canonical `user_personal_collections` tables instead of the dropped `abs_*` tables. The wire shape returned by ABS HTTP endpoints must remain byte-identical so the ABS Android/iOS apps don't notice anything changed.
**Architecture:** The three adapter files expose a struct + ~6 methods each (List, Get, Create, Update, Delete, ListItems, AddItem, RemoveItem) consumed by `internal/audiobooks/abs/` HTTP handlers. We keep every public method signature unchanged and replace each SQL body with one that maps to `user_personal_collections` filtered by `collection_type`. Granular podcast-episode entries map to the new `sub_item_id` column. Library scoping (which library this list "belongs to" in ABS UI) is stored in `query_definition.library_ids`.
**Tech Stack:** Go, `pgx/v5`, `database/sql`-style query patterns from the existing adapters.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.3, §4.5.
**Predecessor sub-projects:**
- Sub-project 1 (migration 156) **must** land before this. The new tables/columns are required.
- Sub-project 2 (smartcoll lift) needed only for the smart-collection adapter — if 2 hasn't landed, Task 3 below can either wait for it or temporarily keep the old import path.
---
## File map
**Modify (rewrite bodies, keep method signatures):**
- `internal/audiobooks/abs_collection_store.go` (193 lines today; ~8 methods)
- `internal/audiobooks/abs_playlist_store.go` (210 lines today; ~9 methods including `coverArg`)
- `internal/audiobooks/abs_smart_collection_store.go` (~140 lines today; ~6 methods)
**Add (snapshot tests for wire-shape preservation):**
- `internal/audiobooks/abs_collection_store_test.go` (if not already present — check first)
- `internal/audiobooks/abs_playlist_store_test.go`
- `internal/audiobooks/abs_smart_collection_store_test.go`
**No new files** beyond those three test files. No new exported types.
---
## Task 1: Capture pre-cutover wire shapes (snapshots)
This task runs against the **pre-migration** ABS endpoints. Capture JSON now so we can diff after.
**Files:** none modified; output captured to `/tmp/abs_wire_*.json`.
- [ ] **Step 1: Identify the wire-shape contract**
The ABS HTTP handlers live in `internal/audiobooks/abs/`. They call the store adapters, which return Go structs. The structs (`abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, etc.) marshal to JSON via standard struct tags. The wire shape is fixed by those struct tag declarations.
Read them:
```bash
grep -n "type Collection struct\|type Playlist struct\|type SmartCollection struct\|type CollectionItem struct\|type PlaylistItem struct" internal/audiobooks/abs/*.go
```
For each struct, note every field's JSON tag. **This is your reference contract.** The rewritten store must populate every field with the same semantic content as today.
- [ ] **Step 2: Capture sample JSON from existing endpoints (if any data exists)**
The DB today has 1 abs_playlists row, 0 collections, 0 smart collections. Even one sample helps. Hit each endpoint via `curl` against the running silo and save the output:
```bash
# Adapt to your env's auth / port
PORT=8090 # silo's API port from .env
TOKEN=... # admin or user JWT
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/collections \
> /tmp/abs_wire_collections.json
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/playlists \
> /tmp/abs_wire_playlists.json
curl -sH "Authorization: Bearer $TOKEN" http://localhost:$PORT/api/libraries/9/smart-collections \
> /tmp/abs_wire_smart_collections.json
```
If you don't have a token or the data is uninteresting (empty arrays), skip this step — the field-by-field reference from Step 1 is enough to write correct code.
- [ ] **Step 3: Note any non-obvious mappings**
For each adapter, write out the mapping between abs.* struct fields and `user_personal_collections` columns. Sample for Collection:
| `abs.Collection` field | Source column |
|---|---|
| `Id` | `user_personal_collections.id` |
| `Name` | `user_personal_collections.name` |
| `Description` | `user_personal_collections.description` |
| `UserId` | `user_personal_collections.user_id` |
| `LibraryId` | derived from `query_definition.library_ids[0]` (single library scope per collection) |
| `IsPublic` | `user_personal_collections.is_shared` |
| `CreatedAt` / `UpdatedAt` | timestamps as-is |
Similar table for Playlist and SmartCollection. **Write this out in a working note** — you'll reference it in Task 2.
No commit for this task.
---
## Task 2: Rewrite `ABSCollectionStore`
**Files:**
- Modify: `internal/audiobooks/abs_collection_store.go`
- Test: `internal/audiobooks/abs_collection_store_test.go` (create if missing)
The current 8 methods are: `ListUserCollections`, `GetCollection`, `CreateCollection`, `UpdateCollection`, `DeleteCollection`, `ListCollectionItems`, `AddCollectionItem`, `RemoveCollectionItem`.
For each, the rewrite swaps `FROM abs_user_collections` / `FROM abs_collection_items` for the canonical equivalents.
- [ ] **Step 1: Write a failing test for `ListUserCollections`**
Append to `internal/audiobooks/abs_collection_store_test.go` (create the file if absent, declaring `package audiobooks`):
```go
package audiobooks
import (
"context"
"testing"
// ...add the test DB harness import discovered by the next step
)
func TestABSCollectionStoreListUserCollections(t *testing.T) {
if testing.Short() {
t.Skip("requires test DB")
}
ctx := context.Background()
// Set up a test pool against migration head (>= 156).
pool := newTestPool(t)
defer pool.Close()
// Insert one user_personal_collections row with collection_type='manual',
// is_shared=true, library_ids=[9] in query_definition.
_, err := pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id, query_definition)
VALUES
('test-c-1', 1, '', 'TestColl', 'desc', 'manual',
true, NOW(), NOW(), '', '{"library_ids":[9]}'::jsonb)
`)
if err != nil {
t.Fatalf("seed: %v", err)
}
store := &ABSCollectionStore{pool: pool}
got, err := store.ListUserCollections(ctx, "1", "")
if err != nil {
t.Fatalf("ListUserCollections: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d collections, want 1", len(got))
}
if got[0].Id != "test-c-1" || got[0].Name != "TestColl" || !got[0].IsPublic {
t.Errorf("unexpected fields: %+v", got[0])
}
}
```
**Verify the test-pool harness:** `grep -rln "func newTestPool\|func testPool" --include="*_test.go" .` finds the project's convention. If none exists, this test stays gated by `testing.Short()` and is best-effort — flag in the PR.
- [ ] **Step 2: Run the test to verify it fails**
```bash
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
```
Expected: FAIL because `ListUserCollections` still queries the dropped `abs_user_collections` table.
- [ ] **Step 3: Rewrite `ListUserCollections`**
Replace the existing function body with:
```go
func (s *ABSCollectionStore) ListUserCollections(ctx context.Context, userID, profileID string) ([]abs.Collection, error) {
const q = `
SELECT
id, name, description, COALESCE(query_definition->'library_ids'->>0, '0')::int AS library_id,
user_id, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE collection_type = 'manual'
AND user_id = $1::int
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
ORDER BY created_at DESC
`
rows, err := s.pool.Query(ctx, q, userID, profileID)
if err != nil {
return nil, fmt.Errorf("abs collection list: %w", err)
}
defer rows.Close()
var out []abs.Collection
for rows.Next() {
var c abs.Collection
if err := rows.Scan(&c.Id, &c.Name, &c.Description, &c.LibraryId, &c.UserId, &c.IsPublic, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan abs collection: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
```
Adjust the `Scan` order to match `abs.Collection`'s actual field types — read the struct from `internal/audiobooks/abs/` to confirm. If `Id` is named `ID` per Go convention, fix accordingly.
- [ ] **Step 4: Run the test, verify it passes**
```bash
go test ./internal/audiobooks/ -run TestABSCollectionStoreListUserCollections -v
```
Expected: PASS.
- [ ] **Step 5: Rewrite the remaining 7 methods using the same pattern**
For each method, apply this template:
| Method | Canonical operation |
|---|---|
| `GetCollection(id)` | `SELECT … WHERE id=$1 AND collection_type='manual'` |
| `CreateCollection(c)` | `INSERT INTO user_personal_collections (...) VALUES (..., 'manual', ..., jsonb_build_object('library_ids', jsonb_build_array(c.LibraryId)))` |
| `UpdateCollection(c)` | `UPDATE user_personal_collections SET name=$2, description=$3, is_shared=$4, query_definition=$5, updated_at=NOW() WHERE id=$1 AND collection_type='manual'` |
| `DeleteCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='manual'` (cascades to items via the existing FK) |
| `ListCollectionItems(collectionID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `media_item_id`→`abs.CollectionItem.LibraryItemId`, `sub_item_id`→`abs.CollectionItem.EpisodeId` |
| `AddCollectionItem(collectionID, libraryItemID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, '', COALESCE(MAX(position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
| `RemoveCollectionItem(collectionID, libraryItemID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=''` |
Each gets its own targeted test mirroring Step 1's structure: insert seed data, call the method, assert the canonical-table side-effect (`SELECT … FROM user_personal_collection_items …`).
For brevity, the plan shows the SQL above as a table; the actual implementation has each method as a complete function. Reuse the connection-error-wrapping style and the `fmt.Errorf("...: %w", err)` pattern already in the file.
- [ ] **Step 6: Run all tests for the file**
```bash
go test ./internal/audiobooks/ -run TestABSCollectionStore -v
```
Expected: all PASS.
- [ ] **Step 7: Commit**
```bash
git add internal/audiobooks/abs_collection_store.go internal/audiobooks/abs_collection_store_test.go
git commit -m "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. Library
scope is now encoded in query_definition.library_ids.
ABS HTTP handlers consume the same method signatures; wire shape
is unchanged."
```
---
## Task 3: Rewrite `ABSPlaylistStore`
**Files:**
- Modify: `internal/audiobooks/abs_playlist_store.go`
- Test: `internal/audiobooks/abs_playlist_store_test.go` (create if missing)
Same pattern as Task 2. Methods: `ListUserPlaylists`, `GetPlaylist`, `coverArg`, `CreatePlaylist`, `UpdatePlaylist`, `DeletePlaylist`, `ListPlaylistItems`, `AddPlaylistItem`, `RemovePlaylistItem`.
Filter on `collection_type='playlist'` instead of `'manual'`.
**Special handling for `coverArg`:** `abs_playlists` has a `cover_item` foreign key to `media_items`; the canonical `user_personal_collections` has only `poster_url` (text). Per spec §6 open question, the design recommends dropping `cover_item` and regenerating poster URLs from the first item. For this rewrite:
- `coverArg` becomes a noop / removed: the new CreatePlaylist doesn't accept a cover_item field on insert.
- The wire-shape struct can keep its `CoverItem` field; the rewrite always serializes it as `""` (empty). If the ABS app surfaces a cover, it falls back to the first item's poster — same as the audiobook UI does today.
- [ ] **Step 1: Write a failing test for `ListUserPlaylists`**
```go
func TestABSPlaylistStoreListUserPlaylists(t *testing.T) {
if testing.Short() {
t.Skip("requires test DB")
}
ctx := context.Background()
pool := newTestPool(t)
defer pool.Close()
_, err := pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id)
VALUES
('test-pl-1', 1, '', 'TestPlaylist', 'desc', 'playlist',
false, NOW(), NOW(), '')
`)
if err != nil { t.Fatalf("seed: %v", err) }
store := &ABSPlaylistStore{pool: pool}
got, err := store.ListUserPlaylists(ctx, "1", "")
if err != nil { t.Fatalf("ListUserPlaylists: %v", err) }
if len(got) != 1 || got[0].Id != "test-pl-1" || got[0].Name != "TestPlaylist" {
t.Errorf("unexpected: %+v", got)
}
}
```
- [ ] **Step 2: Run the test, fail, then rewrite**
```bash
go test ./internal/audiobooks/ -run TestABSPlaylistStoreListUserPlaylists -v
```
Expected: FAIL until rewrite lands.
- [ ] **Step 3: Rewrite `ListUserPlaylists`**
```go
func (s *ABSPlaylistStore) ListUserPlaylists(ctx context.Context, userID, profileID string) ([]abs.Playlist, error) {
const q = `
SELECT id, name, description, user_id, is_shared, created_at, updated_at
FROM user_personal_collections
WHERE collection_type = 'playlist'
AND user_id = $1::int
AND (profile_id = $2 OR ($2 = '' AND profile_id = ''))
ORDER BY created_at DESC
`
rows, err := s.pool.Query(ctx, q, userID, profileID)
if err != nil { return nil, fmt.Errorf("abs playlist list: %w", err) }
defer rows.Close()
var out []abs.Playlist
for rows.Next() {
var p abs.Playlist
if err := rows.Scan(&p.Id, &p.Name, &p.Description, &p.UserId, &p.IsPublic, &p.CreatedAt, &p.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan abs playlist: %w", err)
}
// CoverItem deliberately left as zero value; see plan + spec §6.
out = append(out, p)
}
return out, rows.Err()
}
```
- [ ] **Step 4: Rewrite the remaining methods**
| Method | Canonical operation |
|---|---|
| `GetPlaylist(id)` | `SELECT … WHERE id=$1 AND collection_type='playlist'` |
| `CreatePlaylist(p)` | `INSERT … VALUES (..., 'playlist', ...)` — drop cover_item handling |
| `UpdatePlaylist(p)` | `UPDATE … SET name=$2, description=$3, is_shared=$4, updated_at=NOW() WHERE id=$1 AND collection_type='playlist'` |
| `DeletePlaylist(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='playlist'` |
| `ListPlaylistItems(playlistID)` | `SELECT media_item_id, sub_item_id, position FROM user_personal_collection_items WHERE collection_id=$1 ORDER BY position, added_at` — map `sub_item_id`→`abs.PlaylistItem.EpisodeId` (empty string when no episode) |
| `AddPlaylistItem(playlistID, libraryItemID, episodeID)` | `INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) SELECT user_id, $1, $2, $3, COALESCE(MAX(i.position)+1, 0), NOW() FROM user_personal_collections c LEFT JOIN user_personal_collection_items i ON i.collection_id = c.id WHERE c.id=$1 GROUP BY user_id` |
| `RemovePlaylistItem(playlistID, libraryItemID, episodeID)` | `DELETE FROM user_personal_collection_items WHERE collection_id=$1 AND media_item_id=$2 AND sub_item_id=$3` |
| `coverArg(cover string)` | Delete entirely — no longer used. |
- [ ] **Step 5: Run all playlist tests**
```bash
go test ./internal/audiobooks/ -run TestABSPlaylistStore -v
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add internal/audiobooks/abs_playlist_store.go internal/audiobooks/abs_playlist_store_test.go
git commit -m "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 for episode-level entries). Drops the cover_item field
(no longer modeled in the canonical store; clients fall back to
first-item poster, same as audiobook UI today).
Wire shape preserved otherwise."
```
---
## Task 4: Rewrite `ABSSmartCollectionStore`
**Files:**
- Modify: `internal/audiobooks/abs_smart_collection_store.go`
- Test: `internal/audiobooks/abs_smart_collection_store_test.go` (create if missing)
Filter on `collection_type='smart'`. The rule DSL stored in `query_definition` (jsonb) — same column the silo-native query_definition uses. Note: the source column on `abs_smart_collections` was named `query_def` (not `query_definition`); the canonical column is `query_definition`. **Read+write target the canonical name.**
`color` and `is_pinned` from `abs_smart_collections` have no canonical equivalent (spec §6 defers them). The rewrite emits empty/false for those fields in the JSON output. If the ABS app silently relies on either, ship a follow-up to add the columns.
- [ ] **Step 1: Write a failing test**
```go
func TestABSSmartCollectionStoreListUserSmart(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
ctx := context.Background()
pool := newTestPool(t)
defer pool.Close()
_, err := pool.Exec(ctx, `
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id, query_definition)
VALUES
('test-sc-1', 1, '', 'TestSmart', '', 'smart',
false, NOW(), NOW(), '', '{"rules":[]}'::jsonb)
`)
if err != nil { t.Fatalf("seed: %v", err) }
store := &ABSSmartCollectionStore{pool: pool}
got, err := store.ListUserSmartCollections(ctx, "1", "")
if err != nil { t.Fatalf("ListUserSmartCollections: %v", err) }
if len(got) != 1 || got[0].Id != "test-sc-1" {
t.Errorf("unexpected: %+v", got)
}
}
```
- [ ] **Step 2: Rewrite the 6 methods**
| Method | Canonical operation |
|---|---|
| `ListUserSmartCollections` | `SELECT id, name, description, query_definition, user_id, is_shared FROM user_personal_collections WHERE collection_type='smart' AND user_id=$1::int AND (profile_id=$2 OR ...) ORDER BY created_at DESC` |
| `GetSmartCollection(id)` | same with `WHERE id=$1 AND collection_type='smart'` |
| `CreateSmartCollection(c)` | `INSERT … 'smart' … query_definition=$N::jsonb` |
| `UpdateSmartCollection(c)` | `UPDATE … SET name=$2, description=$3, query_definition=$4, is_shared=$5, updated_at=NOW() WHERE id=$1 AND collection_type='smart'` |
| `DeleteSmartCollection(id)` | `DELETE FROM user_personal_collections WHERE id=$1 AND collection_type='smart'` |
| Materializer (if present) | Calls `internal/smartcoll.Evaluate(...)` against `media_items` filtered by `query_definition.library_ids` |
For `color` and `is_pinned`: read from `abs.SmartCollection` zero-value on read paths; ignore on write paths. If the wire shape unconditionally includes them, leave the zero values in place — clients receive `"color":"", "is_pinned":false`.
- [ ] **Step 3: Tests + commit**
Same shape as Tasks 2 and 3. Commit message:
```
refactor(audiobooks): ABS smart collection store queries canonical tables
Rewrites ABSSmartCollectionStore methods to read/write user_personal_collections
(collection_type='smart'). 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.
See spec §6 for the deferred decision on those columns.
```
---
## Task 5: Wire-shape regression test (snapshot diff)
**Files:**
- Add or extend: an integration test that drives the ABS handlers and asserts the JSON output for each endpoint hasn't changed.
If a snapshot/golden-file test framework already exists in the repo, use it (`grep -rln 'goldenfile\|snapshot' --include="*_test.go" internal/audiobooks/`). Otherwise this task is **manual verification** captured in the MR description:
- [ ] **Step 1: Seed identical fixtures pre- and post-rewrite**
Before the rewrite commits, seed one of each:
- An `abs_user_collections` row (pre-migration) with name "FixtureColl", 1 item.
- An `abs_playlists` row (pre-migration) with name "FixturePL", 1 item with sub_item.
- An `abs_smart_collections` row (pre-migration) with name "FixtureSmart", rules `{"any":[…]}`.
Capture the JSON response from each list endpoint. Save to `/tmp/wire_before_*.json`.
- [ ] **Step 2: Run migration 156, then seed equivalent rows in canonical tables**
Insert one of each `user_personal_collections` row with the same Id, Name, etc.
Capture the JSON response. Save to `/tmp/wire_after_*.json`.
- [ ] **Step 3: Diff**
```bash
for kind in collections playlists smart_collections; do
diff /tmp/wire_before_${kind}.json /tmp/wire_after_${kind}.json
done
```
Expected: empty diff for each. If anything differs, note the field and either patch the rewrite to match or flag it as a wire-shape regression in the PR description so the ABS team is aware.
- [ ] **Step 4: Commit the test (if framework supports it) or paste the diff into the PR description**
```bash
git add internal/audiobooks/abs_wireshape_test.go # if applicable
git commit -m "test(audiobooks): wire-shape snapshot for ABS adapters
Locks in JSON parity for /api/libraries/{id}/collections|playlists|
smart-collections after the canonical-table cutover."
```
---
## Verification (after merge)
1. Silo binary starts cleanly against a migration-156 DB.
2. ABS endpoints respond:
- `GET /api/libraries/9/collections` — returns the 0 manual collections, no errors.
- `GET /api/libraries/9/playlists` — returns the 1 promoted playlist with `Id` preserved.
- `GET /api/libraries/9/smart-collections` — returns 0 smart collections, no errors.
3. ABS Android app (or whatever client is in use) opens the library, sees the same content it saw pre-migration.
4. Creating a new collection from the ABS app inserts into `user_personal_collections`, not anywhere else:
```sql
SELECT collection_type, COUNT(*) FROM user_personal_collections GROUP BY 1;
```
Expected: counts increase for the relevant `collection_type` as the user creates content.
---
## Self-Review
**Spec coverage:**
- Collection store rewrite ✓ (Task 2)
- Playlist store rewrite ✓ (Task 3)
- Smart collection store rewrite ✓ (Task 4)
- Wire-shape preservation ✓ (Tasks 1, 5)
- Drop cover_item ✓ (Task 3, per spec §6)
- color / is_pinned deferred ✓ (Task 4, per spec §6)
**Placeholder scan:** Task 2 Step 5 lists 7 remaining methods as a table rather than spelling out the full Go body of each. The table gives the exact SQL semantics, the column mappings, and a pointer at the existing file's error-wrap style — sufficient for a competent implementer to write the bodies. The full code for each method would balloon this plan from ~700 to ~2000 lines; the table form is the right granularity. Same applies to Task 3 Step 4 and Task 4 Step 2. Each table entry could equally be a TDD task on its own; the implementer is free to expand any row into a write-test-fail-implement-pass-commit cycle.
**Type consistency:** `abs.Collection`, `abs.Playlist`, `abs.SmartCollection`, `abs.CollectionItem`, `abs.PlaylistItem` referenced consistently. `collection_type` enum values `'manual'`, `'playlist'`, `'smart'` consistent. `sub_item_id` column name consistent.
**Risk:** Largest sub-project of the four. Three files rewritten end-to-end. The biggest risk is wire-shape regressions where a struct field's serialized name differs subtly between adapters — Task 5 mitigates with the diff approach, but it's manual unless a snapshot harness exists. Sub-project 1 must land first; sub-project 2 must land first if Task 4 imports the new `internal/smartcoll` path.
@@ -0,0 +1,711 @@
# Collections Unification — Sub-project 4: Section Recipes for Audiobooks
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `page_sections` media-type-parameterized so existing recipes can include audiobooks, and add two new audiobook-flavored recipes (`continue_listening`, `by_audiobook_series`).
**Architecture:** Each recipe declaration in `internal/sections/recipes/` exposes a `SupportedMediaTypes` field. `PageSection` rows carry a `media_types text[]` column (added by migration 156 — sub-project 1). The fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes default to `['movie','series']` so current behavior is preserved.
**Tech Stack:** Go, PostgreSQL, existing `internal/sections/` + `internal/sections/recipes/` packages.
**Commands assume the repository root is the cwd.**
**Source spec:** `docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md` §4.4.
**Predecessor sub-project:** Sub-project 1 (migration 156) must land first — `page_sections.media_types` is required.
---
## File map
**Modify:**
- `internal/sections/types.go` — add `media_types []string` to the `PageSection` Go struct
- `internal/sections/registry.go` (or wherever recipes are registered — verify via grep) — surface `SupportedMediaTypes()` on the Recipe interface
- `internal/sections/recipes/*.go` — every recipe file declares its supported types
- `internal/sections/recipes/registry.go` — recipe registry exposes the supported-types info to the admin API
- Section fetchers (likely `internal/sections/fetcher.go` or similar) — wire `media_types` into the SQL `WHERE` clause
**Create:**
- `internal/sections/recipes/continue_listening.go` + test
- `internal/sections/recipes/by_audiobook_series.go` + test
**Add to admin API:**
- `internal/api/handlers/recipes.go` — the `GET /api/sections/recipes` endpoint surfaces the per-recipe supported-media-types in its response
---
## Task 1: Audit current recipe shape
This task is read-only — produces a working note for the rest of the plan.
- [ ] **Step 1: Map the current recipe interface**
```bash
grep -nE "type Recipe interface|type RecipeDefinition|func.*Type\(\) string|func.*Definition\(\)|SupportedMediaTypes" internal/sections/recipes/*.go internal/sections/types.go internal/sections/registry.go 2>/dev/null
```
Read the `Recipe` interface and any current registration code. Note:
- The exact method signatures recipes already implement.
- How a recipe is registered (`init()` calls? explicit `Register`? slice literal?).
- Whether `RecipeDefinition` carries metadata returned to the admin UI; that's where `SupportedMediaTypes` will surface.
- [ ] **Step 2: Map the fetcher's current SQL**
```bash
grep -nE "FetchOne|FetchSection|SELECT.*FROM media_items|type Fetcher" internal/sections/*.go 2>/dev/null | head -20
```
Find the function that turns a `ResolvedSection` into a list of `media_items`. Note its query shape — where the `WHERE` clause lives, what parameters it already takes.
- [ ] **Step 3: Note the section persistence shape**
```bash
grep -nE "INSERT INTO page_sections|UPDATE page_sections|SELECT.*FROM page_sections" internal/sections/*.go internal/api/handlers/sections*.go 2>/dev/null | head -10
```
Find where `page_sections` rows are read and written. The `media_types` column needs to be read/written there too.
No commit for this task — it produces a mental map you'll reference in Tasks 2–5.
---
## Task 2: Add `media_types` to the `PageSection` struct + serde
**Files:**
- Modify: `internal/sections/types.go` (`PageSection` struct)
- Modify: wherever `page_sections` rows are scanned and written (from Task 1 Step 3)
- [ ] **Step 1: Write failing test for `media_types` round-trip**
The test goes in whichever package owns the `PageSection` repository — likely `internal/sections/` itself. If a repository test file exists, append; otherwise create one:
```go
func TestPageSectionMediaTypesRoundTrip(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
ctx := context.Background()
pool := newTestPool(t) // adapt to project's test pool harness
repo := NewSectionRepository(pool)
sec := &PageSection{
Name: "Audiobook test",
RecipeType: "library_staples",
MediaTypes: []string{"audiobook"},
// ...fill in other required fields from the struct definition
}
if err := repo.Create(ctx, sec); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := repo.GetByID(ctx, sec.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if !reflect.DeepEqual(got.MediaTypes, []string{"audiobook"}) {
t.Errorf("MediaTypes = %v, want [audiobook]", got.MediaTypes)
}
}
```
Adapt the constructor name, struct fields, and DB harness to actual project shape (found in Task 1).
- [ ] **Step 2: Run the test to confirm failure**
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
Expected: FAIL — the `MediaTypes` field doesn't exist yet.
- [ ] **Step 3: Add the field to `PageSection`**
In `internal/sections/types.go`, find the `PageSection` struct (or whatever it's called per Task 1's audit) and add:
```go
type PageSection struct {
// ...existing fields
MediaTypes []string `json:"media_types" db:"media_types"`
}
```
If the project's struct tagging convention differs, follow that convention. The DB column is `text[]`; the Go type is `[]string`. pgx's default decoder handles this directly.
- [ ] **Step 4: Update the repository's read/write SQL**
For each `INSERT`/`UPDATE` against `page_sections`, add `media_types` to the column list and bind parameter list. For each `SELECT`, add it to the projection and scan into the new field.
Example pattern (adapt to actual code):
```go
const insertSQL = `
INSERT INTO page_sections (id, name, recipe_type, ..., media_types)
VALUES ($1, $2, $3, ..., $N::text[])
`
// ...
_, err := pool.Exec(ctx, insertSQL, sec.ID, sec.Name, sec.RecipeType, ..., sec.MediaTypes)
```
For SELECTs, add `media_types` to the projection and `&sec.MediaTypes` to the scan list.
- [ ] **Step 5: Run the test, verify pass**
Run: `go test ./internal/sections/ -run TestPageSectionMediaTypesRoundTrip -v`
Expected: PASS.
- [ ] **Step 6: Build + broader test**
```bash
go build ./...
go test ./internal/sections/ ./internal/api/handlers/ -short -timeout 90s
```
Expected: clean.
- [ ] **Step 7: Commit**
```bash
git add internal/sections/types.go internal/sections/*.go internal/api/handlers/sections*.go
git commit -m "feat(sections): expose page_sections.media_types in Go
Adds MediaTypes []string to PageSection + repo read/write paths.
Migration 156 (sub-project 1) added the underlying column with
DEFAULT ARRAY['movie','series']; existing rows retain that default,
preserving today's behavior."
```
Only stage the files you actually modified. Verify with `git diff --cached --stat` before commit.
---
## Task 3: Recipe interface — `SupportedMediaTypes`
**Files:**
- Modify: `internal/sections/recipes/registry.go` (interface declaration)
- Modify: every `*.go` recipe file in `internal/sections/recipes/` (each gains a one-line method)
- [ ] **Step 1: Find the Recipe interface**
```bash
grep -n "type Recipe " internal/sections/recipes/*.go internal/sections/*.go
```
Note the file + line of the interface declaration.
- [ ] **Step 2: Add a `SupportedMediaTypes()` method to the interface**
In whichever file declares the interface:
```go
type Recipe interface {
// ...existing methods
SupportedMediaTypes() []string
}
```
- [ ] **Step 3: Implement on every existing recipe**
For each `*Recipe` struct in `internal/sections/recipes/`, add the method. Existing recipes default to movies+series to preserve behavior:
```go
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series"} }
func (moodRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
func (handPickedRecipe) SupportedMediaTypes() []string { return []string{"movie", "series"} }
// ...and so on for every recipe in the directory
```
Use this enumeration as a checklist (from `ls internal/sections/recipes/*.go`, excluding tests):
- admin_curated_list
- custom
- discovery
- editorial
- hand_picked
- library_staples
- mood
- personalized
Each gets one method. Audiobook-eligible recipes — `library_staples`, `discovery`, `hand_picked`, `mood`, `personalized` — extend to include `"audiobook"` if their underlying query already handles all `media_items.type` values transparently. Conservative call: only `library_staples` extends to all three on this pass. The rest stay movies+series until each one's recipe-specific query is audited. **Bias toward the conservative default.**
Specifically:
```go
func (libStaple) SupportedMediaTypes() []string { return []string{"movie", "series", "audiobook"} }
```
- [ ] **Step 4: Build**
```bash
go build ./...
```
Expected: clean. If any recipe is missed, the Go compiler will complain that the type doesn't satisfy the interface.
- [ ] **Step 5: Commit**
```bash
git add internal/sections/recipes/
git commit -m "feat(recipes): SupportedMediaTypes per recipe
Adds the SupportedMediaTypes() method to the Recipe interface and
implements it on each existing recipe. Conservative defaults preserve
today's behavior: only library_staples opens to audiobooks; others
stay movies+series until their underlying queries are audited."
```
---
## Task 4: Fetcher applies the `media_types` filter
**Files:**
- Modify: the section fetcher implementation file (found in Task 1 Step 2)
- [ ] **Step 1: Write a failing test**
Append to (or create) the fetcher's test file. Pattern:
```go
func TestFetcherFiltersByMediaTypes(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
ctx := context.Background()
pool := newTestPool(t)
// Seed: one movie, one audiobook, both eligible for the same recipe.
seedTestMediaItem(t, pool, "mov-1", "movie", "Test Movie")
seedTestMediaItem(t, pool, "ab-1", "audiobook", "Test Book")
fetcher := NewFetcher(pool)
t.Run("media_types movies only", func(t *testing.T) {
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
SectionType: "library_staples",
MediaTypes: []string{"movie"},
}, nil, nil, 1, "", catalog.AccessFilter{})
assertContains(t, got.Items, "mov-1")
assertExcludes(t, got.Items, "ab-1")
})
t.Run("media_types audiobook only", func(t *testing.T) {
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
SectionType: "library_staples",
MediaTypes: []string{"audiobook"},
}, nil, nil, 1, "", catalog.AccessFilter{})
assertContains(t, got.Items, "ab-1")
assertExcludes(t, got.Items, "mov-1")
})
t.Run("media_types both", func(t *testing.T) {
got, _ := fetcher.FetchOne(ctx, ResolvedSection{
SectionType: "library_staples",
MediaTypes: []string{"movie", "audiobook"},
}, nil, nil, 1, "", catalog.AccessFilter{})
assertContains(t, got.Items, "mov-1", "ab-1")
})
}
```
Adapt `ResolvedSection`, `FetchOne` signature, `seedTestMediaItem`, `assertContains` / `assertExcludes` to the project's actual shapes. The exact assertions depend on what `SectionWithItems` exposes — likely a `.Items []SectionItem` slice.
- [ ] **Step 2: Run the test, verify fail**
```bash
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
```
Expected: FAIL — `ResolvedSection.MediaTypes` field doesn't exist yet OR the fetcher SQL doesn't filter.
- [ ] **Step 3: Add `MediaTypes` to `ResolvedSection`**
In `internal/sections/types.go` (or wherever `ResolvedSection` lives):
```go
type ResolvedSection struct {
// ...existing fields
MediaTypes []string
}
```
The resolver code that builds `ResolvedSection` from a `PageSection` row needs one more line to pass `MediaTypes` through. Find it:
```bash
grep -n "ResolvedSection{" internal/sections/*.go internal/api/handlers/*.go
```
For each instance that builds one from a `PageSection`, add `MediaTypes: section.MediaTypes,`.
- [ ] **Step 4: Wire the filter into the fetcher SQL**
In the fetcher implementation, find the query that pulls `media_items`. Add to its `WHERE` clause:
```go
const query = `
SELECT ...
FROM media_items mi
WHERE ...
AND mi.type = ANY($N::text[])
...
`
// ...
rows, err := pool.Query(ctx, query, ..., mediaTypes)
```
If the fetcher dispatches to per-recipe resolvers, the filter goes inside each resolver's query. Most likely there's a shared query-building helper — find it via `grep -n "type = ANY\|recipe.*Resolve" internal/sections/`.
If the fetcher receives a `ResolvedSection` and dispatches to recipe resolvers based on `SectionType`, the resolvers each accept the `MediaTypes` slice as part of their `ResolverContext` (or whatever the recipe-side context type is). Add a field to that context type and thread it through.
**Validation rule**: the fetcher must reject a request where `ResolvedSection.MediaTypes` contains a type not in the recipe's `SupportedMediaTypes()`. Add this guard in the dispatch path:
```go
recipe, ok := registry.Get(resolved.SectionType)
if !ok { return result, fmt.Errorf("unknown recipe %q", resolved.SectionType) }
supported := setOf(recipe.SupportedMediaTypes())
for _, mt := range resolved.MediaTypes {
if _, ok := supported[mt]; !ok {
return result, fmt.Errorf("recipe %q does not support media type %q", resolved.SectionType, mt)
}
}
```
`setOf` is a 3-line helper that returns `map[string]struct{}` from a slice.
- [ ] **Step 5: Run the test, verify pass**
```bash
go test ./internal/sections/ -run TestFetcherFiltersByMediaTypes -v
```
Expected: PASS for all three subtests.
- [ ] **Step 6: Commit**
```bash
git add internal/sections/
git commit -m "feat(sections): fetcher honors media_types filter
ResolvedSection carries a MediaTypes slice. Fetcher dispatch validates
the slice against the recipe's SupportedMediaTypes() and forwards it
into the underlying SQL (WHERE mi.type = ANY(\$N::text[]))."
```
---
## Task 5: New recipe — `continue_listening`
**Files:**
- Create: `internal/sections/recipes/continue_listening.go`
- Create: `internal/sections/recipes/continue_listening_test.go`
The audiobook analog of `continue_watching`. Surfaces audiobook items the user has progress on but hasn't finished.
- [ ] **Step 1: Read the existing `continue_watching` recipe (if present)**
```bash
ls internal/sections/recipes/ | grep -i continue
grep -rln "continue_watching\|user_watch_progress" internal/sections/recipes/ internal/sections/
```
Identify the closest analog. Copy its overall structure (registration, params struct, Resolve method shape). The audiobook version differs only in `type = 'audiobook'` filter and possibly which progress columns it inspects (audiobook progress may live in the same `user_watch_progress` table — verify with `\d user_watch_progress` against the DB).
- [ ] **Step 2: Write a failing test**
```go
package recipes
import (
"context"
"testing"
)
func TestContinueListeningRecipeRegistered(t *testing.T) {
r, ok := registry.Get("continue_listening")
if !ok {
t.Fatal("continue_listening recipe not registered")
}
got := r.SupportedMediaTypes()
want := []string{"audiobook"}
if !equalStringSlices(got, want) {
t.Errorf("SupportedMediaTypes = %v, want %v", got, want)
}
}
func TestContinueListeningResolvesProgressedAudiobooks(t *testing.T) {
if testing.Short() { t.Skip("requires test DB") }
// Seed two audiobook items: one with progress > 0 and not finished;
// one with progress = 0. Run the recipe's Resolve method. Assert only
// the first is returned.
t.Skip("Implement once existing continue_watching test pattern is read")
}
```
- [ ] **Step 3: Implement the recipe**
```go
// Package recipes
package recipes
import (
"context"
"encoding/json"
"time"
)
type continueListeningRecipe struct{}
type ContinueListeningParams struct {
// e.g. max items, library scope; mirror continue_watching's shape
MaxItems int `json:"max_items"`
}
func (continueListeningRecipe) Type() string { return "continue_listening" }
func (continueListeningRecipe) NewParams() any { return &ContinueListeningParams{} }
func (continueListeningRecipe) DefaultCacheTTL() time.Duration { return 5 * time.Minute }
func (continueListeningRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
func (continueListeningRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
const q = `
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
FROM media_items mi
JOIN user_watch_progress uwp ON uwp.content_id = mi.content_id
WHERE mi.type = 'audiobook'
AND uwp.user_id = $1
AND uwp.position_seconds > 0
AND COALESCE(uwp.completed, false) = false
ORDER BY uwp.updated_at DESC
LIMIT $2
`
params := rc.Params.(*ContinueListeningParams)
limit := params.MaxItems
if limit <= 0 || limit > 50 { limit = 20 }
rows, err := rc.Pool.Query(rc.Ctx, q, rc.UserID, limit)
if err != nil { return ResolvedItems{}, fmt.Errorf("continue_listening: %w", err) }
defer rows.Close()
var items []ResolvedItem
for rows.Next() {
var it ResolvedItem
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
return ResolvedItems{}, fmt.Errorf("continue_listening scan: %w", err)
}
items = append(items, it)
}
return ResolvedItems{Items: items}, rows.Err()
}
func (continueListeningRecipe) Validate(raw json.RawMessage) error {
var p ContinueListeningParams
return json.Unmarshal(raw, &p)
}
func (continueListeningRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "continue_listening",
Name: "Continue Listening",
Description: "Audiobooks you've started but haven't finished.",
}
}
func init() {
registry.Register(continueListeningRecipe{})
}
```
**Adapt the field names** (`PosterPath`, `Pool`, `UserID`, `Ctx`, `Params`, etc.) to whatever the existing recipes use. **Adapt the SQL** to the actual `user_watch_progress` column names — verify with `\d user_watch_progress` against the DB.
- [ ] **Step 4: Run tests, verify pass**
```bash
go test ./internal/sections/recipes/ -run TestContinueListening -v
```
Expected: registration test PASS; the resolve test still skipped (or implemented and passing).
- [ ] **Step 5: Commit**
```bash
git add internal/sections/recipes/continue_listening.go internal/sections/recipes/continue_listening_test.go
git commit -m "feat(recipes): continue_listening audiobook rail
Audiobook analog of continue_watching. Returns books the user has
progress on but hasn't finished, sorted by most-recently-listened."
```
---
## Task 6: New recipe — `by_audiobook_series`
**Files:**
- Create: `internal/sections/recipes/by_audiobook_series.go`
- Create: `internal/sections/recipes/by_audiobook_series_test.go`
Surfaces books grouped by series, drawing from the `audiobook_series` table populated by the scanner.
- [ ] **Step 1: Write the registration test**
```go
func TestByAudiobookSeriesRecipeRegistered(t *testing.T) {
r, ok := registry.Get("by_audiobook_series")
if !ok { t.Fatal("by_audiobook_series not registered") }
if got := r.SupportedMediaTypes(); !equalStringSlices(got, []string{"audiobook"}) {
t.Errorf("SupportedMediaTypes = %v, want [audiobook]", got)
}
}
```
- [ ] **Step 2: Implement the recipe**
```go
type byAudiobookSeriesRecipe struct{}
type ByAudiobookSeriesParams struct {
SeriesName string `json:"series_name"`
}
func (byAudiobookSeriesRecipe) Type() string { return "by_audiobook_series" }
func (byAudiobookSeriesRecipe) NewParams() any { return &ByAudiobookSeriesParams{} }
func (byAudiobookSeriesRecipe) DefaultCacheTTL() time.Duration { return 30 * time.Minute }
func (byAudiobookSeriesRecipe) SupportedMediaTypes() []string { return []string{"audiobook"} }
func (byAudiobookSeriesRecipe) Resolve(rc ResolverContext) (ResolvedItems, error) {
const q = `
SELECT mi.content_id, mi.title, mi.year, mi.poster_path
FROM media_items mi
JOIN audiobook_series s ON s.content_id = mi.content_id
WHERE mi.type = 'audiobook'
AND s.series_name = $1
ORDER BY COALESCE(s.series_index, 9999), mi.title
`
params := rc.Params.(*ByAudiobookSeriesParams)
if params.SeriesName == "" {
return ResolvedItems{}, fmt.Errorf("by_audiobook_series: series_name required")
}
rows, err := rc.Pool.Query(rc.Ctx, q, params.SeriesName)
if err != nil { return ResolvedItems{}, fmt.Errorf("by_audiobook_series: %w", err) }
defer rows.Close()
var items []ResolvedItem
for rows.Next() {
var it ResolvedItem
if err := rows.Scan(&it.ContentID, &it.Title, &it.Year, &it.PosterPath); err != nil {
return ResolvedItems{}, fmt.Errorf("by_audiobook_series scan: %w", err)
}
items = append(items, it)
}
return ResolvedItems{Items: items}, rows.Err()
}
func (byAudiobookSeriesRecipe) Validate(raw json.RawMessage) error {
var p ByAudiobookSeriesParams
if err := json.Unmarshal(raw, &p); err != nil { return err }
if p.SeriesName == "" { return fmt.Errorf("series_name is required") }
return nil
}
func (byAudiobookSeriesRecipe) Definition() RecipeDefinition {
return RecipeDefinition{
Type: "by_audiobook_series",
Name: "By Audiobook Series",
Description: "Books in a specific audiobook series, ordered by series position.",
}
}
func init() {
registry.Register(byAudiobookSeriesRecipe{})
}
```
- [ ] **Step 3: Run test, verify pass**
```bash
go test ./internal/sections/recipes/ -run TestByAudiobookSeries -v
```
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add internal/sections/recipes/by_audiobook_series.go internal/sections/recipes/by_audiobook_series_test.go
git commit -m "feat(recipes): by_audiobook_series rail
Surfaces books in a named series ordered by series_index. Backed by
the audiobook_series table populated by the scanner."
```
---
## Task 7: Surface `SupportedMediaTypes` in admin API response
**Files:**
- Modify: `internal/api/handlers/recipes.go` (`GET /api/sections/recipes` handler)
The admin section-builder UI needs to know which recipes support audiobooks so it can offer the media-types multi-select to the operator. The handler's response shape adds one field per recipe.
- [ ] **Step 1: Read the current handler**
```bash
cat internal/api/handlers/recipes.go
```
Note the response struct shape — probably `[]RecipeInfo` or similar with `Type`, `Name`, `Description`.
- [ ] **Step 2: Add `SupportedMediaTypes` to the response**
In the response struct definition, add:
```go
type recipeInfo struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
SupportedMediaTypes []string `json:"supported_media_types"`
}
```
In the handler body, set `SupportedMediaTypes: recipe.SupportedMediaTypes()`.
- [ ] **Step 3: Test**
If the project has a handler test for `GET /api/sections/recipes`, extend it. Otherwise the manual verification step covers this:
```bash
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '.[] | select(.type=="continue_listening")'
# Expected: { "type":"continue_listening", "name":"Continue Listening", ..., "supported_media_types":["audiobook"] }
```
- [ ] **Step 4: Commit**
```bash
git add internal/api/handlers/recipes.go
git commit -m "feat(api): expose supported_media_types in /api/sections/recipes
Admin UI section-builder uses this to gate the media-types
multi-select per recipe."
```
---
## Verification (after merge)
1. Existing sections continue to work — their `media_types` defaults to `['movie','series']` so query results are unchanged.
2. Creating a section with `media_types=['audiobook']` and `recipe_type='library_staples'` returns audiobook items.
3. Two new recipes registered:
```bash
curl -sH "Authorization: Bearer $TOKEN" http://localhost:8090/api/sections/recipes | jq '[.[] | .type] | sort'
```
Expected: includes `"continue_listening"` and `"by_audiobook_series"` along with the existing types.
4. A user with active audiobook progress sees the `continue_listening` rail return their in-progress books.
5. The admin section-builder UI (if updated) lets the operator pick `media_types` for any recipe whose `SupportedMediaTypes` includes that type.
---
## Self-Review
**Spec coverage:**
- `page_sections.media_types` Go-side serde ✓ (Task 2)
- `Recipe.SupportedMediaTypes()` on every recipe ✓ (Task 3)
- Fetcher filter by `media_types` ✓ (Task 4)
- `continue_listening` recipe ✓ (Task 5)
- `by_audiobook_series` recipe ✓ (Task 6)
- Admin API exposes per-recipe types ✓ (Task 7)
**Placeholder scan:** Test bodies in Tasks 5 and 6 are partly stubbed with `t.Skip(...)` to be filled in by reading the existing `continue_watching` test pattern. That's a deliberate "read-existing-then-write" instruction, not a TBD. The other stubs (test pool harness lookups, schema verification queries) are explicit grep commands the implementer runs at start of the task. No abstract "implement appropriately."
**Type consistency:** `SupportedMediaTypes`, `MediaTypes`, `ResolverContext`, `ResolvedItems`, recipe `Type()` string values all consistent across tasks.
**Risk:** Lowest-risk of the 4 sub-projects. The fetcher change has an explicit failing-test guard (Task 4 Step 1) so regression is unlikely. The two new recipes are additive; even if one's SQL is mis-specified, existing recipes are unaffected. The main risk is the audit decision in Task 3 Step 3 — being too aggressive (extending too many recipes to audiobooks) could surface bad audiobook rails on existing sections; being too conservative wastes the parameterization. Conservative is the safer default.
@@ -0,0 +1,174 @@
# Audiobooks Stacked PR Split Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the oversized audiobook PR with a stacked series of smaller, reviewable PRs.
**Architecture:** Preserve the existing `feat/audiobooks` work as the source branch, then create branch cut points that expose one logical layer at a time. Each PR targets the previous branch so reviewers see only the incremental diff.
**Tech Stack:** Git, GitHub CLI, Go, pnpm/Vite, PostgreSQL migrations.
---
### Task 1: Close The Oversized PR
**Files:**
- Modify: GitHub PR #17 only.
- [ ] **Step 1: Post a replacement note**
Run:
```bash
gh pr comment 17 --repo Silo-Server/silo-server --body 'Closing this oversized PR in favor of a stacked series of smaller PRs. The existing branch is preserved as the source-of-truth while the stack is rebuilt into logical review units.'
```
Expected: GitHub prints the created comment URL.
- [ ] **Step 2: Close PR #17 without deleting the branch**
Run:
```bash
gh pr close 17 --repo Silo-Server/silo-server
```
Expected: PR #17 state becomes `CLOSED`; branch `feat/audiobooks` remains available.
### Task 2: Create Stacked Branches
**Files:**
- Modify: remote Git branches only.
- [ ] **Step 1: Create branch 1, foundation**
Branch name: `stack/audiobooks-foundation`
Scope:
- audiobook feature flag/settings
- audiobook and podcast schema foundations
- scanner support
- audiobook media item write path
- no native UI
- no ABS listener
- [ ] **Step 2: Create branch 2, native UI MVP**
Branch name: `stack/audiobooks-native-ui`
Base: `stack/audiobooks-foundation`
Scope:
- `/api/v1/audiobooks`
- audiobook detail/progress endpoints
- audiobook frontend route/sidebar/player MVP
- [ ] **Step 3: Create branch 3, ABS core**
Branch name: `stack/audiobooks-abs-core`
Base: `stack/audiobooks-native-ui`
Scope:
- ABS auth/login/refresh/logout
- ABS listener and feature-flag listener gating
- ABS library/items/play/progress
- ABS access control and security hardening
- [ ] **Step 4: Create branch 4, ABS collections**
Branch name: `stack/audiobooks-abs-collections`
Base: `stack/audiobooks-abs-core`
Scope:
- ABS bookmarks
- ABS collections
- ABS playlists
- ABS smart collections
- unified collection migration
- [ ] **Step 5: Create branch 5, extras and polish**
Branch name: `stack/audiobooks-extras`
Base: `stack/audiobooks-abs-collections`
Scope:
- podcasts/RSS
- stats
- author/series extras
- catalog audiobook filters/typeahead
- performance and enrichment cleanup
### Task 3: Open The Stacked PRs
**Files:**
- Modify: GitHub PRs only.
- [ ] **Step 1: Open PR 1**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base main --head stack/audiobooks-foundation --title 'feat(audiobooks): foundation and scanner support' --body 'First PR in the audiobook stack. Adds the schema/settings/scanner foundation without native UI or ABS compatibility.'
```
- [ ] **Step 2: Open PR 2**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-foundation --head stack/audiobooks-native-ui --title 'feat(audiobooks): native API and player MVP' --body 'Second PR in the audiobook stack. Adds native Silo audiobook endpoints and the MVP web player UI.'
```
- [ ] **Step 3: Open PR 3**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-native-ui --head stack/audiobooks-abs-core --title 'feat(audiobooks): Audiobookshelf compatibility core' --body 'Third PR in the audiobook stack. Adds ABS auth, listener, library/items/play/progress, and security hardening.'
```
- [ ] **Step 4: Open PR 4**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-core --head stack/audiobooks-abs-collections --title 'feat(audiobooks): ABS collections, playlists, and smart collections' --body 'Fourth PR in the audiobook stack. Adds ABS bookmarks, collections, playlists, smart collections, and unified collection storage.'
```
- [ ] **Step 5: Open PR 5**
Run:
```bash
gh pr create --repo Silo-Server/silo-server --base stack/audiobooks-abs-collections --head stack/audiobooks-extras --title 'feat(audiobooks): podcasts, catalog filters, and enrichment polish' --body 'Final PR in the audiobook stack. Adds podcast/RSS extras, audiobook catalog filters/typeahead, and scan/enrichment performance polish.'
```
### Task 4: Verify And Report
**Files:**
- Modify: none.
- [ ] **Step 1: Verify PR states**
Run:
```bash
gh pr list --repo Silo-Server/silo-server --state open --search 'audiobooks in:title' --json number,title,headRefName,baseRefName,url
```
Expected: five open stacked PRs with the base/head chain shown above.
- [ ] **Step 2: Run branch verification where feasible**
Run:
```bash
go test ./...
cd web && pnpm run build
```
Expected: both commands pass on the final stack tip.
@@ -0,0 +1,301 @@
# Ebook Reader Full Parity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bring the core ebook reader up to the personal reader feature set with server-persisted config, annotations/bookmarks, selection tools, reading aids, and advanced settings.
**Architecture:** Keep ebook reading in core, matching audiobooks: core owns the ebook media type and reader behavior; metadata remains plugin-provided. Extend the existing `/ebooks/{content_id}` reader API and `EbookReaderHandler` rather than creating a separate plugin-style reader subsystem. Persist per-user/per-profile/per-book state in Postgres so settings, annotations, and bookmarks roam across devices.
**Tech Stack:** Go/Chi handlers, pgx/Postgres migrations, React 19, TanStack Query, Foliate/readest reader wrapper, Vitest, Go unit tests.
---
## File Structure
- `migrations/sql/20260608000300_ebook_reader_state.sql`
- Creates `ebook_reader_config` and `ebook_reader_annotations`.
- `internal/api/handlers/ebook_reader.go`
- Adds reader config and annotation types, store interfaces, handlers, validation, and Postgres store methods.
- `internal/api/handlers/ebook_reader_test.go`
- Adds handler tests for config, annotation CRUD, access scoping, and validation.
- `internal/api/router.go`
- Wires config/annotation stores and routes under `/ebooks/{content_id}`.
- `web/src/reader/FoliateBookReader.tsx`
- Adds selection/annotation/readable-text/content-popup reader handles and applies saved config.
- `web/src/pages/EbookReader.tsx`
- Adds server-backed settings, annotation/bookmark UI, selection tools, TTS controls, wake-lock/e-ink controls, and advanced reader controls.
- `web/src/hooks/useTTS.ts`, `web/src/hooks/useScreenWakeLock.ts`, `web/src/hooks/useEinkMode.ts`
- Port focused personal-reader hooks into core web.
- `web/src/reader/ebookReaderApi.ts`
- Centralizes reader config and annotation API calls for tests and page usage.
- `web/src/pages/EbookReader.test.tsx`, `web/src/reader/FoliateBookReader.test.ts`
- Adds UI and wrapper tests.
## Task 1: Server-Persisted Reader Config
**Files:**
- Create: `migrations/sql/20260608000300_ebook_reader_state.sql`
- Modify: `internal/api/handlers/ebook_reader.go`
- Modify: `internal/api/handlers/ebook_reader_test.go`
- Modify: `internal/api/router.go`
- Create: `web/src/reader/ebookReaderApi.ts`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Write failing Go handler tests**
Add tests that:
- `GET /ebooks/{content_id}/reader-config` returns `{ "config": {} }` when no row exists.
- `PUT /ebooks/{content_id}/reader-config` saves JSON config for the authenticated user/profile/content.
- inaccessible content returns 404 through the existing `ItemAccess` path.
- invalid non-object config returns 400.
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
```
Expected: fail because config store/handlers do not exist.
- [ ] **Step 2: Add migration**
Create `ebook_reader_config`:
```sql
CREATE TABLE ebook_reader_config (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
config JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, profile_id, content_id)
);
CREATE INDEX ebook_reader_config_profile_updated
ON ebook_reader_config (user_id, profile_id, updated_at DESC);
```
- [ ] **Step 3: Implement config store and handlers**
Add `EbookReaderConfig`, `EbookReaderConfigStore`, `HandleGetConfig`, `HandleSaveConfig`, and `PGEbookReaderConfigStore` methods in `ebook_reader.go`.
Validation:
- authenticated user required
- `content_id` required
- `config` must decode to a JSON object
- content access checked with `FileAuthorizer.ItemAccess.EnsureAccessible`
- [ ] **Step 4: Wire routes and store**
In `internal/api/router.go`, create the store when `deps.DB != nil`, assign it to `ebookReaderHandler`, and add:
```go
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
```
- [ ] **Step 5: Add web API wrapper**
Create `web/src/reader/ebookReaderApi.ts` with:
```ts
export function ebookReaderConfigPath(contentID: string): string
export async function fetchEbookReaderConfig(contentID: string): Promise<Record<string, unknown>>
export async function saveEbookReaderConfig(contentID: string, config: Record<string, unknown>): Promise<Record<string, unknown>>
```
- [ ] **Step 6: Move settings persistence from localStorage to server**
Update `EbookReader.tsx` to fetch reader config on load and save settings through the config endpoint. Keep a local fallback only until the server value arrives.
- [ ] **Step 7: Verify and commit**
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Config' -count=1
cd web && pnpm test src/pages/EbookReader.test.tsx src/reader/FoliateBookReader.test.ts --run
cd web && pnpm build
```
Commit:
```bash
git add migrations/sql/20260608000300_ebook_reader_state.sql internal/api/handlers/ebook_reader.go internal/api/handlers/ebook_reader_test.go internal/api/router.go web/src/reader/ebookReaderApi.ts web/src/pages/EbookReader.tsx web/src/pages/EbookReader.test.tsx
git commit -m "feat: persist ebook reader config"
```
## Task 2: Server-Persisted Annotations And Bookmarks
**Files:**
- Modify: `migrations/sql/20260608000300_ebook_reader_state.sql`
- Modify: `internal/api/handlers/ebook_reader.go`
- Modify: `internal/api/handlers/ebook_reader_test.go`
- Modify: `internal/api/router.go`
- Modify: `web/src/reader/ebookReaderApi.ts`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- [ ] **Step 1: Write failing annotation handler tests**
Cover list, create, update, delete. Test annotation fields:
```json
{
"kind": "highlight",
"cfi_range": "epubcfi(/6/4,/1:0,/1:12)",
"selected_text": "sample text",
"note": "note text",
"style": "highlight",
"color": "#facc15"
}
```
Run:
```bash
go test ./internal/api/handlers -run 'TestEbookReader.*Annotation|TestEbookReader.*Bookmark' -count=1
```
Expected: fail because annotation APIs do not exist.
- [ ] **Step 2: Add annotation table**
Extend migration with:
```sql
CREATE TABLE ebook_reader_annotations (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id TEXT NOT NULL,
content_id TEXT NOT NULL REFERENCES media_items(content_id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('highlight', 'note', 'bookmark')),
cfi_range TEXT,
location TEXT,
selected_text TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
style TEXT NOT NULL DEFAULT 'highlight',
color TEXT NOT NULL DEFAULT '#facc15',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK ((kind = 'bookmark' AND location IS NOT NULL) OR (kind <> 'bookmark' AND cfi_range IS NOT NULL))
);
CREATE INDEX ebook_reader_annotations_book
ON ebook_reader_annotations (user_id, profile_id, content_id, updated_at DESC);
```
- [ ] **Step 3: Implement annotation store and handlers**
Add handlers:
- `GET /ebooks/{content_id}/annotations`
- `POST /ebooks/{content_id}/annotations`
- `PATCH /ebooks/{content_id}/annotations/{annotation_id}`
- `DELETE /ebooks/{content_id}/annotations/{annotation_id}`
- [ ] **Step 4: Wire Foliate annotation drawing and selection**
Extend `FoliateBookReaderHandle` with:
- `createSelectionAnnotation()`
- `clearSelection()`
Draw stored annotations using Foliate `addAnnotation`.
- [ ] **Step 5: Add reader UI**
Add:
- selection popover
- highlight button
- note button
- bookmark button
- annotation list in the side panel
- delete/update note controls
- [ ] **Step 6: Verify and commit**
Run handler tests, web tests, and build. Commit:
```bash
git commit -m "feat: add ebook annotations and bookmarks"
```
## Task 3: Reader Tools, TTS, And Reading Aids
**Files:**
- Create: `web/src/hooks/useTTS.ts`
- Create: `web/src/hooks/useScreenWakeLock.ts`
- Create: `web/src/hooks/useEinkMode.ts`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Port focused hooks from personal reader**
Bring over:
- Web Speech TTS controller
- screen wake lock hook
- e-ink body-class hook
- [ ] **Step 2: Add readable text handle**
Expose `getReadableText()` from `FoliateBookReader` for TTS.
- [ ] **Step 3: Add UI controls**
Add side-panel controls for:
- Speak current text
- Pause/resume/stop
- voice/rate/pitch
- wake lock
- e-ink mode
- [ ] **Step 4: Add content popups and helpers**
Add selection popover actions:
- define
- translate
Add reader content popup handling for footnotes/images/tables when Foliate emits them.
- [ ] **Step 5: Verify and commit**
Run focused web tests and build. Commit:
```bash
git commit -m "feat: add ebook reader tools and aids"
```
## Task 4: Advanced Reader Settings
**Files:**
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/pages/EbookReader.test.tsx`
- [ ] **Step 1: Add advanced reader settings**
Add server-persisted settings for:
- RTL
- writing mode
- zoom/scale
- brightness
- hyphenation toggle
- custom font selection/upload if API support is added in a later task
- [ ] **Step 2: Verify and commit**
Run focused tests and build. Commit:
```bash
git commit -m "feat: add ebook advanced reader settings"
```
## Self-Review
- The plan covers server persistence first, matching the user's explicit preference.
- The plan keeps ebook features in core and metadata in plugins, matching the audiobook pattern.
- The plan avoids extra ebook catalog tables; added tables are reader-user-state only.
- The biggest implementation risk is annotation rendering against Foliate selection APIs; Task 2 isolates it after backend persistence exists.
- The migration filename is intentionally after existing ebook migrations and can be adjusted if another migration is added before implementation.
@@ -0,0 +1,68 @@
# Ebook Reader Shell Parity Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Bring the core ebook reader up to the first useful parity slice from `personal/silo-plugin-ebooks`: TOC, search, progress scrub, reader settings, and keyboard navigation.
**Architecture:** Keep the current core reader API and persistence model. Extend `FoliateBookReader` with imperative methods for TOC, search, href/fraction navigation, and runtime style updates; keep `EbookReader` responsible for the chrome/panels/settings state. Store reader preferences in localStorage for this first local slice so no backend schema is added.
**Tech Stack:** React 19, React Router, TanStack Query, foliate-js custom element, existing Silo UI components, Vitest.
---
### Task 1: Reader Handle Capabilities
**Files:**
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/reader/FoliateBookReader.test.ts`
- [ ] Add exported types `ReaderSettings`, `ReaderSearchOptions`, `ReaderSearchResult`, and `ReaderReadyState`.
- [ ] Extend `FoliateBookReaderHandle` with `goToFraction`, `goTo`, `search`, `clearSearch`, and `applySettings`.
- [ ] Add a `settings?: ReaderSettings` prop and convert `readerStyles()` to accept settings.
- [ ] Preserve existing progress save/restore behavior.
- [ ] Verify with `pnpm vitest run src/reader/FoliateBookReader.test.ts`.
### Task 2: TOC and Search Panel
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add panel state for `toc`, `searchResults`, `searchTerm`, and current tab.
- [ ] Render side panel tabs for Contents, Search, and Settings.
- [ ] Wire TOC items to `readerRef.current.goTo(href)`.
- [ ] Wire search form to `readerRef.current.search(term)` and result clicks to `goTo(cfi)`.
- [ ] Verify with `pnpm vitest run src/pages/EbookReader.test.tsx`.
### Task 3: Reader Settings
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add localStorage-backed settings for theme, font size, font family, line height, margin, max width, spread, and flow.
- [ ] Render compact controls in the Settings tab.
- [ ] Apply settings live through `FoliateBookReader`.
- [ ] Verify settings persist across remount in tests.
### Task 4: Progress Scrub and Keyboard Navigation
**Files:**
- Modify: `web/src/pages/EbookReader.tsx`
- Modify: `web/src/reader/FoliateBookReader.tsx`
- Test: `web/src/pages/EbookReader.test.tsx`
- [ ] Add a progress range input in the header.
- [ ] Call `goToFraction` on commit/change and update display from relocate events.
- [ ] Add ArrowLeft/ArrowRight keyboard navigation on the reader page.
- [ ] Verify with page-level tests.
### Task 5: Local Verification
**Files:**
- No source changes.
- [ ] Run `cd web && pnpm run build`.
- [ ] Run `GOWORK=off go test ./internal/api/handlers ./internal/scanner`.
- [ ] Keep changes local; do not push.
@@ -0,0 +1,518 @@
# Encrypt integration credentials at rest (issue #45) — implementation plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Do not edit this plan file while implementing.
**Issue:** [#45 — Encrypt arr integration credentials at rest (Requests + Autoscan)](https://github.com/Silo-Server/silo-server/issues/45)
> **Revision note (post-review):** This plan was revised after a code review. Key corrections baked in:
> the arr backfill now **resolves the legacy `server_settings` reference before encrypting** (otherwise
> rows holding a setting-name like `requests.radarr.api_key` would be encrypted as the *name*, not the
> credential); the sensitive-settings allowlist is **audited from the config loader's real inputs**
> (incl. `redis.sentinel_password` and legacy `s3.operational_*` aliases) rather than copied from the
> admin redaction map; AAD binds to **row identity** (`table:column:pk`); read paths **pass through
> legacy plaintext** and hard-fail only on corrupt ciphertext; `settingsRepo` is **re-wrapped after the
> max-connections pool recreation**; history-import session tokens are **included**; and
> `plugin_runtime_configs.config_value` is an **explicit, documented gap** (see Out of scope).
**Goal:** Stop storing third-party integration credentials in plaintext at rest. Today Sonarr/Radarr
API keys, S3 keys, and a dozen other secrets sit in the database as naked `text` — `request_integrations.api_key_ref`
and `autoscan_connections.api_key_ref` are resolved through a plaintext `server_settings` key/value
store, and both resolvers contain a **ref-vs-literal fallback**: when the secret lookup returns empty,
the stored string is used verbatim as the API key. This plan introduces a real symmetric-encryption
layer (AES-256-GCM, `SECRET_KEY`-derived via HKDF), encrypts every recoverable server-owned credential
**inline** in its owning row, deletes the ambiguous resolver fallback, and backfills existing rows
automatically on startup.
**Decisions (locked with the requester):**
- **Master key:** a **required** `SECRET_KEY` environment variable. The server fatals at startup if it
is missing. The key lives outside Postgres, so encrypted secrets survive a full DB compromise/dump.
- **Scope:** **all silo-server-owned credential storage** (was framed "full sweep"). Every recoverable
server-owned secret — arr keys, S3 keys, all sensitive `server_settings`, and the per-table token
columns — is covered. Two categories are deliberately *not* in this PR because they are a different
*shape* of problem, not because they are unimportant: equality-looked-up secrets (need **hashing**,
not encryption) and plugin runtime config JSONB (cross-repo + manifest-driven). Both are called out in
Out of scope with follow-ups.
- **Data model (best practice):** **inline-encrypted**. Store the ciphertext directly in the owning
column and decrypt on read. Drop the `server_settings` "ref" indirection and the `SecretResolver`
literal fallback entirely — this kills the ambiguity at its root and matches the repo's one existing
good example (`oauth_completion.token_ciphertext` lives inline in its own row).
- **Rollout:** idempotent Go startup **backfill** encrypts any plaintext (no-envelope-prefix) value in
place on first boot after deploy. No manual steps.
**Architecture:** A new `internal/secret` package owns the one cryptographic primitive everything reuses:
AES-256-GCM with a random 12-byte nonce, `base64.RawURLEncoding`, and a versioned `enc:v1:` envelope
prefix; the data key is HKDF-SHA256–derived from `SECRET_KEY` with a domain label. Each ciphertext is
GCM-bound to its logical **row** via additional-authenticated-data (`"table:column:<pk>"`, or
`"server_settings:<key>"`) so a DB-write attacker cannot transplant a credential blob into another row
or column. Three wiring seams consume the cipher: (1) an `EncryptedSettingsRepo` **decorator** around
`catalog.ServerSettingsRepo` that transparently encrypts/decrypts the sensitive `server_settings` keys,
so `config.LoadFromDB` and every downstream consumer keep seeing plaintext; (2) the two `api_key_ref`
columns become inline ciphertext and the entire `SecretResolver` indirection is deleted from
`internal/requests` and `internal/autoscan`; (3) a handful of per-table repos gain a `*secret.Cipher`
and encrypt-on-write / decrypt-on-read. A startup backfill sweeps all groups idempotently — with a
**special resolve-then-encrypt path for the two arr columns** so legacy `server_settings` references are
collapsed to the real credential before encryption.
**Out of scope (each a deliberate, documented decision — not an oversight):**
- **Equality-looked-up secrets → need hashing, not encryption.** `api_keys.api_key`
(`WHERE api_key = $1`) and `webhook_sync_connections.webhook_secret` (`WHERE webhook_secret = $1`,
[repo.go:101](internal/webhooksync/repo.go:101)) are matched by exact value. AES-GCM uses a fresh
random nonce, so the same plaintext encrypts differently each time — encrypting these would silently
break API-key auth and inbound-webhook routing. The correct fix is a deterministic **hash** column +
lookup-by-hash, a distinct lookup-semantics + migration change. **Follow-up issue.**
- **`plugin_runtime_configs.config_value` → cross-repo + manifest-driven.** This is opaque plugin-defined
JSONB ([runtime_config.go:55](internal/plugins/runtime_config.go:55)); some plugins store secrets in it
(e.g. the legacy one-time copy of `introdb.api_key` at [main.go:2341](cmd/silo/main.go:2341)). It is
read/written only through `RuntimeConfigStore` *in this repo*, so whole-blob encrypt-on-write /
decrypt-on-read at that store is technically feasible — **but** host-side plugin runtime lives in the
separate **`Silo`** repo (per CLAUDE.md) and may read this table directly, and which fields are secret
is declared in each plugin's manifest. Doing this correctly needs a coordinated, manifest-aware design
across repos. **Explicitly deferred — known gap.** Note: while deferred, the legacy `introdb.api_key`
copy means that secret still exists in plaintext inside plugin config even after the `server_settings`
copy is encrypted; the follow-up must re-encrypt or purge it.
- **`plex_sync_connections`** — dead table, zero Go references (superseded by `webhook_sync_connections`
in migration `060`). Not touched.
- **Already-protected / non-secret:** `users.password_hash` (bcrypt), `oauth_completion.token_ciphertext`
(already AES-GCM), `abs_sessions.token_hash` / `oauth_completion.code_hash` (hashed), `oauth_session.state`
(equality CSRF), `subtitle_provider_config.username` (a username, not a secret),
`history_import_plex_sessions.pin_id`/`pin_code` (transient OAuth pairing handshake, not a stored
access credential).
- `internal/auth/oauth_store.go` is left as-is (its key is jwt-derived and its data is ephemeral).
Optionally a shared low-level seal/open helper can be extracted later; not required here.
**Tech Stack:** Go 1.26.3 (module declares `go 1.26.3`; stdlib `crypto/hkdf` is available — no new
dependency). PostgreSQL via `pgxpool`. **No schema migration** — every target column is already `text`
(or `jsonb` left untouched) and now simply holds ciphertext; the backfill is pure Go. No frontend or
client (silo-android / silo-apple) changes — API responses already mask these values to `has_api_key` /
`has_credentials` booleans, so no contract changes.
Commands assume the repository root is the cwd.
---
## Running tests
The `internal/secret` package is pure Go (no libvips/CGO). If a Go toolchain is on the host:
```bash
GOWORK=off go test ./internal/secret/... -v
```
`GOWORK=off` is required when running from a `.claude/worktrees/` checkout — the parent `go.work`
otherwise pins the module to `main` and the build fails with "main module does not contain package".
If the host has no Go toolchain, run in a throwaway container (a named volume caches module downloads):
```bash
docker run --rm -v "$PWD":/app -w /app -v silo-gomod:/go/pkg/mod \
-e GOFLAGS=-mod=mod golang:1.26 \
go test ./internal/secret/... -v
```
Full backend build/vet before opening the MR: `GOWORK=off go build ./...` then `make lint` and
`make verify-local-paths`.
---
## File structure
**New files:**
- `internal/secret/cipher.go` — **create**. `Cipher` type; `New`, `Encrypt`, `Decrypt`, `IsEncrypted`,
`EncryptIfPlaintext`; envelope + HKDF + version dispatch. Row-bound AAD passed by callers.
- `internal/secret/cipher_test.go` — **create**. Round-trip, tamper/AAD-mismatch rejection, wrong-key,
`IsEncrypted` edge cases, version dispatch, idempotency.
- `internal/secret/backfill.go` — **create**. Generic in-place backfill for literal-credential columns +
the special resolve-then-encrypt backfill for the two arr columns.
- `internal/secret/backfill_test.go` — **create**. Idempotency, mixed plaintext/ciphertext rows, empty
values skipped, failure isolation, arr resolve-then-encrypt.
**Modified files:**
- `internal/config/bootstrap.go` — add `SecretKey []byte` to `BootstrapConfig`; read `SECRET_KEY`; fatal
if missing or `< 32` chars.
- `cmd/silo/main.go` — construct `*secret.Cipher` after bootstrap; build the `EncryptedSettingsRepo`;
run the settings + column backfills after migrations and before settings are read; **re-wrap
`settingsRepo` after the max-connections pool recreation (line ~399)**; thread the cipher into the
affected repo constructors; delete the `SetSecretResolver` wiring.
- `internal/catalog/encrypted_settings_repo.go` — **create**. `EncryptedSettingsRepo` decorator +
exported, **audited** `SensitiveSettingKeys` (single source of truth) + `BackfillSensitiveSettings`.
- `internal/catalog/server_settings_repo.go` — unchanged behavior; remains the raw inner store.
- `internal/api/handlers/admin.go` — replace the local `sensitiveSettingKeys` map with
`catalog.SensitiveSettingKeys` (redaction now shares the audited allowlist; closes the prior gap where
`redis.sentinel_password` and `s3.operational_*` were returned in plaintext).
- `internal/requests/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
insert/update (AAD bound to integration id); decrypt (pass-through legacy plaintext) in `scanIntegration`.
- `internal/requests/service.go` — delete `SecretResolver` interface, `secrets` field,
`SetSecretResolver`, and `resolveAPIKey`; call sites use the already-decrypted `Integration.APIKeyRef`.
- `internal/autoscan/repository.go` — `Repository` gains `*secret.Cipher`; encrypt `api_key_ref` on
create/update (AAD bound to connection id); decrypt (pass-through legacy) in `scanConnection`.
- `internal/autoscan/connection.go` — delete `SecretResolver` interface, `secrets` field, and the
secret-resolution branch in `Resolve`; linked-integration path uses the requests repo's decrypted key.
- `internal/api/autoscan_wiring.go` — drop the `AutoscanSecretResolver` param from `BuildAutoscanService`.
- `internal/api/router.go` — drop the `SetSecretResolver(settingsRepo)` calls; pass the
`EncryptedSettingsRepo` where `settingsRepo` is used; thread the cipher into repo constructors.
- `internal/subtitles/pgrepo.go` — `*secret.Cipher`; encrypt `api_key` + `password` on upsert; decrypt on
read. PK for AAD = `provider_name`.
- `internal/watchsync/repository.go` — `*secret.Cipher`; encrypt `access_token` + `refresh_token` on
upsert; decrypt in a `decryptConnection` helper called after every scan. PK for AAD = connection `id`.
- `internal/webhooksync/repo.go` — `*secret.Cipher`; encrypt `access_token` on create; decrypt on scan.
**`webhook_secret` is NOT encrypted** (equality lookup — carved out). PK for AAD = connection `id`.
- `internal/historyimport/repo_admin.go` — `*secret.Cipher`; encrypt in `SetSourceAdminToken`; decrypt in
`GetSourceWithAdminToken`. PK for AAD = source `id`.
- `internal/historyimport/repo.go` — `*secret.Cipher`; encrypt `connect_access_token`
(`history_import_connect_sessions`) + `auth_token` (`history_import_plex_sessions`) on insert/update;
decrypt on scan. PK for AAD = session `id`.
- `docs/architecture/secret-encryption.md` — **create**. Operator runbook (SECRET_KEY generation, backup,
key-loss, rollback/downgrade).
**No migration files.** All target columns are already `text`; the backfill is Go-side.
---
## Reference tables
### What is encrypted vs hashed vs excluded
AAD column shows the GCM additional-authenticated-data string; `<pk>` is the row's stable primary key.
| Group | Table.column | AAD binding | Action |
| --- | --- | --- | --- |
| Arr keys | `request_integrations.api_key_ref` | `request_integrations:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
| Arr keys | `autoscan_connections.api_key_ref` | `autoscan_connections:api_key_ref:<id>` | **Resolve-then-encrypt** (backfill) / encrypt inline (writes) |
| Settings | every key in `SensitiveSettingKeys` (see audited list below) | `server_settings:<key>` | **Encrypt via decorator** |
| Subtitles | `subtitle_provider_config.api_key`, `.password` | `subtitle_provider_config:<col>:<provider_name>` | **Encrypt inline** |
| Watch sync | `watch_provider_connections.access_token`, `.refresh_token` | `watch_provider_connections:<col>:<id>` | **Encrypt inline** |
| Webhook sync | `webhook_sync_connections.access_token` | `webhook_sync_connections:access_token:<id>` | **Encrypt inline** |
| History import | `history_import_sources.admin_token` | `history_import_sources:admin_token:<id>` | **Encrypt inline** |
| History import | `history_import_connect_sessions.connect_access_token` | `history_import_connect_sessions:connect_access_token:<id>` | **Encrypt inline** |
| History import | `history_import_plex_sessions.auth_token` | `history_import_plex_sessions:auth_token:<id>` | **Encrypt inline** |
| — | `api_keys.api_key` | — | **Carved out → hash** (follow-up) |
| — | `webhook_sync_connections.webhook_secret` | — | **Carved out → hash** (follow-up) |
| — | `plugin_runtime_configs.config_value` | — | **Deferred → cross-repo/manifest design** (follow-up) |
| — | `plex_sync_connections.*` | — | **Excluded** (dead table) |
### Audited `SensitiveSettingKeys` (single source of truth)
Built from the config loader's actual secret inputs ([internal/config/db_loader.go](internal/config/db_loader.go))
plus the admin redaction map plus other known consumers — **not** just the current redaction map. The
union is intentional and also fixes a pre-existing redaction leak. Implementers must re-audit
`db_loader.go` and `grep` for any `*secret*`/`*token*`/`*password*`/`*api_key*` settings keys before
finalizing.
```
auth.jwt_secret
s3.public_access_key s3.public_secret_key s3.public_token_secret
s3.private_access_key s3.private_secret_key
s3.user_db_access_key s3.user_db_secret_key
s3.operational_access_key s3.operational_secret_key s3.operational_token_secret # legacy aliases still read as fallbacks (db_loader L165-192); migration 086 copied but did not delete them
redis.url # may embed credentials (redis://:pass@host)
redis.sentinel_password # read at db_loader L334 — MISSING from the old redaction map
tmdb.api_key
mdblist.api_key
introdb.api_key # read in main.go (legacy → plugin copy)
subtitle_ai.api_key
recommendations.embedding_auth_token recommendations.openai_api_key # legacy alias (db_loader L409)
watchsync.trakt.client_id watchsync.trakt.client_secret
watchsync.simkl.client_id watchsync.simkl.client_secret
requests.radarr.api_key requests.sonarr.api_key # legacy single-instance arr keys
```
`auth.jwt_secret` IS encrypted at rest under `SECRET_KEY` (it is no longer the encryption root — `SECRET_KEY`
is). `redis.url`/client-id entries are not high-value secrets but are kept in the set as a harmless safe
superset and to match existing redaction.
### Envelope format
```
enc:v1:<base64.RawURLEncoding( nonce[12] ‖ gcm.Seal(...) )>
```
- `enc:v1:` — exactly 7 ASCII chars; not valid base64url, not a UUID/JWT/URL/integer prefix. Collision-proof
against any real plaintext credential in practice.
- `IsEncrypted(s)` ≡ `strings.HasPrefix(s, "enc:v1:")`.
- Decrypt parses the version (`strings.SplitN(s, ":", 3)`), dispatches to the v1 opener; an unknown
version returns an explicit `ErrUnknownVersion`. This is the forward-compat hook for a future `enc:v2:`
key rotation — implement the dispatch now even though only `v1` exists.
### Read-path contract (applies to every decrypt site — decorator and repos)
1. `value == ""` → return `""`.
2. `!IsEncrypted(value)` → **return the value unchanged** (legacy plaintext pass-through during the
backfill window; the value is no worse than today). This is unambiguous — there is no longer any
"ref vs literal" question because the `_ref` indirection is gone.
3. `IsEncrypted(value)` → `Decrypt`; **any error (wrong key, tamper, truncation) is propagated, never
swallowed and never returned as the ciphertext string.** A corrupt ciphertext fails the operation.
This reconciles the best-effort backfill (a skipped/failed row stays readable as plaintext) with the
hard guarantee that a real `enc:v1:` value never silently degrades to using ciphertext as a credential.
---
## Task 1: The `internal/secret` cipher package
**Files:** Create `internal/secret/cipher.go`, `internal/secret/cipher_test.go`.
This is the foundation; it has no dependencies on `catalog`/`config` and is independently testable.
- [ ] **Step 1: `Cipher` type + `New`.** Derive a 32-byte AES key with stdlib
`hkdf.Key(sha256.New, masterKey, nil /*salt*/, "silo/data-encryption/v1", 32)`. Return an error if
`len(masterKey) < 32`. Store the derived key as `[32]byte`. The type is safe for concurrent use.
- [ ] **Step 2: `Encrypt(plaintext, aad string) (string, error)`.** Empty plaintext returns `"", nil`
(do not produce an `enc:v1:` blob for an empty secret). Otherwise: `aes.NewCipher` → `cipher.NewGCM` →
12-byte `rand` nonce → `gcm.Seal(nonce, nonce, []byte(plaintext), []byte(aad))` →
`envelopePrefix + base64.RawURLEncoding.EncodeToString(sealed)`. `aad` is the row-bound context string.
- [ ] **Step 3: `Decrypt(ciphertext, aad string) (string, error)`.** Strip + validate the version prefix;
unknown version → `ErrUnknownVersion`. base64-decode; reject if shorter than the nonce size; split
nonce/body; `gcm.Open(nil, nonce, body, []byte(aad))`. **Any failure returns an error — never fall back
to returning the ciphertext or an empty string.** (Pass-through of *non-prefixed* plaintext is the
caller's job per the read-path contract, not `Decrypt`'s.)
- [ ] **Step 4: `IsEncrypted(s string) bool`** and `EncryptIfPlaintext(s, aad string) (string, bool, error)`.
`EncryptIfPlaintext` returns `(s, false, nil)` when already encrypted or empty; otherwise encrypts and
returns `(ct, true, nil)`. This is the idempotent primitive the backfill uses.
- [ ] **Step 5: Tests** (`cipher_test.go`):
- round-trip with AAD recovers plaintext; ciphertext has the `enc:v1:` prefix.
- two encryptions of the same plaintext+AAD differ (random nonce works).
- tamper: flip one byte of the decoded body → `Decrypt` errors.
- AAD mismatch: encrypt with `"t:c:1"`, decrypt with `"t:c:2"` → errors (proves row binding).
- wrong key: encrypt with key A, decrypt with key B → errors.
- `New` with a `< 32`-byte key → error.
- `IsEncrypted`: `"enc:v1:x"`→true, `"sa_abc"`→false, `""`→false, `"ENC:V1:x"`→false.
- unknown version `"enc:v99:..."` → `ErrUnknownVersion`.
- `EncryptIfPlaintext` is idempotent (second call is a no-op, `changed=false`).
- [ ] **Step 6:** `GOWORK=off go test ./internal/secret/... -v` passes.
---
## Task 2: `SECRET_KEY` bootstrap + cipher construction
**Files:** Modify `internal/config/bootstrap.go`, `cmd/silo/main.go`.
- [ ] **Step 1:** Add `SecretKey []byte` to `BootstrapConfig`. In `LoadBootstrap`, after the
`DATABASE_URL` check, read `os.Getenv("SECRET_KEY")`; return an error (which the existing
`log.Fatalf` path surfaces) if it is empty or `< 32` characters. Message must be actionable, e.g.
`SECRET_KEY is required (>=32 chars); generate one with: openssl rand -base64 48`. `godotenv` already
loads `.env`, so dev sets it there.
- [ ] **Step 2:** In `cmd/silo/main.go`, immediately after `LoadBootstrap` returns, construct the cipher:
`dataCipher, err := secret.New(bc.SecretKey)` and `log.Fatalf` on error. The cipher is threaded
explicitly as a dependency — **never** a package-level global.
- [ ] **Step 3:** Add `SECRET_KEY=...` to `.env.example` (or the documented dev env) and note it in the
operator runbook (Task 7).
- [ ] **Step 4:** `GOWORK=off go build ./cmd/silo` compiles; starting without `SECRET_KEY` fatals with the
actionable message.
---
## Task 3: `server_settings` encryption decorator
**Files:** Create `internal/catalog/encrypted_settings_repo.go`; modify `internal/api/handlers/admin.go`,
`cmd/silo/main.go`, `internal/api/router.go`.
- [ ] **Step 1: Build the audited allowlist.** Create exported `var SensitiveSettingKeys map[string]bool`
in `internal/catalog` populated from the "Audited `SensitiveSettingKeys`" list above. **Do not** simply
move the admin redaction map — re-audit `internal/config/db_loader.go` (and other `server_settings`
consumers) for every secret-bearing key, and include the legacy aliases (`s3.operational_*`,
`recommendations.openai_api_key`) and `redis.sentinel_password`. Update `admin.go`
(`HandleGetSettings` redaction, `HandleGetSensitiveStatus`, the `PUT` response branch) to reference
`catalog.SensitiveSettingKeys`. One allowlist now drives **both** redaction and encryption.
- [ ] **Step 2:** Implement `EncryptedSettingsRepo` wrapping `*ServerSettingsRepo` + `*secret.Cipher`:
- `Set(key, value)`: if `SensitiveSettingKeys[key]` and `value != ""`, encrypt with AAD
`"server_settings:"+key` before delegating; else delegate raw.
- `Get(key)` / `GetAll()`: delegate, then apply the **read-path contract** to each sensitive key
(pass through legacy plaintext; decrypt prefixed values; propagate decrypt errors).
- [ ] **Step 3:** Add `BackfillSensitiveSettings(ctx)` on the decorator: for each key in
`SensitiveSettingKeys`, read the raw inner value; if non-empty and not `IsEncrypted`, `inner.Set` the
encrypted value (AAD `"server_settings:"+key`). Idempotent. Returns a count + aggregated errors.
- [ ] **Step 4:** In `cmd/silo/main.go`, build `settingsRepo` as the `EncryptedSettingsRepo` wrapping the
raw `catalog.NewServerSettingsRepo(pool)`. Confirm the auto-generate-`jwt_secret` step still works: the
`GetAll` map carries plaintext, the `== ""` check is unchanged, and `Set("auth.jwt_secret", encoded)`
now writes **encrypted** while the in-memory map keeps the plaintext for `LoadFromDB`. Any function
currently typed to the concrete `*catalog.ServerSettingsRepo` changes to the `ServerSettingsStore`
interface that both already satisfy.
- [ ] **Step 5 (re-wrap after pool recreation):** `cmd/silo/main.go` rebuilds a **raw**
`catalog.NewServerSettingsRepo(pool)` after recreating the pool for `max_connections` (line ~399).
Re-wrap it there too (`settingsRepo = catalog.NewEncryptedSettingsRepo(catalog.NewServerSettingsRepo(pool), dataCipher)`)
so every later consumer gets the encrypting repo, not a raw one. Audit for any other raw construction
of the settings repo and wrap each.
- [ ] **Step 6:** Tests: decorator round-trip (raw store holds `enc:v1:`, decorator `Get` returns
plaintext); non-sensitive keys pass through unencrypted; legacy-plaintext pass-through on read;
`BackfillSensitiveSettings` idempotency.
- [ ] **Step 7:** `GOWORK=off go build ./...` compiles; admin settings GET still redacts (now including
`redis.sentinel_password`); a sensitive key written via the admin API lands as `enc:v1:` in the DB.
---
## Task 4: Inline-encrypt arr keys + delete the `SecretResolver` indirection
**Files:** Modify `internal/requests/repository.go`, `internal/requests/service.go`,
`internal/autoscan/repository.go`, `internal/autoscan/connection.go`,
`internal/api/autoscan_wiring.go`, `internal/api/router.go`, `cmd/silo/main.go`.
> The legacy-data resolution for these two columns is handled by the **resolve-then-encrypt backfill** in
> Task 6 (a row may currently hold a `server_settings` key name, not a literal key). The repo write path
> below only ever encrypts the literal credential an admin submits going forward.
- [ ] **Step 1:** `requests.NewRepository` gains a `*secret.Cipher` parameter. In `insertIntegration` and
`updateIntegration`, encrypt `i.APIKeyRef` (AAD `"request_integrations:api_key_ref:"+i.ID`) before
binding — preserve the existing `CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END` keep-existing
semantics (encrypt only the non-empty incoming value). In `scanIntegration`, apply the read-path
contract to the scanned `api_key_ref`.
- [ ] **Step 2:** Delete `requests.SecretResolver` (interface), the `Service.secrets` field,
`Service.SetSecretResolver`, and `Service.resolveAPIKey`. Every former `resolveAPIKey(ctx, integration)`
call site uses `strings.TrimSpace(integration.APIKeyRef)` directly (the repo already decrypted it).
- [ ] **Step 3:** `autoscan.NewRepository` gains a `*secret.Cipher`. Encrypt `api_key_ref` in
`CreateConnection` / `UpdateConnection` (AAD `"autoscan_connections:api_key_ref:"+id`, same
keep-existing CASE semantics); apply the read-path contract in `scanConnection`.
- [ ] **Step 4:** In `internal/autoscan/connection.go`, delete the `SecretResolver` interface, the
`ConnectionResolver.secrets` field, and the secret-resolution branch in `Resolve`. For a linked Requests
integration, `RequestIntegrationLookup.Get` returns the requests repo's **already-decrypted** key — use
it directly. For a standalone connection, the autoscan repo already decrypted it. `Resolve` now only
resolves base URL + linkage and returns plaintext.
- [ ] **Step 5:** Remove the `AutoscanSecretResolver` parameter from `BuildAutoscanService`
(`internal/api/autoscan_wiring.go`) and drop the `SetSecretResolver(settingsRepo)` calls in
`internal/api/router.go` and `cmd/silo/main.go` (including the reconcile-worker service). Thread
`dataCipher` into the requests/autoscan repo constructors at all wiring sites.
- [ ] **Step 6:** Verify no API response serializes the raw key: the integration/connection response DTOs
still emit only `has_api_key`. Add/extend a test asserting the create→store→read round-trip yields the
original key, and that the stored column value is `enc:v1:`-prefixed.
- [ ] **Step 7:** `GOWORK=off go build ./...`; `GOWORK=off go test ./internal/requests/... ./internal/autoscan/...`.
---
## Task 5: Per-table credential columns
**Files:** Modify `internal/subtitles/pgrepo.go`, `internal/watchsync/repository.go`,
`internal/webhooksync/repo.go`, `internal/historyimport/repo_admin.go`, `internal/historyimport/repo.go`,
plus their constructor call sites.
Each repo gains a `*secret.Cipher` constructor arg and follows encrypt-on-write / decrypt-on-read with a
row-bound AAD. Preserve existing keep-existing CASE/COALESCE semantics — encrypt only non-empty incoming
values. All read paths use the read-path contract (pass through legacy plaintext, hard-fail corrupt
ciphertext). These columns **never** had the resolver indirection — they always stored literal
credentials — so no resolve-then-encrypt is needed (unlike the arr columns).
- [ ] **Step 1: subtitles** — encrypt `api_key` + `password` in `UpsertProviderConfig`; decrypt in
`ListProviderConfigs` and `GetProviderConfig`. Keep the existing `HasAPIKey`/`HasCredentials` flags
(set them from the decrypted values). AAD PK = `provider_name`.
- [ ] **Step 2: watchsync** — encrypt `access_token` + `refresh_token` in `UpsertConnection`. Add a
`decryptConnection(*Connection)` helper and call it after **every** scan path (list/get/by-provider/etc.
— there are several). AAD PK = connection `id`.
- [ ] **Step 3: webhooksync** — encrypt `access_token` in `CreateConnection`; decrypt in `scanConnection`.
**Do NOT touch `webhook_secret`** — it is equality-looked-up in `GetConnectionBySecret`
(`WHERE webhook_secret = $1`) and is carved out for the hashing follow-up. AAD PK = connection `id`.
- [ ] **Step 4: historyimport admin token** — encrypt in `SetSourceAdminToken`; decrypt in
`GetSourceWithAdminToken`; `ClearSourceAdminToken` (writes NULL) is unchanged. AAD PK = source `id`.
- [ ] **Step 5: historyimport session tokens** — in `internal/historyimport/repo.go`, encrypt
`connect_access_token` (`history_import_connect_sessions`) and `auth_token`
(`history_import_plex_sessions`) on insert/update; decrypt on scan (note the existing
`COALESCE(auth_token, '')` read). These are read by session `id`, not by token value. `pin_id`/`pin_code`
are transient pairing artifacts — leave them. AAD PK = session `id`.
- [ ] **Step 6:** Thread `dataCipher` into each constructor at its wiring site (`router.go` / `main.go`).
- [ ] **Step 7:** `GOWORK=off go build ./...`; package tests for each modified repo pass.
---
## Task 6: Idempotent startup backfill
**Files:** Create `internal/secret/backfill.go`, `internal/secret/backfill_test.go`; modify `cmd/silo/main.go`.
- [ ] **Step 1: Generic in-place backfill** for the literal-credential columns (subtitle ×2, watchsync ×2,
webhook `access_token`, historyimport `admin_token`, historyimport session `connect_access_token` +
`auth_token`). Per `BackfillTarget {table, column, idColumn, aadPrefix}`: select rows where the column
is non-null, non-empty, and `NOT LIKE 'enc:v1:%'`; for each, compute AAD = `aadPrefix + ":" + id`,
`EncryptIfPlaintext`, and `UPDATE ... SET col = $1 WHERE id = $2 AND col = $3` (the `AND col = $3` guard
makes concurrent multi-node boots converge without double-encrypting). Empty values are skipped (never
encrypt `""`).
- [ ] **Step 2: Resolve-then-encrypt backfill for the two arr columns** (`request_integrations.api_key_ref`,
`autoscan_connections.api_key_ref`). This is **separate** from Step 1 because legacy rows may hold a
`server_settings` key name rather than a literal key. For each row whose value is not already
`enc:v1:`-prefixed, replicate the old `resolveAPIKey` semantics **once**:
1. `resolved := encryptedSettingsRepo.Get(ctx, value)` (the decrypting decorator — handles both a
sensitive-and-encrypted target like `requests.radarr.api_key` and a plaintext custom key).
2. If `resolved` is non-empty, the row held a *reference* → encrypt `resolved` inline.
Else the row held a *literal* key → encrypt `value` inline.
3. `UPDATE ... SET api_key_ref = <enc> WHERE id = $2 AND api_key_ref = <original>`, AAD bound to the row id.
Run this **after** `BackfillSensitiveSettings` so the referenced settings are consistent (the decorator
decrypts regardless, but ordering keeps it deterministic). Optionally log/flag now-orphaned
`requests.{radarr,sonarr}.api_key` settings rows for a later cleanup (do not delete in this PR).
- [ ] **Step 3: Failure posture (decided):** best-effort. Collect per-row/per-target errors, log each at
ERROR, and emit a single startup summary line (`secret backfill: encrypted N, failed M`). A failed
encrypt leaves the pre-existing plaintext (no *new* exposure vs. today) and reads still succeed via the
read-path pass-through, so it must **not** block boot — reliability over a hard stop. (Read-path decrypt
failures on an `enc:v1:` value still hard-fail the individual operation.)
- [ ] **Step 4: Ordering in `cmd/silo/main.go`** — gate on migration-running modes only (mirror the
existing migration guard; proxy/transcode nodes skip the backfill). Sequence:
`LoadBootstrap (read SECRET_KEY) → open pool → RunMigrations → construct dataCipher → wrap settingsRepo →
BackfillSensitiveSettings → generic column backfill → arr resolve-then-encrypt backfill → GetAll(decrypts)
→ auto-gen jwt_secret (writes encrypted) → [pool recreate? re-wrap settingsRepo] → LoadFromDB(plaintext) → validate`.
The settings backfill + `GetAll` must precede `LoadFromDB` so config consumers read decrypted values.
- [ ] **Step 5: Concurrency note** — the `AND col = $3` idempotency guard is sufficient for the common
single-primary deploy. For multi-primary races, optionally wrap the backfill in a dedicated Postgres
advisory lock with a **fresh** lock id (do **not** reuse the migration `legacyBootstrapLocker` id — that
would extend the migration lock's held duration unpredictably).
- [ ] **Step 6: Tests** (`backfill_test.go`, against a test DB): seed plaintext rows → backfill →
all become `enc:v1:` and decrypt to the originals; second run is a no-op (no double-encrypt); mixed
plaintext/ciphertext rows handled; empty values skipped; one failing row does not abort the rest;
**arr resolve-then-encrypt:** a row holding `requests.radarr.api_key` (with that settings key present)
ends up encrypting the *resolved* credential, and a row holding a literal key encrypts the literal.
- [ ] **Step 7:** Boot against a DB pre-seeded with plaintext credentials **and** a legacy arr row that
references a settings key; confirm the summary log, that every target column is `enc:v1:` afterward, that
the arr row decrypts to the real Radarr key (not the setting name), and that a second boot reports
`encrypted 0`.
---
## Task 7: Verification, docs, follow-up
- [ ] **Step 1: Regression guard tests:**
- API key auth still works — `api_keys.api_key` is stored as the literal `sa_`-prefixed token and
`GetByKey` (`WHERE api_key = $1`) still resolves (proves it was *not* routed through encryption).
- `auth.jwt_secret` is consistent across two simulated boots (encrypted at rest, plaintext to
`LoadFromDB`), and the stored `server_settings` row is `enc:v1:`-prefixed.
- Inbound webhook routing still works (`webhook_secret` untouched).
- A legacy arr integration that referenced `requests.sonarr.api_key` still fulfills after backfill.
- [ ] **Step 2:** `GOWORK=off go build ./...`, `make lint`, `make verify-local-paths`,
`cd web && pnpm run lint && pnpm run format:check` (frontend unaffected — confirm green).
- [ ] **Step 3:** Write `docs/architecture/secret-encryption.md`: how `SECRET_KEY` is generated, that it
must be backed up **separately** from DB dumps (treat like a CA private key), what happens on key loss
(encrypted secrets unrecoverable → re-enter integrations), the rollback/downgrade hazard (an old binary
reads `enc:v1:auth.jwt_secret` as a literal JWT secret → all sessions invalid; document that downgrade
requires decrypting settings back to plaintext or clearing `auth.jwt_secret`), and the envelope/version
scheme for future rotation.
- [ ] **Step 4: File the follow-up issues:**
1. **Hash equality-lookup secrets** (`api_keys.api_key`, `webhook_sync_connections.webhook_secret`) —
store a deterministic hash, look up by hash.
2. **Encrypt plugin runtime config** (`plugin_runtime_configs.config_value`) — coordinated with the
`Silo` host repo and plugin-manifest secret-field metadata; must also re-encrypt/purge the legacy
plaintext `introdb.api_key` copy. Note the dead `plex_sync_connections` table as a drop candidate.
- [ ] **Step 5:** Update the issue #45 acceptance check: no plaintext arr keys at rest (Requests +
Autoscan); `api_key_ref` is unambiguous (inline ciphertext, no literal fallback); existing rows
encrypted by the backfill (arr rows resolved-then-encrypted); S3 + the broader server-owned credential
set covered; remaining gaps (equality-lookup hashing, plugin JSONB) tracked as follow-ups.
---
## Safety checklist (the implementer must not skip)
1. `SECRET_KEY` is read in `LoadBootstrap` and **fatals** if absent/short — never defaults to a zero key
or a hash of `DATABASE_URL`.
2. The cipher is constructed once, right after bootstrap, before any `settingsRepo` call — threaded as a
dependency, never a global.
3. The settings backfill + decryption happen **before** `config.LoadFromDB` consumes the map.
4. The auto-generated `auth.jwt_secret` is written **encrypted**, while the in-memory map keeps plaintext
for `LoadFromDB`.
5. **`settingsRepo` is re-wrapped as the `EncryptedSettingsRepo` after the max-connections pool recreation
(main.go ~399)** and at every other construction site — no raw settings repo escapes into later wiring.
6. The two **arr columns** use the **resolve-then-encrypt** backfill (not in-place) so legacy
`server_settings` references become the real credential before encryption; the other columns use the
plain in-place backfill.
7. `SensitiveSettingKeys` is **audited from the config loader's real inputs** (incl.
`redis.sentinel_password` and the legacy `s3.operational_*` / `recommendations.openai_api_key`
aliases), not copied from the old redaction map.
8. `api_keys.api_key` and `webhook_sync_connections.webhook_secret` are **never** routed through
encryption — they are equality-looked-up and would break auth/routing (separate hashing task).
9. Every decrypt site obeys the **read-path contract**: pass through non-prefixed plaintext; **error
(never fall back)** on a corrupt `enc:v1:` value. Audit every old `if resolved == ""` site removed in Task 4.
10. AAD binds to **row identity** (`table:column:<pk>`); PKs in scope (integration/connection ids, uuids,
`provider_name`) are stable. server_settings binds to its key.
11. The backfill is prefix-gated and idempotent (`AND col = $3` guard); proxy/transcode nodes skip it.
12. `plex_sync_connections` is not a target (dead table); `plugin_runtime_configs.config_value` is a
documented deferred gap, not silently ignored; `subtitle_provider_config.username` stays plaintext.
@@ -0,0 +1,377 @@
# AI Services Core, Metadata Translation & Whisper ASR — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Design:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md` (read it first; this plan does not repeat the rationale).
**Goal:** Extract the subtitle-AI LLM plumbing into shared packages (`internal/ai/llm`, `internal/ai/translate`, `internal/ai/jobrunner`), add AI translation of overviews/taglines into the existing localization tables with field provenance, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). Single PR.
**Architecture:** New `internal/ai/*` packages consumed by a refactored `internal/subtitles/ai` and a new `internal/metadata/translation` service. One new Goose migration (jobs table, provenance columns, library auto-translate flag). New `ai.*` settings keys with loader fallback to legacy `subtitle_ai.*` rows (encrypted values are GCM-bound to their key — never rename rows in SQL). Frontend: AI Services settings page, metadata-editor translate action, library toggle, player "generate from audio" mode.
**Tech stack:** Go (chi, pgx), PostgreSQL via Goose SQL migrations, React + TypeScript (TanStack Query), Vitest.
Commands assume the repository root is the cwd.
---
## Ordering and verification
Tasks 1–4 are a pure refactor and must leave subtitle translation behaviorally unchanged — run `go build ./... && go test ./internal/...` after each. Tasks 5–11 are backend features; 12–15 frontend; 16 final verification.
Before opening the MR: `make lint`, `cd web && pnpm run lint && pnpm run format:check`, `make verify-local-paths`.
---
## File structure
**Create**
- `internal/ai/llm/client.go`, `client_test.go`, `config.go` — shared OpenAI-compatible client (chat + transcription)
- `internal/ai/llm/transcribe.go` — multipart `/v1/audio/transcriptions`, `verbose_json` types
- `internal/ai/translate/translate.go`, `translate_test.go` — generic segment batch translator (package `aitranslate`)
- `internal/ai/jobrunner/runner.go`, `runner_test.go` — dispatch/semaphore/heartbeat/reaper/cancel registry
- `internal/metadata/translation/{job.go,pgrepo.go,service.go,prompt.go,service_test.go}`
- `internal/subtitles/ai/transcriber.go`, `transcriber_test.go` — ASR pipeline
- `internal/playback/audio_extract.go` — ffmpeg chunked audio extraction helper
- `internal/api/handlers/metadata_ai.go`
- `migrations/sql/<timestamp>_ai_metadata_translation_and_asr.sql` (via `make migrate-create`)
- `web/src/pages/admin-settings/AIServicesSettings.tsx`
**Modify**
- `internal/subtitles/ai/{client.go→deleted,translator.go,service.go,engine.go,job.go,config.go,pgrepo.go}`
- `internal/config/{config.go,db_loader.go}`; `internal/catalog/encrypted_settings_repo.go`
- `internal/catalog/{localization_repo.go,detail.go}`; `internal/models/media.go`
- `internal/metadata/service.go` (provider provenance + auto-enqueue hook)
- `internal/api/handlers/subtitle_ai.go`; router registration; `cmd/silo/main.go` wiring
- `web/src/pages/admin-settings/SubtitlesSettings.tsx`, `web/src/components/EditMetadataDialog.tsx`, `web/src/player/components/SubtitleTranslateModal.tsx`, library settings form, `web/src/api/` types/client, admin settings nav/route registration
---
## Task 1: `internal/ai/llm` — shared client
- [ ] **Step 1: Move the chat client.** Create `internal/ai/llm` (package `llm`). Move `Client`, `chat` (export as `Chat`), `chatMessage` (export as `Message`), retry helpers (`sleepCtx`, `rateLimitBackoff`, `truncate`) from `internal/subtitles/ai/client.go`. New `llm.Config`:
```go
type Config struct {
BaseURL string // chat endpoint, no trailing /v1
APIKey string
ChatModel string
ASRBaseURL string // empty = BaseURL
ASRAPIKey string // empty = APIKey
ASRModel string
MaxConcurrentJobs int
}
```
Log lines lose the "subtitle" wording (they are shared now).
- [ ] **Step 2: Add `Transcribe`.** In `transcribe.go`:
```go
type TranscribeRequest struct {
Filename string
Audio io.Reader
Language string // optional ISO-639-1 hint
Timeout time.Duration // per-request; sized to chunk length by the caller
}
type TranscriptionSegment struct{ Start, End float64; Text string }
type Transcription struct{ Language string; Segments []TranscriptionSegment }
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error)
```
Multipart fields: `file`, `model` (=`ASRModel`), `response_format=verbose_json`, `temperature=0`, optional `language`. Reuse the same 429/5xx/transport retry-with-backoff loop as `Chat` (extract a shared `doWithRetry` rather than duplicating it). Empty `segments` in the response is an error (`transcription returned no segments`), not a silent fallback.
- [ ] **Step 3: Tests** (`client_test.go`, httptest): chat retries on 429 (honors `Retry-After`), 5xx, 200-with-error-object, empty choices; transcribe happy path (multipart fields present, segments parsed), missing-segments error, ASR base-url/key override falling back to chat values.
- [ ] **Step 4: Commit.** `refactor(ai): extract shared OpenAI-compatible LLM client with transcription support`
---
## Task 2: `internal/ai/translate` — generic segment translator
- [ ] **Step 1: Move the batch logic.** Package `aitranslate`. Move `buildIndexedJSON`, `extractJSONObject`, and the batch loop from `internal/subtitles/ai/translator.go`, generalized:
```go
type Segment struct{ ID, Text string }
type Request struct {
Segments []Segment
SystemPrompt string // caller-supplied; domain-specific
BatchSize int
ContextNeighbors int // preceding source segments sent untranslated
}
type ChatFn func(ctx context.Context, system, user string) (string, error)
func Translate(ctx context.Context, chat ChatFn, req Request,
onBatch func(batch []Segment, done, total int)) ([]Segment, error)
```
Wire protocol unchanged: 1-based indexed JSON per batch, same-keys response, malformed-response retries (`maxRetries = 2`), completeness check. `ChatFn` keeps the package free of an `llm` dependency and trivially testable.
- [ ] **Step 2: Tests:** batch splitting/boundaries, context neighbors, code-fence tolerance, omitted-key retry then failure, ctx cancellation between batches.
- [ ] **Step 3: Commit.** `refactor(ai): extract generic batched segment translator`
---
## Task 3: `internal/ai/jobrunner` — shared job lifecycle
- [ ] **Step 1: Extract the runner.** Move dispatch/heartbeat/reaper/cancel mechanics from `internal/subtitles/ai/service.go` (lines ~136–319) behind:
```go
type Store interface {
Heartbeat(ctx context.Context, id int64) error
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
MarkCancelled(ctx context.Context, id int64, message string) error
}
type Runner struct{ /* baseCtx, sem (shared), store, logger, cancels, wg */ }
func New(appCtx context.Context, sem chan struct{}, store Store, logger *slog.Logger) *Runner
func (r *Runner) Recover() // reap + background reaper loop
func (r *Runner) Dispatch(id int64, run func(ctx context.Context)) // semaphore + heartbeat + cancel registry
func (r *Runner) Cancel(id int64) bool // true if an in-flight goroutine was cancelled
```
Heartbeat-while-queued behavior (the comment block in `dispatch`) must be preserved verbatim — it is load-bearing for multi-instance safety. Constants (30 s heartbeat, 2 min stale, 1 min reaper) move here.
- [ ] **Step 2: Shared semaphore.** The semaphore is **constructed by the caller** (`cmd/silo`) at size `ai.max_concurrent_jobs` and passed to every `Runner`, so subtitle + metadata + ASR jobs share one bound.
- [ ] **Step 3: Tests:** dispatch bounded by a size-1 shared semaphore across two runners; cancel of queued job marks cancelled without running; reaper resets a stale heartbeat row (fake store).
- [ ] **Step 4: Commit.** `refactor(ai): extract shared job lifecycle runner`
---
## Task 4: Refactor `internal/subtitles/ai` onto the shared core
- [ ] **Step 1:** Delete `client.go`; `LLMTranslator` becomes an adapter: cues → `aitranslate.Segment` (ID = 1-based index, Text = joined lines), subtitle system prompt stays here, `splitCueLines` maps back. `Service` keeps job semantics but delegates lifecycle to `jobrunner.Runner`; `pgrepo` satisfies `jobrunner.Store`.
- [ ] **Step 2:** Update `cmd/silo/main.go` wiring (build `llm.Client` + shared semaphore once; pass into the subtitle service).
- [ ] **Step 3:** `go build ./... && go test ./internal/...` — subtitle translation behavior unchanged (same prompts, same batching, same job rows).
- [ ] **Step 4: Commit.** `refactor(subtitles): consume shared AI core`
---
## Task 5: Migration
- [ ] **Step 1:** `make migrate-create NAME=ai_metadata_translation_and_asr`, then fill the generated file:
```sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE public.metadata_translation_jobs (
id bigserial PRIMARY KEY,
target_kind text NOT NULL, -- 'item' | 'season' | 'episode'
content_id text NOT NULL,
include_children boolean NOT NULL DEFAULT true,
source_language text NOT NULL DEFAULT '',
target_language text NOT NULL,
engine text NOT NULL DEFAULT 'openai',
model text NOT NULL DEFAULT '',
status text NOT NULL DEFAULT 'pending', -- pending|running|completed|failed|cancelled
progress double precision NOT NULL DEFAULT 0,
progress_message text NOT NULL DEFAULT '',
fields_done integer NOT NULL DEFAULT 0,
fields_total integer NOT NULL DEFAULT 0,
force boolean NOT NULL DEFAULT false,
error_message text NOT NULL DEFAULT '',
idempotency_key text NOT NULL,
requested_by integer,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
heartbeat_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX metadata_translation_jobs_active_idempotency_idx
ON public.metadata_translation_jobs (idempotency_key)
WHERE status IN ('pending', 'running');
CREATE INDEX metadata_translation_jobs_content_idx
ON public.metadata_translation_jobs (content_id, created_at DESC);
CREATE INDEX metadata_translation_jobs_status_idx
ON public.metadata_translation_jobs (status) WHERE status IN ('pending', 'running');
-- Field provenance: 'provider' | 'ai' | 'manual'. manual > provider > ai.
ALTER TABLE media_item_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual')),
ADD COLUMN tagline_source text NOT NULL DEFAULT 'provider'
CHECK (tagline_source IN ('provider', 'ai', 'manual'));
ALTER TABLE season_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
ALTER TABLE episode_localizations
ADD COLUMN overview_source text NOT NULL DEFAULT 'provider'
CHECK (overview_source IN ('provider', 'ai', 'manual'));
ALTER TABLE media_folders
ADD COLUMN auto_translate_metadata boolean NOT NULL DEFAULT false;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE media_folders DROP COLUMN auto_translate_metadata;
ALTER TABLE episode_localizations DROP COLUMN overview_source;
ALTER TABLE season_localizations DROP COLUMN overview_source;
ALTER TABLE media_item_localizations DROP COLUMN tagline_source, DROP COLUMN overview_source;
DROP TABLE IF EXISTS public.metadata_translation_jobs;
-- +goose StatementEnd
```
- [ ] **Step 2:** `make migrate-up` against the local stack (`docker compose up -d postgres redis`), then `make migrate-status`.
- [ ] **Step 3: Commit.** `feat(metadata): migration for translation jobs, localization provenance, library auto-translate`
---
## Task 6: Settings & config
- [ ] **Step 1: Config structs** (`internal/config/config.go`): add `AIConfig` (fields mirroring `llm.Config`); slim `SubtitleAIConfig` to `Enabled`, `TranscribeEnabled`, `BatchSize`, `ContextNeighbors`; add `MetadataAIConfig{Enabled bool}`.
- [ ] **Step 2: Loader** (`internal/config/db_loader.go`): load `ai.*` with legacy fallback, following the `recommendations.embedding_auth_token` precedent at line ~409:
```go
cfg.AI.BaseURL = stringOr(m, "ai.base_url", stringOr(m, "subtitle_ai.base_url", "https://api.openai.com"))
cfg.AI.APIKey = stringOr(m, "ai.api_key", stringOr(m, "subtitle_ai.api_key", ""))
cfg.AI.ChatModel = stringOr(m, "ai.chat_model", stringOr(m, "subtitle_ai.chat_model", "gpt-4o-mini"))
// ai.max_concurrent_jobs ← subtitle_ai.max_concurrent_jobs ← 2
// ai.asr_model (default "whisper-1"), ai.asr_base_url, ai.asr_api_key (default "")
// subtitle_ai.transcribe_enabled, metadata_ai.enabled (default false)
```
**Never** rename the legacy rows in SQL — encrypted values are GCM-bound to their setting key (`internal/catalog/encrypted_settings_repo.go`).
- [ ] **Step 3:** Add `ai.api_key`, `ai.asr_api_key` to `sensitiveSettingKeys`.
- [ ] **Step 4:** Readiness helpers: subtitle translate = `SubtitleAI.Enabled && AI chat ready`; ASR = `SubtitleAI.TranscribeEnabled && AI.ASRModel != "" && (asr or chat base URL set)`; metadata = `MetadataAI.Enabled && AI chat ready`.
- [ ] **Step 5: Commit.** `feat(config): shared ai.* settings with legacy subtitle_ai fallback`
---
## Task 7: Provenance-aware localization writes
- [ ] **Step 1: Models** (`internal/models/media.go`): add `OverviewSource`/`TaglineSource` to `MediaItemLocalization`, `OverviewSource` to `SeasonLocalization`/`EpisodeLocalization`.
- [ ] **Step 2: Provider upsert rules** (`internal/catalog/localization_repo.go`): rewrite the three `Upsert` statements so, per AI-writable field:
- existing source `manual` → keep existing value and source;
- incoming value empty → keep existing value and source;
- otherwise → take incoming value, set source `provider`.
Single-statement `ON CONFLICT … DO UPDATE` with `CASE` (no read-modify-write). Pattern for one field:
```sql
overview = CASE
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN media_item_localizations.overview_source = 'manual' THEN media_item_localizations.overview_source
WHEN EXCLUDED.overview = '' THEN media_item_localizations.overview_source
ELSE 'provider' END
```
- [ ] **Step 3: AI upsert methods**: `UpsertAITranslation(ctx, contentID, language string, overview, tagline *string, force bool)` per repo (nil pointer = field not part of this write). Insert path populates only the translated fields (other text columns empty). Update path per field: write when existing source is `ai`, existing value is empty, or `force` — never when `manual`; `force` may overwrite `provider` (the admin asked). Sets source `ai`.
- [ ] **Step 4: Serving hardening** (`internal/catalog/detail.go`): verify `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel` only override base fields with **non-empty** localization values (AI rows carry empty titles). Fix any field that clobbers; add a regression test.
- [ ] **Step 5: Tests** for the upsert matrix (provider-over-ai, ai-skips-provider-unless-force, manual-untouchable, empty-never-blanks), following the existing repo/service test patterns in `internal/catalog`.
- [ ] **Step 6: Commit.** `feat(catalog): field provenance for localizations with provider/ai/manual precedence`
---
## Task 8: `internal/metadata/translation` service
- [ ] **Step 1: Job model + repo** (`job.go`, `pgrepo.go`): mirror `internal/subtitles/ai/{job.go,pgrepo.go}` shapes against `metadata_translation_jobs`; satisfy `jobrunner.Store`. Idempotency key = SHA-256 of `content_id|target_kind|target_language|model`.
- [ ] **Step 2: Service** (`service.go`): `Enqueue`, `GetJob`, `ListJobs(contentID)`, `Cancel`, `Recover`, using `jobrunner.Runner` + the shared semaphore. The run loop:
1. Expand targets: `item` → item overview+tagline; series item with `include_children` → + every season overview + every episode overview; `season` (+children → its episodes); `episode` → its overview. Source text comes from the **base** rows (default metadata language); `source_language` recorded from the item's `default_metadata_language`.
2. Skip-if-filled: drop any field whose target-language localization value is already non-empty, unless `force`. All skipped → complete immediately ("Nothing to translate").
3. Build `aitranslate.Segment`s (IDs `item:overview`, `item:tagline`, `season:<content_id>:overview`, `episode:<content_id>:overview`), translate with `prompt.go`'s system prompt (names the title/year, "translate media catalog descriptions", preserve proper nouns/character names/tone, no added information), `BatchSize` = package constant `metadataBatchSize = 10`, `ContextNeighbors = 0`.
4. Per batch: provenance-aware AI upserts (Task 7) + `fields_done`/progress update. Persisting per batch means a cancelled job keeps completed fields.
- [ ] **Step 3: Tests** (fake repos + fake `ChatFn`): series expansion counts, skip-if-filled short-circuit (zero chat calls), force overwrites `ai`+`provider` but never `manual`, per-batch persistence on cancellation mid-job.
- [ ] **Step 4: Commit.** `feat(metadata): AI translation service for overviews and taglines`
---
## Task 9: Ingestion hook + library flag
- [ ] **Step 1:** Switch the provider localization writes in `internal/metadata/service.go` (item ~line 1476, season ~2921, episode ~3080) to the provenance-aware upserts — no other behavior change.
- [ ] **Step 2:** Thread `auto_translate_metadata` through the `MediaFolder` model, folder repo scan/update, and the folder settings API payload.
- [ ] **Step 3: Auto-enqueue hook.** After an item's refresh persists (single point at the end of the item flow — not per season/episode), when: folder flag set, folder `metadata_language` non-empty and ≠ item `default_metadata_language`, metadata AI ready, and the item's target-language localization is missing overview (or tagline) → `Enqueue` a non-force `item` job with `include_children=true`. Fire-and-forget (log on error); the active-idempotency index plus skip-if-filled make repeat refreshes free.
- [ ] **Step 4: Commit.** `feat(metadata): per-library auto-translate fallback on refresh`
---
## Task 10: Metadata AI API
- [ ] **Step 1:** `internal/api/handlers/metadata_ai.go`, mirroring `subtitle_ai.go` shapes:
- `GET /api/v1/metadata/ai/status` → `{enabled}`
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}` → `{job}` (joins an in-flight duplicate, same as subtitles)
- `GET /api/v1/metadata/ai/jobs/{job_id}`, `POST /api/v1/metadata/ai/jobs/{job_id}/cancel`, `GET /api/v1/metadata/ai/jobs?content_id=…`
- [ ] **Step 2:** Gate with the same permission as metadata editing (metadata curation permission; see `internal/catalog/update.go` callers). Register routes; wire the service in `cmd/silo/main.go`.
- [ ] **Step 3:** Handler tests following `internal/api/handlers` conventions (validation: bad kind, missing language, disabled engine → 400/503 mapping).
- [ ] **Step 4: Commit.** `feat(api): metadata AI translation endpoints`
---
## Task 11: Whisper ASR
- [ ] **Step 1: Audio extraction** (`internal/playback/audio_extract.go`), alongside the existing subtitle extraction helpers:
```go
// ExtractAudioChunks extracts one audio track to 16 kHz mono WAV chunks in dir.
// Returns ordered chunk paths; chunkSeconds = 600 in production.
func ExtractAudioChunks(ctx context.Context, filePath string, audioTrackIndex int,
dir, ffmpegPath string, chunkSeconds int) ([]string, error)
```
Single ffmpeg pass: `-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time <n> <dir>/chunk%05d.wav`.
- [ ] **Step 2: Transcriber** (`internal/subtitles/ai/transcriber.go`):
```go
type Transcriber interface {
Transcribe(ctx context.Context, req TranscribeJobRequest,
onChunk func(cues []SubtitleCue, done, total int)) ([]SubtitleCue, string /*detected lang*/, error)
}
```
`WhisperTranscriber` implementation: temp dir (`os.MkdirTemp`, removed on all exit paths) → `ExtractAudioChunks` → per chunk `llm.Client.Transcribe` (per-request timeout ∝ chunk duration; pass the job's language hint) → offset segment times by `chunkIndex*chunkSeconds` → cues (one per segment, wrapped to ≤2 lines ×~42 chars via a `wrapCueText` helper; whitespace-only segments dropped). Chunk processing order starts at the chunk containing `StartPosition`, then wraps (mirrors `reorderFromPosition` semantics).
- [ ] **Step 3: Wire kinds into the service.** `Service.run` branches on `job.Kind`:
- `translate`: existing path.
- `transcribe`: resolve audio track (`source_index` = audio index, `-1` → default/first; reject files without audio), transcribe (progress 10–70 %, live cue streaming via the existing notifier callbacks), store SRT as provider `transcribed`, language = hint or detected, release name `"<Language> (AI transcribed)"`, notify `SubtitleReady`.
- `transcribe_translate`: `transcribe`, store the transcript track, then run the existing `Translator` on the cues (70–95 %), store provider `translated`, `result_subtitle_id` = translated track.
Enqueue validation per kind (ASR requires `TranscribeEnabled`; idempotency model component = `asr_model` or `asr_model+chat_model` for the chained kind).
- [ ] **Step 4:** API surface: `kind` field (optional, default `translate`) on the existing enqueue request in `subtitle_ai.go`; `transcribe_enabled` on the status response.
- [ ] **Step 5: Tests** (`transcriber_test.go`, fake client): timestamp stitching across chunks, playhead-first chunk ordering, cue wrapping, empty-segments chunk → job error, temp-dir cleanup on failure (assert via `t.TempDir` layout).
- [ ] **Step 6: Commit.** `feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs`
---
## Task 12: Frontend — AI Services settings page
- [ ] **Step 1:** `web/src/pages/admin-settings/AIServicesSettings.tsx`: connection card (`ai.base_url`, `ai.api_key` via the sensitive-key pattern already used in `SubtitlesSettings.tsx:264`, `ai.chat_model`, `ai.asr_model`, optional `ai.asr_base_url`/`ai.asr_api_key`, `ai.max_concurrent_jobs`) + features card (`subtitle_ai.enabled`, `subtitle_ai.transcribe_enabled`, `metadata_ai.enabled`, `subtitle_ai.batch_size`, `subtitle_ai.context_neighbors`). Reads show effective values (new key, falling back to the legacy key when unset — same fallback order as the loader); writes always target the new `ai.*` keys.
- [ ] **Step 2:** Remove the AI card from `SubtitlesSettings.tsx` (leave a link/hint to the new page); register the page in the admin settings nav + route following the existing page registrations.
- [ ] **Step 3: Commit.** `feat(web): AI services settings page`
---
## Task 13: Frontend — metadata translate action
- [ ] **Step 1:** API client functions + types for the Task 10 endpoints.
- [ ] **Step 2:** `EditMetadataDialog.tsx`: "Translate with AI" action (visible when `metadata_ai` status is enabled): target language (default = library `metadata_language`), `include_children` (series only), `force` checkbox; enqueue, poll the job every ~1.5 s, show `progress_message`/`fields_done`, invalidate the item detail query on completion.
- [ ] **Step 3: Commit.** `feat(web): translate descriptions from the metadata editor`
---
## Task 14: Frontend — library auto-translate toggle
- [ ] **Step 1:** Add the `auto_translate_metadata` switch to the library settings form next to the metadata-language field (helper text: "When metadata providers have no translation for this library's language, translate descriptions with AI"). Disabled state with hint when `metadata_ai` is off.
- [ ] **Step 2: Commit.** `feat(web): library auto-translate toggle`
---
## Task 15: Frontend — player "Generate from audio"
- [ ] **Step 1:** `SubtitleTranslateModal.tsx`: when `transcribe_enabled`, add a source option "Generate from audio (AI)" listing the file's audio tracks; submit with `kind: "transcribe"` (or `transcribe_translate` when the chosen output language differs from the audio language). Surface it prominently when no text subtitle source exists (today's dead end for bitmap-only files).
- [ ] **Step 2: Commit.** `feat(web): generate subtitles from audio in the player`
---
## Task 16: Verification
- [ ] `go build ./... && go test ./...`
- [ ] `make lint`
- [ ] `cd web && pnpm run lint && pnpm run format:check`
- [ ] `make verify-local-paths`
- [ ] Manual smoke against the local stack: enqueue a metadata translation for a series (verify localization rows + provenance values), re-run a refresh (verify provider values overwrite `ai` rows and `manual` simulation survives), run a `transcribe` job on a short file against a local Whisper-compatible server.
- [ ] MR description: problem, approach, link to the design doc, risks (Whisper endpoint variance, chunk-boundary artifacts), AI-use disclosure, screenshots of the new settings page / editor action / player mode.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,153 @@
# Audiobooks Absorption — Discovery Findings
Produced by sub-plan 1, Task 1. Locks data-model and integration
decisions for the audiobook foundation migrations and downstream sub-plans.
## D1 — Next migration number
Original snapshot: `138_search_number_word_normalization.up.sql` was the
highest existing migration. The landed implementation was renumbered to
`147_abs_sessions`, `157_podcast_feeds`, `159_media_folders_kind_noop`, and
`160_audiobooks_feature_flag`.
## D2 — `media_libraries` kind/type column
The spec refers to `media_libraries` but the actual table is `media_folders`.
`media_folders` has an existing column `type` (text, NOT NULL) that discriminates
library content. Current values in production: `movies`, `series`, `mixed`.
Existing column 'type' (text) discriminates library content on `media_folders`.
Task 4 (migration 159) should ADD the value `audiobooks` to the type vocabulary
rather than add a new column. The audiobook scanner branch will set
`media_folders.type = 'audiobooks'` for audiobook libraries. No schema change
needed for the column itself; migration 159 becomes a no-op DDL migration that
documents the new allowed value and adds any supporting indexes if needed.
No CHECK constraint or enum enforces the `type` column values, so adding
`audiobooks` as a value requires no DDL constraint change.
## D3 — `media_files.chapters` JSONB shape
Sample chapter JSON (live data):
[{"index": 0, "title": "Intro start", "source": "embedded", "end_seconds": 27.944, "start_seconds": 0}, {"index": 1, "title": "Intro end", "source": "embedded", "end_seconds": 1343, "start_seconds": 27.944}]
Sub-plan 2 (scanner) MUST emit objects with the same keys when writing
audiobook chapters so the existing player and serialization code accept
them without changes.
Required keys: `index` (integer), `title` (text), `source` (text),
`start_seconds` (float), `end_seconds` (float).
## D4 — `user_watch_progress` scoping (profile vs user)
user_watch_progress is profile-scoped: column 'profile_id' (text, NOT NULL, FK via
composite PK on user_id + profile_id + media_item_id). Audiobook progress slots in directly.
Additional context: the table also stores `last_file_id`, `last_resolution`,
`last_hdr`, `last_codec_video`, and `last_edition_key`. For audiobooks, only
`position_seconds`, `duration_seconds`, `completed`, and `last_file_id` are
semantically relevant; the video-specific columns (`last_resolution`,
`last_hdr`, `last_codec_video`) will be NULL for audiobook progress rows,
which is acceptable.
## D5 — `user_playback_sessions` audiobook fit
Column list:
Table "public.user_playback_sessions"
Column | Type | Collation | Nullable | Default
------------------+--------------------------+-----------+----------+---------
session_id | text | | not null |
user_id | integer | | not null |
profile_id | text | | not null |
media_file_id | integer | | not null |
play_method | text | | not null |
position_seconds | double precision | | not null | 0
is_paused | boolean | | not null | false
started_at | timestamp with time zone | | not null | now()
updated_at | timestamp with time zone | | not null | now()
Columns required by audiobook sessions: media_item_id (or equivalent),
profile/user FK, started_at, current_position_seconds (or equivalent),
status. Mark any required column as MISSING and surface in sub-plan 3.
Assessment:
- media_item_id: MISSING — table stores `media_file_id` (FK to media_files) rather
than `media_item_id`. For audiobooks, a file maps to one audiobook item, so the
item can be looked up via the file join. No schema change strictly required, but
sub-plan 3 should note this indirect join cost.
- profile_id: PRESENT (text, not null)
- user_id: PRESENT (integer, not null)
- started_at: PRESENT
- position_seconds: PRESENT (as `position_seconds`)
- status / is_paused: PRESENT (as `is_paused`); no explicit `status` enum, but
paused/playing state is representable.
- `play_method`: required for existing sessions; audiobook sessions must supply a
value (e.g. `'direct'`).
No blocking gaps. Audiobook sessions can be written to `user_playback_sessions`
without migration using `media_file_id` as the join key.
## D6 — `people` / `item_people` role conventions
item_people.role storage: `kind` smallint (NOT NULL) — NOT a text `role` column.
The column is named `kind` with type `smallint`. No CHECK constraint or enum.
Existing kind values in use (mapped from models/media.go):
1 = Actor, 2 = Director, 3 = Writer, 4 = Producer, 5 = GuestStar, 6 = Composer
(6 = Composer defined in code but 0 rows in production data)
Sub-plan 2 will UPSERT `author` and `narrator` into item_people for
audiobook items. Since the role column is an unconstrained smallint (not
a text role column and not an enum or CHECK constraint), Sub-plan 2 must:
1. Add new PersonKind constants to `internal/models/media.go`:
`PersonKindAuthor PersonKind = 7` and `PersonKindNarrator PersonKind = 8`
2. Add corresponding cases to `PersonKind.String()` returning `"Author"` and
`"Narrator"` respectively.
No migration is needed to extend a constraint — the smallint column accepts
any integer value.
## D7 — Catalog FTS handling of `type='audiobook'`
Indexes / generated columns that filter by media_items.type:
- `001_schema.up.sql`: `idx_media_items_search` — GIN on `to_tsvector('english', title || ' ' || overview)` — NO type filter, indexes ALL rows
- `001_schema.up.sql`: `idx_media_items_search_exact_title` — btree on `lower(title)` — NO type filter
- `001_schema.up.sql`: `idx_media_items_search_overview` — GIN on overview tsvector — NO type filter
- `001_schema.up.sql`: `idx_media_items_search_title_fields` — GIN on weighted title/original_title/sort_title tsvector — NO type filter (rebuilt by migrations 127 and 138)
- `001_schema.up.sql`: `idx_media_items_type_created` — btree on `(type, created_at DESC)` — indexes ALL types, used for filtering by type
- `057_calendar_indexes.up.sql`: `idx_media_items_movie_release_date` — btree WHERE `type = 'movie'` — movie-only, not FTS
- `103_media_items_last_air_date_denorm.up.sql`: `idx_media_items_last_air_date_at` — btree WHERE `type = 'series'` — series-only, not FTS
- `105_media_items_title_normalized.up.sql`: `idx_media_items_title_normalized_trgm` — gin trigram on `title_normalized` — NO type filter (rebuilt by 127 and 138)
- `138_search_number_word_normalization.up.sql`: `idx_media_items_search_title_fields` (current) — GIN on weighted tsvector — NO type filter
- `138_search_number_word_normalization.up.sql`: `idx_media_items_title_normalized_trgm` (current) — gin trigram — NO type filter
Verdict: audiobooks WILL be FTS-searchable out of the box.
All FTS indexes on `media_items` operate on the full table with no type
restriction. A row with `type = 'audiobook'` will be indexed automatically
by `idx_media_items_search_title_fields` and `idx_media_items_title_normalized_trgm`
as soon as it is inserted. No extra migration is needed for Sub-plan 3 to
extend type filters.
## D8 — First-party scheduled-task registration
First-party scheduled tasks register at: `cmd/silo/main.go:1239–1285`
Registration call shape (from existing first-party tasks):
```go
taskMgr.Register(tasks.NewSyncCollectionsTask(collectionSyncScheduler))
```
Full interface required (from `internal/taskmanager/tasks/sync_collections.go`):
- `Key() string` — unique string key e.g. `"sync_podcast_feeds"`
- `Name() string` — human-readable name
- `Description() string` — human-readable description
- `Category() taskmanager.TaskCategory` — e.g. `taskmanager.TaskCategoryLibrary`
- `IsHidden() bool`
- `DefaultTriggers() []taskmanager.TriggerConfig` — e.g. interval trigger
- `Execute(ctx context.Context, progress taskmanager.ProgressReporter) error`
Sub-plan 5 (podcasts) will register `podcastfeed.Refresher` at `cmd/silo/main.go`
in the task registration block (around line 1259–1265) using the same pattern:
```go
taskMgr.Register(tasks.NewSyncPodcastFeedsTask(podcastFeedRefresher))
```
@@ -0,0 +1,224 @@
# Audiobook Player QoL Improvements
Commands assume the repository root is the cwd. All paths are repository-relative.
## Goal
Bring the web audiobook player to feature parity with best-in-class audiobook apps
(Audiobookshelf, Audible, BookPlayer, Prologue) on the quality-of-life axis. The current
surface architecture (persistent mini bar + expandable Now Listening view, playback
survives navigation) is already the de-facto standard and stays as-is.
Explicitly **out of scope**: Media Session / lock-screen integration, bookmarks (needs a
server endpoint — follow-up), offline/download, queue.
## Features
1. **Keyboard shortcuts** — space/K play-pause, arrow skips, volume, chapter nav, speed step.
2. **Configurable skip intervals** — asymmetric defaults (back 10s, forward 30s), user-tunable.
3. **Expanded speed control** — 0.5×–3.0× with fine stepping, presets, and per-book memory.
4. **Chapter prev/next buttons** — alongside the seconds-skip buttons.
5. **Volume control** — reuse the shared `VolumeControl`, persisted like the video player.
6. **Smart rewind** — auto-rewind on resume, scaled by how long playback was paused.
7. **Time remaining at current speed** — real-clock remaining display in Now Listening.
No server/API changes are required; everything is web-frontend only, so no Android/Apple
client coordination is needed. (Per-book speed memory is device-local in v1; syncing it
server-side via profile settings is a noted follow-up.)
## Existing seams (read these first)
| File | Relevance |
| --- | --- |
| `web/src/pages/audiobooks/player/useAudiobookPlayback.ts` | Playback engine. `togglePlay` (~line 499), `skip` (~534), `setRate` (~541), returned API (~690+). All engine work lands here. |
| `web/src/pages/audiobooks/player/MiniBar.tsx` | Bottom bar. Hardcoded `SKIP_*_SECONDS = 30` and `PLAYBACK_RATES` at lines 10–12. |
| `web/src/pages/audiobooks/player/NowListening.tsx` | Full-screen view. Same hardcoded constants; dead "More" button (lines 50–56) becomes the settings menu trigger; remaining-time toggle at lines 33–37. |
| `web/src/pages/audiobooks/player/AudiobookPlayer.tsx` | Wrapper that owns mini/now-listening mode — mount point for the keyboard shortcuts hook. |
| `web/src/player/components/VolumeControl.tsx` | Existing slider + `getPersistedVolume`/`persistVolume` helpers (shared `player-volume`/`player-muted` storage keys). Styled for dark video overlay — needs a surface-toned variant. |
| `web/src/player/components/SpeedMenu.tsx` | Current preset-only menu (shared with video player). Audiobook gets a richer `SpeedControl`; video keeps `SpeedMenu` untouched. |
| `web/src/player/hooks/useKeyboardShortcuts.ts` | Video player's shortcut hook — pattern to mirror (input-field guard, single document listener). |
| `web/src/utils/storage.ts` | Typed localStorage wrapper — add new keys here, never call `localStorage` directly. |
| `web/src/player/components/SeekBar.tsx`, `CircleButton.tsx`, `SleepTimerMenu.tsx`, `ChaptersMenu.tsx` | Established player design language; reuse, don't fork. |
## Design language
These are additions to an existing, refined system — extend it, don't restyle it:
- **Surfaces**: mini bar is a themed surface (`bg-background`, `border-t`, token colors).
Popovers anchored to it use the established dark-glass treatment
(`bg-black/90 backdrop-blur-sm rounded-lg shadow-xl`, white/75 text, `data-active` rows)
exactly as `SpeedMenu`/`SleepTimerMenu` do today.
- **Type**: time and rate values are always `font-mono`/`tabular-nums` so nothing shifts as
digits tick. The new speed readout (`1.45×`) must reserve width for two decimals.
- **Buttons**: all transport controls are `CircleButton` (`sm` secondary flanks, `md`/`lg`
primary center). Chapter prev/next use `SkipBack`/`SkipForward` lucide glyphs at the same
stroke weight (1.6) as the rotate icons, so the cluster reads as one family.
- **Micro-interactions**, small and purposeful:
- Skip buttons: a quick ~18° icon flick in the skip direction on press
(CSS transform transition, ~150ms ease-out) — confirms the action without a toast.
- Speed stepper: value crossfades (opacity 120ms) on change; the active preset chip gets
the same `bg-white/5 text-white` treatment as current menu rows.
- Smart rewind: when it fires, the seek bar playhead animates the small jump back rather
than teleporting (SeekBar already animates position; verify it holds for ≤30s deltas).
- **Accessibility**: every new control keeps the existing patterns — `aria-label`,
`aria-expanded`/`aria-haspopup` on triggers, roving focus + Escape in menus (copy from
`SleepTimerMenu`), `role="slider"` semantics from `VolumeControl`/`SeekBar`.
## Phase 1 — Engine + preferences model
### 1a. Preferences hook
New `web/src/pages/audiobooks/player/useAudiobookPrefs.ts`:
- Add storage keys: `AUDIOBOOK_SKIP_BACK`, `AUDIOBOOK_SKIP_FORWARD`,
`AUDIOBOOK_SMART_REWIND` (bool), `AUDIOBOOK_RATES` (JSON map) in `web/src/utils/storage.ts`.
- Exposes `{ skipBack, skipForward, smartRewind, setSkipBack, setSkipForward, setSmartRewind }`.
- Defaults: back **10s**, forward **30s** (the asymmetric convention — small "what did she
say?" hops back, bigger hops forward), smart rewind **on**.
- Allowed skip values: 5, 10, 15, 30, 45, 60, 90 (two digits max keeps `SkipIcon`'s inset
number legible).
- Per-book rate map: single JSON object `{ [contentId]: rate }` under `AUDIOBOOK_RATES`,
LRU-capped at 50 entries (store `{ rate, at }` and evict oldest on insert).
Helpers `getBookRate(contentId)` / `setBookRate(contentId, rate)` live here too.
### 1b. Engine extensions (`useAudiobookPlayback.ts`)
- **Volume/mute**: initialize from `getPersistedVolume()`; apply to the `<audio>` element in
an effect; expose `volume`, `muted`, `setVolume`, `setMuted`; persist via `persistVolume`.
Sharing the video player's keys is intentional — one volume preference per device.
- **Per-book rate**: on mount, initialize `rate` from `getBookRate(contentId)` (fallback 1);
in `setRate`, also `setBookRate`. The hook needs `contentId` passed in (it already
receives the files/options object — extend that input).
- **Chapter navigation**: expose `nextChapter()` and `prevChapter()`.
- `next`: seek to start of the chapter after `currentChapter`; no-op at the last chapter.
- `prev`: if more than 3s into the current chapter, seek to its start; otherwise seek to
the previous chapter's start (the universal music/audiobook convention).
- **Smart rewind**: record `pausedAtRef = performance.now()` whenever playback pauses (in
the `pause` event handler so it also catches OS-initiated pauses). On resume, before
`audio.play()`, compute rewind from pause duration and `seekTo(currentTime - rewind)`:
- < 10s paused → 0s; < 1min → 3s; < 10min → 10s; < 1h → 20s; ≥ 1h → 30s.
- Clamp at 0; skip entirely when the smart-rewind pref is off or the resume immediately
follows a user seek (seeking sets a short suppress flag so explicit jumps aren't undone).
- Also apply on **cold resume**: when starting with `initialPositionSeconds > 0` from the
detail page, rewind by the same schedule using the age of the saved progress if the
API exposes an updated-at timestamp; otherwise apply a flat 10s. Keep this logic in one
pure function `smartRewindSeconds(pauseMs)` so it's unit-testable.
- **Skip intervals**: no engine change — `skip(delta)` already takes a signed delta; the UI
passes the configured values.
### 1c. Tests
Vitest, colocated like existing `*.test.tsx`:
- `smartRewindSeconds` schedule boundaries.
- Per-book rate LRU (cap, eviction, malformed-JSON tolerance).
- `prevChapter` 3s threshold behavior and first/last chapter edges
(chapter math is pure — test against `buildChapterList` fixtures from
`web/src/lib/audiobooks/chapters.ts`).
## Phase 2 — Controls UI
### 2a. SpeedControl (new, audiobook-specific)
New `web/src/pages/audiobooks/player/SpeedControl.tsx`, replacing `SpeedMenu` usage in both
audiobook views (video player keeps `SpeedMenu`):
- Trigger: same `player-utility-btn` showing the current rate (`1.45×`, tabular-nums).
- Popover (dark-glass, bottom-anchored like today):
- A stepper row: `−` / big mono readout / `+`, stepping **0.05** per tap, clamped to
**0.5–3.0**. Press-and-hold repeats (250ms initial, 80ms repeat).
- A preset chip row: 1× · 1.25× · 1.5× · 1.75× · 2× · 2.5× · 3×.
- Footnote line: "Remembered for this book" (quiet `text-white/40`, 11px) — makes the
per-book memory discoverable instead of magical.
- Keyboard: ArrowUp/Down step inside the popover; Escape closes (copy menu plumbing from
`SleepTimerMenu`).
### 2b. Chapter prev/next buttons
- Add `SkipBack`/`SkipForward` `CircleButton size="sm" variant="secondary"` on the outside
of the transport cluster in both `MiniBar` and `NowListening`:
`⏮ ↺10 ▶ ↻30 ⏭`. Wire to `prevChapter`/`nextChapter`; render only when
`playback.chapters.length > 0`; disable at the ends (prev stays enabled mid-chapter).
- In `MiniBar`, hide the chapter buttons below `sm:` — the 3-column grid is already tight
on phones and the chapters menu remains available.
### 2c. Volume
- Add a `tone` prop to `web/src/player/components/VolumeControl.tsx`
(`"overlay"` = current white-on-dark, `"surface"` = theme tokens: track `bg-muted`,
fill `bg-foreground`, focus ring `ring-ring`). Default `"overlay"` so the video player is
untouched.
- Mount in `MiniBar`'s right cluster (before the sleep timer) at `md:` and up, and in
`NowListening`'s utility row. Wire to the new engine `volume`/`muted` state.
### 2d. Player settings menu
New `web/src/pages/audiobooks/player/PlayerSettingsMenu.tsx` (dark-glass popover):
- **Skip back** / **Skip forward**: rows of small value chips (5/10/15/30/45/60/90s).
- **Smart rewind**: toggle row with a one-line description ("Backs up a little after a pause").
- Triggers: the currently-dead "More" (`MoreHorizontal`) button in `NowListening`
(lines 50–56) and a matching trigger in `MiniBar`'s right cluster.
- `MiniBar`/`NowListening` read `useAudiobookPrefs()` and pass the configured seconds to
the skip buttons and `SkipIcon` (which already renders a dynamic number) — delete the
`SKIP_*_SECONDS` constants.
## Phase 3 — Shortcuts + time display + polish
### 3a. Keyboard shortcuts
New `web/src/pages/audiobooks/player/useAudiobookKeyboardShortcuts.ts`, mounted in
`AudiobookPlayer.tsx` (so it's active in both mini and expanded modes, on every route):
| Key | Action |
| --- | --- |
| Space / K | play-pause |
| ← / → | skip by configured back/forward seconds |
| ↑ / ↓ | volume ±5% |
| M | mute toggle |
| N / P | next / previous chapter |
| Shift+. / Shift+, | speed +0.05 / −0.05 (YouTube convention) |
| E | expand / collapse Now Listening |
| Esc | collapse Now Listening (expanded mode only) |
- Mirror `useKeyboardShortcuts.ts` guards: ignore when target is input/textarea/
contentEditable, single document listener, cleanup on unmount.
- Additional guard: **do nothing while a video session is active** — the video player binds
the same keys. Check `WatchPlaybackProvider` state (or simply whether a `<video>` watch
route is mounted) and bail; the audiobook bar is backgrounded in that case anyway. Space
must also not fire when a button has focus (let the focused control handle Enter/Space).
- Add `title` tooltips with the shortcut hint to transport buttons
(e.g. `Back 10 seconds (←)`) for discoverability.
### 3b. Time remaining at current speed
In `NowListening`, the right time label currently toggles total ↔ remaining. Make it cycle
three states (persist last choice in component state only):
1. total (`12:04:00`)
2. remaining (`−3:21:09`)
3. remaining at speed (`−2:14:06 at 1.5×`) — `remaining / rate`, hidden (skipped in the
cycle) when `rate === 1`.
Keep the `data-testid="now-listening-right-time"` hook and update its test.
### 3c. Polish pass
- Skip-flick and speed-crossfade micro-interactions from the design section.
- Verify mini bar layout on 360px-wide viewports with all new controls (chapter buttons and
volume hidden, settings menu accessible).
- `cd web && pnpm run lint && pnpm run format:check`; run the player test suites.
## Risks / notes
- `useAudiobookPlayback.ts` is 707 lines and growing — extract smart rewind + prefs-coupled
logic into small pure modules (`smartRewind.ts`) rather than inflating the hook; consider
splitting volume/rate concerns into a `useAudioElementSettings` helper if the hook passes
~800 lines.
- Smart rewind must not fight the 10s progress reporter: rewinding on resume changes
`currentTime`, which triggers a progress write — that's correct (the rewound position is
the truth), just confirm no oscillation with the suppress-after-seek flag.
- Per-book rate uses `contentId`; switching narrators navigates to a different `contentId`,
so each narration remembers its own speed — acceptable, arguably desirable.
- Cross-device sync of prefs/per-book speed is a candidate follow-up once a profile
settings endpoint exists; the `useAudiobookPrefs` API is the seam to swap storage behind.
@@ -0,0 +1,447 @@
# Audiobook UI Redesign — Design Spec
**Date:** 2026-05-24
**Branch context:** `feat/audiobooks` in `silo-server`
**Status:** Approved — ready for implementation plan
**Related spec:** [`2026-05-24-audiobooks-absorption-design.md`](./2026-05-24-audiobooks-absorption-design.md)
## Goal
Bring the audiobook detail page and audiobook player to visual and
interaction parity with Silo's existing video player, translated for
audio. Today the audiobook surfaces work but feel generic, hide useful
information, and re-implement primitives the video player already has
in a polished form.
User-visible outcome:
- The audiobook player has the same two-mode shape as the video player
(compact HUD + immersive full-screen mode), so listening can be
background or foreground depending on intent.
- The detail page foregrounds the information audiobook listeners care
about — current chapter, narrator, listened progress — instead of
burying it.
- The audiobook-only affordances that don't exist today (sleep timer,
speed menu, bookmarks, narrator emphasis) have a home.
## Hard constraints
- **Reuse video-player primitives.** `SeekBar`, `ChaptersMenu`, and the
glass-disc button visual treatment all live in `web/src/player/` and
are already wired up correctly. The audiobook player must consume
them, not re-implement them.
- **One audio element.** Switching between mini and Now Listening must
not remount the audio element or restart the stream. Both modes are
chrome layered over the same playback state.
- **No new top-level routes.** The Now Listening view is an overlay on
whatever route the user is on, not a `/audiobooks/listen` page. This
mirrors how fullscreen video works — fullscreen is a mode, not a
destination.
- **Player state must be liftable.** The split between
`useAudiobookPlayback` (state hook) and `MiniBar` / `NowListening`
(chrome) must allow a future v1.1 change to lift the hook into an
app-level provider so the mini bar can survive page navigation.
v1 itself ships with the player tied to the detail route, same as
today.
## Scope
### In
- New mini-bar layout (cover tile, chapter title, glass-disc transport,
utility rail with sleep / chapters / speed / expand / close).
- New Now Listening full-overlay mode (large cover, chapter heading,
tall seek bar, sleep / chapters / speed / bookmark / car-mode row,
remaining-time toggle, overflow menu).
- Restructured audiobook detail page (progress + chapter-aware Resume
action, chapters expanded by default with currently-playing
highlight, narrator card, embedding-based "Similar audiobooks"
rail, "Also by author", "In this series").
- Extraction of `CircleButton` and a new `SpeedMenu` into
`web/src/player/components/` as shared primitives between video and
audiobook players.
- Split of today's monolithic `AudiobookPlayer.tsx` into a state hook
(`useAudiobookPlayback`) plus two chrome components (`MiniBar`,
`NowListening`) under `web/src/pages/audiobooks/player/`.
- Sleep timer (client-side only; pauses + short fade-out when the
timer fires).
### Out (deferred to v1.1)
- **Bookmarks.** UI affordances (mini-bar button, Now Listening
utility-row button, "Bookmarks (n)" detail-page action) ship
**hidden** in v1. Full feature needs `audiobook_bookmarks` table +
CRUD endpoints, which is its own sub-spec.
- **"Also by narrator" / cross-book "In this series" rails.** Render
only when the backend already exposes the data; otherwise hide.
Server-side joins to power them are a follow-up.
- **Car mode.** Hidden in v1; v1.1 will add a huge-buttons layout
variant reached from the utility row.
- **Audiobook library page redesign.** Out of scope; current grid
stays.
- **Persistent player across page navigation as a lifted React
context.** The current detail page owns the player state; lifting it
to an app-level provider so the bar survives navigating away from
the detail page is a follow-up. v1 ships with the bar tied to the
detail route, same as today, but visually and structurally ready
for that lift.
## Architecture
### Three connected surfaces
```
detail page ──opens──► mini bar ──expand──► Now Listening
▲ │
└─────── collapse ──────┘
```
- **Detail page** (`/audiobooks/book/:id`) is the landing surface.
Stays mounted while the player is open.
- **Mini bar** is a fixed bottom strip (current behavior) that appears
once the user clicks Play/Resume.
- **Now Listening** is a full-viewport overlay (z-index above the mini
bar, below modals) reached by clicking the cover-art tile or the
expand chevron in the mini bar. Dismissed via a collapse chevron.
The mini ↔ Now Listening transition uses a CSS view transition keyed
on the cover art element so the small tile appears to grow into the
big cover. The same primitive already used by `ViewTransitionLink`.
### Shared player primitives
`web/src/player/components/` already houses the visual building blocks
used by the video player. Two of them get extracted so both players
consume the same source of truth:
| File | Status | Purpose |
|---|---|---|
| `SeekBar.tsx` | reused as-is | already shared |
| `ChaptersMenu.tsx` | reused as-is | already shared |
| `CircleButton.tsx` | **new** — extracted from `PlayerControls.tsx` | the glass-disc primary/secondary button used in both transport clusters |
| `SpeedMenu.tsx` | **new** | popover speed menu matching the styling of `ChaptersMenu`; replaces the raw `<select>` today; video player can adopt later |
| `SleepTimerMenu.tsx` | **new** | popover with off / 5 / 15 / 30 / 45 / 60 / end-of-chapter; audiobook-only but lives here for symmetry |
Extracting `CircleButton` removes a divergence rather than adding a
layer — it's the kind of focused improvement that earns its keep
because today the video player and the audiobook player each render
their own visually-similar-but-not-identical disc buttons.
### Audiobook player component tree
```
web/src/pages/audiobooks/
AudiobookDetail.tsx (restructured per "Detail page" below)
AudiobookLibrary.tsx (unchanged)
player/ (new folder)
AudiobookPlayer.tsx (top level: owns audio element and state)
MiniBar.tsx (compact chrome)
NowListening.tsx (overlay chrome)
CoverExpandTile.tsx (left-edge tile inside MiniBar — also the view-transition anchor)
useAudiobookPlayback.ts (state hook: audio events, progress reporting, sleep timer)
```
`AudiobookPlayer` becomes a thin shell:
```tsx
function AudiobookPlayer(props) {
const playback = useAudiobookPlayback(props);
const [mode, setMode] = useState<"mini" | "now-listening">("mini");
return (
<>
<audio ref={playback.audioRef} src={playback.streamUrl} preload="metadata" hidden />
{mode === "mini"
? <MiniBar playback={playback} onExpand={() => setMode("now-listening")} onClose={props.onClose} />
: <NowListening playback={playback} onCollapse={() => setMode("mini")} />}
</>
);
}
```
The audio element lives in the parent so a mode swap never touches it.
### useAudiobookPlayback responsibilities
Lifts today's monolithic `AudiobookPlayer.tsx` into a single hook
returning a stable shape:
```ts
{
audioRef, // ref<HTMLAudioElement>
streamUrl, // string
playing, currentTime, duration, buffered, rate,
chapters, // PlayerChapter[] (flattened across files)
currentChapter, // PlayerChapter | null
sleep, // { remainingMs: number | null, end: SleepEndCondition | null }
togglePlay, seekTo, skip,
setRate,
setSleep, // arms the timer
// bookmarks (v1.1 — initially returns []/no-op stubs)
bookmarks, addBookmark, removeBookmark,
}
```
The hook owns: audio event wiring, periodic progress reporting (the
existing 10s `useReportAudiobookProgress` cadence), sleep-timer state
+ fade-out, computing `currentChapter` from `currentTime`, and the
unmount-time pause + final report. The chrome components stay pure
presentational.
## Mini bar
Layout (one seek-bar row above a controls row):
```
[ full-width SeekBar with chapter ticks ]
[ cover tile | title + chapter + time | ◀30 ▶❚❚ 30▶ | ⌛ ☰ 1× ⌃ ✕ ]
↑ left col middle center right rail
36×54px truncating shared CircleButton cluster
sleep, chapters, speed,
expand chevron, close
(bookmark hidden in v1)
```
Differences from today's implementation:
- **CoverExpandTile** added at the left edge. 36×54 (portrait
aspect-2:3). On hover: subtle scale + a small "expand" chevron
overlay. Clicking it triggers the view transition to Now Listening.
- **Left text column** shows title (line 1) and current chapter title
(line 2). Time row stays where it is. Today the chapter title isn't
shown anywhere while playing — this is the single most useful
"where am I" cue and it's missing.
- **Center cluster** swaps the bespoke `CircleButton` defined inline
in the current `AudiobookPlayer` for the extracted shared one. Same
three controls (back 30 / play-pause / forward 30).
- **Right rail** replaces the raw `<select>` for speed with
`SpeedMenu` (popover, same styling as `ChaptersMenu`). Adds
`SleepTimerMenu`, an expand-up chevron (`ChevronUp`), and the
existing close `X`. The bookmark button is **hidden in v1** (mini
bar has no bookmark control until v1.1 lands).
Mini bar height is unchanged from today.
## Now Listening overlay
Full-viewport overlay, dark surface (`bg-background`). Z-index above
the mini bar, below modals.
Layout on desktop (width ≥ md):
```
┌─────────────────────────────────────────────────────────────────┐
│ ⌄ (collapse, top-left) ⋯ More (top-right) │
│ │
│ ┌──────────────────────┐ │
│ │ │ PROJECT HAIL MARY │
│ │ [ COVER ] │ Andy Weir │
│ │ ~360 × 540 │ Narrated by Ray Porter │
│ │ │ │
│ │ │ ── CHAPTER 7 ── │
│ │ │ The Astrophage │
│ └──────────────────────┘ │
│ │
│ [ tall SeekBar with chapter ticks ] │
│ 12:43 -4:08:35 │
│ │
│ ◀30 ▶❚❚ (large) 30▶ │
│ │
│ ⌛ Sleep ☰ Chapters (24) 1× Speed │
└─────────────────────────────────────────────────────────────────┘
```
Layout on mobile (portrait): same elements stacked top-to-bottom —
header row, cover (centered, smaller), metadata block, seek bar,
transport, utility row. No horizontal split.
Key behaviors:
- **Tap right time** toggles between `-remaining` and the wall-clock
end time. The toggle preference is in-memory only (resets per
session) — no persistence concern.
- **Sleep timer** opens `SleepTimerMenu`. When armed, the trigger
shows the countdown (`Sleep 04:32`). When the timer hits zero the
player fades volume to 0 over 5s and pauses.
- **Car mode** and **bookmark** buttons are **hidden in v1** — the
utility row ships with sleep / chapters / speed only. Car mode +
bookmark slots are documented here so v1.1 has a clear home for
them.
- **Overflow ⋯** menu items for v1: "Go to detail page", "Report a
sync problem" (opens a mailto/issue link). Future additions live
here.
The same `<audio>` element backs both modes, so all controls in Now
Listening are bound to the same `useAudiobookPlayback` state.
## Detail page restructure
The page keeps its current top-level shape (hero band + content
below) and adds/rearranges these blocks:
### Hero band
Today: `DetailHero` with a stack of `Play` / `Play from Start`
actions and a small progress bar that appears below the buttons only
when progress exists.
New version (still using `DetailHero` but with a richer `actions`
slot):
```
[ progress bar — full width of actions column, always shown if any progress ]
[ "3h 12m listened · 19%" caption ]
▶ Resume Ch 7 · The Astrophage ↻ Start Over 🔖 Bookmarks (3)
```
- Progress bar moves up *above* the buttons so the listener sees
position context first.
- Resume button label includes the chapter the listener will land
in. Pulled from the same `buildChapterList` already in the file.
- "Bookmarks (n)" button visible only when n > 0; opens a side sheet
listing them. v1 ships with an empty stub (always 0, button never
shown) — full feature in v1.1.
### Chapters section
Today: collapsed-by-default disclosure showing a chapter list.
New version:
- Expanded by default. (Collapsed in this position made sense when
the page was sparse; once we're foregrounding chapter awareness
everywhere else, hiding the list here is contradictory.)
- Currently-playing chapter row gets a left-edge accent and a
"▶ playing" badge on the right.
- Lightweight sort menu in the section header: "By position" (default),
"Longest first" — no UI for "By title" since chapter titles rarely
sort meaningfully. Sort preference is in-memory only for v1.
- Clicking a chapter row still calls `openPlayer(absoluteStart)` as
today; if the player is open, it seeks rather than reopens.
### Narrator card
New section between Chapters and the cross-rails. Renders only when
`data.narrator` is non-empty.
```
── NARRATOR ──
[ avatar 64×64 ] Ray Porter
47 audiobooks in your library →
```
Avatar source: if the absorbed audiobook data model exposes a
narrator photo, use it; otherwise an initials avatar in a circle.
The `→` link goes to a narrator detail page if one exists, or a
search-results page filtered by narrator name as a fallback.
### Cross-book rails
Three sections, each rendered only when its data is present, ordered
top-to-bottom:
- **"Similar audiobooks"** — embedding-based recommendations. Ranked
by vector similarity against this book; ordering and inclusion
decided server-side. Renders with a subtitle "Based on listening
patterns" to give the rail a small bit of provenance copy that the
metadata rails don't need.
- **"Also by {author}"** — horizontal scroller of cover tiles, same
pattern as `MediaRow` used elsewhere in Silo.
- **"In this series"** — same pattern, with "Book n of m" subtitle and
the current book non-clickable / highlighted.
All three rails ship behind feature-detection: if the API response
for the book includes the related array, render it; if not, hide the
section without showing a placeholder. This avoids blocking v1 on any
specific backend rail support — each lights up as the data lands.
### What's *not* in the new detail page
- Reviews / ratings — out of scope, not in the data model.
- Tabs — page is short enough that one scroll surface beats tabs.
- Social / cohost listening — no signal it's wanted.
## Data & API impact
Most of the work is pure frontend, but three small backend-adjacent
items to call out:
| Item | Impact |
|---|---|
| Sleep timer | Frontend-only. No API change. |
| Speed menu | Frontend-only — already a client-side audio setting. |
| Bookmarks | **Deferred to v1.1.** Needs `audiobook_bookmarks` table (`content_id`, `profile_id`, `position_seconds`, `note`, `created_at`) plus CRUD endpoints. v1 ships with the button hidden and `useAudiobookPlayback` exposing no-op bookmark stubs. |
| Narrator card with "n audiobooks in library" count | Needs narrator-aware query. If the detail endpoint already returns it, render; otherwise hide the count line and show only the name. |
| "Also by author" / "In this series" rails | Render only when arrays present. v1 doesn't require backend changes — it just adapts to whatever the response contains. |
| "Similar audiobooks" rail (embedding-based) | Render only when `similar_audiobooks` array present on the detail response. Backend computes similarity from per-book embeddings server-side and includes the top-N already ranked. v1 frontend has no embedding logic — it just renders whatever ordered list the API returns. |
## Routing & view-transition wiring
- `/audiobooks/book/:contentId` continues to be the only audiobook
route added by this work. Mini bar and Now Listening are layered
inside this route's component tree.
- View transition name: `audiobook-cover-{contentId}`. Set on the
cover tile inside `CoverExpandTile` and on the large cover element
inside `NowListening`. Same `contentId` on both = the browser
animates the rect change automatically.
- The detail page's hero cover and the mini bar's cover tile share
the same view-transition name so the *initial* open (clicking
Play/Resume on the hero) also animates the cover into the mini bar
position.
## Accessibility
- All transport controls have `aria-label`s (already true today; keep
parity).
- The expand chevron and the cover tile are both labeled "Expand
player" / "Open Now Listening" — two ways in, both discoverable to
screen readers.
- `SleepTimerMenu`, `SpeedMenu`, `ChaptersMenu` use the existing
`Popover` primitive with proper roving focus + Escape-to-close
(inherits from `ChaptersMenu` behavior).
- Now Listening overlay traps focus while open and restores focus to
the mini bar's expand chevron on close.
- Color contrast for the chapter-title text in the mini bar must
meet WCAG AA against the bar's background (use `text-foreground`
with a `text-muted-foreground` chapter title is the existing
pattern and passes).
## Testing
- **Unit:** `useAudiobookPlayback` — chapter computation from current
time across multi-file audiobooks, sleep-timer arm/disarm/fire,
progress reporting cadence and pause/seek/end triggers.
- **Component:** `MiniBar` and `NowListening` render expected
elements given a mocked playback object; expand/collapse calls the
right callbacks; speed menu / sleep menu open and emit the right
values.
- **Integration / Playwright** (if the repo has Playwright wired for
these flows): start playback from detail page → mini bar appears
with chapter title → click cover tile → Now Listening overlay
appears → seek bar works in both modes → collapse returns to mini
bar with playback uninterrupted (key assertion: `audio.currentTime`
monotonically advances across the mode swap).
## Out of scope (recap)
- Bookmarks backend (v1.1 sub-spec)
- Cross-book rails backend joins
- Car mode full layout
- Audiobook library grid redesign
- Lifting the player to an app-level provider so it survives
navigation away from the detail page
## Open questions / risks
- **Multi-file audiobooks.** Today's player only plays `files[0]` —
multi-file audiobooks display all chapters but can't actually cross
file boundaries. This redesign doesn't fix that; it inherits the
limitation. Worth flagging in the implementation plan whether to
fold a fix into v1 or leave it for a separate ticket.
- **View transition browser support.** CSS view transitions are
supported in modern Chromium and Safari Tech Preview; Firefox is
not there yet. The mini ↔ Now Listening swap must work without
the transition (graceful fallback to a plain unmount/mount).
- **Sleep-timer fade.** A 5s gain ramp on `HTMLAudioElement.volume`
works but is not as smooth as a Web Audio API gain node. Starting
simple; revisit if it feels janky.
@@ -0,0 +1,382 @@
# Audiobooks Absorption — Design Spec
**Date:** 2026-05-24
**Branch context:** `feat/audiobooks` in `silo-server`
**Status:** Approved — ready for implementation plan
## Goal
Absorb the `silo-plugin-audiobooks` plugin into the primary `silo-server`
Go application. Eliminate it as a plugin. User-visible outcome:
- Silo's existing web UI gains audiobook + podcast browse/playback.
- Audiobookshelf-compatible (ABS) mobile/desktop clients connect to silo
directly and play audiobooks from libraries silo already scans.
## Hard constraints
- **Minimize changes.** Reuse silo-server's existing tables and infrastructure
wherever they fit. Add new tables only where there is no existing equivalent.
- **No separate SPA.** Audiobook UI lives inside silo's existing React app at
`web/src/`. The plugin's standalone SPA is dropped.
- **Audiobook files = scanned by silo.** Silo's existing scanner discovers
audiobook files in configured library paths the same way it scans movies
and TV. The `silo-plugin-local-audiobooks` plugin is no longer needed.
- **No external backends.** BookWarehouse / other audiobook backend plugins
are out of scope. Local filesystem is the only source.
- **No standalone HTTP listener.** Everything runs on silo's main `:8080`
listener, including ABS Socket.io. The plugin's standalone-listener
workaround existed only because the host plugin-proxy could not bridge
WebSocket upgrades; that problem disappears once code runs in-process.
## Scope
### In
- Audiobook entity, library, playback (silo SPA + ABS clients).
- ABS-compatible REST API + Socket.io realtime channel.
- Podcasts: subscribed RSS feeds with episode refresh, plus filesystem podcasts.
- Per-profile listening progress, active play sessions, basic chapter navigation.
### Out
- Audiobook requests flow (`silo-plugin-audiobook-requests` untouched).
- Smart collections, share links, content restrictions, metadata-provider integration.
- Embedding-powered "similar books" recommender.
- External enrichment through the audiobook metadata plugin/provider — deferred;
scanner extracts local tags and identifiers for v1.
- Group listening / cowatch parity for audiobooks (silo already has cowatch for
video; revisit later if needed).
## Architecture
### Package layout
New top-level Go package: `internal/audiobooks/`.
```text
internal/audiobooks/
abs/ ← ported from plugin's internal/abs/ (~6.8k LOC)
ABS-compatible REST handlers, mounted under /abs/* and the
legacy /api/* paths ABS clients hardcode.
abssocket/ ← ported from plugin's internal/abssocket/ (~250 LOC)
Socket.io protocol on top of gorilla/websocket; optional
Redis pub/sub adapter for multi-replica fan-out.
podcastfeed/ ← ported from plugin's internal/podcastfeed/ (~350 LOC)
RSS feed refresher; registered as a scheduled task.
service.go ← thin orchestrator wiring the above to silo's existing
catalog/playback/session/auth services.
```
### Wiring touchpoints in existing silo code
| Existing file | Change |
|---|---|
| `internal/api/router.go` | Mount silo-native audiobook routes under `/api/v1/audiobooks/*`; mount ABS routes under `/abs/*` plus the small set of legacy `/api/*` paths ABS clients hit; mount the `/abs/socket.io/` endpoint. |
| `internal/scanner/` | Per-library dispatch on `media_libraries.kind`. New parsers for `audiobooks` and `podcasts`. |
| `cmd/silo/main.go` (task wiring) | Register `podcastfeed.Refresher` as a scheduled task. |
| `internal/playback/` | Accept `media_items.type='audiobook'` and `'podcast_episode'` (mostly already generic). |
| `web/src/pages/`, `web/src/player/` | New audiobook/podcast pages and player component. |
### What the plugin contributed that is dropped
The plugin's other packages — `server`, `store`, `enrich`, `recommend`,
`smartcoll`, `event`, `consumer`, `libsync`, `migrate`, `runtime`,
`bookref`, `cdn`, `mediatoken`, `streaming` — are either dropped (out-of-scope
features) or replaced by reuse of silo's existing equivalents (catalog store,
media-token signing, CDN, streaming, event bus, migrations runner).
### Total code budget
- ~7.4k LOC ported from the plugin.
- ~1.5–2k LOC new TypeScript/TSX in silo's SPA.
- ~400 LOC of new Go glue (scanner branches, router wiring, service.go).
- Three new SQL migrations in the current stack (`147_abs_sessions`,
`157_podcast_feeds`, `160_audiobooks_feature_flag`) plus one documented
no-op migration for the already-present media-folder type column.
## Data model
### Reused silo tables (no schema changes)
| Audiobook concept | Silo table | Notes |
|---|---|---|
| Audiobook entity | `media_items` | New value: `type='audiobook'`. Existing title/year/overview/poster/sort_title cover the basics. |
| Podcast (show) | `media_items` | New value: `type='podcast'`. |
| Podcast episode | `episodes` | Parallel to TV episodes; FK to parent `media_items` row. |
| Audio file + chapters | `media_files` | The `chapters jsonb` column from migration 066 stores chapters exactly as silo already does for video. |
| Library scope | `media_libraries` + `media_folders` + `media_item_libraries` | Audiobook libraries are libraries with `kind='audiobooks'` (see below). |
| Author / narrator | `people` + `item_people` | Two new role string constants: `'author'`, `'narrator'`. |
| Listening position | `user_watch_progress` | Per-profile position in file. Naming reads as video-only but works fine for audio. |
| Active play session | `user_playback_sessions` | Generic enough to host audiobook sessions. |
| Generic auth | `auth_sessions` | Used by silo's own clients. ABS clients use the new `abs_sessions` table. |
| Series / author shelves | `library_collections` + `library_collection_items` | Reuse for audiobook series, "by author" collections. |
### New tables (two migrations)
1. **`abs_sessions`** (migration 147) — parallel to existing `jellycompat_sessions`. Tracks ABS client device, token hash, last-seen so ABS apps reconnect without re-auth. Columns: `id`, `user_id`, `token_hash`, `device_id`, `device_name`, `abs_client_version`, `created_at`, `last_seen_at`. Same shape as `jellycompat_sessions` plus the `abs_client_version` text.
2. **`podcast_feeds`** (migration 157) — one row per subscribed podcast. Columns: `media_item_id` (FK to the podcast `media_items` row), `feed_url`, `etag`, `last_refreshed_at`, `refresh_interval_seconds`. Episode rows live in the reused `episodes` table.
### Schema-touch column add
- `media_libraries.kind` — `'movies' | 'tv' | 'audiobooks' | 'podcasts'`. If a
similar column already exists on `media_libraries`, reuse it; otherwise this
is a third small migration. The scanner reads it to pick the per-library
parser.
### Plugin migrations NOT ported
Smart collections, share links, embeddings, content restrictions,
metadata-provider integration, request provider, file cache, listening stats aggregates,
reading goals, notification prefs, standalone-mode tables, recommender
embeddings. All represent features dropped from scope.
### Data migration
None. The plugin's `audiobooks.*` Postgres schema is left intact until cutover
is confirmed, then dropped. Silo's scanner repopulates from the filesystem on
first run. ABS clients re-authenticate once (their tokens lived in the plugin
DB, not silo's). This is the one user-visible cost.
## Routing & ABS-compatibility
All routes mount on silo's main `:8080` HTTP listener.
### 1. Silo-native audiobook API
- Prefix: `/api/v1/audiobooks/*`.
- Examples: `GET /api/v1/audiobooks/library/{id}/items`,
`GET /api/v1/audiobooks/{item_id}`,
`POST /api/v1/audiobooks/{item_id}/progress`.
- Mounted next to other v1 routes. Standard silo auth middleware. JSON shapes
follow silo's existing patterns — not the ABS shape.
### 2. ABS-compatible API
- Prefix: `/abs/*` for the bulk of client-facing endpoints, plus the small set
of `/api/*` paths ABS clients hardcode (e.g. `/api/libraries`, `/api/me`,
`/api/items/{id}`). To avoid colliding with silo's existing `/api/v1/*`,
ABS gets its full namespace at `/abs/`; ABS-legacy `/api/*` paths are
registered explicitly and scoped to ABS auth.
- Auth: a separate middleware `audiobooks.RequireABSSession` validates the ABS
bearer token against `abs_sessions`.
- Login: `POST /abs/login` accepts ABS-shaped credentials, internally calls
silo's existing auth backend (same code path as `POST /api/v1/auth/login`),
then issues an ABS token bound to a row in `abs_sessions`. No password
handling lives in audiobooks code itself.
### 3. ABS Socket.io endpoint
- Path: `/abs/socket.io/`.
- Uses silo's existing `gorilla/websocket` upgrader. The ported `abssocket/`
package handles Socket.io handshake, framing, and the long-polling fallback.
- Multi-replica fan-out: if `REDIS_URL` is set, use a Redis pub/sub adapter
(already implemented in plugin). Unset → in-memory single-replica.
### What goes away
- No standalone listener — main `:8080` only.
- No `SILO_HOST_URL` / `SILO_PLUGIN_TOKEN` env plumbing — those existed because
the plugin called back into silo over HTTP. Direct Go calls replace this.
- No host plugin-proxy in the request path.
### Streaming reuse
ABS stream URLs internally rewrite to silo's existing
`/api/v1/stream/{session_id}` machinery — direct play, range requests,
transcode fallback if needed, media-token signing. **Zero new transcode or
stream code.**
## Scanner integration
### Per-library dispatch (~30 LOC in `internal/scanner`)
The scanner reads `media_libraries.kind` and picks a parser. Existing
`movies`/`tv` paths are unchanged. New branches: `audiobooks`, `podcasts`.
### Audiobook parser (~200 LOC, `internal/scanner/audiobook.go`)
- Folder convention: one audiobook = one folder. Files = chapters in filename
order, or one `.m4b` with embedded chapters.
- Recognized extensions: `.m4b`, `.mp3`, `.m4a`, `.flac`, `.opus`.
- Single `.m4b`: one `media_items` row + one `media_files` row. Embedded
chapters extracted via the existing ffprobe call site → `media_files.chapters`.
- Multi-file folder: one `media_items` row + N `media_files` rows. Chapter
index = filename ordering; each file's `chapters` carries one synthesized
chapter for that file.
- Metadata: ID3 / MP4 tags (title, author, narrator, series, year, cover).
Reuses silo's existing tag-extraction helpers.
### Podcast parser (~150 LOC, `internal/scanner/podcast.go`)
Two paths:
1. **Filesystem podcasts** (downloaded episodes on disk): folder = podcast,
file = episode. Same `series → episodes` shape silo already handles for TV.
2. **RSS-subscribed podcasts**: no filesystem walk. The `podcastfeed.Refresher`
scheduled task fetches RSS, upserts `media_items` (podcast) + `episodes`,
optionally downloads enclosures to a configured cache dir (reusing silo's
existing download/cache infrastructure). `media_files` rows point at the
cached file once downloaded.
### Author / narrator extraction
Tag-derived names → upsert into `people` → link via `item_people` with roles
`'author'` / `'narrator'`. Same upsert pattern silo already uses for actors
and directors. No new code, just two more role string constants.
### External enrichment
Deferred. The scanner extracts local tags and identity hints only. Canonical
enrichment remains the responsibility of the audiobook metadata plugin/provider
path and is not ported in this foundation pass.
### Scheduled task
`podcastfeed.Refresher` runs every 10 minutes (matches plugin behavior).
Registered with silo's existing task manager. No other new background jobs.
### Impact on existing scanner behavior
None for movie/TV libraries. Audiobook/podcast libraries were not being
scanned before; now they are when `kind` is set appropriately.
## Web UI surfaces
### Stack
Silo's existing React 19 + Vite SPA at `web/src/`. React Query, radix-ui,
tailwind. The plugin's SPA uses the same stack so component idioms transfer
cleanly, but we are writing fresh silo pages — the plugin's `web/` is dropped.
### New pages under `web/src/pages/audiobooks/`
| Route | Purpose |
|---|---|
| `/audiobooks` | Home: continue-listening shelf, recent additions, library-wide browse. |
| `/audiobooks/library/:id` | Library view: grid of audiobooks, filter/sort, paginated. |
| `/audiobooks/book/:id` | Detail: cover, author, narrator, series, chapter list, play/continue CTA. |
| `/audiobooks/authors`, `/audiobooks/series` | Index pages built on `library_collections`. |
| `/podcasts` | Subscribed podcasts grid. |
| `/podcasts/show/:id` | Podcast detail + episode list. |
| `/podcasts/episode/:id` | Episode detail + play. |
### Player
`web/src/player/AudiobookPlayer.tsx`:
- HTML5 `<audio>` (no HLS / transcoding needed for typical audio). Falls back
to silo's existing transcode flow only if the file needs it.
- Chapter list panel, sleep timer, playback rate (0.5×–3×), 30-second seek
buttons, skip-silence toggle.
- Position updates via `POST /api/v1/audiobooks/{id}/progress` at 5–10s
intervals + on pause/seek. Mirrors silo's existing video progress cadence.
### Navigation
Add "Audiobooks" and "Podcasts" to the existing sidebar/library switcher,
driven by `media_libraries.kind`. Same dispatch point silo uses today for
Movies vs TV.
### Reused silo components (no copies)
Card grids, library shelves, search box, profile/auth chrome, image cache,
virtualized list (`@tanstack/react-virtual` already in `package.json`).
### Not building (out of scope)
Smart collections UI, share-link manager, request submission UI, embeddings
"similar books", admin enrich button, content-restriction admin.
### Frontend volume
Roughly 8 new page components + 1 player + a handful of audiobook-specific
hooks/types under `web/src/hooks/audiobooks/` and `web/src/lib/audiobooks/`.
~1.5–2k LOC of new TS/TSX total. No new top-level dependencies beyond what
silo's `package.json` already has.
## Plugin retirement & rollout
### Sibling repos
| Repo | Action |
|---|---|
| `silo-plugin-audiobooks` | Archive on GitHub; remove from catalog. |
| `silo-plugin-local-audiobooks` | Archive (silo scanner replaces it). |
| `silo-plugin-bookwarehouse-audio` | Archive (out of scope). |
| `silo-plugin-audiobook-requests` | Untouched — request flow out of scope. |
Stance: **archive, do not delete.** Leaves a recoverable home for any feature
dropped from this port that might come back later.
### Catalog update
One-line PR in `silo-plugins`: remove the three audiobook-related plugin
entries from `manifest.json`. The fourth (audiobook-requests) stays.
### Rollout sequence
1. Land the absorbed code in silo-server behind a feature-flag server setting
(`audiobooks.enabled`, default off).
2. Side-by-side: silo-server has audiobooks compiled in, the plugin is still
installed and running on this host. Verify silo's flow end-to-end
(scanner → SPA → ABS clients).
3. Stop the plugin runtime. Flip `audiobooks.enabled` on. ABS clients
reconnect to silo's `/abs/*` endpoints (config change in the ABS app if
the base URL differs).
4. Remove plugin entries from catalog; archive repos.
5. After confidence in cutover, `DROP SCHEMA audiobooks CASCADE` in Postgres.
### Rollback
Revert the silo flag. Until step 4 ships, the plugin is still installed and
runnable — single-flag rollback.
### Data migration
None. Scanner repopulates from filesystem on first run. ABS clients
re-authenticate once. This is the one user-visible cost.
## Testing
- **Unit tests** for the ported `abs/`, `abssocket/`, `podcastfeed/` packages
carry over from the plugin where they apply; rewrite tests that depended on
the plugin's separate schema.
- **Integration tests** against the real silo Postgres (per project preference
to avoid mocking the DB):
- Scanner: audiobook folder → `media_items` + `media_files` + chapters.
- ABS auth: `POST /abs/login` issues a token rooted in silo's `users`.
- ABS playback: GET a library → GET an item → stream a chapter → progress upsert.
- Podcast refresher: feed URL → upserted episodes.
- **Manual E2E**: an ABS mobile client points at silo, browses, plays, scrubs
through chapters, sleep-timer expires; silo SPA shows the listening
progress and resumes from the right chapter.
## Risks & open questions
- **Socket.io protocol port quality.** The plugin's `abssocket/` is ~250 LOC of
Socket.io framing on top of gorilla/websocket. It works today for the
plugin; once mounted on silo's main listener, verify the long-polling
fallback path still works under silo's middleware stack (auth, rate-limit,
request-id).
- **`media_libraries.kind` column.** If the existing schema already has an
equivalent column under a different name, use that instead. Verify before
writing migration 134/135.
- **`media_files.chapters` shape compatibility.** Silo's chapter JSONB shape
was designed for video chapters. Confirm the audiobook ffprobe output maps
cleanly into the same shape, or extend the JSON tolerantly.
- **Search.** The plugin had its own search index for books/authors. Silo's
existing FTS (catalog/pg-fts) needs to index audiobooks too; verify that
setting `type='audiobook'` is enough or whether the FTS index needs a small
config tweak.
- **Profile-scoped vs user-scoped state.** `user_watch_progress` is keyed —
confirm it's keyed on profile, not just user (CLAUDE.md notes silo
separates login accounts from household profiles).
## Next step
Hand off to the `writing-plans` skill to produce a phased implementation plan
covering migrations, package ports, router wiring, scanner branches, SPA
pages, and cutover. The plan's first phase should resolve the open questions
in the **Risks** section before code lands.
@@ -0,0 +1,262 @@
# ABS Bookmarks — Phase 1 Sub-Project 1
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** First sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Bookmarks bullet.
Commands in this document assume the repository root is the cwd.
## 1. Goal
Let an Audiobookshelf mobile client (official Android, official iOS, and well-behaved third parties such as Plappa) create, edit, and delete in-book bookmarks against a silo audiobook library, and see those bookmarks sync in real time to the user's other connected devices.
## 2. Non-goals
- No ebook bookmarks (audiobook only — matches ABS's own surface).
- No bulk bookmark import / export, no merge-by-title, no fuzzy time matching.
- No `/api/me/bookmarks` aggregate-list endpoint. The Android client only calls per-item endpoints; adding the aggregate would be dead code today.
- No client UI work — silo's web admin doesn't ship a bookmarks view yet, and the mobile clients already render bookmarks from these endpoints.
- No socket-server overhaul; Phase 1 just publishes events through the existing `Handler.publish` wrapper. Phase 2 covers the full socket event surface.
## 3. Architecture
Three ABS-compatible REST handlers + one socket event family + one migration.
- **HTTP layer:** new `internal/audiobooks/abs/bookmarks_handler.go` alongside existing `items_handler.go` / `progress.go`. Handler-method pattern mirrors `handleSetItemProgress`. Routes registered under both `/abs/api` and `/api` prefixes inside the existing bearerAuth group.
- **Storage layer:** new `BookmarkStore` interface in `internal/audiobooks/abs/bookmarks.go` (split out rather than crowd `progress.go`) plus a concrete `ABSBookmarkStore` Postgres implementation in `internal/audiobooks/abs_bookmark_store.go` — naming mirrors `abs_playback_session_store.go`. Wiring lives in `internal/audiobooks/service.go`, alongside the other store constructions in `BuildABSHandler`.
- **Migration:** `migrations/148_abs_bookmarks.up.sql` + `.down.sql`.
- **Socket events:** the existing nil-safe `Handler.publish(userID, event, payload)` wrapper. Each handler fires `user_updated` with a `reason` discriminator on success — three lines per handler. No new event publisher code.
## 4. Endpoint surface
All mounted at both `/abs/api/*` and `/api/*` inside the bearerAuth group.
| Verb | Path | Body | Returns |
|---|---|---|---|
| POST | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list |
| PATCH | `/me/item/{itemId}/bookmark` | `{title, time}` | full item bookmark list (upserts at `time`) |
| DELETE | `/me/item/{itemId}/bookmark/{time}` | — | full item bookmark list (idempotent) |
All three handlers fire `user_updated` on success with the relevant `reason`:
| Handler | `reason` value |
|---|---|
| Create | `bookmark_created` |
| Update | `bookmark_updated` |
| Delete | `bookmark_deleted` |
Socket event payload shape:
```json
{
"reason": "bookmark_created",
"bookmark": { "id": "01K…", "libraryItemId": "126887…", "time": 1234.5, "title": "Cliffhanger", "createdAt": 1779786284823, "updatedAt": 1779786284823 }
}
```
For `bookmark_deleted`, `bookmark.title` is the pre-delete title; for `bookmark_created` / `bookmark_updated` it is the freshly-persisted row.
### 4.1 Wire shape
```json
{
"id": "01KSHR…",
"libraryItemId": "126887440911695876",
"time": 1234.5,
"title": "Good cliffhanger",
"createdAt": 1779786284823,
"updatedAt": 1779786284823
}
```
- All six fields always present (never `omitempty`).
- `time` is fractional seconds (`double`).
- `createdAt` / `updatedAt` are JS-epoch milliseconds (`int64`), matching every other ABS handler's timestamp emission.
- Empty title serializes as `"title": ""`.
### 4.2 Why no GET-list endpoint
The canonical continuum plugin handler, the Android `BookmarksModal.vue`, booklore-ng's per-item route, and the official ABS server all return the full item bookmark list from the mutating endpoints. A separate `GET /me/item/{id}/bookmark` would be dead code.
## 5. Data model
### 5.1 Migration 148_abs_bookmarks
`migrations/148_abs_bookmarks.up.sql`:
```sql
CREATE TABLE abs_bookmarks (
id text PRIMARY KEY,
user_id integer NOT NULL,
profile_id uuid,
library_item_id text NOT NULL,
time_seconds double precision NOT NULL,
title text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX abs_bookmarks_user_profile_item_time_uniq
ON abs_bookmarks (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid),
library_item_id,
time_seconds
);
CREATE INDEX abs_bookmarks_user_item_idx
ON abs_bookmarks (user_id, library_item_id);
```
`migrations/148_abs_bookmarks.down.sql`:
```sql
DROP TABLE abs_bookmarks;
```
### 5.2 Schema rationale
- **`id` text (ULID), not UUID** — matches every other ABS surface ID emitted by silo (`abs_tokens.id`, `abs_playback_sessions.id`). Clients get a consistent shape; ULIDs are also lexicographically sortable by creation time, which simplifies debug output.
- **`profile_id` nullable, with COALESCE sentinel in the unique index** — silo's primary-profile convention is `NULL` profile. Postgres treats raw `NULL` as distinct for uniqueness, so the COALESCE-to-fixed-UUID trick collapses NULL to a single bucket per user, which is what makes "one bookmark per timestamp per profile" work.
- **Unique on `(user, profile, item, time)`** — encodes the "one bookmark per timestamp" invariant directly, which is what makes `Upsert(...,time,title)` the natural primitive: PATCH at an existing time updates the title; PATCH at a new time creates.
- **Secondary index on `(user_id, library_item_id)`** — covers the hot-path `List` query that runs after every mutation. Avoids a sort or full scan when a user has many bookmarks across many items.
- **`title` NOT NULL DEFAULT ''** — Android lets users create quick bookmarks without a title; empty string stores cleanly. Avoids null-guard branches in client iteration code.
### 5.3 Go model
```go
type Bookmark struct {
ID string // ULID
LibraryItemID string
Time float64 // fractional seconds
Title string
CreatedAt time.Time
UpdatedAt time.Time
}
```
Handlers convert to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
## 6. Storage contract
```go
type BookmarkStore interface {
// List returns all bookmarks for (user, profile, item) ordered by time
// ASC. Empty slice (never nil) when none exist.
List(ctx context.Context, userID, profileID, itemID string) ([]Bookmark, error)
// Upsert inserts a bookmark or updates the title at the exact
// (user, profile, item, time) tuple. ID is generated on insert and
// preserved on update. Returns the resulting row.
Upsert(ctx context.Context, userID, profileID, itemID string, time float64, title string) (Bookmark, error)
// Delete removes the bookmark at (user, profile, item, time). Returns
// nil when no row matched — DELETE is idempotent (a UX convenience,
// not a 404 surface).
Delete(ctx context.Context, userID, profileID, itemID string, time float64) error
}
```
**Behavior:**
- `Upsert` uses a single `INSERT ... ON CONFLICT (user_id, COALESCE(profile_id,'…'), library_item_id, time_seconds) DO UPDATE SET title = EXCLUDED.title, updated_at = now() RETURNING *` — one round-trip, no read-then-write race.
- `List` sort happens in SQL (`ORDER BY time_seconds ASC`), not in Go.
- Profile mapping: handlers pass `a.ProfileID` (string, empty = primary) straight through to the store; the store maps empty → NULL on writes and uses the same COALESCE-to-sentinel-UUID trick on reads. Mirrors the pattern already used by `abs_session_store.go` (`*string` round-trip) — pick whichever pgx approach reads cleanest in the impl, just keep the empty-as-primary convention consistent with the rest of `internal/audiobooks/`.
- Time precision: exact float64 equality on PATCH/DELETE-at-time. Plappa and the official client always echo back the exact value they received, so epsilon comparison would be complexity nothing exercises.
- `Delete` on a non-existent row returns `nil`. The caller still re-fetches the list and returns it, so double-tapped deletes produce consistent state.
## 7. Error model
| Condition | Status | Body |
|---|---|---|
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
| `time` missing or NaN | 400 | `time required` |
| `itemId` not in `MediaStore` | 404 | `item not found` |
| Store upsert / mutate fails | 500 | sanitized `bookmark persist failed`; err logged via `slog.Error` |
| Store delete fails (DB error, not missing row) | 500 | sanitized `bookmark delete failed` |
| List fetch fails after a successful mutation | 200 + empty `[]` + `slog.Warn` | mutation already committed; returning empty list beats 500 |
**Cross-cutting:**
- **No leaked-existence side channel.** DELETE for a bookmark belonging to another user/profile returns 200 with the caller's list (which doesn't contain the target) — indistinguishable from DELETE for a non-existent bookmark. No 403, no enumeration vector.
- **Item validation.** Validate `itemId` via `MediaStore.GetAudiobookByID` before touching the store (same pattern as `handleItem`). Catches typos and deleted items early; avoids orphan bookmark rows whose item no longer exists.
- **Body size limit.** Request body wrapped in `io.LimitReader(r.Body, 1<<20)` — same 1 MiB cap as `handleStandaloneLogin`.
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
## 8. Testing
### 8.1 Unit tests — `bookmarks_handler_test.go`
In-memory fake `BookmarkStore` (mirrors `memTokenStore` / `fakePlaybackSessionStore` pattern already used in this package).
| Test | Asserts |
|---|---|
| `Create_NewBookmark_ReturnsListContainingIt` | POST → 200, response is `[{id, time, title, …}]`, list length 1, fields populated |
| `Create_TwoAtDifferentTimes_ListOrderedByTime` | POST × 2 (times 100 and 50), list returned `[{time:50},{time:100}]` |
| `Upsert_SameTime_UpdatesTitleNoDuplicate` | POST then PATCH at same time with new title → list length 1, title updated, id preserved |
| `Delete_ExistingBookmark_RemovedFromList` | POST then DELETE same time → 200, list empty |
| `Delete_NonExistentTime_IdempotentReturnsEmptyList` | DELETE without prior POST → 200, empty list |
| `Delete_OtherUserBookmark_NoOpAndNoExistenceLeak` | seed bookmark for user B, user A DELETEs same item+time → 200, user B's bookmark untouched |
| `ProfileIsolation_BookmarksScopedPerProfile` | (user, profile A) POST → (user, profile B) List returns empty |
| `MissingItem_404` | POST against unknown itemId → 404 |
| `InvalidBody_400` | POST with malformed JSON → 400, no DB write |
| `MissingTime_400` | POST with body `{title:"x"}` (no time) → 400 |
| `SocketEvent_FiredOnCreate` | mock `EventPublisher` captures the publish; assert userID + `user_updated` + `reason:"bookmark_created"` |
| `SocketEvent_FiredOnUpdate` | same for `bookmark_updated` |
| `SocketEvent_FiredOnDelete` | same for `bookmark_deleted` |
### 8.2 Wire-shape test — `bookmarks_envelope_test.go`
One marshal test that takes a `Bookmark` (including the empty-title case) and asserts every required JSON key is present and uses the camelCase spelling: `id`, `libraryItemId`, `time`, `title`, `createdAt`, `updatedAt`. Mirrors the existing `TestSiloItemToMetadata_JSONKeysAlwaysPresent` and `TestLoginEnvelope_HasRequiredKeys` patterns.
### 8.3 Live integration smoke (post-deploy)
```
TOKEN=$(curl -s -X POST -H 'Content-Type: application/json' -H 'x-return-tokens: true' \
-d '{"username":"<u>","password":"<p>"}' http://127.0.0.1:13378/login \
| python3 -c "import sys,json;print(json.load(sys.stdin)['accessToken'])")
ITEM=$(curl -s -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:13378/api/libraries/<lid>/items?limit=1 \
| python3 -c "import sys,json;print(json.load(sys.stdin)['results'][0]['id'])")
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"smoke","time":42.5}' \
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"updated","time":42.5}' \
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark | python3 -m json.tool
curl -s -X DELETE -H "Authorization: Bearer $TOKEN" \
http://127.0.0.1:13378/api/me/item/$ITEM/bookmark/42.5 | python3 -m json.tool
```
Expected: each call returns the updated list; the final DELETE returns `[]`.
### 8.4 Explicitly out-of-scope
- No real-Postgres integration test for the SQL. The `ON CONFLICT` clause and unique-index COALESCE behavior are small enough that the in-memory fake covers the semantics; CI does not currently run a Postgres for the ABS subdomain.
- No load test. Bookmark mutations are user-driven and rare (handful per session).
## 9. Risks & open questions
- **Time-precision collision risk.** If a future client rounds `time` differently between POST and DELETE (e.g. POST sends `42.500001`, DELETE sends `42.5`), the DELETE would silently no-op. Acceptable for the current client set (Android and official iOS echo exactly), but worth a comment on the `Delete` method so future-us doesn't burn time chasing a ghost.
- **No migration rollback test.** The `.down.sql` is one `DROP TABLE` — low risk — but the migration suite doesn't currently exercise rolls. Same posture as recent migrations; not a blocker.
- **Bookmark fan-out on socket events.** `Handler.publish(userID, ...)` targets the user room. If the user has many connected sockets, the publish fans out per-socket. Volume here is trivial (manual user action). No backpressure concern at Phase 1 scale.
## 10. Out-of-scope follow-ups
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
- Aggregate `GET /api/me/bookmarks` (caller's full bookmark inventory). Adds value only when a "Bookmarks" tab lands in a client that needs it.
- Hydrating `user.bookmarks` in the `/login` envelope. Currently emits `[]` placeholder; populating costs a join on every login and only matters to clients that read from there at startup.
- Embedding `bookmarks` on item-detail (`GET /api/items/{id}`). The Android player fetches its bookmarks via the per-item bookmark endpoint on modal-open, not from item-detail.
## 11. References
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
- Wire-shape reference (canonical, working against real ABS Android): the continuum-plugin-audiobooks `bookmarks_handler.go` + the `r.Post/Patch/Delete` mounts in its `handler.go`. Diff against silo before flagging response-shape concerns.
- Client wire usage: `audiobookshelf-app/components/modals/BookmarksModal.vue` (the only place the official client builds bookmark requests).
- Booklore-ng has its own `me/bookmarks` aggregate endpoint that the official client does not use; ignored here.
@@ -0,0 +1,523 @@
# ABS Collections + Playlists — Phase 1 Sub-Project 2
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** Second sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
**Predecessor spec:** `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md` §"Phase 1 — Feature surface completion" → Manual Collections + Playlists bullets.
**Sibling sub-project:** `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md` (sub-project 1; the bookmarks data model, in-memory test fakes, store wiring, and error-handling conventions established there are reused here without re-justification).
Commands in this document assume the repository root is the cwd.
## 1. Goal
Land manual user collections and ordered playlists on the silo audiobook surface so the official Audiobookshelf Android, iOS, and Plappa clients can create, browse, mutate, and share named groupings of audiobooks. "Manual collection" means a user-curated unordered set of audiobooks with a name + description (think: "Favorites", "To Read"). "Playlist" means an ordered queue with a cover image (think: "Wind-down listening"). Both are owned by a profile, optionally public to other users on the same silo instance.
## 2. Non-goals
- **No podcast episode hydration.** Playlist items accept and echo `episodeId`, but only audiobook items are looked up in `MediaStore`. A future sub-project will wire `internal/audiobooks/podcastfeed` hydration.
- **No reorder API.** Clients simulate reorder via remove+add (the re-added item lands at the end).
- **No smart collections.** Those are sub-project 3 (separate `query_def` storage + DSL evaluator).
- **No silo-web UI.** The web admin doesn't ship a collections/playlists view yet; ABS mobile clients render these.
- **No socket-server overhaul.** Playlists publish three existing-pattern events; collections publish none. Full socket parity is Phase 2 of the parent spec.
## 3. Architecture
Two REST surfaces sharing a uniform shape, mounted under `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group.
- **HTTP layer:**
- `internal/audiobooks/abs/collections_handler.go` — 7 routes (list, create, get, update, delete, add item, remove item).
- `internal/audiobooks/abs/playlists_handler.go` — 9 routes (list, create, get, update, delete, add single item, batch add, batch remove, remove single item; remove-episode variant uses two URL params).
- **Storage layer:**
- New interfaces in `internal/audiobooks/abs/collections.go` and `playlists.go` (one file each, parallel to `bookmarks.go`):
- `CollectionStore` — `ListUserCollections / GetCollection / CreateCollection / UpdateCollection / DeleteCollection / ListCollectionItems / AddCollectionItem / RemoveCollectionItem`.
- `PlaylistStore` — `ListUserPlaylists / GetPlaylist / CreatePlaylist / UpdatePlaylist / DeletePlaylist / ListPlaylistItems / AddPlaylistItem / RemovePlaylistItem`.
- Concrete pgx impls in `internal/audiobooks/abs_collection_store.go` and `abs_playlist_store.go` (parallel to `abs_bookmark_store.go`).
- **Migrations** (paired up/down per `CLAUDE.md`):
- `149_abs_user_collections`, `150_abs_collection_items`, `151_abs_playlists`, `152_abs_playlist_items`.
- **Socket events:** Playlists publish `playlist_added` / `playlist_updated` / `playlist_removed` (continuum-canonical event names). Collections publish nothing. All publishes go through the existing nil-safe `Handler.publish(...)` wrapper.
- **Service wiring:** Both new stores wired in `BuildABSHandler` (mirrors the BookmarkStore wiring landed in sub-project 1).
## 4. Endpoint surface
All routes mounted under both `/abs/api/*` and `/api/*` inside `bearerAuth`. Success status code is **200 OK** in every row of the tables below, except where the `Returns` column explicitly says **204 No Content** (DELETE collection / DELETE playlist themselves; the item-mutation DELETEs return 200 with the parent's full-shape body, matching continuum).
### 4.1 Collections (7 routes)
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | `/collections` | — | `{"collections": [Collection list-shape]}` |
| POST | `/collections` | `{name, description, isPublic?}` | Collection full-shape |
| GET | `/collections/{id}` | — | Collection full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
| PATCH | `/collections/{id}` | `{name?, description?, isPublic?}` | Collection full-shape |
| DELETE | `/collections/{id}` | — | 204 No Content |
| POST | `/collections/{id}/book/{bookId}` | — | Collection full-shape (with updated `books[]`) |
| DELETE | `/collections/{id}/book/{bookId}` | — | Collection full-shape |
### 4.2 Playlists (9 routes)
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | `/playlists` | — | `{"playlists": [Playlist list-shape]}` |
| POST | `/playlists` | `{name, description, cover_item?, isPublic?}` | Playlist full-shape |
| GET | `/playlists/{id}` | — | Playlist full-shape when caller is owner, OR when `is_public=true`; otherwise 404 |
| PATCH | `/playlists/{id}` | `{name?, description?, cover_item?, isPublic?}` | Playlist full-shape |
| DELETE | `/playlists/{id}` | — | 204 No Content |
| POST | `/playlists/{id}/item` | `{libraryItemId, episodeId?}` | Playlist full-shape |
| POST | `/playlists/{id}/batch/add` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
| POST | `/playlists/{id}/batch/remove` | `{items: [{libraryItemId, episodeId?}]}` | Playlist full-shape |
| DELETE | `/playlists/{id}/item/{libraryItemId}` | — | Playlist full-shape |
| DELETE | `/playlists/{id}/item/{libraryItemId}/{episodeId}` | — | Playlist full-shape |
### 4.3 Wire shape — Collection
**List shape (no `books[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "Favorites",
"description": "My top picks",
"isPublic": false,
"lastUpdate": 1779786284823,
"createdAt": 1779786284823
}
```
**Full shape (with `books[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "Favorites",
"description": "My top picks",
"isPublic": false,
"lastUpdate": 1779786284823,
"createdAt": 1779786284823,
"books": [
{
"id": "126887...",
"libraryId": "9",
"media": { "metadata": { "title": "Book Title", "authors": [...] } }
}
]
}
```
All seven top-level keys always present (`description` is `""` when unset, never omitted — fixes the continuum-reference bug where description was always emitted as empty regardless of stored value). `books[]` is always an array (possibly empty) on the full shape; omitted on the list shape.
### 4.4 Wire shape — Playlist
**List shape (no `items[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "My Queue",
"description": "",
"isPublic": false,
"coverPath": "126887...",
"createdAt": 1779786284823,
"lastUpdate": 1779786284823
}
```
**Full shape (with `items[]`):**
```json
{
"id": "01HXXX",
"userId": "1",
"name": "My Queue",
"description": "",
"isPublic": false,
"coverPath": "126887...",
"createdAt": 1779786284823,
"lastUpdate": 1779786284823,
"items": [
{ "libraryItemId": "126887...", "position": 0, "title": "Book Title", "libraryId": "9" },
{ "libraryItemId": "podcast-x", "episodeId": "ep-1", "position": 1 }
]
}
```
`coverPath` is emitted when set; omitted when unset. `description` always present. Items always sorted by `position` ASC. Entries with `episodeId` set are emitted with the field; audiobook entries omit it. Audiobook items hydrate `title` + `libraryId` via `MediaStore.GetAudiobookByID`; episode items emit the bare reference.
### 4.5 Hydration semantics
- `MediaStore.GetAudiobookByID(libraryItemId)` is called per book on the **full** shape only (the list shape skips it to keep response sizes small).
- On miss (item deleted between collection/playlist mutation and list-fetch), the entry degrades to `{id, libraryId}` only — clients render a placeholder.
- `libraryId` resolution: same `resolveDefaultLibrary` helper from `items_handler.go` (returns numeric library ID as decimal string).
### 4.6 Socket events
| Event | When | Payload |
|---|---|---|
| `playlist_added` | After POST `/playlists` | `{id, name}` |
| `playlist_updated` | After PATCH or any item mutation (single or batch) | `{id}` |
| `playlist_removed` | After DELETE `/playlists/{id}` | `{id}` |
Collections fire no socket events (matches continuum; Phase 2 of the parent spec covers full event parity).
## 5. Data model
### 5.1 Migration 149 — `abs_user_collections`
`migrations/149_abs_user_collections.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_user_collections (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_user_collections_user_profile_idx
ON public.abs_user_collections (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/149_abs_user_collections.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_user_collections_user_profile_idx;
DROP TABLE IF EXISTS public.abs_user_collections;
```
### 5.2 Migration 150 — `abs_collection_items`
`migrations/150_abs_collection_items.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_collection_items (
collection_id text NOT NULL REFERENCES public.abs_user_collections(id) ON DELETE CASCADE,
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
added_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (collection_id, library_item_id)
);
CREATE INDEX IF NOT EXISTS abs_collection_items_library_item_idx
ON public.abs_collection_items (library_item_id);
```
`migrations/150_abs_collection_items.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_collection_items_library_item_idx;
DROP TABLE IF EXISTS public.abs_collection_items;
```
### 5.3 Migration 151 — `abs_playlists`
`migrations/151_abs_playlists.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_playlists (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
cover_item text REFERENCES public.media_items(content_id) ON DELETE SET NULL,
is_public boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_playlists_user_profile_idx
ON public.abs_playlists (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/151_abs_playlists.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_playlists_user_profile_idx;
DROP TABLE IF EXISTS public.abs_playlists;
```
### 5.4 Migration 152 — `abs_playlist_items`
`migrations/152_abs_playlist_items.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_playlist_items (
playlist_id text NOT NULL REFERENCES public.abs_playlists(id) ON DELETE CASCADE,
library_item_id text NOT NULL,
episode_id text NOT NULL DEFAULT '',
position integer NOT NULL,
added_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (playlist_id, library_item_id, episode_id)
);
CREATE INDEX IF NOT EXISTS abs_playlist_items_playlist_position_idx
ON public.abs_playlist_items (playlist_id, position);
```
`migrations/152_abs_playlist_items.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_playlist_items_playlist_position_idx;
DROP TABLE IF EXISTS public.abs_playlist_items;
```
### 5.5 Schema rationale
- **`is_public boolean`** — cross-user-public read semantics. `false` default. List endpoints never expose other users' rows; GET-by-id allows non-owner reads only when `is_public = true`.
- **Collection items: FK + CASCADE on `library_item_id`** — when a book is deleted from the library, drop it from all collections (clean orphan-free state). Same pattern as migration 143's `abs_playback_sessions.content_id`.
- **Collection items: composite PK on `(collection_id, library_item_id)`** — enforces "a book appears at most once in a collection" without a synthetic ID.
- **Playlist items: `library_item_id` NOT FK'd** — collections enforce one-shot membership; playlists are ordered queues that may legitimately reference items the user hasn't bookmarked. Decoupling lets a future migration FK it when episode support lands properly. Today: handler validates via MediaStore (same pattern as bookmarks).
- **Playlist items: `episode_id text NOT NULL DEFAULT ''`** — empty string for audiobook items, populated for podcast episodes. Empty-string-default lets `(playlist_id, library_item_id, episode_id)` be a clean unique key without COALESCE.
- **Playlist items: no synthetic ID** — `(playlist_id, library_item_id, episode_id)` is naturally unique; `position` is sort hint (gaps allowed when items are removed).
- **`cover_item REFERENCES media_items(content_id) ON DELETE SET NULL`** — playlist survives cover-item deletion; cover gracefully becomes nothing.
### 5.6 Go models
```go
type Collection struct {
ID string
UserID string
ProfileID string
Name string
Description string
IsPublic bool
CreatedAt time.Time
UpdatedAt time.Time
}
type CollectionItem struct {
CollectionID string
LibraryItemID string
AddedAt time.Time
}
type Playlist struct {
ID string
UserID string
ProfileID string
Name string
Description string
CoverItem string // empty when unset
IsPublic bool
CreatedAt time.Time
UpdatedAt time.Time
}
type PlaylistItem struct {
PlaylistID string
LibraryItemID string
EpisodeID string // empty for audiobook items
Position int
AddedAt time.Time
}
```
Handlers convert these to the wire shape (camelCase JSON keys, timestamps as `UnixMilli()`).
## 6. Storage contract
### 6.1 `CollectionStore` (in `internal/audiobooks/abs/collections.go`)
```go
type CollectionStore interface {
ListUserCollections(ctx context.Context, userID, profileID string) ([]Collection, error)
GetCollection(ctx context.Context, id string) (Collection, error)
CreateCollection(ctx context.Context, c Collection) error
UpdateCollection(ctx context.Context, c Collection) error
DeleteCollection(ctx context.Context, id string) error
ListCollectionItems(ctx context.Context, collectionID string) ([]CollectionItem, error)
AddCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
RemoveCollectionItem(ctx context.Context, collectionID, libraryItemID string) error
}
```
### 6.2 `PlaylistStore` (in `internal/audiobooks/abs/playlists.go`)
```go
type PlaylistStore interface {
ListUserPlaylists(ctx context.Context, userID, profileID string) ([]Playlist, error)
GetPlaylist(ctx context.Context, id string) (Playlist, error)
CreatePlaylist(ctx context.Context, p Playlist) error
UpdatePlaylist(ctx context.Context, p Playlist) error
DeletePlaylist(ctx context.Context, id string) error
ListPlaylistItems(ctx context.Context, playlistID string) ([]PlaylistItem, error)
AddPlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
RemovePlaylistItem(ctx context.Context, playlistID, libraryItemID, episodeID string) error
}
```
### 6.3 Behavior
- **List ordering:** Collections list ordered by `created_at DESC`. Playlists list ordered by `created_at DESC`. Playlist items ordered by `position ASC`. Collection items ordered by `added_at ASC`. All sorting happens in SQL, not in Go.
- **Empty results:** All List methods return an empty slice (never nil) when no rows match.
- **`GetCollection` / `GetPlaylist`:** Return `ErrNotFound` when absent. NO owner check — caller authorizes via the response's `UserID` and `IsPublic`.
- **`AddPlaylistItem` position assignment:** Single SQL statement computing `position = COALESCE(MAX(position), 0) + 1 WHERE playlist_id = $1` inside the INSERT. No read-before-write race.
- **`AddCollectionItem` / `AddPlaylistItem` idempotency:** `INSERT ... ON CONFLICT (...) DO NOTHING`. Re-adding an existing tuple is a silent no-op (no error, no row mutation, no `updated_at` bump beyond what the handler does separately).
- **`RemoveCollectionItem` / `RemovePlaylistItem` idempotency:** Return `nil` on no-match.
- **Profile mapping:** Handlers pass `a.ProfileID` straight through (empty = primary). Store uses `profileArg` helper (returns `nil` for empty, the string otherwise). `COALESCE(profile_id, '00000000-...'::uuid)` in WHERE clauses on both column and bind value, mirroring the BookmarkStore convention.
- **`updated_at` bump on item mutations:** Both `Add*Item` and `Remove*Item` execute the item mutation and an `UPDATE abs_user_collections SET updated_at = now() WHERE id = $1` (or `abs_playlists`) in the same transaction. Wire field `lastUpdate` reflects this.
- **Cascade cleanup:** Deleting a collection drops all its `abs_collection_items` via FK CASCADE. Deleting a playlist drops all its `abs_playlist_items`. Deleting a `media_items` row drops it from all `abs_collection_items` (via FK CASCADE); playlists surface stale references on the next List, where the handler degrades to bare-id entries.
### 6.4 Cross-user public read
Handler pattern after `Get*`:
```go
c, err := store.GetCollection(ctx, id)
if errors.Is(err, ErrNotFound) || (c.UserID != a.UserID && !c.IsPublic) {
http.Error(w, "collection not found", http.StatusNotFound)
return
}
```
Same pattern for playlists. The condition collapses unknown-id and not-authorized into one branch so existence-leak vectors don't open up.
## 7. Error model
| Condition | Status | Body |
|---|---|---|
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
| Required field missing (POST: `name`; POST item: `libraryItemId`) | 400 | `<field> required` |
| Unknown collection/playlist on GET (owner or not) | 404 | `collection not found` / `playlist not found` |
| Non-owner GET on private collection/playlist | 404 | same body — no existence leak |
| Non-owner PATCH/DELETE/item-mutation | 404 | same body |
| `library_item_id` not in MediaStore (audiobook add-item) | 404 | `item not found` |
| Store insert/update/delete fails | 500 | sanitized `<collection\|playlist> persist failed`; err logged via `slog.Error` |
| List fetch fails after a successful mutation | 200 + best-effort response (in-memory state) + `slog.Warn` | mutation already committed; falling back beats 500 |
### 7.1 Cross-cutting
- **Anti-enumeration.** All non-owner-or-private paths funnel to the same `<type> not found` 404 (never 403, never "private collection"). Indistinguishable from real not-found.
- **Item validation on add.** `handleAddCollectionBook` and `handleAddPlaylistItem` call `MediaStore.GetAudiobookByID(libraryItemID)` before touching the store. Catches typos and orphan refs early. **Exception:** `handleAddPlaylistItem` with non-empty `episodeId` SKIPS item validation (audiobook-only hydration scope; podcast episodes are stored opaque-id-style).
- **Body size limit.** All POST/PATCH wrap `r.Body` in `io.LimitReader(r.Body, 1<<20)` before `json.NewDecoder`. Same as bookmarks.
- **Batch endpoints.** `POST /playlists/{id}/batch/add` and `/batch/remove` tolerate per-item failures silently (matching continuum's `_ = h.store.Add...`). Total failure (e.g., body decode error) is still a 400. Item validation on the batch path: each item validated individually; failed validations skipped with `slog.Debug` (no 404 — the operation as a whole succeeds with the remaining items).
- **Socket event on batch mutation.** A batch add/remove fires exactly one `playlist_updated` event, regardless of how many items succeeded or failed. Clients re-render from the response.
- **Socket publish never fails the request.** `h.publish(...)` is nil-safe and fire-and-forget; if the Publisher is unwired the response still completes.
## 8. Testing
### 8.1 Unit tests — `collections_handler_test.go`
In-memory fake `memCollectionStore` (parallel to `memBookmarkStore`). Reuses `stubMediaStore` and `recordingPublisher` from `bookmarks_handler_test.go`. The `dispatchBookmark` helper is generalized in-place (or a sibling `dispatchABS` is added) so the same wiring (URL params + `ctxAuth` injection) drives both new surfaces.
| Test | Asserts |
|---|---|
| `Collection_Create_ReturnsFullShape` | POST `{name:"x"}` → 200, response carries all 7 top-level keys + empty `books[]`, ID is a ULID |
| `Collection_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
| `Collection_List_ReturnsWrappedEnvelope` | GET → 200, body is `{"collections":[…]}`, list-shape omits `books` |
| `Collection_List_DoesNotLeakOtherUsers` | User 1 creates; User 2 GETs → empty list |
| `Collection_Get_Owner_ReturnsFullShape` | POST → owner GET → 200 + `books[]` |
| `Collection_Get_NonOwner_Public_OK` | User 1 creates with `isPublic:true`; User 2 GET → 200 |
| `Collection_Get_NonOwner_Private_404` | User 1 creates (default private); User 2 GET → 404 |
| `Collection_Patch_OwnerUpdatesNameAndDescription` | POST → PATCH `{name:"y", description:"d"}` → 200, fields updated, `lastUpdate` advanced |
| `Collection_Patch_NonOwner_404` | User 2 PATCH on User 1's collection → 404 (no existence leak) |
| `Collection_Delete_OwnerRemovesItAndItems` | POST + add book → DELETE → 204; subsequent GET → 404; items table empty |
| `Collection_Delete_NonOwner_404` | User 2 DELETE → 404, User 1's collection still present |
| `Collection_AddBook_Owner_HydratesInResponse` | POST + add book → 200 + `books[]` contains the entry with `media.metadata.title` |
| `Collection_AddBook_Idempotent` | Add same book twice → 200 both times, `books[]` length stays at 1 |
| `Collection_AddBook_UnknownItem_404` | Add a non-existent item → 404 `item not found` |
| `Collection_RemoveBook_Idempotent` | Remove book not in collection → 200, `books[]` unchanged |
| `Collection_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
| `Collection_Envelope_HasRequiredKeys` | Marshal-test: 7 top-level keys present even when description and books are empty |
### 8.2 Unit tests — `playlists_handler_test.go`
In-memory fake `memPlaylistStore`.
| Test | Asserts |
|---|---|
| `Playlist_Create_ReturnsFullShape` | POST `{name:"x", description:"d", isPublic:true}` → 200, all 8 keys present, ID is ULID |
| `Playlist_Create_NameRequired_400` | POST `{description:"only"}` → 400 |
| `Playlist_Create_FiresPlaylistAddedEvent` | POST → `recordingPublisher` captures `playlist_added` with `{id, name}` |
| `Playlist_List_WrappedEnvelope` | GET → 200, body `{"playlists":[…]}`, list-shape omits `items` |
| `Playlist_AddItem_AppendsAtNextPosition` | POST + add 3 items → response `items[]` positions are 1,2,3 in insertion order |
| `Playlist_AddItem_Idempotent` | Add same `(libraryItemId, episodeId)` twice → list length 1 |
| `Playlist_AddItem_AudiobookHydrates` | Add audiobook item → response item has `title` |
| `Playlist_AddItem_Episode_AcceptsAndEchoes` | Add `{libraryItemId, episodeId:"ep-1"}` → response item has `episodeId`, no `title` (un-hydrated by design) |
| `Playlist_AddItem_UnknownAudiobook_404` | Add non-existent audiobook → 404; episode adds skip validation, so episode-only adds succeed |
| `Playlist_BatchAdd_TolerantOfPartialFailures` | Batch of `[valid, invalid, valid]` → 200, response has 2 items, no 404 |
| `Playlist_BatchAdd_FiresOneUpdatedEvent` | Batch add → single `playlist_updated` event (not one per item) |
| `Playlist_BatchRemove` | Seed 3 items, batch-remove 2 → response items[] length 1 |
| `Playlist_RemoveItem_Single` | DELETE /item/{libraryItemId} → 200, item gone |
| `Playlist_RemoveItem_WithEpisode` | DELETE /item/{libraryItemId}/{episodeId} → 200, episode-keyed item removed; audiobook-keyed item with same libraryItemId still present |
| `Playlist_Patch_UpdatesCover` | PATCH `{cover_item:"id"}` → 200, `coverPath` updated; `playlist_updated` event fires |
| `Playlist_Delete_FiresPlaylistRemovedEvent` | DELETE → 204 + `playlist_removed` event |
| `Playlist_Get_NonOwner_Public_OK` | User 1 creates `isPublic:true`; User 2 GET → 200 |
| `Playlist_Get_NonOwner_Private_404` | Private playlist; non-owner GET → 404 |
| `Playlist_NonOwner_Mutation_404` | User 2 add-item / remove-item / PATCH / DELETE → 404 each, User 1's playlist intact |
| `Playlist_ProfileIsolation` | Profile A creates; Profile B's list returns empty |
| `Playlist_Envelope_HasRequiredKeys` | Marshal-test: 8 top-level keys present; `coverPath` omitted when empty |
### 8.3 Shared envelope tests
One marshal test per surface (`collections_envelope_test.go`, `playlists_envelope_test.go`) asserting the wire shape end-to-end. Parallel to `bookmarks_envelope_test.go`.
### 8.4 Live integration smoke (post-deploy, operator)
```bash
TOKEN=$(curl ... /login | jq -r .accessToken)
ITEM=$(curl ... /api/libraries/9/items?limit=1 | jq -r .results[0].id)
# Collections
COLL=$(curl -X POST -d '{"name":"smoke"}' ... /api/collections | jq -r .id)
curl -X POST ... /api/collections/$COLL/book/$ITEM
curl ... /api/collections/$COLL # books[] contains $ITEM
curl -X DELETE ... /api/collections/$COLL/book/$ITEM
curl -X DELETE ... /api/collections/$COLL # 204
# Playlists
PL=$(curl -X POST -d '{"name":"queue"}' ... /api/playlists | jq -r .id)
curl -X POST -d "{\"libraryItemId\":\"$ITEM\"}" ... /api/playlists/$PL/item
curl ... /api/playlists/$PL # items[] contains $ITEM at position 1
curl -X DELETE ... /api/playlists/$PL/item/$ITEM
curl -X DELETE ... /api/playlists/$PL # 204
```
Expected: each call returns the wire shape from §4; `playlist_added` / `_updated` / `_removed` events appear on the user's Socket.io channel.
### 8.5 Explicitly out of scope
- **No real-Postgres SQL test.** Same posture as bookmarks (§8.4 of that spec): in-memory fakes cover the semantics; CI does not run Postgres for the ABS subdomain.
- **No episode hydration.** Episode IDs accepted and echoed but not resolved to podcast metadata. A future podcast-playlist sub-project will plug in `podcastfeed` hydration.
- **No reorder API.** Future follow-up.
- **No two-user smoke.** Cross-user-public visibility is unit-tested but the operator smoke uses one user.
## 9. Risks & open questions
- **Position gaps after remove.** Removing the middle item from a playlist leaves a `position` gap (1, 3, 4 instead of 1, 2, 3). Clients sort by position; gaps are harmless. If a future reorder API lands, it'll compact positions in a single statement.
- **Cross-user public + profile scoping interaction.** A public collection owned by `(user A, profile primary)` is visible to user B regardless of B's active profile. The collection's `userId` field discloses A's user identity. Acceptable for v1; if A wanted to hide who owns the collection, that's a separate feature.
- **No migration rollback test.** Down migrations are `DROP INDEX + DROP TABLE` — low risk, same posture as bookmarks. Not a blocker.
- **`cover_item` references a `media_items.content_id` but the playlist owner may not have access to that library** — silo's library visibility isn't enforced at the schema level. The cover image hydrator (when added) will need to check visibility. For now, the field round-trips opaquely.
- **Concurrent `AddPlaylistItem` position collision.** Two parallel appends compute `MAX(position)+1` concurrently and may both produce the same value. The UNIQUE constraint on `(playlist_id, library_item_id, episode_id)` prevents collisions for the SAME item, but distinct items could land at the same position. Acceptable — clients tolerate equal positions and break ties by insertion order. A SERIALIZABLE transaction would close the race; not warranted for a low-traffic UX surface.
## 10. Out-of-scope follow-ups
These are intentionally deferred — they belong to later Phase 1 sub-projects or Phase 2:
- **Smart collections** — sub-project 3 (`abs_smart_collections` + `query_def` JSONB + DSL evaluator).
- **RSS feeds** — sub-project 4 (`abs_rss_feeds`).
- **Listening stats** — sub-project 4 (aggregations on `abs_playback_sessions`).
- **Author / series detail endpoints** — small sub-project, separate.
- **Continue-listening toggles** — small sub-project, separate.
- **Reorder API for playlists** — compaction + drag-reorder UX. Probably warrants its own design pass.
- **Cover-image hydration** — currently the wire emits `coverPath: <content_id>` opaquely. A future enhancement resolves it through `DetailService.PresignURL` to a fully-qualified cover URL.
- **Episode hydration in playlists** — when the podcast-playlist sub-project lands.
- **Collection socket events** — Phase 2 of the parent spec.
## 11. References
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
- Sibling sub-project spec: `docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md`.
- Wire-shape reference: `continuum-plugin-audiobooks/internal/abs/collections_handler.go` and `playlists_handler.go` (canonical, diff against silo's adaptation before flagging response-shape concerns).
- Client wire usage: `audiobookshelf-app/components/...` Collections and Playlists modal/page components.
@@ -0,0 +1,384 @@
# ABS Implementation Fix — Design
**Status:** Approved (brainstorming complete, awaiting writing-plans handoff)
**Date:** 2026-05-26
**Scope:** Bring silo-server's Audiobookshelf-compatible API to full parity with the canonical `continuum-plugin-audiobooks` implementation so that official ABS iOS, Android, and 3rd-party (Plappa, AudioBookShelfFully) clients work end-to-end against silo.
**Out of scope:** silo's native audiobook surface (`/api/v1/audiobooks/*`), silo-android/silo-apple/silo-plugin-sdk repos, Continuum's plugin-host RPC layer, the "standalone listener" port (silo has no separate process), cover transcoding pipelines, transcribed-audio search.
Commands assume the repository root is the cwd.
---
## 1. Problem
silo's ABS-compat layer at `internal/audiobooks/abs/` exposes ~20% of the surface that the canonical Continuum plugin implements (`continuum-plugin-audiobooks` reference). Real ABS mobile clients cannot complete the basic flow today — login is reported broken, and even when it succeeds many subsequent calls (filterdata, resume position, author/series IDs) return data shapes that break the client. Bookmarks, collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats are entirely absent. Socket.io publishes only 3 of the ~30 events real clients subscribe to.
The user's directive: every feature of ABS clients must function. Source of truth for behavior is the Continuum plugin; the booklore-ng reference docs (`BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`, `src/lib/socket/events.ts`) document specific response-shape bugs and the canonical socket event list.
## 2. Goals & Non-goals
### Goals
- Official ABS iOS app, ABS Android app, and 3rd-party clients (Plappa, AudioBookShelfFully) work end-to-end: add server → login → browse libraries → play → progress sync → bookmark → collection → playlist.
- Token lifecycle is complete: login, refresh, logout, revocation, multi-device tracking.
- Socket.io publishes the full event surface real clients subscribe to.
- New data lives in ABS-scoped tables that don't entangle silo's existing collection system.
### Non-goals
- silo's native audiobook UI changes.
- silo-android / silo-apple client changes.
- Importing real audiobook content via ABS-protocol requests (silo has its own `internal/requests/` system that is not being bridged here).
- Watch-together, podcast download queues, server-side backup event streams.
## 3. Architecture & Strategy
**Source of truth:** `continuum-plugin-audiobooks` (canonical). Port file-by-file, adapt to silo's data model (catalog repos, `auth.Service`, profile model).
**Topology:** silo is monolithic; the ABS layer reads/writes the local DB directly. The Continuum plugin's `HostClient → backend plugin RPC` layer has no analog and is dropped.
**Package layout:** keep silo's existing `internal/audiobooks/{abs,abssocket,podcastfeed}` — it already mirrors the Continuum plugin's structure.
**Listener:** continue using the dedicated `:13378` ABS-compat HTTP server (configured in `internal/config/db_loader.go` as `audiobookshelf_compat.listen`). Routes mounted on a fresh chi router; no SPA fallback collisions.
**Data isolation:** new ABS surface uses dedicated tables (`abs_bookmarks`, `abs_user_collections`, `abs_collection_items`, `abs_playlists`, `abs_playlist_items`, `abs_smart_collections`, `abs_rss_feeds`). silo's existing `library_collections` / `user_personal_collections` / `user_smart_collections` / collection groups are untouched. Future bridging is possible but out of scope here.
## 4. Phasing
Four phases, each independently mergeable and independently verifiable against a real mobile client.
### Phase 0 — Login + critical bug fixes
**Goal:** iOS/Android app can `add server → login → browse library → tap book → play` end-to-end. No bookmarks/collections/etc. yet.
**Concrete changes:**
| File:line | Change |
|---|---|
| `internal/audiobooks/abs/login.go:195-234` | Add to login envelope: `user.itemTagsAccessible: []`, `user.itemTagsSelected: []`, `user.lastSeen: <epoch ms>`, `user.createdAt: <epoch ms>`. Enrich `serverSettings` with `coverAspectRatio`, `dateFormat`, `timeFormat`, `storeCoverWithItem`, `scannerDisableWatcher`, `metadataFileFormat`, `chromecastEnabled`. Mirror Continuum's `completeLogin` (`continuum-plugin-audiobooks/internal/abs/handler.go:591-697`) verbatim. |
| `internal/audiobooks/abs/login.go:274-315` (`handleABSAuthorize`) | Return the identical envelope as `/login`, including `accessToken` and `refreshToken`. Currently omits them, which breaks resume-on-launch. |
| `internal/audiobooks/abs/handler.go:353-391` (bearerAuth) | Audit JTI lookup against `abs_session_store.go:GetTokenByJTI` to confirm the freshly-minted JTI is found. Add `slog.Debug` lines at each rejection branch so the next 401 is traceable. Confirm `?token=` query-param fallback parses correctly (iOS AVPlayer requirement). |
| `internal/audiobooks/abs/libraries_handler.go:499-549` (`siloItemToMetadata`) | Include `id` on every `authors[]` entry (use `item_people.id` UUID). Include `id` on every `series[]` entry (slugified name until a series table lands). Populate `genres` and `tags` from real catalog data instead of empty arrays. Cross-reference `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md` lines 9-100. |
| `internal/audiobooks/abs/play_response.go:119` | Replace hardcoded `currentTime: 0` with `ProgressStore.GetItemProgress(userID, profileID, libraryItemID)` lookup; emit the persisted `currentTime` and `progress` fields. |
| `internal/audiobooks/abs/libraries_handler.go:56-66` | When `?include=filterdata`, hydrate `authors`, `series`, `narrators`, `genres`, `languages`, `tags` aggregations so iOS filter UI populates. |
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /auth/refresh`. Validate refresh token type, check JTI not revoked, mint new access+refresh pair, persist new JTIs. Port from `continuum-plugin-audiobooks/internal/abs/handler.go:handleRefresh`. |
| `internal/audiobooks/abs/login.go` (new handler) | Add `POST /logout`. Marks caller's JTIs revoked in `abs_sessions`. Port from Continuum's `handleLogout`. |
| `internal/audiobooks/abs/handler.go:225` (`mountRoutes`) | Mount the new `/auth/refresh` and `/logout` routes (the latter inside `bearerAuth`). |
**Deliverable:** silo build where official ABS iOS app smoke test passes the core flow.
**Size:** ~600-800 lines across ~6 files + 2 new endpoints.
### Phase 1 — Feature surface completion
**Goal:** Bookmarks, manual collections, playlists, smart collections, RSS feeds, author/series detail, and listening stats functional in mobile clients.
**Endpoints to add** (all under `bearerAuth` unless noted; mounted at both `/abs/api/*` and `/api/*`):
**Bookmarks** (port `continuum-plugin-audiobooks/internal/abs/bookmarks_handler.go`)
- `POST /api/me/item/{itemId}/bookmark` — body `{title, time}` → array of all bookmarks for the item. Fires socket `user_updated` (`reason: "bookmark_created"`).
- `PATCH /api/me/item/{itemId}/bookmark` — upsert at time position. Fires `user_updated` (`reason: "bookmark_updated"`).
- `DELETE /api/me/item/{itemId}/bookmark/{time}` — fires `user_updated` (`reason: "bookmark_deleted"`).
**Manual Collections** (port `collections_handler.go`)
- `GET /api/collections` — list with embedded `libraryItems[]`.
- `POST /api/collections` — `{name, description}` → single collection.
- `GET/PATCH/DELETE /api/collections/{id}`.
- `POST /api/collections/{id}/book/{bookId}` — add item.
- `DELETE /api/collections/{id}/book/{bookId}` — remove item.
**Playlists** (port `playlists_handler.go`)
- `GET /api/playlists` → `{playlists: [...]}`.
- `POST /api/playlists` — `{name, description, cover_item, is_public}`.
- `GET/PATCH/DELETE /api/playlists/{id}`.
- `POST /api/playlists/{id}/item` — `{libraryItemId, episodeId?}`.
- `POST /api/playlists/{id}/batch/add` — `{items: [...]}`.
- `POST /api/playlists/{id}/batch/remove`.
- `DELETE /api/playlists/{id}/item/{libraryItemId}[/{episodeId}]`.
**Smart Collections** (port `smart_collection_handler.go` + the entire `internal/smartcoll/` DSL package; adapt SQL to silo's `media_items` schema)
- `GET /api/me/smart-collections` → `{items: [...]}`.
- `POST /api/me/smart-collections` — `{name, description, color, is_public, is_pinned, query_def}`.
- `GET /api/me/smart-collections/{id}`.
- `GET /api/me/smart-collections/{id}/items` — evaluates `query_def` against the catalog.
- `PATCH/DELETE /api/me/smart-collections/{id}`.
**Author detail**
- `GET /api/authors/{id}` — single author with `books[]`.
- `GET /api/authors/{id}/image` — wire to `item_people.poster_path` via `DetailService.PresignURL` (currently 404s).
**Series detail**
- `GET /api/series/{id}` — single series with `books[]` sorted by `series_sequence`.
**RSS Feeds** (port `rss_feed_handler.go` + `internal/podcastfeed/`)
- `POST /api/feeds` — `{itemId, slug, minified}` → `{success, feed: {id, slug, url, ...}}`.
- `GET /api/feeds` — caller's feeds.
- `DELETE /api/feeds/{id}`.
- `GET /abs/public/feed/{slug}` — public RSS XML (no auth, slug is capability token).
- `GET /abs/public/feed/{slug}/cover`.
- `GET /abs/public/feed/{slug}/item/{itemId}/{fileIdx}`.
**Listening stats** (port `internal/abs/handler.go:handleListeningStats` + `handleListeningSessions`)
- `GET /api/me/listening-stats` → `{totalTime, items, days, dayOfWeek, monthly}` from `abs_playback_sessions` aggregation.
- `GET /api/me/listening-sessions?limit=...` — paginated session history.
- `GET /api/me/listening-sessions/{sid}` — single session detail.
**Continue-listening toggles** (port `continue_listening.go`)
- `GET /api/me/progress/{itemId}/remove-from-continue-listening`.
- `GET /api/me/progress/{itemId}/readd-to-continue-listening`.
- Adds `hide_from_continue boolean` column to `user_watch_progress` (the table the ABS layer writes via `abs_progress_store.go`).
**Migrations** (numbered after silo's current max, paired up/down per `CLAUDE.md`):
- `148_abs_bookmarks` — `(id ULID PK, user_id, profile_id, library_item_id, time_seconds, title, created_at, updated_at)` + unique `(user_id, profile_id, library_item_id, time_seconds)`.
- `149_abs_user_collections` + `150_abs_collection_items`.
- `151_abs_playlists` + `152_abs_playlist_items`.
- `153_abs_smart_collections` — includes `query_def jsonb`.
- `154_abs_rss_feeds` — slug is unique capability token.
- `155_abs_progress_hide_from_continue` — adds `hide_from_continue boolean default false`.
**Deliverable:** ABS iOS/Android Library, Collections, Playlists, Bookmarks, RSS, Stats tabs all functional.
**Size:** ~3000-4000 lines across ~15 new files + 7 migration pairs. Splittable into 4 sub-commits (bookmarks, collections+playlists, smart collections, RSS+stats).
### Phase 2 — Socket.io full event parity
**Goal:** A logged-in mobile client sees real-time updates when another device on the same account changes progress, when a server scan adds items, when collections/playlists are mutated remotely, and when an admin posts notifications.
**Event surface** (union of Continuum + booklore-ng — what real ABS iOS source subscribes to):
**Server → Client** (~30 events):
- Lifecycle: `init`, `auth_failed`, `user_online`, `user_offline`, `listener_count`.
- Progress/session: `user_item_progress_updated`, `user_session_open`, `user_session_updated`, `user_session_closed`, `user_stream_update`, `user_stream_end`.
- User-scoped: `user_updated` (bookmark reasons).
- Library: `library_added`, `library_updated`, `library_removed`.
- Items: `item_added`, `item_updated`, `item_removed`, `items_added`.
- People/series: `author_added`, `author_updated`, `author_removed`, `series_added`, `series_updated`, `series_removed`.
- Collections/playlists: `collection_added/updated/removed`, `playlist_added/updated/removed`.
- RSS: `rss_feed_open`, `rss_feed_closed`.
- Tasks: `scan_start`, `scan_progress`, `scan_complete`, `task_started`, `task_progress`, `task_finished`.
- Misc: `notification`.
**Client → Server** (10 events):
- `auth` (exists), `ping`, `join_library`, `leave_library`, `playback_start`, `playback_sync`, `playback_end`, `stream_open`, `stream_close`, `sync_progress`.
**Architecture changes to `internal/audiobooks/abssocket/`:**
- Add room types: existing `user:<userID>`, new `lib:<libraryID>` (joined via `join_library`), new `admin:*`.
- New file `abssocket/publisher.go` exposing `Publisher` interface (`PublishUser`, `PublishLibrary`, `Broadcast`). Replaces ad-hoc `h.publish/h.broadcast` helpers on the Handler struct (currently silent no-ops when `SocketIO == nil`). Tests pass a recording Publisher.
**Cross-package event-source hooks** (via a new `audiobooks.EventBroadcaster` interface in `api.Dependencies` so the consumer packages don't import `abssocket` directly):
- `internal/scanner/` — emit `scan_*`, `item_*`, `items_added` on audiobook-library scan operations.
- `internal/taskmanager/` — emit `task_*` for audiobook-scoped tasks.
- `internal/api/handlers/libraries.go` (or wherever library CRUD lives) — emit `library_*`.
**Auth/lifecycle improvements:**
- Richer `init` payload: `{userId, connectedAt, libraries[], itemTagsAccessible[]}`.
- Heartbeat: respond to `ping` with `pong`.
- On disconnect, decrement listener count and broadcast `user_offline` if this was the user's last device.
- Reconnect replay (last 30s of user-scoped events) is out of scope for v1; leave a hook.
**Migrations:** none — sockets are stateless except for in-memory connection registry.
**Size:** ~1500-2000 lines: ~600 in `abssocket/`, ~400 publisher wiring across audiobook handlers, ~500 cross-package hooks.
### Phase 3 — Hardening
**Media token signing layer** (port `continuum-plugin-audiobooks/internal/mediatoken/`):
- Separate HS256 secret (`audiobooks.abs.media_signing_secret`), 15-min TTL, claims bound to `(user_id, profile_id, book_id, file_idx)`.
- `file_handler.go` mints when building stream URLs; validates on file request.
- Defense-in-depth: a leaked stream URL grants access to ONE file for 15 minutes instead of full account.
- Secret stored in `server_settings`, auto-generated on first read.
**Device tracking** (currently `abs_sessions.device_id = JTI`, useless for UI):
- Parse `User-Agent` on `/login` → derive `device_name`, `client_name`, `client_version`, store on `abs_sessions` insert.
- `GET /api/me/sessions` — list user's sessions (so they see "iPhone 15 Pro — Sep 1").
- `DELETE /api/me/sessions/{jti}` — selective revocation.
**Audit log:**
- New table `abs_audit_log`: `(id, user_id, action, ip, user_agent, metadata jsonb, created_at)`.
- Logged events: login success/failure, logout, token refresh, session revoke, smart-collection-rule changes.
- Optional `GET /api/admin/audit-log` for ops visibility.
**Rate limiting beyond login:**
- Per-user limit on `POST /me/progress` (1/sec — clients sometimes spam this).
- Per-user limit on `POST /auth/refresh` (5/hour — prevent JTI accumulation).
**Size:** ~800-1200 lines + 2 migration pairs.
## 5. Data Model
### New tables (all in Phase 1 unless noted)
```
abs_bookmarks (
id text PRIMARY KEY, -- ULID
user_id integer NOT NULL,
profile_id uuid, -- nullable: primary profile
library_item_id text NOT NULL,
time_seconds double precision NOT NULL,
title text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
UNIQUE (user_id, profile_id, library_item_id, time_seconds)
)
abs_user_collections (
id text PRIMARY KEY, -- ULID
user_id integer NOT NULL,
profile_id uuid,
name text NOT NULL,
description text,
is_public boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
)
abs_collection_items (
collection_id text NOT NULL REFERENCES abs_user_collections(id) ON DELETE CASCADE,
library_item_id text NOT NULL,
added_at timestamptz DEFAULT now(),
PRIMARY KEY (collection_id, library_item_id)
)
abs_playlists (
id text PRIMARY KEY,
user_id integer NOT NULL,
profile_id uuid,
name text NOT NULL,
description text,
cover_item text, -- library_item_id for cover
is_public boolean DEFAULT false,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
)
abs_playlist_items (
playlist_id text NOT NULL REFERENCES abs_playlists(id) ON DELETE CASCADE,
position integer NOT NULL,
library_item_id text NOT NULL,
episode_id text, -- nullable: book item vs podcast episode
added_at timestamptz DEFAULT now(),
PRIMARY KEY (playlist_id, position)
)
abs_smart_collections (
id text PRIMARY KEY,
user_id integer NOT NULL,
profile_id uuid,
name text NOT NULL,
description text,
color text,
is_public boolean DEFAULT false,
is_pinned boolean DEFAULT false,
query_def jsonb NOT NULL,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
)
abs_rss_feeds (
id text PRIMARY KEY,
user_id integer NOT NULL,
slug text UNIQUE NOT NULL, -- capability token in URL
item_id text NOT NULL,
minified boolean DEFAULT false,
created_at timestamptz DEFAULT now()
)
-- Phase 3
abs_audit_log (
id bigserial PRIMARY KEY,
user_id integer,
action text NOT NULL,
ip inet,
user_agent text,
metadata jsonb,
created_at timestamptz DEFAULT now()
)
```
### Modifications to existing tables
```
-- Phase 1: hide-from-continue toggle
ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean DEFAULT false;
-- Phase 3: real device tracking
ALTER TABLE abs_sessions
ALTER COLUMN device_id DROP DEFAULT,
ADD COLUMN parsed_user_agent text;
```
## 6. Testing Strategy
**Unit tests** (Go, in-package):
- Table-driven per handler in `internal/audiobooks/abs/`: happy path, missing field, wrong field type, IDOR (other user's session/bookmark/collection).
- `abssocket/` `Publisher` recording mock — verify events fire on the right operations with the right payload shape.
- Smart collection DSL evaluator — port Continuum's `smartcoll_test.go`.
**Integration tests** (against real Postgres + Redis via silo's `internal/audiobooks/testutil` harness):
- Full login → browse → play → progress → close-session flow.
- Token refresh — old token rejected, new token accepted.
- Bookmark CRUD round-trip with socket event capture.
- Smart collection rule evaluation against seeded catalog.
**End-to-end (manual)**:
- iOS official ABS app: add server → login → browse → play offline downloads → progress sync across devices.
- Plappa: same flow. Strictest client for response shape; if Plappa works, everything works.
- Android official: same.
**Migration safety:**
- Each migration includes `.down.sql`.
- New helper `scripts/test-abs-migrations.sh` runs up → down → up against a fresh DB.
## 7. Rollout
Single-shot per phase, no human review gate. **Each phase gets its own implementation plan** (separate `writing-plans` cycle) since a combined plan would be unwieldy at this size. Start with Phase 0; subsequent phases planned only after the prior phase merges and verifies.
| Phase | PR | Verify |
|---|---|---|
| Phase 0 | One PR | `make build` → restart silo → curl `/ping` → iOS app smoke test |
| Phase 1 | One PR (or 4 sub-PRs if reviewable size matters) | iOS app: Bookmarks, Collections, Playlists, Stats tabs functional |
| Phase 2 | One PR | Two devices on same account: progress sync visible in real time |
| Phase 3 | One PR | `/api/me/sessions` shows real device names; leaked stream URL expires |
**Cross-repo coordination:** silo-android and silo-apple are NOT touched. The ABS-compat surface targets OFFICIAL ABS clients; silo's own clients use silo's native API.
**Rollback:** every migration has a working `.down.sql`. New tables don't break existing functionality; silo's native audiobook API (`/api/v1/audiobooks/*`) is untouched.
## 8. Open Follow-ups (Explicitly Out of Scope)
- ABS server-side import requests (`/api/me/request`) — Continuum had this; silo has its own `internal/requests/` system. Bridging is a future decision.
- Podcast episode-download queue events (`episode_download_*`) — only relevant if silo gains podcast download capability beyond RSS refresh.
- Backup events — silo's backup story is separate.
- Watch-Together over the ABS socket — silo has `internal/watchtogether/`, deferred.
- Reconnect replay buffer for socket events — Phase 2 leaves a hook but does not implement.
## 9. References
- Source-of-truth implementation: `continuum-plugin-audiobooks` (sibling worktree). Key files: `internal/abs/handler.go`, `bookmarks_handler.go`, `collections_handler.go`, `playlists_handler.go`, `smart_collection_handler.go`, `rss_feed_handler.go`, `continue_listening.go`, `abssocket/server.go`, `mediatoken/`.
- Bug catalog for response shape: `booklore-ng/BOOKLORE_ABS_IMPLEMENTATION_ISSUES.md`.
- Canonical socket event list: `booklore-ng/src/lib/socket/events.ts`.
- ABS API documentation: `booklore-ng/AUDIOBOOKSHELF_API_DOCUMENTATION.md`.
## 10. Phase 0 — Status
**Implemented:** 2026-05-26. Plan: `docs/superpowers/plans/2026-05-26-abs-phase-0-login-and-critical-fixes.md`. All 11 plan tasks committed on `feat/audiobooks` and deployed to the production silo container. Endpoint smoke tests confirm correct mount + status codes for `/ping`, `/login`, `/auth/refresh`, `/logout`, `/me`, `/authorize` at both root and `/api`/`/abs/api` prefixes. End-to-end iOS/Plappa app validation pending operator hand-off.
**Phase 0 commits (13 total — implementation + review-feedback fixes):**
| # | SHA | Subject |
|---|-----|---------|
| 1 | `6c9c721` | feat(audiobooks): diagnostic logging to ABS bearer auth and login |
| 1+ | `d5edb24` | chore(audiobooks): normalize slog "err" key + add path to secret-fetch log |
| 2 | `877120f` | fix(audiobooks): enrich ABS login envelope |
| 2+ | `47c04f1` | chore(audiobooks): reuse existing now var in login envelope |
| 3 | `045984c` | fix(audiobooks): /authorize returns identical envelope to /login |
| 3+ | `0306cfe` | docs(audiobooks): restore x-return-tokens and displayName fallback comments |
| 4 | `e163858` | fix(audiobooks): emit IDs on authors/series and stable genres/tags arrays |
| 4+ | `470a483` | fix(audiobooks): proper pagination total + tags key in play session |
| 5 | `7f600b2` | fix(audiobooks): hydrate filterdata authors and series in library detail |
| 5+ | `2e2b411` | chore(audiobooks): rename const cap to fetchCap in buildFilterData |
| 6 | `7e956f1` | fix(audiobooks): seed currentTime from ProgressStore so resume works |
| 7 | `93e321f` | feat(audiobooks): POST /auth/refresh for ABS token rotation |
| 8 | `278f815` | feat(audiobooks): POST /logout for ABS sign-out |
**Tests added:** 15 (6 metadata + 4 resume + 5 refresh + 4 logout — note 4 logout overlaps with refresh's memTokenStore). `go test ./internal/audiobooks/... -count=1` all pass.
**Next:** Phase 1 (bookmarks, collections, playlists, smart collections, RSS, author/series detail, listening stats) will start with its own brainstorming → writing-plans cycle after operator confirms Phase 0 works against real clients.
@@ -0,0 +1,344 @@
# ABS Phase 1 Close-out — Sub-Project 4
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** Final sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`). Closes out the four remaining feature surfaces in one combined spec/plan to ship in a single push.
**Predecessor specs:** bookmarks, collections+playlists, smart collections. Established conventions (anti-enumeration 404, `io.LimitReader(1<<20)`, `errors.Is(err, pgx.ErrNoRows)`, profile-scoped + cross-user-public, 200-on-POST, in-package test fakes, `dispatchABSWithParams`) carry over without re-justification.
Commands assume the repository root is the cwd.
## 1. Goal
Land the four remaining Phase 1 surfaces:
- **Listening stats** — totalTime / days / dayOfWeek / monthly aggregations + paginated session history + per-session detail.
- **Author detail** — `GET /authors/{id}` and `GET /authors/{id}/image` (the latter currently 404s; wire to people poster image).
- **Series detail** — `GET /series/{id}` with embedded books sorted by series_sequence.
- **Continue-listening toggles** — `/me/progress/{itemId}/remove-from-continue-listening` + `/readd-to-continue-listening`, backed by a new `hide_from_continue` column on `user_watch_progress`.
- **RSS feeds** — minimal viable surface: open a feed for a library item, list caller's feeds, close a feed, and serve the public RSS XML to podcast clients. Series/collection feed variants deferred.
## 2. Non-goals
- **No socket events.** Continue-listening toggles, stats, author/series, and RSS feed mutations fire no events (Phase 2 covers full event parity).
- **No series/collection RSS feeds.** Only single-item feeds in v1. The continuum `/api/feeds/series/{id}/open` and `/api/feeds/collection/{id}/open` variants are explicitly deferred.
- **No RSS cover / per-track endpoints.** `GET /feed/{slug}/cover` and `GET /feed/{slug}/item/{itemId}/{idx}` are deferred — podcast clients can fetch covers from the enclosure URL embedded in the RSS XML.
- **No stable series IDs.** silo stores series denormalised on `audiobook_series.content_id` (one row per book); a "series" is identified by `LOWER(series_name)`. The detail handler accepts URL-decoded series names; URL slugs use the lowercased name. Continuum's stable series-row schema is out of scope.
- **No author write surface.** Author detail is read-only.
## 3. Architecture
Five surfaces, sharing the established structure. Three new HTTP handler files; one new migration; minimal extensions to existing stores; one new pgx store for RSS.
- **HTTP layer (`internal/audiobooks/abs/`):**
- `listening_stats_handler.go` — three handlers (stats / list / detail).
- `author_series_handler.go` — three handlers (author / author-image / series).
- `continue_listening_handler.go` — two handlers.
- `rss_feeds_handler.go` — four authenticated handlers + one public XML handler.
- **Store extensions:**
- `ABSPlaybackSessionStore.AggregateStats(ctx, userID, profileID) (Stats, error)` — single SQL with grouped aggregates.
- `ABSPlaybackSessionStore.ListClosedSessions(ctx, userID, profileID, limit, offset) ([]ABSPlaybackSession, int, error)` — paginated history.
- `ProgressStore.SetHideFromContinue(ctx, userID, profileID, contentID string, hide bool) error` — toggle column.
- `MediaStore.GetAuthorByID(ctx, authorID string) (Author, error)` — author detail + books list.
- `MediaStore.GetSeriesByName(ctx, seriesName string) (Series, error)` — series detail + books list ordered by series_index.
- **New store interface + concrete:**
- `RSSFeedStore` interface (CRUD + lookup-by-slug) in `internal/audiobooks/abs/rss_feeds.go`.
- `ABSRSSFeedStore` (pgx) in `internal/audiobooks/abs_rss_feed_store.go`.
- **Migration:**
- `154_user_watch_progress_hide_from_continue.up.sql` — `ALTER TABLE user_watch_progress ADD COLUMN hide_from_continue boolean NOT NULL DEFAULT false`.
- `155_abs_rss_feeds.up.sql` — feed rows (id, user_id, profile_id, library_item_id, slug, minified, created_at, closed_at).
- **Service wiring + route registration:** new fields on `abs.Dependencies` + new routes in `mountRoutes` (auth-gated group plus a new unauth public group for `/feed/{slug}.xml`).
## 4. Endpoint surface
All authenticated routes mount under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** except where the `Returns` column says otherwise.
### 4.1 Listening stats
| Verb | Path | Returns |
|---|---|---|
| GET | `/me/listening-stats` | `{totalTime, items, days, dayOfWeek, monthly}` aggregated from `abs_playback_sessions` |
| GET | `/me/listening-sessions` (?limit=, ?page=) | `pagedEnvelope` of session rows (most-recent-first) |
| GET | `/me/listening-sessions/{sid}` | single session detail (404 when not found or not owned) |
Stats wire shape:
```json
{
"totalTime": 36000,
"items": 12,
"days": [{"date": "2026-05-26", "seconds": 1800}, ...],
"dayOfWeek": {"0": 1200, "1": 3600, ..., "6": 900},
"monthly": [{"month": "2026-05", "seconds": 18000}, ...]
}
```
Session row shape:
```json
{
"id": "01HSESS...",
"libraryItemId": "126887...",
"userId": "1",
"startedAt": 1779786284823,
"lastUpdate": 1779786884823,
"duration": 1500,
"currentTime": 1234.5,
"timeListening": 600
}
```
### 4.2 Author + Series detail
| Verb | Path | Returns |
|---|---|---|
| GET | `/authors/{id}` | `{id, name, numBooks, books: [LibraryItem]}` |
| GET | `/authors/{id}/image` | redirect to PresignURL for `item_people.poster_path` when set; otherwise 404 |
| GET | `/series/{id}` | `{id, name, numBooks, books: [LibraryItem]}` ordered by series_index ASC |
`{id}` for authors is the stringified `people.id`. `{id}` for series is the URL-decoded series name (case-insensitive lookup). The author-image route was already mounted unauthenticated in handler.go but currently returns 404 — this spec wires the body.
### 4.3 Continue-listening toggles
| Verb | Path | Returns |
|---|---|---|
| GET | `/me/progress/{itemId}/remove-from-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = true` |
| GET | `/me/progress/{itemId}/readd-to-continue-listening` | 200 `{ok: true}` after setting `hide_from_continue = false` |
Both endpoints are idempotent. Item validation via `MediaStore.GetAudiobookByID` (404 on unknown item). The existing `MediaStore.ListContinueListening` SQL is updated to add `AND uwp.hide_from_continue = false` to the WHERE clause.
### 4.4 RSS feeds
| Verb | Path | Auth | Body | Returns |
|---|---|---|---|---|
| GET | `/api/feeds` | bearer | — | `{feeds: [Feed]}` for caller (owner + profile scope, only open feeds) |
| POST | `/api/feeds/item/{itemId}/open` | bearer | `{slug?, minified?}` | created Feed (full shape) |
| POST | `/api/feeds/{id}/close` | bearer | — | 204 (sets closed_at) |
| GET | `/feed/{slug}.xml` | none | — | RSS 2.0 XML; 404 on unknown / closed |
| GET | `/feed/{slug}` | none | — | same as above (some podcast clients omit the `.xml`) |
Feed wire shape:
```json
{
"id": "01HFEED...",
"userId": "1",
"libraryItemId": "126887...",
"slug": "abc-randomsluggenerated",
"minified": false,
"createdAt": 1779786284823,
"url": "https://silo.example.com/feed/abc-randomsluggenerated.xml"
}
```
`slug` is a 16-char URL-safe random string when caller omits it from the body. Manual slugs accepted as long as they match `^[a-z0-9-]{4,64}$`. Slugs are globally unique (UNIQUE index).
RSS XML uses standard RSS 2.0 + iTunes namespace; each `<item>` is one media_file (chapter) of the underlying audiobook. URLs are absolute (`absBaseURL` + `/public/session/...` style — but RSS feeds need a stable public path, so v1 uses a new public file route below). For v1 simplicity, the `<enclosure url>` points at the existing `/abs/public/session/{sid}/track/{idx}` style URL — but feeds don't have an associated session. So we use a new public route mounted in tandem:
**Additional public RSS support route:**
| Verb | Path | Auth | Returns |
|---|---|---|---|
| GET | `/feed/{slug}/file/{ino}` | none | streams the media file for the feed's library_item_id (404 if ino doesn't belong to the item) |
This is a feed-scoped public file route — slug is the capability token, so no Bearer needed. Mirrors the bookmarked-session pattern from sub-project 1.
## 5. Data model
### 5.1 Migration 154 — `hide_from_continue` column
`migrations/154_user_watch_progress_hide_from_continue.up.sql`:
```sql
ALTER TABLE public.user_watch_progress
ADD COLUMN IF NOT EXISTS hide_from_continue boolean NOT NULL DEFAULT false;
```
`migrations/154_user_watch_progress_hide_from_continue.down.sql`:
```sql
ALTER TABLE public.user_watch_progress
DROP COLUMN IF EXISTS hide_from_continue;
```
### 5.2 Migration 155 — `abs_rss_feeds`
`migrations/155_abs_rss_feeds.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_rss_feeds (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
library_item_id text NOT NULL REFERENCES public.media_items(content_id) ON DELETE CASCADE,
slug text NOT NULL,
minified boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz
);
CREATE UNIQUE INDEX IF NOT EXISTS abs_rss_feeds_slug_uniq
ON public.abs_rss_feeds (slug);
CREATE INDEX IF NOT EXISTS abs_rss_feeds_user_profile_idx
ON public.abs_rss_feeds (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/155_abs_rss_feeds.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_rss_feeds_user_profile_idx;
DROP INDEX IF EXISTS public.abs_rss_feeds_slug_uniq;
DROP TABLE IF EXISTS public.abs_rss_feeds;
```
### 5.3 Go models
```go
// Stats aggregates per spec §4.1.
type Stats struct {
TotalTime int // seconds
Items int // distinct content_ids listened to
Days []DayStat // last 30 days
DayOfWeek [7]int // index 0=Sunday
Monthly []MonthStat // last 12 months
}
type DayStat struct{ Date string; Seconds int } // "2026-05-26"
type MonthStat struct{ Month string; Seconds int } // "2026-05"
// Author is the detail-shape row.
type Author struct {
ID string
Name string
PosterPath string // empty when unset; resolved via CoverResolver on emit
Books []*models.MediaItem
}
// Series is the detail-shape row.
type Series struct {
ID string // lowercased series_name
Name string // canonical series_name
Books []*models.MediaItem // ordered by series_index ASC
}
// RSSFeed mirrors an abs_rss_feeds row.
type RSSFeed struct {
ID string
UserID string
ProfileID string
LibraryItemID string
Slug string
Minified bool
CreatedAt time.Time
ClosedAt *time.Time
}
```
## 6. Storage contracts
```go
// Extensions to existing interfaces:
// On ABSPlaybackSessionStore (existing):
AggregateStats(ctx context.Context, userID, profileID string) (Stats, error)
ListClosedSessions(ctx context.Context, userID, profileID string, limit, offset int) ([]ABSPlaybackSession, int, error)
// On ProgressStore (existing):
SetHideFromContinue(ctx context.Context, userID, profileID, contentID string, hide bool) error
// On MediaStore (existing):
GetAuthorByID(ctx context.Context, authorID string) (Author, error)
GetSeriesByName(ctx context.Context, seriesName string) (Series, error)
// New store interface — RSSFeedStore (defined in internal/audiobooks/abs/rss_feeds.go):
type RSSFeedStore interface {
ListUserFeeds(ctx context.Context, userID, profileID string) ([]RSSFeed, error)
GetFeedBySlug(ctx context.Context, slug string) (RSSFeed, error)
GetFeed(ctx context.Context, id string) (RSSFeed, error)
CreateFeed(ctx context.Context, f RSSFeed) error
CloseFeed(ctx context.Context, id string) error
}
```
Behavior:
- `AggregateStats` runs one SQL with three CTE subqueries (days / day_of_week / monthly aggregates) plus a `SUM(time_listening_seconds)` and `COUNT(DISTINCT content_id)`.
- `SetHideFromContinue` writes `UPDATE user_watch_progress SET hide_from_continue = $4 WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`. Returns `nil` even when no row matched (idempotent — no-progress means nothing to hide; client can readd later).
- `GetAuthorByID` parses `authorID` as int (`strconv.Atoi`), then `SELECT name FROM people WHERE id = $1` for the author row + `SELECT mi.* FROM item_people ip JOIN media_items mi ON mi.content_id = ip.content_id WHERE ip.person_id = $1 AND ip.kind = 7 AND mi.type = 'audiobook' ORDER BY mi.title`. Returns `ErrNotFound` when the people row is missing.
- `GetSeriesByName` does case-insensitive lookup on `audiobook_series.series_name`: `SELECT DISTINCT series_name FROM audiobook_series WHERE LOWER(series_name) = LOWER($1) LIMIT 1` → name; then `SELECT mi.* FROM audiobook_series s JOIN media_items mi ON mi.content_id = s.content_id WHERE LOWER(s.series_name) = LOWER($1) AND mi.type = 'audiobook' ORDER BY s.series_index NULLS LAST`. Returns `ErrNotFound` when no rows.
- `RSSFeedStore.CreateFeed` inserts with the slug as-is; UNIQUE constraint on slug surfaces as a 409 client-side. `GetFeedBySlug` only returns rows where `closed_at IS NULL` (closed feeds 404 to podcast clients). `CloseFeed` sets `closed_at = now()` — idempotent on already-closed rows.
## 7. Error model
| Condition | Status | Body |
|---|---|---|
| 401 missing/invalid bearer | 401 | bearerAuth |
| Body decode (POST `/feeds/.../open`) | 400 | `invalid body` |
| Slug fails `^[a-z0-9-]{4,64}$` | 400 | `invalid slug` |
| Item not found on author/series/feed POST | 404 | `<type> not found` |
| Author/Series unknown ID | 404 | `<author\|series> not found` |
| RSS slug collision on POST | 409 | `slug taken` |
| Non-owner DELETE/close feed | 404 | `feed not found` (anti-enumeration) |
| Public `/feed/{slug}` unknown or closed slug | 404 | `feed not found` (plain text body — podcast clients ignore it) |
| Store mutate fails | 500 | sanitized; err logged |
Cross-cutting:
- Same `io.LimitReader(1<<20)` on POST/PATCH bodies.
- Continue-listening toggles are idempotent and return 200 even on no-row-matched.
- The public RSS routes (`/feed/{slug}.xml`, `/feed/{slug}`, `/feed/{slug}/file/{ino}`) mount OUTSIDE the bearerAuth group — they're capability-gated by the slug. Mount alongside the existing public-session routes in `mountRoutes`.
## 8. Testing
### 8.1 Listening stats
- `TestStats_TotalAggregation` — seed 3 closed sessions, assert `totalTime == sum(time_listening_seconds)` and `items == 2` (3 sessions across 2 distinct items).
- `TestStats_DayOfWeekBucketing` — sessions on a Monday + a Wednesday + another Monday, assert `dayOfWeek[1] == 2 sessions worth, dayOfWeek[3] == 1`.
- `TestSessions_List_PaginatedAndScoped` — paged response shape, other-user sessions excluded.
- `TestSession_Detail_Owner_OK` + `TestSession_Detail_NonOwner_404`.
### 8.2 Author + Series
- `TestAuthor_Detail_ReturnsBooks` — seed author with 2 books; GET returns both, name matches.
- `TestAuthor_Unknown_404`.
- `TestAuthor_Image_PresignWired` — when CoverResolver returns a URL, image route 302s; when poster_path is empty, 404.
- `TestSeries_Detail_OrderedByIndex` — books returned in series_index ASC.
- `TestSeries_Unknown_404` (name doesn't match any audiobook_series row).
### 8.3 Continue-listening
- `TestContinue_Remove_SetsHideTrue` — seed progress row, GET remove-from-continue, assert column becomes true.
- `TestContinue_Readd_SetsHideFalse` — inverse.
- `TestContinue_UnknownItem_404`.
- `TestContinue_NoProgressRow_OK_Idempotent` — no progress row exists; remove-from-continue still returns 200 (idempotent no-op).
### 8.4 RSS
- `TestFeed_Open_Item_GeneratesSlug` — POST `/api/feeds/item/{id}/open` returns a feed with a random 16-char slug.
- `TestFeed_Open_AcceptsCustomSlug`.
- `TestFeed_Open_RejectsInvalidSlug_400`.
- `TestFeed_Open_RejectsCollision_409`.
- `TestFeed_List_OwnerOnly` — only caller's open feeds, profile-scoped.
- `TestFeed_Close_Owner_204`.
- `TestFeed_Close_NonOwner_404`.
- `TestPublicFeed_UnknownSlug_404`.
- `TestPublicFeed_ClosedSlug_404`.
- `TestPublicFeed_HappyPath_RSSXML` — open a feed with a known item; fetch `/feed/{slug}.xml`; assert Content-Type is `application/rss+xml`, body contains `<rss`, `<channel>`, `<item>`, `<enclosure url=`.
### 8.5 Out of scope
- No real-Postgres SQL test.
- No live smoke for the public RSS endpoints (operator can hit them; v1 doesn't include the curl snippet).
- No RSS validator test against a third-party tool.
## 9. References
- Continuum reference: `continuum-plugin-audiobooks/internal/abs/rss_feed_handler.go` (port routing names + slug generation; adapt store/auth to silo).
- silo's existing public-route precedent: `handlePublicTrack` in `internal/audiobooks/abs/file_handler.go` — same slug-as-capability pattern.
- Item-people kinds: 7 = author per `media_store.go:ListLibraryAuthors`. Narrator kind not used here.
## 10. Out-of-scope follow-ups
- RSS feed variants: series feeds, collection feeds, cover endpoint, per-track public route is included but is the minimum.
- Listening-stats `/all-time` vs `/last-N-days` filters — current spec returns a single aggregate.
- Author edit / merge surface.
- Series metadata edit.
- Stats charts beyond the three buckets (per-genre, per-author, per-narrator) — defer.
@@ -0,0 +1,377 @@
# ABS Smart Collections — Phase 1 Sub-Project 3
**Status:** Approved 2026-05-26. Ready for implementation plan.
**Scope:** Third sub-project of Phase 1 (per `2026-05-26-abs-implementation-fix-design.md`).
**Predecessor specs:** `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`. Conventions established there (anti-enumeration 404, `io.LimitReader(1<<20)` body cap, `errors.Is(err, pgx.ErrNoRows)` store pattern, profile-scoped + cross-user-public read, 200 OK on POST, in-package test fakes + `dispatchABSWithParams`) are reused without re-justification.
Commands in this document assume the repository root is the cwd.
## 1. Goal
Land rule-based "smart" audiobook collections on the silo ABS surface so the official Audiobookshelf clients can define dynamic groupings ("books from 2020-2024 by Brandon Sanderson", "audiobooks I've started but haven't finished", "5-star fantasy under 12 hours"). Rules are stored as a JSON DSL; the items endpoint evaluates the rules against the catalog at request time and returns paginated `LibraryItem` results.
## 2. Non-goals
- **No SQL pushdown.** Evaluation walks the candidate list in Go. silo's audiobook library is small enough (hundreds of items) that linear scan per request is fine. SQL pushdown is a Phase 4 follow-up if the library grows past ~5000 items.
- **No rule-builder UI.** Clients ship their own; silo just stores and evaluates `query_def`.
- **No podcast smart collections.** The DSL catalog is audiobook-domain only (title/author/narrator/series/genre/...). Podcast smart collections, if ever needed, get their own DSL.
- **No socket events.** Matches the manual-collections decision in sub-project 2. Phase 2 covers full event parity.
- **No saved-search aliases.** A smart collection is fully described by its `query_def`; there's no separate "saved query" entity.
- **No background materialisation.** Each `/items` call re-evaluates. If perf matters later, add a 30s in-memory result cache keyed on `(collectionID, userID)`.
## 3. Architecture
Three layers, sharing the established silo ABS structure:
- **DSL layer (new package `internal/audiobooks/smartcoll/`):**
- `query.go` — `QueryDefinition`, `QueryGroup`, `QueryRule`, `QuerySort` types + field/sort catalogs + `Normalize`/`Validate`/`MarshalJSON`. Ported from continuum's `internal/smartcoll/query.go` verbatim except for the import path.
- `evaluator.go` — `Candidate`, `EvaluateOptions`, `Evaluate(ctx, qd, candidates, opts) []Candidate`. Pure function, no I/O. Ported from continuum.
- `query_test.go` + `evaluator_test.go` — port continuum's test suites; remove any references to continuum-specific backend types.
- **HTTP layer (`internal/audiobooks/abs/`):**
- `smart_collections_handler.go` — six handlers: `handleListSmartCollections`, `handleCreateSmartCollection`, `handleGetSmartCollection`, `handleSmartCollectionItems`, `handleUpdateSmartCollection`, `handleDeleteSmartCollection`.
- `smart_collections_handler_test.go` — in-memory `memSmartCollectionStore` + handler tests reusing `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher` from prior sub-projects' test files.
- `smart_collections_envelope_test.go` — wire-shape marshal test.
- **Storage layer:**
- `internal/audiobooks/abs/smart_collections.go` — `SmartCollectionStore` interface + `SmartCollection` Go model + `smartCollectionToABS` serialiser.
- `internal/audiobooks/abs_smart_collection_store.go` — pgx-backed `ABSSmartCollectionStore` (parallel to `abs_collection_store.go`).
- **Migration:** `153_abs_smart_collections.up.sql` + `.down.sql`.
- **Bookmark store extension:**
- `BookmarkStore.CountByUser(ctx, userID, profileID) (map[string]int, error)` returning `{libraryItemID: count}` for batch hydration of the personalized `bookmark_count` rule. Single SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2, sentinel) GROUP BY library_item_id`.
- **Service wiring:** `BuildABSHandler` constructs the new store; field lands on `abs.Dependencies` next to `PlaylistStore`.
## 4. Endpoint surface
All routes mounted under both `/abs/api/*` and `/api/*` inside the existing `bearerAuth` group. Success status is **200 OK** unless the `Returns` column says otherwise.
| Verb | Path | Body | Returns |
|---|---|---|---|
| GET | `/me/smart-collections` | — | `{"items": [SmartCollection list-shape]}` |
| POST | `/me/smart-collections` | `{name, description?, color?, isPublic?, isPinned?, query_def}` | SmartCollection full-shape |
| GET | `/me/smart-collections/{id}` | — | SmartCollection full-shape when owner OR `isPublic=true`; otherwise 404 |
| GET | `/me/smart-collections/{id}/items` | (query: `?limit=`, `?page=`) | Paged envelope of `LibraryItem` results from rule evaluation |
| PATCH | `/me/smart-collections/{id}` | `{name?, description?, color?, isPublic?, isPinned?, query_def?}` | SmartCollection full-shape |
| DELETE | `/me/smart-collections/{id}` | — | 204 No Content |
### 4.1 Wire shape — SmartCollection
**List-shape and full-shape are identical** (unlike manual collections / playlists where the list-shape omits child items). Smart collections have no stored items; the items are computed by the separate `/items` route. So one shape:
```json
{
"id": "01HSC...",
"userId": "1",
"name": "Recent Fantasy",
"description": "fantasy added in the last 6 months",
"color": "#3b82f6",
"isPublic": false,
"isPinned": true,
"queryDef": {
"library_ids": [9],
"match": "all",
"groups": [{
"match": "all",
"rules": [
{"field": "genre", "op": "contains", "value": "Fantasy"},
{"field": "added_at", "op": "in_last", "value": "180d"}
]
}],
"sort": {"field": "added_at", "order": "desc"},
"limit": null
},
"createdAt": 1779786284823,
"updatedAt": 1779786284823
}
```
All ten top-level keys always present (`description`, `color` default to `""`; `isPublic`, `isPinned` default to `false`; `queryDef` is the parsed JSON, never raw bytes on the wire). Timestamps are JS-epoch milliseconds.
### 4.2 Wire shape — `/items` envelope
Standard ABS paged envelope (`pagedEnvelope` helper already in `handler.go`):
```json
{
"results": [ /* hydrated LibraryItem objects */ ],
"total": 42,
"limit": 30,
"page": 0,
"sortBy": "added_at",
"sortDesc": true,
"filterBy": "",
"minified": false,
"include": ""
}
```
`results` items use the same `siloItemToLibraryItem` shape as `/api/libraries/{id}/items`. Pagination is post-eval slice (eval order is preserved; pages are stable for a given `(collection, user)` as long as the underlying catalog doesn't change).
### 4.3 List-envelope wrap key
Smart-collection LIST uses `{"items": [...]}` — matches continuum's wrap key (which differs from manual collections' `{"collections": [...]}`). Clients pattern-match on the wrap key when distinguishing surfaces; don't override.
## 5. DSL surface (full vocabulary)
### 5.1 Fields (15 total)
**Non-personalized (10):**
| Field | Type | Valid ops | Source |
|---|---|---|---|
| `title` | scalar string | `is`, `is_not`, `contains` | `media_items.title` |
| `author` | array of strings | `is`, `is_not`, `contains` | item people: role=author |
| `narrator` | array of strings | `is`, `is_not`, `contains` | item people: role=narrator |
| `series` | array of strings | `is`, `is_not`, `contains` | `audiobook_series` |
| `genre` | array of strings | `is`, `is_not`, `contains` | `media_items.genres` |
| `year` | int | `is`, `is_not`, `gt`, `gte`, `lt`, `lte`, `between` | `media_items.release_year` |
| `rating` | float | `gt`, `gte`, `lt`, `lte`, `between` | `media_items.rating_imdb` (or whichever rating silo populates) |
| `language` | scalar string | `is`, `is_not` | `media_items.original_language` |
| `publisher` | scalar string | `is`, `is_not`, `contains` | `media_items.publisher` |
| `added_at` | timestamp | `gt`, `lt`, `between`, `in_last` | `media_items.added_at` |
| `duration_seconds` | int | `gt`, `gte`, `lt`, `lte`, `between` | sum of media_files.duration for the item |
**Personalized (5)** — require `AllowPersonalized: true` (only when caller is the collection's owner):
| Field | Valid ops | Source |
|---|---|---|
| `finished` | `is` | `user_watch_progress.is_finished` |
| `in_progress` | `is` | progress exists AND `is_finished == false` AND `current_seconds > 0` |
| `last_played` | `gt`, `gte`, `lt`, `lte`, `between`, `in_last` | `user_watch_progress.updated_at` |
| `abandoned` | `is` | `in_progress == true` AND `last_played < now() - 60d` (configurable via `EvaluateOptions.AbandonedAfter`) |
| `bookmark_count` | `gt`, `gte`, `lt`, `lte`, `between` | count of `abs_bookmarks` rows for (user, profile, item) |
### 5.2 Sort fields
`title` (asc), `added_at` (desc), `year` (desc), `duration_seconds` (desc), `rating` (desc), `random` (deterministic shuffle seeded by `userID + ":" + collectionID`), and personalized: `progress` (desc), `last_played` (desc), `plays` (desc).
### 5.3 Operators
- `is` / `is_not` — equality (case-insensitive for strings; numeric otherwise). For array fields, `is` matches when ANY element equals the value.
- `contains` — substring (case-insensitive) for scalar strings; element-substring for array fields.
- `gt` / `gte` / `lt` / `lte` — numeric or timestamp comparison.
- `between` — `value` is a 2-element array `[low, high]`, inclusive bounds.
- `in_last` — relative window. `value` is a duration string (`"7d"`, `"180d"`, `"24h"`, `"4w"`). Evaluator parses to `time.Duration`; field must be ≥ `opts.Now - duration`.
### 5.4 Aliases
`authors` → `author`, `narrators` → `narrator`, `genres` → `genre` for fields. `sort_title` → `title`, `recently_added` → `added_at`, `duration` → `duration_seconds` for sorts. Applied in `Normalize()`; older clients with plural / verbose names keep working.
## 6. Data model
### 6.1 Migration 153
`migrations/153_abs_smart_collections.up.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.abs_smart_collections (
id text PRIMARY KEY,
user_id integer NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
profile_id uuid,
name text NOT NULL,
description text NOT NULL DEFAULT '',
color text NOT NULL DEFAULT '',
is_public boolean NOT NULL DEFAULT false,
is_pinned boolean NOT NULL DEFAULT false,
query_def jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS abs_smart_collections_user_profile_idx
ON public.abs_smart_collections (
user_id,
COALESCE(profile_id, '00000000-0000-0000-0000-000000000000'::uuid)
);
```
`migrations/153_abs_smart_collections.down.sql`:
```sql
DROP INDEX IF EXISTS public.abs_smart_collections_user_profile_idx;
DROP TABLE IF EXISTS public.abs_smart_collections;
```
### 6.2 Schema rationale
- **`query_def jsonb`** — JSONB so future GIN-indexed JSON path probes are an option; default `'{}'::jsonb` so rows with no rules are queryable without NULL guards.
- **No items table** — smart collections have no stored children; items are computed at eval time.
- **`color`, `is_pinned`** — UI decorations. silo doesn't validate the color format.
- **No `library_ids` column** — that list is part of the DSL, lives inside `query_def`.
### 6.3 Go model
```go
type SmartCollection struct {
ID string
UserID string
ProfileID string
Name string
Description string
Color string
IsPublic bool
IsPinned bool
QueryDef []byte // raw JSONB bytes; decoded on the items path
CreatedAt time.Time
UpdatedAt time.Time
}
```
## 7. Storage contract
```go
type SmartCollectionStore interface {
ListUserSmartCollections(ctx context.Context, userID, profileID string) ([]SmartCollection, error)
GetSmartCollection(ctx context.Context, id string) (SmartCollection, error)
CreateSmartCollection(ctx context.Context, c SmartCollection) error
UpdateSmartCollection(ctx context.Context, c SmartCollection) error
DeleteSmartCollection(ctx context.Context, id string) error
}
```
Behavior follows the established silo pattern: ordered by `created_at DESC`, empty slice never nil, `GetSmartCollection` returns `ErrNotFound` without owner check (handler authorizes), `Update` sets `updated_at = now()`, `Delete` returns nil even on no-match.
**Extension to existing `BookmarkStore`:**
```go
// CountByUser returns a map of library_item_id → bookmark count for
// the given (user, profile). Empty map (never nil) when none.
// Used by the smart-collection items evaluator to hydrate the
// personalized `bookmark_count` rule in one SQL pass.
CountByUser(ctx context.Context, userID, profileID string) (map[string]int, error)
```
SQL: `SELECT library_item_id, COUNT(*) FROM abs_bookmarks WHERE user_id = $1 AND COALESCE(profile_id, sentinel) = COALESCE($2::uuid, sentinel) GROUP BY library_item_id`.
## 8. Items evaluation flow
`handleSmartCollectionItems` runs:
1. Auth + store-nil + URL `{id}` checks (same boilerplate as other handlers).
2. `GetSmartCollection(ctx, id)` → 404 on ErrNotFound OR `(non-owner AND !isPublic)`.
3. Decode `c.QueryDef` → `smartcoll.QueryDefinition`. 500 on malformed (shouldn't happen — `Validate` is called on persist; defensive guard).
4. Resolve target libraries: `qd.LibraryIDs` if non-empty, else every audiobook library from `MediaStore.ListAudiobookLibraries`.
5. For each target library, fetch the audiobook list via `MediaStore.ListAudiobooks(ctx, libID, 5000, 0)`. (5000 cap matches continuum's; silo's libraries are smaller.)
6. **Build Candidates.** For each item:
- `Candidate.Item` = the `*models.MediaItem`.
- If `c.UserID == a.UserID` (owner): hydrate per-user state.
- Fetch all progress rows: `ProgressStore.ListProgressForAudiobooks(ctx, a.UserID, a.ProfileID, 10000)`. Build `map[contentID]ProgressRow`.
- Fetch bookmark counts: `BookmarkStore.CountByUser(ctx, a.UserID, a.ProfileID)`. Map `contentID → count`.
- Populate `IsFinished`, `ProgressPct`, `CurrentSeconds`, `LastPlayedAt`, `BookmarkCount`.
- Non-owner viewing public collection: skip hydration; personalized rules eval against zero-values.
7. `smartcoll.Evaluate(ctx, qd, candidates, EvaluateOptions{AllowPersonalized: c.UserID == a.UserID, UserSeed: a.UserID + ":" + c.ID, Now: time.Now(), AbandonedAfter: 60*24*time.Hour})` → matched + sorted.
8. Paginate: `limit, page := readPagedQuery(r, 30)`. Override default limit with `qd.Limit` when `?limit=` query param is absent. Slice `matched[page*limit : (page+1)*limit]`.
9. Hydrate each result via the existing `siloItemToLibraryItem` helper (same as `/api/libraries/{id}/items`).
10. Wrap in `pagedEnvelope`. Write 200.
### 8.1 Performance budget
- Library fetch: one SQL query per library (~hundreds of rows).
- Progress hydration: one SQL query for the user's full progress list.
- Bookmark hydration: one SQL query for the aggregated counts.
- Total: 2 + N (libraries) queries, all paginated/limited at the store layer. silo's current 1-library config means 3 total queries per `/items` request. Fast enough.
## 9. Error model
| Condition | Status | Body |
|---|---|---|
| Missing/invalid bearer | 401 | handled by `bearerAuth` middleware |
| Body decode failure (POST/PATCH) | 400 | `invalid body` |
| `name` missing on POST | 400 | `name required` |
| `query_def` fails `Validate(allowPersonalized=true)` | 400 | `invalid query_def: <reason>` (reason from validator — safe to surface) |
| Unknown collection on GET (owner or not) | 404 | `smart collection not found` |
| Non-owner GET on private | 404 | same body — no leak |
| Non-owner PATCH/DELETE/items | 404 | same body |
| Store mutate fails | 500 | `smart collection persist failed` (delete: `smart collection delete failed`); err logged via `slog.Error` |
| `/items` eval — `MediaStore.ListAudiobooks` fails for a library | log `slog.Warn` and skip that library | partial results beat 500 |
| Personalized rule on non-owner-public eval | silently dropped at `smartcoll.Evaluate` via `AllowPersonalized: false` | no error to the client |
**Cross-cutting:** Body size limit `io.LimitReader(r.Body, 1<<20)` on all POST/PATCH. `validate(allowPersonalized=true)` runs on every persist call; on read, the items handler passes `AllowPersonalized = (c.UserID == a.UserID)` so saved personalized rules are non-fatally dropped when viewed by non-owners.
## 10. Testing
### 10.1 DSL unit tests (`internal/audiobooks/smartcoll/`)
Port continuum's `query_test.go` and `evaluator_test.go` verbatim. The continuum reference covers:
- `Normalize`: aliasing, lowercase, dedupe library_ids, default-match `"all"`, default-sort `"added_at"`.
- `Validate`: unknown field, invalid op for field, personalized rule without scope, invalid sort field, invalid sort order, negative limit.
- `Evaluate`: each operator for each field type, AND/OR combinator for groups and definition, personalized rules drop when `AllowPersonalized: false`, `random` sort stability per `UserSeed`, `Limit` honored, empty rules match-everything.
### 10.2 Handler tests (`smart_collections_handler_test.go`)
In-memory `memSmartCollectionStore` (parallel to `memCollectionStore`); reuses `dispatchABSWithParams`, `stubMediaStore`, `recordingPublisher`. The bookmark store extension (`CountByUser`) needs a matching method on `memBookmarkStore` from sub-project 1's test file — extend that fake.
| Test | Asserts |
|---|---|
| `SmartCollection_Create_ReturnsFullShape` | POST → 200, 10 top-level keys present, ULID, queryDef round-tripped as a nested object (not raw bytes) |
| `SmartCollection_Create_NameRequired_400` | empty name → 400 |
| `SmartCollection_Create_InvalidBody_400` | malformed JSON → 400 |
| `SmartCollection_Create_InvalidQueryDef_400` | `{"groups":[{"rules":[{"field":"nonsense","op":"is","value":1}]}]}` → 400 with reason in body |
| `SmartCollection_Create_PersonalizedRuleAllowed` | rule with `field:"finished"` accepted (persist always runs with `allowPersonalized=true`) |
| `SmartCollection_List_WrappedAsItems` | GET → `{"items": [...]}` envelope key (NOT `"collections"`) |
| `SmartCollection_List_DoesNotLeakOtherUsers` | user 1 creates; user 2 lists → empty |
| `SmartCollection_List_ProfileIsolation` | profile A creates; profile B lists same user → empty |
| `SmartCollection_Get_Owner_ReturnsFullShape` | owner GET → 200 + full-shape |
| `SmartCollection_Get_NonOwner_Public_OK` | public GET by other user → 200 |
| `SmartCollection_Get_NonOwner_Private_404` | private GET by other user → 404 (anti-enumeration) |
| `SmartCollection_Get_Unknown_404` | unknown ID → 404 |
| `SmartCollection_Patch_Owner_UpdatesFields` | partial PATCH updates only present fields |
| `SmartCollection_Patch_NonOwner_404` | non-owner PATCH → 404, original untouched |
| `SmartCollection_Patch_InvalidQueryDef_400` | PATCH with bad query_def → 400 |
| `SmartCollection_Delete_Owner_204` | DELETE → 204; subsequent GET → 404 |
| `SmartCollection_Delete_NonOwner_404` | non-owner DELETE → 404, original still present |
| `SmartCollection_Items_Owner_EvaluatesRules` | seed 3 audiobooks (two match the rule, one doesn't); GET /items returns 2 results |
| `SmartCollection_Items_PersonalizedDroppedForNonOwner` | public collection with `field:"finished"` rule — non-owner sees ALL matching books (personalized rule silently dropped); owner sees only finished |
| `SmartCollection_Items_PaginatedEnvelope` | GET /items → standard pagedEnvelope with 9 fields |
| `SmartCollection_Items_RespectsQueryDefLimit` | qd.Limit=5; GET without ?limit returns 5; GET with ?limit=10 returns 10 (request overrides) |
| `SmartCollection_Items_NonOwner_Private_404` | private collection /items by non-owner → 404 |
| `SmartCollection_Envelope_HasRequiredKeys` | marshal-test for 10 top-level keys with empty defaults |
### 10.3 Live integration smoke (operator)
```bash
TOKEN=$(curl ... /login | jq -r .accessToken)
# Create a smart collection: "fantasy added in the last 6 months"
SC=$(curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"recent fantasy","query_def":{"match":"all","groups":[{"match":"all","rules":[{"field":"genre","op":"contains","value":"Fantasy"},{"field":"added_at","op":"in_last","value":"180d"}]}],"sort":{"field":"added_at","order":"desc"}}}' \
http://127.0.0.1:13378/api/me/smart-collections | jq -r .id)
# Evaluate
curl ... /api/me/smart-collections/$SC/items | jq '.results | length'
# Patch name
curl -X PATCH ... -d '{"name":"renamed"}' /api/me/smart-collections/$SC | jq .name
# Delete
curl -X DELETE ... /api/me/smart-collections/$SC # 204
```
### 10.4 Out of scope
- No real-Postgres SQL test (same posture as prior sub-projects).
- No GIN-index on `query_def`. Phase 4 follow-up if rule-querying becomes a feature.
- No performance test. Library is small; linear scan is fine.
## 11. Risks & open questions
- **5000-item per-library cap.** If a library exceeds this, the over-fetch silently truncates. silo's libraries are well below this today; if it changes, switch to paged ListAudiobooks + accumulate.
- **Personalized rule eval against non-owners is silent.** A non-owner viewing a public collection sees results that don't honor the owner's personalized rules. This is the privacy-correct behavior, but a confused user might wonder why "books I haven't finished" shows everything when viewed by their friend. Document in the parent spec's UX notes.
- **`random` sort + pagination.** Two requests with the same `(user, collection)` produce the same shuffle (seeded). Browser-back / page-2 stays consistent. If the catalog mutates between requests, the seed still works but new items land in unpredictable positions — acceptable.
- **`query_def` migration on field-catalog changes.** If silo renames a field or removes an op in the future, existing rows persist the old vocab. `Validate` would reject them, but Normalize won't drop them — we'd surface stale rules as a runtime warning in the items handler. Acceptable for v1.
- **`description` length.** No cap; relies on the 1 MiB body limit. Same posture as other surfaces.
## 12. Out-of-scope follow-ups
- SQL-pushdown evaluator for large libraries.
- GIN-indexed JSON path queries on `query_def`.
- Background materialisation cache for hot smart collections.
- Cross-collection composition ("collection A + collection B").
- Result count badges on `/me/smart-collections` (would require running eval on every list call — expensive).
- Audiobook recommender hooks (the `relevance` sort placeholder in continuum).
## 13. References
- Spec parent: `docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md`.
- Sibling sub-project specs: `2026-05-26-abs-bookmarks-design.md`, `2026-05-26-abs-collections-playlists-design.md`.
- DSL reference: `continuum-plugin-audiobooks/internal/smartcoll/{query,evaluator}.go` + matching tests (port verbatim).
- HTTP reference: `continuum-plugin-audiobooks/internal/abs/smart_collection_handler.go`.
@@ -0,0 +1,295 @@
# Unified User Collections (Audiobooks + Movies + TV) Design
**Status:** design — pending implementation plan.
**Source brainstorm:** in-session, 2026-05-27. Driven by audiobook coverage gaps in `page_sections`, `library_collections`, and `user_personal_collections`, plus a parallel `abs_*` collection/playlist/smart-collection stack that doesn't intersect with the silo-canonical tables.
**Commands assume the repository root is the cwd.**
---
## 1. Problem
Audiobooks today live in a parallel collections world from movies and TV:
| Surface | Audiobook coverage today |
|---|---|
| `page_sections` (homepage rails) | None. Recipes reference only `movie`/`series`. |
| `library_collections` (admin-owned, library-scoped) | Schema supports `MediaAudiobook` kind but nothing surfaces audiobook-typed admin collections in practice. |
| `user_personal_collections` (user-owned) | Zero references to audiobooks anywhere in `internal/usercollections/`. |
| `abs_user_collections` + `abs_collection_items` | Full audiobook-only user-curated collections behind the ABS-compat API. |
| `abs_smart_collections` | Audiobook-only smart collections with a rule DSL (port of continuum-plugin-audiobooks). |
| `abs_playlists` + `abs_playlist_items` | Audiobook + podcast-episode ordered playlists. |
Net effect: audiobooks don't appear in any of silo's first-party discovery surfaces, the ABS-app's collection/playlist features don't intersect with silo's native ones, and we maintain two parallel storage stacks.
The data inside the `abs_*` tables today is essentially empty (1 playlist, 0 user collections, 0 smart collections), so unification can land via hard cutover with minimal data migration cost.
---
## 2. Goals
- One canonical storage for user-owned lists across all media types.
- Audiobook items eligible for `page_sections` recipes, `library_collections`, and `user_personal_collections`.
- ABS-compat endpoints (`/api/collections`, `/api/playlists`, `/api/smart-collections`) continue to work and return identical wire shapes, but their persistence collapses into the canonical store.
- One smart-collection engine evaluated against all media types.
- Podcast-episode-level granularity preserved (ABS playlists can name a specific episode within a library item).
## 3. Non-goals
- Admin-curated audiobook collections via the ABS API surface. `library_collections` already supports audiobooks; whether ABS clients should see those admin lists is a separate question deferred for a later design.
- Frontend changes. Audiobook detail/library pages already exist (per `2026-05-24-audiobook-ui-redesign`) and will consume the unified endpoints when they ship. An admin section-builder media-type selector and any UI surfacing of audiobook sections/collections are follow-up plans.
- Cross-user shared collections, public sharing, social features. Out of scope.
- Episode-level granularity for movies/TV. The new `sub_item_id` column is nullable and only populated by podcast playlists.
## 4. Architecture
### 4.1 Storage model
One canonical user-owned-lists table — `user_personal_collections` — extended to discriminate four flavors via `collection_type`:
| `collection_type` | Semantics |
|---|---|
| `manual` | Existing. Hand-curated list of items. Order optional. |
| `synced` | Existing. External sync (Trakt etc.). Items derived from external source. |
| `playlist` | **New.** Ordered sequence; `position` column on items is meaningful. |
| `smart` | **New.** Rule-based; items materialized at read time from `query_definition` via `internal/smartcoll/`. |
`user_personal_collection_items` gains one nullable column:
```sql
ALTER TABLE user_personal_collection_items ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
```
`sub_item_id` is populated only when the entry refers to a sub-item of a library item (today: podcast episode within a podcast library item). All other entries leave it empty. Audiobook entries always use empty `sub_item_id` because an audiobook is a single `media_items` row.
The CHECK constraint on `collection_type` is widened to admit the new values:
```sql
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
```
Admin-curated lists (`library_collections`, `library_collection_items`, etc.) are unchanged. They already support audiobooks through the existing `MediaKind` enum.
### 4.2 Smart-collection engine
The current audiobook-only smart-collection engine in `internal/audiobooks/smartcoll/` is promoted to `internal/smartcoll/`:
```
internal/audiobooks/smartcoll/ → internal/smartcoll/
```
Rationale: smart collections are no longer audiobook-only. The DSL operates on `media_items` regardless of type; audiobook-specific rule kinds (e.g. `narrator`, `series_position`) stay registered but evaluate to no-op predicates on non-audiobook items.
The existing simple-shape `query_definition` on `user_personal_collections` (mostly `library_ids` filter) is a strict subset of the smartcoll DSL — existing rows continue to evaluate correctly because their library-id filter maps to the new DSL's library-id predicate.
### 4.3 ABS-compat handler mapping
The three ABS store adapter files in `internal/audiobooks/` get their bodies rewritten to query the canonical tables. Method signatures stay the same so the ABS HTTP handlers above them don't change.
| ABS endpoint | Backing query after cutover |
|---|---|
| `GET /api/libraries/{id}/collections` | `SELECT … FROM user_personal_collections WHERE collection_type='manual'` joined to items in this library |
| `POST /api/collections` | INSERT `user_personal_collections` with `collection_type='manual'`, scoping via `query_definition.library_ids` |
| `GET /api/libraries/{id}/playlists` | `SELECT … FROM user_personal_collections WHERE collection_type='playlist'` joined to items in this library |
| `POST /api/playlists` | INSERT `user_personal_collections` with `collection_type='playlist'` |
| `GET /api/libraries/{id}/smart-collections` | `SELECT … FROM user_personal_collections WHERE collection_type='smart'`; items materialized via `internal/smartcoll` |
| `episode_id` in playlist items wire shape | maps to `sub_item_id` column |
### 4.4 Page sections (homepage rails)
`page_sections` rows gain a media-types filter. The cleanest carrier is a typed column:
```sql
ALTER TABLE page_sections
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
```
Default preserves the current "movies and TV" behavior on existing rows so the migration doesn't change any user-visible rails.
Each recipe declaration in `internal/sections/recipes/` gains a `SupportedMediaTypes` field. Fetchers add `WHERE mi.type = ANY($media_types)` to their queries. Existing recipes set `SupportedMediaTypes = ['movie','series']`; they keep their current behavior. Recipes that work for audiobooks declare `['movie','series','audiobook']` (or `['audiobook']` for audiobook-only ones).
Two new recipes ship at the same time:
- **`continue_listening`** — analog of `continue_watching`; audiobook items with non-zero progress and not finished.
- **`by_audiobook_series`** — book series rail, drawing from the `audiobook_series` table; mirrors `by_show`.
More audiobook-flavored recipes (top narrators, by genre, new from followed authors) are deferred until the baseline two are exercised.
### 4.5 ABS app contract preservation
Wire-shape compatibility is part of the contract — the ABS Android/iOS apps cannot break. The store-adapter rewrites must produce byte-for-byte identical JSON for the existing endpoints. Concretely:
- IDs remain stable for the moved row(s). The migration preserves `abs_playlists.id` as the new `user_personal_collections.id`.
- `episode_id` field is emitted from `sub_item_id` (empty string when null/empty).
- `is_public` field on playlists/smart collections is emitted from `is_shared`.
- `cover_item` on playlists — needs decision (see §6).
### 4.6 Module structure after the change
```
internal/
smartcoll/ -- NEW (promoted from audiobooks/smartcoll/)
types.go
evaluator.go
rule_registry.go
usercollections/ -- existing; extended for playlist/smart kinds
listing.go -- gains Kind filter
types.go -- adds CollectionKind = playlist|smart
smartfetch.go -- new; materializes smart-collection items via internal/smartcoll
sections/
recipes/
*.go -- each gains SupportedMediaTypes
continue_listening.go -- new
by_audiobook_series.go -- new
audiobooks/
abs_collection_store.go -- rewritten: canonical SQL
abs_playlist_store.go -- rewritten: canonical SQL
abs_smart_collection_store.go -- rewritten: canonical SQL + smartcoll
smartcoll/ -- REMOVED (lifted to internal/smartcoll/)
```
---
## 5. Migration
Single pair: `migrations/156_unify_user_collections.up.sql` / `.down.sql`. Hard cutover.
### 5.1 Up
```sql
-- 1. Extend canonical items table with sub-item granularity.
ALTER TABLE user_personal_collection_items
ADD COLUMN sub_item_id text NOT NULL DEFAULT '';
-- 2. Widen the collection_type enum.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced', 'playlist', 'smart'));
-- 3. Move the single existing abs_playlists row.
INSERT INTO user_personal_collections
(id, user_id, profile_id, name, description, collection_type,
is_shared, created_at, updated_at, creator_profile_id)
SELECT
id, user_id, COALESCE(profile_id::text, ''), name, description, 'playlist',
is_public, created_at, updated_at, COALESCE(profile_id::text, '')
FROM abs_playlists;
INSERT INTO user_personal_collection_items
(user_id, collection_id, media_item_id, sub_item_id, position, added_at)
SELECT p.user_id, i.playlist_id, i.library_item_id, i.episode_id, i.position, i.added_at
FROM abs_playlist_items i
JOIN abs_playlists p ON p.id = i.playlist_id;
-- 4. Drop the abs_* collection tables.
DROP TABLE abs_playlist_items;
DROP TABLE abs_playlists;
DROP TABLE abs_collection_items;
DROP TABLE abs_user_collections;
DROP TABLE abs_smart_collections;
-- 5. Extend page_sections with media-type filter.
ALTER TABLE page_sections
ADD COLUMN media_types text[] NOT NULL DEFAULT ARRAY['movie','series'];
```
### 5.2 Down
```sql
-- Reverse the page_sections column.
ALTER TABLE page_sections DROP COLUMN media_types;
-- Recreate the abs_* tables empty by re-applying their CREATE TABLE
-- statements verbatim from the original migration files (no data restored):
-- migrations/149_abs_user_collections.up.sql → abs_user_collections
-- migrations/150_abs_collection_items.up.sql → abs_collection_items
-- migrations/151_abs_playlists.up.sql → abs_playlists
-- migrations/152_abs_playlist_items.up.sql → abs_playlist_items
-- migrations/153_abs_smart_collections.up.sql → abs_smart_collections
-- The implementer copies the CREATE TABLE bodies (and indexes) into this
-- down migration; constraint/index names must match the originals so a
-- subsequent up-down-up cycle is idempotent.
-- Remove rows we promoted.
DELETE FROM user_personal_collection_items
WHERE collection_id IN (
SELECT id FROM user_personal_collections WHERE collection_type IN ('playlist','smart')
);
DELETE FROM user_personal_collections WHERE collection_type IN ('playlist','smart');
-- Restore narrow CHECK constraint.
ALTER TABLE user_personal_collections
DROP CONSTRAINT IF EXISTS user_personal_collections_type_check;
ALTER TABLE user_personal_collections
ADD CONSTRAINT user_personal_collections_type_check
CHECK (collection_type IN ('manual', 'synced'));
-- Drop the sub_item_id column.
ALTER TABLE user_personal_collection_items DROP COLUMN sub_item_id;
```
The down migration is symmetrically structured but lossy in reverse: rolling back loses any playlist/smart rows created after the cutover. That's acceptable because the abs_* tables were near-empty before the up.
### 5.3 Risk
| Risk | Probability | Mitigation |
|---|---|---|
| Down-migration loses real user data | Low (one row today; grows over time) | Snapshot the table before running down in any prod context |
| ABS app sees wire-shape regressions | Medium | Snapshot tests on JSON output of each ABS endpoint before/after |
| smartcoll DSL gap (silo-native query_definition row that doesn't fit DSL subset) | Low | Pre-migration query enumerates non-conforming rows; spec asserts subset compat |
| `library_collections` UI gains audiobook rows it can't render | Low | UI changes deferred; admin doesn't currently create audiobook-typed library collections |
---
## 6. Open questions
1. **Playlist `cover_item` field.** `abs_playlists.cover_item` is a FK to `media_items.content_id`. `user_personal_collections.poster_url` is a plain text URL. Two options for the migration: (a) drop `cover_item`, regenerate poster URLs from the first item in the playlist; or (b) add a `cover_content_id` column to `user_personal_collections`. Recommend (a) for simplicity — the existing playlist row has no `cover_item` set.
2. **`is_pinned` on smart collections.** `abs_smart_collections.is_pinned` has no analog in `user_personal_collections`. Likely needs a new boolean column; defer until smart-collection UI work names a use case.
3. **`color` on smart collections.** `abs_smart_collections.color` has no analog. Same call as `is_pinned` — defer; the existing 0 rows means no data to preserve.
4. **Admin-curated audiobook collections in the ABS API.** Out of scope per §3, but flagged for a future design.
---
## 7. Testing
- **Migration round-trip test.** Run `up.sql` → `down.sql` → `up.sql` on a fresh test DB seeded with one of each `abs_*` row. Assert canonical-table contents are identical before and after.
- **ABS endpoint wire-shape snapshots.** Generate JSON for `GET /api/libraries/{id}/collections|playlists|smart-collections` before the cutover; assert byte-equal after.
- **smartcoll engine portability.** Move the existing `internal/audiobooks/smartcoll/*_test.go` into `internal/smartcoll/` and add three new tests:
- audiobook-specific rule (`narrator` filter) evaluated against an audiobook → expected items.
- audiobook-specific rule evaluated against a movie → empty set (no-op predicate).
- simple library-id filter (silo-native query_definition shape) evaluated under the unified DSL → expected items.
- **Section recipe regression.** Existing recipes with default `media_types=['movie','series']` return identical results to today. New `continue_listening` and `by_audiobook_series` recipes covered with focused unit tests.
---
## 8. Sub-projects
This design is decomposed for implementation:
1. **Schema migration + canonical storage extensions** — migration 156, CHECK/column additions, no behavior changes yet.
2. **Smart-collection engine lift** — move `internal/audiobooks/smartcoll/` to `internal/smartcoll/`, update imports, no logic changes.
3. **ABS store adapter rewrites** — collection/playlist/smart-collection stores query canonical tables.
4. **Section recipes for audiobooks** — recipe `SupportedMediaTypes` field, two new recipes, fetcher type filter.
5. **Documentation + follow-up tickets** — open-question call-outs (cover_item, is_pinned, color, admin collections in ABS).
Each sub-project has a single-MR scope and ships independently of the others. Sub-project 1 lands first; sub-projects 2–4 can land in any order after it.
---
## 9. Source references
- Existing canonical tables: `internal/usercollections/`, `internal/collections/templates/templates.go`
- Existing smart-collection engine: `internal/audiobooks/smartcoll/`
- Existing section recipes: `internal/sections/recipes/`, `internal/api/handlers/sections_preview.go`, `internal/api/handlers/sections_bulk.go`, `internal/api/handlers/recipes.go`
- Existing ABS store adapters: `internal/audiobooks/abs_collection_store.go`, `internal/audiobooks/abs_playlist_store.go`, `internal/audiobooks/abs_smart_collection_store.go`
- Related shipped specs: `docs/superpowers/specs/2026-05-26-abs-smart-collections-design.md`, `docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md`
@@ -0,0 +1,311 @@
# AI services: shared core, metadata translation, Whisper ASR — design
**Date:** 2026-06-10
**Status:** Draft, pending review
**Scope:** Extract the AI subtitle translation engine into a shared AI core, add AI translation of movie/series/season/episode descriptions into the existing localization tables, and add Whisper ASR subtitle generation (`transcribe`, `transcribe_translate`). One PR.
Commands assume the repository root is the cwd.
## Problem
`internal/subtitles/ai` ships an on-demand LLM subtitle translator. The LLM plumbing inside
it (OpenAI-compatible chat client with retry/backoff, batched indexed-JSON translation,
job lifecycle with idempotency/heartbeat/reaper/bounded concurrency) is generic, but it is
buried in a subtitle-specific package, so nothing else can use it.
Two features want exactly that plumbing:
1. **Metadata translation.** Libraries whose `metadata_language` the providers cannot fully
serve end up with overviews/taglines in the item's default language (usually English).
The localization tables (`media_item_localizations`, `season_localizations`,
`episode_localizations`) and the serving path (`LocalizeItemModel` and friends in
`internal/catalog/detail.go`) already exist — the gap is producing rows for languages the
providers don't have.
2. **Whisper ASR.** `subtitle_ai_jobs.kind` already reserves `transcribe` and
`transcribe_translate`; media with no usable text subtitle track (foreign audio with only
bitmap subs, or no subs at all) currently has no AI path to a subtitle track.
## Goals
- One shared AI core: a single OpenAI-compatible endpoint configuration (chat + audio
transcription), one retry/backoff implementation, one generic batched text translator,
one job-lifecycle runner — consumed by subtitle translation, metadata translation, and ASR.
- AI-translated overviews and taglines stored in the **existing** localization tables, so
web, Android, Apple, and jellycompat clients get them through the current serving path
with **zero client changes**.
- Provenance on AI-writable localization fields so refreshes never regress data quality:
`manual` beats `provider` beats `ai`. A later provider localization overwrites an AI
translation; the reverse never happens.
- Two metadata-translation triggers: a manual admin action, and an opt-in per-library
fallback ("provider had no localization for the library language → AI-translate") wired
into metadata ingestion.
- ASR jobs that produce ordinary `downloaded_subtitles` rows (provider `transcribed`),
with `transcribe_translate` chaining the existing LLM translator for a target-language
track; live cue streaming to the requesting playback session, same as translation today.
- Total AI load on the configured endpoint bounded by **one** shared semaphore.
## Non-goals
- **Episode/movie/series title translation.** Titles often have official localized forms
that are not literal translations; provider data only.
- **Collection names or descriptions.** Deferred: collections have no localization table
yet. The metadata translation service is written against content-ID + field-list inputs,
so a `collection_localizations` table can be added in a follow-up without reshaping it.
- **Genre translation.** If ever wanted, that is a static mapping, not an LLM call.
- **Per-profile metadata language.** Presentation language stays library-scoped
(`media_folders.metadata_language`); changing that is an orthogonal feature.
- **A localization editor UI.** The `manual` provenance value is honored by the write
rules but no UI sets it yet (the existing metadata editor edits base fields, not
localizations).
- **Whisper word-level timing, diarization, VAD-aligned chunking.** v1 uses fixed-length
chunks; boundary-word artifacts are an accepted limitation noted for follow-up.
## Architecture
### Package layout
```
internal/ai/llm Client: OpenAI-compatible /v1/chat/completions (moved from
internal/subtitles/ai/client.go) + new /v1/audio/transcriptions.
Shared retry/backoff/429 handling. Config for the endpoint(s).
internal/ai/translate Generic batched text translation: []Segment{ID, Text} in,
translated segments out. Indexed-JSON protocol, context
neighbors, malformed-response retries — moved from
internal/subtitles/ai/translator.go and de-subtitled.
internal/ai/jobrunner Generic job lifecycle: bounded dispatch off a shared semaphore,
heartbeat loop, stale-job reaper, cancel registry — extracted from
internal/subtitles/ai/service.go behind a small repo interface.
internal/subtitles/ai Stays: subtitle job semantics. Translate path becomes a thin
adapter (cues ↔ segments); gains the transcribe paths.
internal/metadata/translation
New: metadata translation job service + repo + provenance-aware
writes into the localization repos.
```
Package names: `llm`, `aitranslate`, `jobrunner` (import paths above; `aitranslate` avoids
a name collision when imported from package `ai`).
### Shared semaphore
`cmd/silo` builds one `chan struct{}` of size `ai.max_concurrent_jobs` and hands it to both
job services via `jobrunner`. Subtitle, metadata, and ASR jobs all draw from it, so the
operator's endpoint sees a bounded number of concurrent jobs regardless of job mix.
(An ASR job holds one slot for its whole pipeline: extract → transcribe → translate.)
## Settings
Connection settings move to a shared `ai.*` namespace. Because encrypted values are
GCM-bound to their setting key (see `internal/catalog/encrypted_settings_repo.go`), the
legacy rows are **not renamed**; the loader falls back to them, following the precedent of
`recommendations.embedding_auth_token` → `recommendations.openai_api_key` in
`internal/config/db_loader.go`.
| Key | Default | Fallback | Notes |
| --- | --- | --- | --- |
| `ai.base_url` | `https://api.openai.com` | `subtitle_ai.base_url` | |
| `ai.api_key` | `""` | `subtitle_ai.api_key` | sensitive |
| `ai.chat_model` | `gpt-4o-mini` | `subtitle_ai.chat_model` | |
| `ai.max_concurrent_jobs` | `2` | `subtitle_ai.max_concurrent_jobs` | shared semaphore size |
| `ai.asr_model` | `whisper-1` | — | |
| `ai.asr_base_url` | `""` (= `ai.base_url`) | — | separate local Whisper server |
| `ai.asr_api_key` | `""` (= `ai.api_key`) | — | sensitive |
| `subtitle_ai.enabled` | `false` | — | existing; subtitle translation toggle |
| `subtitle_ai.transcribe_enabled` | `false` | — | new; ASR toggle |
| `subtitle_ai.batch_size` | `40` | — | existing; cues per chat request |
| `subtitle_ai.context_neighbors` | `2` | — | existing |
| `metadata_ai.enabled` | `false` | — | new; metadata translation toggle |
`ai.api_key` and `ai.asr_api_key` join `sensitiveSettingKeys`. The admin UI reads through
the same fallback (effective values come from the loaded config) and writes the new keys.
Config structs: `AIConfig` (connection + concurrency), `SubtitleAIConfig` (slims down to
toggles + batch tuning), new `MetadataAIConfig`. Readiness: subtitle translate requires
`subtitle_ai.enabled` + chat config; ASR requires `subtitle_ai.transcribe_enabled` + ASR
config; metadata requires `metadata_ai.enabled` + chat config.
## Metadata translation
### What gets translated
| Target | Fields |
| --- | --- |
| movie / series (`media_item_localizations`) | `overview`, `tagline` |
| season (`season_localizations`) | `overview` |
| episode (`episode_localizations`) | `overview` |
Titles, sort titles, and artwork columns in those rows are never written by AI.
### Provenance
New columns (default `'provider'`, check-constrained to `provider|ai|manual`):
- `media_item_localizations.overview_source`, `media_item_localizations.tagline_source`
- `season_localizations.overview_source`
- `episode_localizations.overview_source`
Write rules, enforced in the localization repos (single-statement upserts with CASE):
| Writer | May overwrite | Sets source | Notes |
| --- | --- | --- | --- |
| provider ingestion | `provider`, `ai` | `provider` | a non-empty provider value always replaces an AI value; an **empty** provider value never blanks an existing one |
| AI translation | `ai`, empty | `ai` | with `force`, may also overwrite `provider` (admin explicitly re-translating) — never `manual` |
| manual (future editor) | anything | `manual` | |
Serving hardening: `LocalizeItemModel` / `LocalizeSeasonModel` / `LocalizeEpisodeModel`
must only override base fields with **non-empty** localization values, since AI-created
rows legitimately carry empty titles. (Verify current behavior; fix if it clobbers.)
### Job model
New table `metadata_translation_jobs`, mirroring `subtitle_ai_jobs` conventions
(status enum, progress, idempotency partial-unique index, heartbeat):
- `target_kind` (`item` | `season` | `episode`), `content_id`, `include_children`
- `target_language`, `source_language` (default language of the base row)
- `engine`, `model`, `status`, `progress`, `progress_message`, `fields_done`, `fields_total`
- `error_message`, `idempotency_key`, `requested_by`, `force`, timestamps, `heartbeat_at`
A job for a series item with `include_children` expands to: item overview + tagline, every
season overview, every episode overview. All fields become segments
(`item:overview`, `season:<content_id>:overview`, `episode:<content_id>:overview`, …) fed
to the generic translator in batches (metadata batch size constant, smaller than the
subtitle one — paragraphs, not cue lines), so episode descriptions share terminology in one
prompt context. The system prompt names the series/movie and year for grounding.
Skip logic in the run loop (not at enqueue): a field whose localization value is already
non-empty is skipped unless `force` — so the auto trigger costs zero LLM calls on repeat
refreshes. If every field is skipped the job completes immediately.
Idempotency key: `content_id | target_kind | target_language | model`, active-only partial
unique index (completed/failed rows never block a retry).
### Triggers
1. **Manual.** Admin action on the item detail metadata editor: pick target language
(defaults to the library's `metadata_language`), `include_children`, `force`. Gated by
the same permission as metadata editing.
2. **Auto fallback.** New `media_folders.auto_translate_metadata boolean NOT NULL DEFAULT
false`. At the end of a metadata refresh for an item (after provider localizations are
persisted in `internal/metadata/service.go`), if the folder has the flag, has a
`metadata_language`, that language differs from the item's default metadata language,
and the localization row for it is missing a translated field — enqueue a non-force job
for the item (children included for series). Enqueue is fire-and-forget; refresh latency
is unaffected.
### API
Admin-gated, mirroring the subtitle AI handler shapes:
- `GET /api/v1/metadata/ai/status`
- `POST /api/v1/metadata/ai/translate` `{content_id, target_kind, target_language, include_children, force}`
- `GET /api/v1/metadata/ai/jobs/{job_id}` / `POST …/cancel`
- `GET /api/v1/metadata/ai/jobs?content_id=…`
Progress UI polls the job endpoint (jobs are short; no websocket events in v1).
## Whisper ASR
### Kinds
`subtitle_ai_jobs.kind` gains its reserved values:
- `transcribe` — audio track → subtitle track in the spoken language.
- `transcribe_translate` — `transcribe`, then the existing LLM translation chained to
`target_language`. Both the transcript track and the translated track are stored (the
transcript is a cache: a second target language skips ASR entirely because the transcript
is now a selectable text source for a plain `translate` job). `result_subtitle_id`
points at the translated track.
For ASR kinds, `source_index` holds the **audio** track index (`-1` = default/first audio
track); `source_language` is an optional hint passed to Whisper, else taken from the
detected `verbose_json.language`.
### Pipeline
1. Resolve the audio track from `media_files`; reject files with no audio.
2. Extract once with ffmpeg into fixed chunks in a job temp dir
(`-vn -map 0:a:<idx> -ac 1 -ar 16000 -c:a pcm_s16le -f segment -segment_time 600`):
16 kHz mono WAV, 10-minute chunks (~19 MB, under typical 25 MB API limits). Temp dir
removed on every exit path.
3. Per chunk: `POST /v1/audio/transcriptions` (multipart: file, model,
`response_format=verbose_json`, optional `language`, `temperature=0`) via the shared
`llm.Client` with the shared retry/backoff. Segment timestamps are offset by the chunk
start and merged.
4. Cue building: one cue per Whisper segment; text wrapped to at most two lines
(~42 chars/line). Empty/whitespace segments dropped.
5. Store as `downloaded_subtitles` (provider `transcribed`, SRT, release name
`"<Language> (AI transcribed)"`), notify `SubtitleReady`.
6. `transcribe_translate`: feed the cues to the existing `Translator`; store the translated
track (provider `translated`) and complete.
Chunks are processed starting from the chunk containing the viewer's playhead, then
wrapping — same UX as translation — and cues stream live to the requesting session through
the existing notifier. Progress bands: extract 0–10 %, transcribe 10–70 % (per chunk),
translate 70–95 %.
Known v1 limitation: fixed chunk boundaries can clip a word at the seam; acceptable, noted
for a silence-aligned follow-up.
### API / UI
The existing enqueue endpoint `POST /api/v1/subtitles/ai/translate` gains an optional
`kind` (default `translate`; backward compatible). `GET /api/v1/subtitles/ai/status` gains
`transcribe_enabled`. The player's translate modal gains a "Generate from audio" mode that
lists audio tracks and a target language — the natural path when the only subtitle tracks
are bitmap (today's hard error) or none exist.
## Frontend
- **AI Services settings page** (`web/src/pages/admin-settings/`): connection card
(base URL, API key, chat model, ASR model, optional ASR base URL/key), features card
(subtitle translation, ASR, metadata translation toggles; concurrency; batch tuning).
The AI card currently inside `SubtitlesSettings.tsx` moves here; subtitle settings keep
the download-provider config.
- **Metadata editor**: "Translate with AI" action in `EditMetadataDialog.tsx` (language,
include children, force), with job-poll progress and query invalidation on completion.
- **Library settings**: `auto_translate_metadata` toggle next to the metadata-language
field.
- **Player**: "Generate from audio" mode in `SubtitleTranslateModal.tsx`.
## Client (Android / Apple) impact
None required. Translated overviews/taglines arrive through the existing localized detail
responses; ASR/translated tracks arrive as ordinary downloaded subtitles. Optional
follow-ups in the client repos: surface "Generate from audio" in their subtitle menus
(the jobs API is shared) and the library auto-translate toggle in their admin surfaces.
## Reliability
- All jobs: idempotency dedup of in-flight work, heartbeat + stale reaper (crash-safe,
multi-instance safe), terminal-state handling on cancel/shutdown — one implementation in
`jobrunner`, no copy-paste between the two services.
- Provider-wins/manual-wins provenance is enforced in SQL (single-statement upserts), not
in racy read-modify-write Go code.
- The shared semaphore bounds total endpoint load; ASR temp space is cleaned on success,
failure, cancel, and shutdown.
- Auto-translate re-runs are free: skip-if-filled happens before any LLM call.
## Migrations
One Goose migration (`make migrate-create NAME=ai_metadata_translation_and_asr`):
1. `metadata_translation_jobs` table + active-idempotency / content / status indexes.
2. Provenance columns on the three localization tables (+ check constraints).
3. `media_folders.auto_translate_metadata`.
Down: drop the table, columns, and constraint.
## Risks / open questions
- **Whisper-endpoint compatibility.** OpenAI, Groq, faster-whisper-server/speaches all
speak `verbose_json`, but segment quality varies; the cue builder must tolerate missing
segment arrays (fall back to one cue per chunk worth of `text` is *not* acceptable —
fail the job with a clear message instead).
- **`LocalizeItemModel` empty-field semantics** must be verified before AI rows (empty
titles) ship; this is called out as an explicit plan task.
- **Long-running ASR on slow local Whisper servers** can exceed the 10-minute HTTP client
timeout per chunk; the transcription call uses a per-request timeout sized for chunk
duration rather than the client default.
- **Settings sprawl.** The `ai.*`/legacy fallback leaves old `subtitle_ai.*` connection
rows in place indefinitely; harmless, but a future settings-GC could prune them once the
new keys are written.
@@ -0,0 +1,78 @@
# On-view description translation + per-profile metadata language — design
**Date:** 2026-06-10
**Status:** Approved direction (per-profile preference; auto with button fallback; any user behind an opt-in setting)
**Builds on:** `docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md`
Commands assume the repository root is the cwd.
## Problem
Metadata translation currently triggers from the admin editor or the per-library
refresh fallback, always targeting the library's metadata language. A viewer whose
language differs from the library's (or whose library was never auto-translated) has
no way to get descriptions in their language, and there is no per-person language
preference at all — presentation language is library-scoped.
## Design
### Per-profile preferred metadata language
- New column `user_profiles.preferred_metadata_language text NOT NULL DEFAULT ''`
(empty = inherit the library's metadata language). Threaded through the profile
model/repo/API exactly like the existing `language` (UI language) field, plus a
picker in the profile settings UI with an "Inherit library default" option.
- `access.Scope` gains `PreferredMetadataLanguage`, populated when the access
resolver loads the profile, so every request already carries it.
- `catalog.AccessFilter` gains `ProfilePreferredLanguage`, and presentation language
resolution becomes: explicit `PresentationLanguage` (request param) →
**profile preference** → library `metadata_language`. Applied wherever the scope
is available (native API and jellycompat), so all clients get profile-localized
metadata server-side.
### "Not in your language" signal
`ItemDetail` gains `pending_translation_language` (omitted when empty): set when the
resolved presentation language differs from the item's default metadata language,
the base overview is non-empty, and no localization overview exists for that
language. It is pure data — clients combine it with the AI feature status to decide
what to render.
### On-view trigger
- New setting `metadata_ai.on_view`: `off` (default) | `button` | `auto`, surfaced
on `GET /api/v1/metadata/ai/status` as `on_view`.
- New authenticated endpoint `POST /api/v1/items/{id}/translate-description`
`{target_language}` — any profile, gated by `metadata_ai` readiness +
`on_view != off`, item access enforced with the same `EnsureAccessible` check the
watched-state endpoints use. The client echoes back the language the server
reported in `pending_translation_language`.
- The endpoint goes through a new `Service.RequestOnView`, which adds one guard on
top of the existing pipeline: if the latest job for (content, language) failed
within a cooldown window, return it instead of enqueuing — a broken endpoint must
not be retried on every page view. Existing guarantees do the rest: in-flight
idempotency collapses concurrent viewers onto one job, skip-if-filled makes
repeats free, and the shared semaphore caps endpoint load.
### Web UX
On the detail page, when `pending_translation_language` is set:
- `on_view=auto`: fire the request on view (once per item), pulse-animate the
description while waiting, and poll the detail query until the flag clears (the
job's first batch translates the item's own overview, so this lands in seconds
even when a series job continues episodes in the background). Time out after ~45 s
and show the original text — no error states in the viewer's face.
- `on_view=button`: a small "Translate" chip near the description triggers the same
flow on click.
No job-status endpoint for viewers: completion is observed as the flag clearing on
refetch; failures surface only as the shimmer timing out (the cooldown stops
re-triggering).
## Non-goals
- Per-profile language in the Android/Apple UIs (server-side serving means they
already *receive* localized metadata; surfacing the trigger there is a client
follow-up using the same endpoint).
- Translating titles, or any admin-side behavior changes.
@@ -0,0 +1,170 @@
# Tiered Security Model: Group-Based Permissions — Design
Date: 2026-06-10
Status: Approved design, pending implementation plan
Commands and paths in this document assume the repository root is the cwd.
## Overview
Replace Silo's per-user authorization fields with a group-based model:
- Admins create **groups**; each group carries a full policy (feature permissions, library access, playback/transcode limits, download rights).
- Users are assigned to one or more groups; effective policy is the most-permissive union of their groups.
- Groups are the **only** source of policy. Per-user policy columns are removed.
- Two protected built-in groups ship by default: `administrators` and `users`. New accounts join the group(s) named by a server setting (initially `users`).
- The `users.role` column and JWT role claim are removed; "admin" becomes a permission granted by group membership.
Deployments may have **thousands of users**. All fan-out paths (revision bumps, member listings, migration artifacts) are designed set-based and paginated accordingly.
## Current state (what this replaces)
- `users.role` text (`"admin"`/`"user"`), checked by `RequireAdmin` middleware via JWT `Claims.Role` (`internal/api/middleware/auth.go`).
- `users.permissions text[]` with two assignable permissions (`marker_edit`, `metadata_curation`) defined in `internal/auth/permissions.go`; admins implicitly hold all.
- Per-user resource policy columns on `users`: `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
- `users.access_policy_revision` invalidates profile tokens via `internal/access/resolver.go`.
- Household profiles (`user_profiles`) carry a separate restriction layer (child flag, content rating, allowed libraries). **Profiles are out of scope** — that layer is unchanged and continues to apply on top of the user's effective policy.
## Data model
### `groups`
```sql
CREATE TABLE groups (
id serial PRIMARY KEY,
slug text NOT NULL UNIQUE, -- stable identifier: 'administrators', 'users', ...
name text NOT NULL, -- display name, renamable
description text NOT NULL DEFAULT '',
built_in boolean NOT NULL DEFAULT false,
permissions text[] NOT NULL DEFAULT '{}',
library_ids integer[], -- NULL = all libraries
max_streams integer NOT NULL DEFAULT 6,
max_transcodes integer NOT NULL DEFAULT 2,
max_profiles integer NOT NULL DEFAULT 5,
max_playback_quality text NOT NULL DEFAULT '', -- '' = unrestricted
download_allowed boolean NOT NULL DEFAULT true,
download_transcode_allowed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
```
### `user_groups`
```sql
CREATE TABLE user_groups (
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id integer NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX user_groups_group_id_idx ON user_groups (group_id);
```
### Admin as a permission
`'admin'` is a permission string in `groups.permissions`, not a column. The resolver treats it as implying every permission and unrestricted policy, mirroring the current `HasEffectivePermission` short-circuit for `role == "admin"`. One mechanism covers everything.
Permission catalog at launch: `admin`, `marker_edit`, `metadata_curation`. The catalog stays a Go-level allowlist in `internal/auth/permissions.go`; adding a permission is a code change, as today.
### Built-in groups
| Slug | Permissions | Policy | Protections |
|---|---|---|---|
| `administrators` | `['admin']` | irrelevant (admin implies unrestricted) | not deletable; `admin` permission not removable; last enabled member not removable |
| `users` | `['marker_edit']` | all libraries, 6 streams, 2 transcodes, 5 profiles, downloads on, transcode downloads off | not deletable |
Protections are enforced at the service layer (consistent with existing last-admin guards), not by triggers.
### `users` table changes
Dropped: `role`, `permissions`, `library_ids`, `max_streams`, `max_transcodes`, `max_profiles`, `max_playback_quality`, `download_allowed`, `download_transcode_allowed`.
Kept: `enabled` (account kill-switch stays user-level), `access_policy_revision` (still the invalidation counter).
### Default group setting
A `server_settings` entry `users.default_group_slugs` (initially `["users"]`), consumed by all account-provisioning paths (signup, invite, OAuth) in `internal/auth/account_provisioner.go`. Invite codes may later override it; out of scope now.
## Resolution semantics
A user's **effective policy** is the most-permissive union of their groups:
| Field | Rule |
|---|---|
| `permissions` | set union; `admin` present → every permission granted |
| `library_ids` | union; any group with `NULL` (all) or `admin` → unrestricted |
| `max_streams`, `max_transcodes`, `max_profiles` | 0 means unlimited at enforcement, so any group with 0 wins; otherwise max across groups |
| `max_playback_quality` | highest wins; `''` (unrestricted) beats everything (ordering as in `internal/access/quality.go`) |
| `download_allowed`, `download_transcode_allowed` | OR |
**Zero groups → empty policy**: no permissions, no libraries (lockout is enforced by the empty library list, so stream/transcode limits are irrelevant; `max_profiles` is floored at 1 since 0 would mean unlimited profile creation). The user can authenticate but sees an empty server. This is deliberate ("groups only grant") and gives admins a soft-disable distinct from `enabled = false`.
**Where computed:** the user repository (`internal/auth/repository.go`) aggregates group policy in the same load path as the user row (a second indexed query over `user_groups`/`groups`, batched for list views; cost is O(memberships per user), independent of total user count). `models.User` keeps its existing policy fields (`LibraryIDs`, `MaxStreams`, `Permissions`, …) but they become **effective** values populated from the aggregate. The ~25 consumer packages (playback, catalog access filters, jellycompat, sections, audiobooks, requests, …) keep working unchanged. `Role` is replaced by `IsAdmin bool` derived from effective permissions. Assigned memberships are exposed separately (`GroupIDs`) for the admin API.
**Invalidation:** unchanged mechanism, new triggers:
- Group policy edit → bump `access_policy_revision` for all members in **one set-based `UPDATE ... FROM user_groups`** (never a per-user loop; the `users` group may have thousands of members).
- Membership add/remove → bump the affected user's revision.
The profile-token revision check in `internal/access/resolver.go` then invalidates stale scopes automatically. The post-bump re-resolution stampede is bounded: each active session re-resolves once with a single indexed query on its next request. No new caching layer until profiling shows a need.
## Enforcement changes
- **JWT claims:** `Claims.Role` removed. Claims carry identity only (`user_id`, session, profile, revision) — never policy. Fixes the existing staleness gap where a role change waited for token re-issuance; after this, revoking admin takes effect on the next request.
- **`RequireAdmin`** (`internal/api/middleware/auth.go`): loads the user (effective policy included) and checks `IsAdmin`; stashes the loaded user in request context so handlers don't re-fetch. One indexed query on low-traffic admin routes.
- **Permission middleware** (`internal/api/middleware/permissions.go`): logic unchanged — it already operates on `models.User`. Only `user.Role == "admin"` comparisons become `user.IsAdmin`.
- **Jellyfin compat** (`internal/jellycompat/handlers_auth.go`): `UserPolicy` maps from effective policy — `IsAdministrator` ← `IsAdmin`, `EnableContentDownloading` ← `DownloadAllowed`, `EnableAllFolders`/`EnabledFolders` ← `LibraryIDs`. Replaces the currently hardcoded policy values.
- **Requests service** (`internal/requests`): role-string comparisons → `IsAdmin`; otherwise unchanged.
## API surface
All new endpoints behind `RequireAdmin`, under the existing admin API namespace:
- `GET /api/admin/groups` — list groups with member counts (single aggregated count query)
- `POST /api/admin/groups` — create; slug auto-derived from name
- `GET /api/admin/groups/{id}` — group detail: policy + member **count** (members are paginated separately)
- `GET /api/admin/groups/{id}/members?offset=&limit=` — paginated member list
- `PATCH /api/admin/groups/{id}` — update name/description/policy; rejects removing `admin` from `administrators`
- `DELETE /api/admin/groups/{id}` — rejects built-ins; memberships cascade
- `PUT /api/admin/groups/{id}/members/{userID}` — add member
- `DELETE /api/admin/groups/{id}/members/{userID}` — remove member; rejects removing the last enabled member of `administrators`
Changed endpoints:
- `POST`/`PATCH` admin user endpoints: per-user policy fields removed from request bodies, replaced by `group_ids`. Responses gain `groups: [{id, slug, name}]`; the existing top-level policy fields (`library_ids`, `max_streams`, …) become read-only effective values derived from group membership.
- User-facing account/session responses keep a **derived** `role` field (`"admin"`/`"user"` from `IsAdmin`) so `silo-android` and `silo-apple` need no immediate changes; group info is added alongside.
Default-group setting rides the existing server-settings endpoints.
## Admin UI (`web/src/pages/admin`)
- New **Groups** page: list (name, member count, key-policy summary) and an editor with three sections — permissions (checkboxes), library access (all / picker, reusing the user editor's component), limits (streams, transcodes, quality, downloads, profiles). Member management with a paginated member list and user search to add.
- **Users** page: per-user policy controls replaced by a group multi-select plus a read-only "effective policy" summary showing the resolved union — the admin's answer to "why can this user do X".
- Built-in groups render with a badge; delete and protected fields disabled with explanatory tooltips.
## Migration
One transactional Goose migration created with `make migrate-create NAME=group_based_permissions`:
1. Create `groups` and `user_groups`; seed `administrators` and `users` (policy from current column defaults).
2. Map members: `role = 'admin'` → `administrators` only. Non-admin users whose policy columns match the defaults → `users`.
3. Preserve outliers **bucketed by distinct policy tuple**: non-admin users whose policy columns deviate from the defaults are grouped by their unique `(permissions, library_ids, max_streams, max_transcodes, max_profiles, max_playback_quality, download_allowed, download_transcode_allowed)` combination; one group per distinct tuple (`migrated-policy-1`, `-2`, …) is created carrying those values, and matching users are added **to that group only — not to `users`**. (Adding outliers to `users` would erase their restrictions via the most-permissive union: `users` grants all libraries, so a user previously limited to specific libraries would become unrestricted.) Group count is bounded by distinct policies in use, not user count. Post-migration effective policy is exactly each user's prior policy; nothing is silently lost or gained. (Admins were already unrestricted, so admin outliers need no preservation.)
4. Drop the old `users` columns; bump all `access_policy_revision` values set-based.
Down-migration recreates the columns and writes back each user's flattened effective policy (best-effort; acceptable at WIP stage).
## Testing
- Union resolver unit tests: multi-group merges, `admin` short-circuit, zero-group empty policy, NULL-libraries propagation, quality ordering (extending `internal/access/quality_test.go` coverage).
- Service-layer guard tests: delete built-in, strip `admin` from `administrators`, remove last enabled administrator.
- Migration test against a seeded DB asserting before/after effective-policy equivalence, including outlier users sharing a policy tuple.
- Handler tests for group endpoints incl. member pagination; updated user-endpoint tests for changed payloads.
- Jellycompat test asserting `UserPolicy` reflects group-derived policy.
## Risks & follow-ups
- **Client coordination:** `silo-android` / `silo-apple` keep working via the derived `role` field; follow-up tickets to surface group membership in account UI.
- **Revision-bump stampede:** bounded and acceptable (one re-resolve per active session); revisit with a cache only if profiling shows pressure.
- **Invite-code group overrides** and an expanded permission catalog (requests, watch-together, etc.) are natural follow-ups, out of scope here.
- **Plugin/API-key auth paths** (`internal/auth/api_key_repository.go`, jellycompat API keys) must be audited during implementation for any `role` reads not covered above.
+31 -8
View File
@@ -1,6 +1,6 @@
module github.com/Silo-Server/silo-server
go 1.26.3
go 1.26.4
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
@@ -14,8 +14,8 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2 // indirect
github.com/redis/go-redis/v9 v9.18.0
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.45.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -26,34 +26,55 @@ require (
github.com/h2non/bimg v1.1.9
github.com/hashicorp/go-hclog v1.6.3
github.com/joho/godotenv v1.5.1
github.com/mmcdole/gofeed v1.3.0
github.com/oklog/ulid/v2 v2.1.0
github.com/pgvector/pgvector-go v0.3.0
github.com/pressly/goose/v3 v3.27.1
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.39.0
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gookit/color v1.5.4 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
github.com/zishang520/engine.io-go-parser v1.3.2 // indirect
github.com/zishang520/engine.io/v2 v2.5.0 // indirect
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 // indirect
github.com/zishang520/webtransport-go v0.9.1 // indirect
go.opentelemetry.io/otel/sdk/metric v1.41.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/net v0.53.0 // indirect
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.5.0
github.com/Silo-Server/silo-plugin-sdk v0.6.0
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
@@ -78,8 +99,10 @@ require (
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/sync v0.20.0
golang.org/x/text v0.36.0
golang.org/x/text v0.37.0
golang.org/x/time v0.14.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
+64 -15
View File
@@ -1,9 +1,15 @@
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
github.com/Silo-Server/silo-plugin-sdk v0.5.0 h1:0yJpD0RP1AetGsWVCXoPih2ggLjN8hVp8blS+2RkIWU=
github.com/Silo-Server/silo-plugin-sdk v0.5.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0 h1:Gi9TdH9kt7b8X4xRXH493/nSYb9n0GO4VCWmlll0hKI=
github.com/Silo-Server/silo-plugin-sdk v0.6.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
@@ -38,7 +44,6 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -46,6 +51,8 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
@@ -68,8 +75,11 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg=
@@ -98,6 +108,8 @@ github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
@@ -121,6 +133,15 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -144,6 +165,14 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -174,16 +203,28 @@ github.com/uptrace/bun/driver/pgdriver v1.1.12 h1:3rRWB1GK0psTJrHwxzNfEij2MLibgg
github.com/uptrace/bun/driver/pgdriver v1.1.12/go.mod h1:ssYUP+qwSEgeDDS1xm2XBip9el1y9Mi5mTAvLoiADLM=
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser v0.1.2 h1:gnjoVuB/kljJ5wICEEOpx98oXMWPLj22G67Vbd1qPqc=
github.com/vmihailenco/tagparser v0.1.2/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHgvgickp1Yw510KJOqX7H24mg8=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
github.com/zishang520/engine.io-go-parser v1.3.2 h1:aEVrhQVhfk99Ct6htNffgHydUBC4dGclO/OXPz5CSy0=
github.com/zishang520/engine.io-go-parser v1.3.2/go.mod h1:fg/R4V7aytYwUTu4lGcPdjenDSXFWLlkDAGewWVOo3o=
github.com/zishang520/engine.io/v2 v2.5.0 h1:0ayZCt51c8lntxG5AWoM2mX40ryZlvRodAULXB1XK/s=
github.com/zishang520/engine.io/v2 v2.5.0/go.mod h1:ohfMsnzOCA9NEklEGiQ5Y9j6cWvzLNeVFaB+Bkn1KcQ=
github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+qUJZo9beZLK8ZXOwKYo=
github.com/zishang520/socket.io-go-parser/v2 v2.5.0/go.mod h1:GK9GIIs/KQbBKfnxgZJMBYSlsFemB2rL7EFUn0kmTfM=
github.com/zishang520/socket.io/v2 v2.5.0 h1:+KdZLbl4wWVzyI84RG+h21t8OY4ifz/Oo6qwgt9zHl8=
github.com/zishang520/socket.io/v2 v2.5.0/go.mod h1:+GyoPyakXDS6KsW81RAQpDA9+mJBXbcYcQ+Itx2D+rU=
go.n16f.net/thumbhash v1.1.0 h1:aBEvuAd4yiwzeQ7Sm4BZoHJYbrQ1ewjrmrRlCE79snk=
go.n16f.net/thumbhash v1.1.0/go.mod h1:mo9pP7WtfdV9ojIamGFR/Vc0PaPA2l0CUtmYQf/SweU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -202,29 +243,37 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
+5
View File
@@ -5,4 +5,9 @@ import "errors"
var (
ErrProfileNotFound = errors.New("profile not found")
ErrProfileUnverified = errors.New("profile unverified")
// ErrUserDisabled is returned when the viewer's account is disabled.
// Scope resolution re-loads the user on every request, so disabling an
// account denies content access on the next request for every surface
// that resolves viewer scope (native API and Jellyfin compat alike).
ErrUserDisabled = errors.New("user account disabled")
)
+14
View File
@@ -79,3 +79,17 @@ func MinQuality(a, b string) string {
return b
}
}
// MaxQuality returns the more permissive quality ceiling. An empty string
// means unrestricted and beats any concrete ceiling.
func MaxQuality(a, b string) string {
a = NormalizePlaybackQuality(a)
b = NormalizePlaybackQuality(b)
if strings.TrimSpace(a) == "" || strings.TrimSpace(b) == "" {
return ""
}
if CompareQuality(a, b) >= 0 {
return a
}
return b
}
+19
View File
@@ -54,3 +54,22 @@ func TestParsePlaybackQualityPreset(t *testing.T) {
t.Fatal("expected invalid preset to return ok=false")
}
}
func TestMaxQuality(t *testing.T) {
cases := []struct {
a, b, want string
}{
{"", "", ""},
{"", "1080p", ""}, // unrestricted beats any ceiling
{"2160p", "", ""},
{"1080p", "2160p", "2160p"},
{"2160p", "1080p", "2160p"},
{"1080p", "1080p", "1080p"},
{"720p", "480p", "1080p"}, // presets normalize: 720p/480p both -> 1080p (standard)
}
for _, tc := range cases {
if got := MaxQuality(tc.a, tc.b); got != tc.want {
t.Errorf("MaxQuality(%q, %q) = %q, want %q", tc.a, tc.b, got, tc.want)
}
}
}
+4
View File
@@ -46,6 +46,9 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
if err != nil {
return Scope{}, fmt.Errorf("loading user %d: %w", input.UserID, err)
}
if !user.Enabled {
return Scope{}, ErrUserDisabled
}
scope := Scope{
UserID: user.ID,
@@ -73,6 +76,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.MaxContentRating = profile.MaxContentRating
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(user.LibraryIDs, profile)
scope.ProfileVerified = profile.PINHash == "" || input.SkipPINVerification
+20 -7
View File
@@ -256,7 +256,7 @@ func (s stubStore) DeleteLibraryPlaybackPreference(context.Context, string, int)
func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -285,7 +285,7 @@ func TestResolver_UnrestrictedAccountRestrictedProfile(t *testing.T) {
func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 3}, MaxPlaybackQuality: "1080p", AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
nil,
)
@@ -304,7 +304,7 @@ func TestResolver_RestrictedAccountInheritingProfile(t *testing.T) {
func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3}, AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -324,7 +324,7 @@ func TestResolver_IntersectsAccountAndProfileLibraries(t *testing.T) {
func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1}, AccessPolicyRevision: 4}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{
ID: "prof-1",
LibraryRestrictionsEnabled: true,
@@ -344,7 +344,7 @@ func TestResolver_EmptyEffectiveLibraries(t *testing.T) {
func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[3,5]"},
@@ -367,7 +367,7 @@ func TestResolver_DisabledLibraries_UnrestrictedUser(t *testing.T) {
func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, LibraryIDs: []int{1, 2, 3, 4}, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{ID: "prof-1"},
settings: map[string]string{"disabled_library_ids": "[2,4]"},
@@ -388,9 +388,22 @@ func TestResolver_DisabledLibraries_RestrictedUser(t *testing.T) {
}
}
func TestResolver_DisabledUserIsDenied(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, Enabled: false, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{profile: &userstore.Profile{ID: "prof-1"}}},
nil,
)
_, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if !errors.Is(err, ErrUserDisabled) {
t.Fatalf("Resolve() error = %v, want ErrUserDisabled", err)
}
}
func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubUserRepo{user: &models.User{ID: 1, Enabled: true, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
settings: map[string]string{"disabled_library_ids": "[7]"},
}},
+5 -2
View File
@@ -9,8 +9,11 @@ type Scope struct {
LibrariesRestricted bool
MaxContentRating string
MaxPlaybackQuality string
PolicyRevision int64
ProfileVerified bool
// PreferredMetadataLanguage is the profile's metadata (presentation)
// language; "" inherits the library's metadata language.
PreferredMetadataLanguage string
PolicyRevision int64
ProfileVerified bool
}
// ResolveInput is the request input for resolving a viewer access scope.
+197
View File
@@ -0,0 +1,197 @@
// Package jobrunner owns the lifecycle mechanics shared by Silo's AI job
// services (subtitle translation/ASR, metadata translation): bounded dispatch
// off a semaphore shared across services, a heartbeat loop that keeps a row
// alive even while the job is queued, a stale-job reaper for rows orphaned by
// a crashed worker, and a per-job cancel registry. The services keep their own
// job tables, validation, and run logic; this package keeps them honest about
// concurrency and crash recovery without copy-pasting the trickiest code.
package jobrunner
import (
"context"
"log/slog"
"sync"
"time"
)
const (
// A running job refreshes its heartbeat every HeartbeatInterval; one whose
// heartbeat has not advanced for StaleJobThreshold is treated as orphaned
// by a crashed worker and reaped. The margin over HeartbeatInterval avoids
// reaping a job that is merely mid–LLM-call.
HeartbeatInterval = 30 * time.Second
StaleJobThreshold = 2 * time.Minute
// How often the background reaper scans for orphaned jobs.
ReaperInterval = time.Minute
)
// Store is the minimal persistence surface the runner needs. Both AI job
// repositories satisfy it.
type Store interface {
Heartbeat(ctx context.Context, id int64) error
// ResetStaleJobs marks pending/running jobs whose heartbeat predates
// `before` as failed with the given message. Returns rows reset.
ResetStaleJobs(ctx context.Context, before time.Time, message string) (int64, error)
}
// NewSemaphore builds the dispatch semaphore shared across runners, so the
// configured endpoint sees one global bound regardless of job mix. size <= 0
// falls back to 2.
func NewSemaphore(size int) chan struct{} {
if size <= 0 {
size = 2
}
return make(chan struct{}, size)
}
// Runner executes jobs with bounded concurrency, heartbeats, cancellation,
// and crash recovery. One Runner per job table; the semaphore may be shared
// across Runners.
type Runner struct {
// baseCtx is the application context; dispatched jobs and the reaper
// derive from it so they stop when the server shuts down.
baseCtx context.Context
sem chan struct{}
store Store
// label names the job family in log lines ("subtitle ai", "metadata translation").
label string
logger *slog.Logger
mu sync.Mutex
cancels map[int64]context.CancelFunc
wg sync.WaitGroup
}
// New wires a runner. A nil appCtx falls back to context.Background(); a nil
// sem gets a private default-size semaphore (used by tests; production passes
// the shared one).
func New(appCtx context.Context, sem chan struct{}, store Store, label string, logger *slog.Logger) *Runner {
if appCtx == nil {
appCtx = context.Background()
}
if sem == nil {
sem = NewSemaphore(0)
}
if logger == nil {
logger = slog.Default()
}
return &Runner{
baseCtx: appCtx,
sem: sem,
store: store,
label: label,
logger: logger,
cancels: make(map[int64]context.CancelFunc),
}
}
// Recover clears jobs orphaned by a crashed worker and starts a background
// reaper that keeps doing so. Reaping is heartbeat-based (not "every active
// job"), so it is safe when multiple instances share one database: a job
// still being heartbeat-updated by a live worker is never reset. Call once at
// startup; jobs and the reaper derive from the application context, so they
// stop on shutdown.
func (r *Runner) Recover() {
r.reapStaleJobs()
go r.reaperLoop()
}
func (r *Runner) reaperLoop() {
ticker := time.NewTicker(ReaperInterval)
defer ticker.Stop()
for {
select {
case <-r.baseCtx.Done():
return
case <-ticker.C:
r.reapStaleJobs()
}
}
}
func (r *Runner) reapStaleJobs() {
before := time.Now().Add(-StaleJobThreshold)
n, err := r.store.ResetStaleJobs(context.WithoutCancel(r.baseCtx), before, "interrupted by server restart")
if err != nil {
r.logger.Warn("failed to reset stale jobs", "jobs", r.label, "error", err)
return
}
if n > 0 {
r.logger.Info("reset stale jobs", "jobs", r.label, "count", n)
}
}
// Dispatch launches a bounded background goroutine for job id. run executes
// once a semaphore slot is acquired; onAbort runs instead if the job is
// cancelled (user cancel or shutdown) while still waiting for a slot. Both
// receive a context derived from the application context that is cancelled by
// Cancel(id) or server shutdown.
func (r *Runner) Dispatch(id int64, run func(ctx context.Context), onAbort func(ctx context.Context)) {
runCtx, cancel := context.WithCancel(r.baseCtx)
r.mu.Lock()
r.cancels[id] = cancel
r.mu.Unlock()
r.wg.Add(1)
go func() {
defer r.wg.Done()
defer func() {
r.mu.Lock()
delete(r.cancels, id)
r.mu.Unlock()
cancel()
}()
// Heartbeat for the whole lifetime — crucially including while queued
// behind the semaphore — so the stale-job reaper never reaps a job that
// is alive but merely waiting for a slot (which would otherwise mark it
// failed and let it resurrect itself on acquire, or admit a duplicate).
stopHeartbeat := make(chan struct{})
defer close(stopHeartbeat)
go r.heartbeatLoop(runCtx, id, stopHeartbeat)
select {
case r.sem <- struct{}{}:
case <-runCtx.Done():
if onAbort != nil {
onAbort(context.WithoutCancel(runCtx))
}
return
}
defer func() { <-r.sem }()
run(runCtx)
}()
}
// Cancel cancels the in-flight goroutine for id, returning false when none is
// registered (job on another node, or never dispatched) so the caller can
// fall back to a best-effort terminal transition in the database.
func (r *Runner) Cancel(id int64) bool {
r.mu.Lock()
cancel := r.cancels[id]
r.mu.Unlock()
if cancel == nil {
return false
}
cancel()
return true
}
// heartbeatLoop keeps a job's heartbeat_at fresh until the job ends or the
// context is cancelled, so the stale-job reaper only ever reaps jobs orphaned
// by a crashed worker.
func (r *Runner) heartbeatLoop(ctx context.Context, jobID int64, stop <-chan struct{}) {
ticker := time.NewTicker(HeartbeatInterval)
defer ticker.Stop()
for {
select {
case <-stop:
return
case <-ctx.Done():
return
case <-ticker.C:
_ = r.store.Heartbeat(context.WithoutCancel(ctx), jobID)
}
}
}
+158
View File
@@ -0,0 +1,158 @@
package jobrunner
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
type fakeStore struct {
mu sync.Mutex
heartbeats int
resets int
lastBefore time.Time
}
func (s *fakeStore) Heartbeat(context.Context, int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.heartbeats++
return nil
}
func (s *fakeStore) ResetStaleJobs(_ context.Context, before time.Time, _ string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.resets++
s.lastBefore = before
return 0, nil
}
func (s *fakeStore) snapshot() (int, time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
return s.resets, s.lastBefore
}
func TestRecoverReapsImmediatelyWithStaleCutoff(t *testing.T) {
store := &fakeStore{}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
approxNow := time.Now()
New(ctx, nil, store, "test", nil).Recover()
resets, before := store.snapshot()
if resets < 1 {
t.Fatalf("Recover did not reap immediately: resets=%d", resets)
}
want := approxNow.Add(-StaleJobThreshold)
if diff := before.Sub(want); diff > 2*time.Second || diff < -2*time.Second {
t.Errorf("stale cutoff = %v, want ~%v", before, want)
}
}
// One shared semaphore bounds jobs across BOTH runners: with size 1, the
// second runner's job must not start until the first runner's job finishes.
func TestSharedSemaphoreBoundsAcrossRunners(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sem := NewSemaphore(1)
r1 := New(ctx, sem, &fakeStore{}, "one", nil)
r2 := New(ctx, sem, &fakeStore{}, "two", nil)
release := make(chan struct{})
aStarted := make(chan struct{})
var bStarted atomic.Bool
bDone := make(chan struct{})
r1.Dispatch(1, func(context.Context) {
close(aStarted)
<-release
}, nil)
<-aStarted
r2.Dispatch(2, func(context.Context) {
bStarted.Store(true)
close(bDone)
}, nil)
time.Sleep(50 * time.Millisecond)
if bStarted.Load() {
t.Fatal("job B ran while job A held the only slot")
}
close(release)
select {
case <-bDone:
case <-time.After(2 * time.Second):
t.Fatal("job B never ran after the slot freed")
}
}
// Cancelling a job that is queued behind the semaphore aborts it without
// running it, via the onAbort callback.
func TestCancelWhileQueuedCallsOnAbort(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sem := NewSemaphore(1)
r := New(ctx, sem, &fakeStore{}, "test", nil)
release := make(chan struct{})
defer close(release)
started := make(chan struct{})
r.Dispatch(1, func(context.Context) {
close(started)
<-release
}, nil)
<-started
var ran atomic.Bool
aborted := make(chan struct{})
r.Dispatch(2, func(context.Context) {
ran.Store(true)
}, func(context.Context) {
close(aborted)
})
if !r.Cancel(2) {
t.Fatal("Cancel(2) found no in-flight goroutine")
}
select {
case <-aborted:
case <-time.After(2 * time.Second):
t.Fatal("onAbort never ran")
}
if ran.Load() {
t.Fatal("cancelled queued job still ran")
}
}
func TestCancelUnknownJobReturnsFalse(t *testing.T) {
r := New(context.Background(), nil, &fakeStore{}, "test", nil)
if r.Cancel(99) {
t.Fatal("Cancel(99) = true for unknown job")
}
}
// The run context is cancelled by Cancel(id) so an in-flight job can stop.
func TestCancelRunningJobCancelsContext(t *testing.T) {
r := New(context.Background(), NewSemaphore(1), &fakeStore{}, "test", nil)
started := make(chan struct{})
stopped := make(chan struct{})
r.Dispatch(7, func(ctx context.Context) {
close(started)
<-ctx.Done()
close(stopped)
}, nil)
<-started
if !r.Cancel(7) {
t.Fatal("Cancel(7) found no in-flight goroutine")
}
select {
case <-stopped:
case <-time.After(2 * time.Second):
t.Fatal("running job did not observe cancellation")
}
}
+22
View File
@@ -0,0 +1,22 @@
package jobrunner
// Status is the lifecycle state shared by every AI job table.
type Status string
const (
StatusPending Status = "pending"
StatusRunning Status = "running"
StatusCompleted Status = "completed"
StatusFailed Status = "failed"
StatusCancelled Status = "cancelled"
)
// Terminal reports whether a status is final.
func (s Status) Terminal() bool {
switch s {
case StatusCompleted, StatusFailed, StatusCancelled:
return true
default:
return false
}
}
+297
View File
@@ -0,0 +1,297 @@
// Package llm provides the shared OpenAI-compatible API client used by every
// AI feature in Silo (subtitle translation, metadata translation, Whisper ASR).
// One endpoint configuration, one retry/backoff implementation; the operator
// can point it at OpenAI, Groq, a local Ollama/llama.cpp/faster-whisper
// server, etc.
package llm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
)
// Config holds the connection settings for the shared OpenAI-compatible
// endpoints. The ASR fields are optional overrides for operators who run a
// separate Whisper-compatible server next to their chat endpoint; when empty,
// transcription uses the chat endpoint's base URL and key.
type Config struct {
BaseURL string // e.g. "https://api.openai.com" (no trailing /v1)
APIKey string // empty for keyless local servers
ChatModel string // chat-completions model used for translation
ASRBaseURL string // optional; empty = BaseURL
ASRAPIKey string // optional; empty = APIKey
ASRModel string // audio-transcription model, e.g. "whisper-1"
}
func (c Config) asrBaseURL() string {
if c.ASRBaseURL != "" {
return c.ASRBaseURL
}
return c.BaseURL
}
func (c Config) asrAPIKey() string {
if c.ASRAPIKey != "" {
return c.ASRAPIKey
}
return c.APIKey
}
// ChatConfigured reports whether chat completions are minimally configured.
func (c Config) ChatConfigured() bool { return c.BaseURL != "" && c.ChatModel != "" }
// ASRConfigured reports whether audio transcription is minimally configured.
func (c Config) ASRConfigured() bool { return c.asrBaseURL() != "" && c.ASRModel != "" }
// Client is a minimal OpenAI-compatible API client with shared retry/backoff
// conventions (429 with Retry-After, 5xx, transport errors, and gateway "200
// with embedded error object" responses are retried; other 4xx fail fast).
type Client struct {
cfg Config
// chatHTTP caps a single chat completion at 10 minutes. asrHTTP has no
// client-level timeout: a transcription upload's deadline is set per request
// (sized to the chunk duration), which a client timeout would silently cap.
chatHTTP *http.Client
asrHTTP *http.Client
}
// NewClient builds a client from the endpoint config.
func NewClient(cfg Config) *Client {
return &Client{
cfg: cfg,
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
asrHTTP: &http.Client{},
}
}
// Message is one chat-completions message.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatResponseFormat struct {
Type string `json:"type"`
}
type chatCompletionRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float32 `json:"temperature"`
ResponseFormat *chatResponseFormat `json:"response_format,omitempty"`
}
type chatCompletionResponse struct {
Choices []struct {
Message Message `json:"message"`
} `json:"choices"`
// Some OpenAI-compatible gateways (e.g. OpenRouter) return a 200 with an
// error object instead of an HTTP error status when an upstream provider
// fails. We surface and retry on it.
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Chat performs one chat completion and returns the first choice's content.
// When jsonObject is true it requests response_format=json_object; providers
// that ignore the field still work because the prompt itself demands JSON.
func (c *Client) Chat(ctx context.Context, messages []Message, jsonObject bool) (string, error) {
reqBody := chatCompletionRequest{
Model: c.cfg.ChatModel,
Messages: messages,
Temperature: 0.2,
}
if jsonObject {
reqBody.ResponseFormat = &chatResponseFormat{Type: "json_object"}
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("marshal chat request: %w", err)
}
url := endpointURL(c.cfg.BaseURL, "chat/completions")
var content string
err = c.doWithRetry(ctx, c.chatHTTP, "chat API",
func() (*http.Request, error) {
httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if reqErr != nil {
return nil, fmt.Errorf("create request: %w", reqErr)
}
httpReq.Header.Set("Content-Type", "application/json")
if c.cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
}
return httpReq, nil
},
func(respBody []byte) error {
var parsed chatCompletionResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("decode chat response: %w", err)
}
if parsed.Error != nil && parsed.Error.Message != "" {
// 200 with an upstream error object — transient on gateways.
return fmt.Errorf("chat API error: %s", parsed.Error.Message)
}
if len(parsed.Choices) == 0 || parsed.Choices[0].Message.Content == "" {
slog.Warn("AI chat returned no choices, retrying",
"model", c.cfg.ChatModel, "response_bytes", len(respBody))
return fmt.Errorf("chat API returned no choices")
}
content = parsed.Choices[0].Message.Content
return nil
})
if err != nil {
return "", err
}
return content, nil
}
// SystemUserChat performs one chat completion from a system + user prompt
// pair, requesting a JSON object response. Its signature matches
// aitranslate.ChatFn so a client method reference wires straight in.
func (c *Client) SystemUserChat(ctx context.Context, system, user string) (string, error) {
return c.Chat(ctx, []Message{
{Role: "system", Content: system},
{Role: "user", Content: user},
}, true)
}
// permanentError marks a parse failure that retrying cannot fix (e.g. an
// endpoint that structurally does not support the requested response format).
type permanentError struct{ err error }
func (e *permanentError) Error() string { return e.err.Error() }
func (e *permanentError) Unwrap() error { return e.err }
// doWithRetry runs the shared request/retry loop: build constructs a fresh
// request per attempt, parse consumes a 200 body. Transport errors, read
// errors, 429 (honoring Retry-After), 5xx, and retryable parse errors back
// off and retry; other 4xx and permanentError parse failures return at once.
func (c *Client) doWithRetry(ctx context.Context, httpClient *http.Client, label string,
build func() (*http.Request, error), parse func(body []byte) error) error {
const maxAttempts = 6
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
httpReq, err := build()
if err != nil {
return err
}
resp, doErr := httpClient.Do(httpReq)
if doErr != nil {
lastErr = fmt.Errorf("%s request failed: %w", label, doErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
}
respBody, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
// A truncated/failed read could otherwise be misparsed as a valid
// (empty) response; treat it as a retryable transport error.
lastErr = fmt.Errorf("read %s response: %w", label, readErr)
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
wait := rateLimitBackoff(resp, attempt)
slog.Warn("rate limited by AI API, waiting", "api", label, "attempt", attempt+1, "wait", wait)
lastErr = fmt.Errorf("%s returned 429: %s", label, Truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, wait); waitErr != nil {
return waitErr
}
continue
case resp.StatusCode >= 500:
lastErr = fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
continue
case resp.StatusCode != http.StatusOK:
// 4xx other than 429: not retryable.
return fmt.Errorf("%s returned %d: %s", label, resp.StatusCode, Truncate(string(respBody), 300))
}
parseErr := parse(respBody)
if parseErr == nil {
return nil
}
var perm *permanentError
if errors.As(parseErr, &perm) {
return perm.err
}
lastErr = parseErr
if waitErr := sleepCtx(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil {
return waitErr
}
}
if lastErr == nil {
lastErr = fmt.Errorf("%s: retries exhausted", label)
}
return lastErr
}
// endpointURL joins a configured base URL with an OpenAI API path,
// tolerating bases that already include the version segment (e.g.
// DeepInfra's https://api.deepinfra.com/v1/openai) alongside bare hosts
// (https://api.openai.com) and prefixed hosts (https://api.groq.com/openai).
func endpointURL(base, path string) string {
base = strings.TrimRight(base, "/")
if strings.Contains(base, "/v1") {
return base + "/" + path
}
return base + "/v1/" + path
}
// Truncate caps a string for inclusion in an error or log line.
func Truncate(s string, maxLen int) string {
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// sleepCtx waits for d or until ctx is cancelled.
func sleepCtx(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
// rateLimitBackoff returns how long to wait after a 429, honoring Retry-After
// and otherwise backing off exponentially (capped at 60s).
func rateLimitBackoff(resp *http.Response, attempt int) time.Duration {
if ra := resp.Header.Get("Retry-After"); ra != "" {
if secs, err := strconv.Atoi(ra); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
}
wait := 10 * time.Second * (1 << attempt)
if wait > 60*time.Second {
wait = 60 * time.Second
}
return wait
}
+350
View File
@@ -0,0 +1,350 @@
package llm
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func chatConfig(baseURL string) Config {
return Config{BaseURL: baseURL, APIKey: "test-key", ChatModel: "test-model"}
}
const chatOK = `{"choices":[{"message":{"role":"assistant","content":"hello"}}]}`
func TestChatSuccessSendsAuthAndModel(t *testing.T) {
var gotAuth, gotBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
buf := make([]byte, 4096)
n, _ := r.Body.Read(buf)
gotBody = string(buf[:n])
w.Write([]byte(chatOK))
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, true)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if out != "hello" {
t.Errorf("content = %q, want hello", out)
}
if gotAuth != "Bearer test-key" {
t.Errorf("auth = %q", gotAuth)
}
if !strings.Contains(gotBody, `"model":"test-model"`) || !strings.Contains(gotBody, `"json_object"`) {
t.Errorf("request body missing model/response_format: %s", gotBody)
}
}
func TestChatRetriesOn429HonoringRetryAfter(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if calls.Add(1) == 1 {
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Write([]byte(chatOK))
}))
defer srv.Close()
start := time.Now()
c := NewClient(chatConfig(srv.URL))
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err != nil {
t.Fatalf("Chat: %v", err)
}
if calls.Load() != 2 {
t.Errorf("calls = %d, want 2", calls.Load())
}
if elapsed := time.Since(start); elapsed < time.Second {
t.Errorf("did not honor Retry-After: elapsed %v", elapsed)
}
}
func TestChatRetriesOn5xxAndEmbeddedErrorAndEmptyChoices(t *testing.T) {
responses := []func(w http.ResponseWriter){
func(w http.ResponseWriter) { w.WriteHeader(http.StatusBadGateway) },
func(w http.ResponseWriter) { w.Write([]byte(`{"error":{"message":"upstream sad"}}`)) },
func(w http.ResponseWriter) { w.Write([]byte(`{"choices":[]}`)) },
func(w http.ResponseWriter) { w.Write([]byte(chatOK)) },
}
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
responses[calls.Add(1)-1](w)
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
out, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false)
if err != nil {
t.Fatalf("Chat: %v", err)
}
if out != "hello" || calls.Load() != 4 {
t.Errorf("out=%q calls=%d, want hello/4", out, calls.Load())
}
}
func TestChatFailsFastOnNon429ClientError(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c := NewClient(chatConfig(srv.URL))
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "hi"}}, false); err == nil {
t.Fatal("expected error")
}
if calls.Load() != 1 {
t.Errorf("calls = %d, want 1 (no retry on 401)", calls.Load())
}
}
const verboseJSON = `{"language":"english","text":"hi there","segments":[{"start":0.0,"end":1.5,"text":" hi"},{"start":1.5,"end":3.0,"text":" there"}]}`
func TestTranscribeParsesSegmentsAndMultipart(t *testing.T) {
var gotModel, gotFormat, gotLang, gotAuth, gotVAD string
var gotGranularities []string
var gotFile []byte
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if err := r.ParseMultipartForm(32 << 20); err != nil {
t.Errorf("parse multipart: %v", err)
}
gotModel = r.FormValue("model")
gotFormat = r.FormValue("response_format")
gotLang = r.FormValue("language")
gotVAD = r.FormValue("vad_filter")
gotGranularities = r.MultipartForm.Value["timestamp_granularities[]"]
f, _, err := r.FormFile("file")
if err == nil {
buf := make([]byte, 64)
n, _ := f.Read(buf)
gotFile = buf[:n]
f.Close()
}
w.Write([]byte(verboseJSON))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
c := NewClient(cfg)
tr, err := c.Transcribe(context.Background(), TranscribeRequest{
Filename: "chunk.wav", Audio: []byte("RIFFfake"), Language: "ja",
})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if gotModel != "whisper-test" || gotFormat != "verbose_json" || gotLang != "ja" {
t.Errorf("fields model=%q format=%q lang=%q", gotModel, gotFormat, gotLang)
}
// Local (non-hosted) endpoint: faster-whisper VAD must be requested or
// segment timestamps stretch wall-to-wall across silence.
if gotVAD != "true" {
t.Errorf("vad_filter = %q, want true for a self-hosted endpoint", gotVAD)
}
if fmt.Sprint(gotGranularities) != "[segment word]" {
t.Errorf("timestamp_granularities[] = %v, want [segment word]", gotGranularities)
}
if gotAuth != "Bearer test-key" {
t.Errorf("auth = %q (should fall back to chat key)", gotAuth)
}
if string(gotFile) != "RIFFfake" {
t.Errorf("file payload = %q", gotFile)
}
if tr.Language != "english" || len(tr.Segments) != 2 || tr.Segments[1].Text != " there" || tr.Segments[1].End != 3.0 {
t.Errorf("unexpected transcription: %+v", tr)
}
}
func TestTranscribeOmitsVADFilterForStrictHostedEndpoints(t *testing.T) {
for url, wantVAD := range map[string]bool{
"https://api.openai.com": false,
"https://myorg.openai.azure.com": false,
"https://api.groq.com/openai": false,
"http://192.168.1.10:8000": true,
"https://whisper.example.com": true,
"https://api.deepinfra.com/v1/openai": true,
} {
got := !hostMatchesAny(url, strictHostedASRHosts)
if got != wantVAD {
t.Errorf("vad_filter for %s = %v, want %v", url, got, wantVAD)
}
}
}
func TestTranscribeParsesPerSegmentWords(t *testing.T) {
// speaches/faster-whisper shape: words nested inside each segment.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"en","text":"hi there","segments":[
{"start":0.0,"end":3.0,"text":" hi there","words":[
{"start":0.2,"end":0.5,"word":" hi"},{"start":0.6,"end":1.0,"word":" there"}]}]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
words := tr.Segments[0].Words
if len(words) != 2 || words[1].Text != " there" || words[1].Start != 0.6 || words[1].End != 1.0 {
t.Errorf("segment words = %+v", words)
}
}
func TestTranscribeAttachesTopLevelWordsBySegmentTime(t *testing.T) {
// OpenAI shape: words in a top-level array, segments without words.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"en","text":"hi there friend","segments":[
{"start":0.0,"end":1.5,"text":" hi there"},{"start":1.5,"end":3.0,"text":" friend"}],
"words":[{"start":0.2,"end":0.5,"word":"hi"},{"start":0.6,"end":1.0,"word":"there"},
{"start":1.8,"end":2.2,"word":"friend"}]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if len(tr.Segments[0].Words) != 2 || len(tr.Segments[1].Words) != 1 {
t.Errorf("word distribution = %d/%d, want 2/1", len(tr.Segments[0].Words), len(tr.Segments[1].Words))
}
if tr.Segments[1].Words[0].Text != "friend" {
t.Errorf("segment 1 word = %+v", tr.Segments[1].Words[0])
}
}
func TestTranscribeEmptySegmentsIsNotAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"language":"english","text":"","segments":[]}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
tr, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if len(tr.Segments) != 0 {
t.Errorf("segments = %v, want empty", tr.Segments)
}
}
func TestTranscribeMissingSegmentsFieldFailsFast(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.Write([]byte(`{"text":"plain response without segments"}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "verbose_json") {
t.Fatalf("err = %v, want verbose_json complaint", err)
}
if calls.Load() != 1 {
t.Errorf("calls = %d, want 1 (permanent error must not retry)", calls.Load())
}
}
func TestTranscribeUsesASROverrides(t *testing.T) {
var gotAuth atomic.Value
asrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth.Store(r.Header.Get("Authorization"))
w.Write([]byte(verboseJSON))
}))
defer asrSrv.Close()
cfg := Config{
BaseURL: "http://chat.invalid", APIKey: "chat-key", ChatModel: "m",
ASRBaseURL: asrSrv.URL, ASRAPIKey: "asr-key", ASRModel: "whisper-test",
}
if _, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err != nil {
t.Fatalf("Transcribe: %v", err)
}
if gotAuth.Load() != "Bearer asr-key" {
t.Errorf("auth = %q, want asr-key", gotAuth.Load())
}
}
func TestTranscribeRequiresConfig(t *testing.T) {
c := NewClient(Config{BaseURL: "http://x", ChatModel: "m"}) // no ASR model
if _, err := c.Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")}); err == nil {
t.Fatal("expected not-configured error")
}
}
func TestTranscribeChatOnlyGatewayGetsConfigHint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":{"message":"invalid content-type: multipart/form-data","code":400}}`))
}))
defer srv.Close()
cfg := chatConfig(srv.URL)
cfg.ASRModel = "whisper-test"
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "Whisper-compatible Transcription base URL") {
t.Fatalf("err = %v, want configuration hint", err)
}
}
func TestIsChatOnlyGateway(t *testing.T) {
cases := map[string]bool{
"https://openrouter.ai/api": true,
"https://openrouter.ai": true,
"openrouter.ai/api/v1": true,
"https://gateway.openrouter.ai": true,
"https://api.groq.com/openai": false,
"https://api.openai.com": false,
"http://localhost:8000": false,
"https://my-openrouter.ai.example.io": false,
"": false,
}
for in, want := range cases {
if got := IsChatOnlyGateway(in); got != want {
t.Errorf("IsChatOnlyGateway(%q) = %v, want %v", in, got, want)
}
}
}
func TestTranscribeRefusesChatOnlyGateway(t *testing.T) {
cfg := Config{BaseURL: "https://openrouter.ai/api", ChatModel: "m", ASRModel: "whisper-1"}
_, err := NewClient(cfg).Transcribe(context.Background(), TranscribeRequest{Filename: "c.wav", Audio: []byte("x")})
if err == nil || !strings.Contains(err.Error(), "cannot produce timestamped transcriptions") {
t.Fatalf("err = %v, want chat-only refusal", err)
}
}
func TestEndpointURLToleratesVersionedBases(t *testing.T) {
cases := map[string]string{
"https://api.openai.com": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai": "https://api.groq.com/openai/v1/chat/completions",
"https://api.deepinfra.com/v1/openai": "https://api.deepinfra.com/v1/openai/chat/completions",
"http://localhost:8969": "http://localhost:8969/v1/chat/completions",
"http://localhost:8969/v1": "http://localhost:8969/v1/chat/completions",
"https://api.openai.com/": "https://api.openai.com/v1/chat/completions",
}
for base, want := range cases {
if got := endpointURL(base, "chat/completions"); got != want {
t.Errorf("endpointURL(%q) = %q, want %q", base, got, want)
}
}
}
+286
View File
@@ -0,0 +1,286 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
)
// defaultTranscribeTimeout bounds a single transcription request when the
// caller does not size one to its chunk duration. Generous on purpose: local
// Whisper servers on modest hardware can run well below realtime.
const defaultTranscribeTimeout = 20 * time.Minute
// TranscribeRequest is one audio-transcription call. Audio is held in memory
// so the request can be rebuilt across retries; callers chunk long files
// (a 10-minute 16 kHz mono WAV is ~19 MB).
type TranscribeRequest struct {
Filename string // e.g. "chunk00001.wav"; the extension hints the container
Audio []byte
Language string // optional ISO-639-1 hint; empty lets the model detect
Timeout time.Duration // per-request deadline; 0 uses defaultTranscribeTimeout
}
// TranscriptionSegment is one timed segment of recognized speech, in seconds
// relative to the start of the submitted audio.
type TranscriptionSegment struct {
Start float64
End float64
Text string
// Words carries per-word timings when the endpoint honors
// timestamp_granularities[]=word; empty otherwise. Word-level times let
// cue building split paragraph-length segments and end cues when speech
// actually stops instead of when the next segment begins.
Words []TranscriptionWord
}
// TranscriptionWord is one recognized word with timing, in seconds relative
// to the start of the submitted audio.
type TranscriptionWord struct {
Start float64
End float64
Text string
}
// Transcription is the parsed verbose_json transcription result.
type Transcription struct {
// Language is the detected (or hinted) language as reported by the
// endpoint. OpenAI returns an English language name ("english"); other
// servers return ISO codes. Callers must normalize.
Language string
// Segments may legitimately be empty for speech-free audio (silence,
// music-only chunks).
Segments []TranscriptionSegment
}
type transcriptionWordJSON struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Word string `json:"word"`
}
type transcriptionResponse struct {
Language string `json:"language"`
Text string `json:"text"`
// Segments distinguishes "verbose_json honored, no speech" (empty array)
// from "endpoint ignored verbose_json" (field absent) — the latter cannot
// produce timed cues and must fail rather than silently emit nothing.
Segments *[]struct {
Start float64 `json:"start"`
End float64 `json:"end"`
Text string `json:"text"`
Words []transcriptionWordJSON `json:"words"`
} `json:"segments"`
// Words is where OpenAI puts word timings; faster-whisper servers
// (speaches) nest them inside each segment instead.
Words []transcriptionWordJSON `json:"words"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// chatOnlyGatewayHosts lists OpenAI-compatible gateways that cannot serve
// Silo's transcription needs: they either lack /v1/audio/transcriptions
// entirely or (OpenRouter) return plain text with no segment timestamps,
// which subtitle cues require. Matched by host suffix.
var chatOnlyGatewayHosts = []string{
"openrouter.ai",
}
// strictHostedASRHosts lists hosted transcription providers that reject
// multipart fields outside the OpenAI spec, so the faster-whisper-only
// vad_filter field must be omitted for them. Nothing is lost: hosted Whisper
// runs voice-activity detection server-side. Self-hosted servers (speaches,
// faster-whisper) need the explicit field — without it segment timestamps
// stretch wall-to-wall across silence. Matched by host suffix.
var strictHostedASRHosts = []string{
"api.openai.com",
"openai.azure.com",
"api.groq.com",
"api.mistral.ai",
}
// IsChatOnlyGateway reports whether baseURL points at a known chat-only
// gateway that cannot produce timestamped transcriptions. Used to validate
// the transcription settings and to disable ASR rather than fail jobs at
// runtime.
func IsChatOnlyGateway(baseURL string) bool {
return hostMatchesAny(baseURL, chatOnlyGatewayHosts)
}
// hostMatchesAny reports whether baseURL's hostname equals or is a subdomain
// of any of the given host suffixes.
func hostMatchesAny(baseURL string, hosts []string) bool {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return false
}
if !strings.Contains(baseURL, "://") {
baseURL = "https://" + baseURL
}
u, err := url.Parse(baseURL)
if err != nil || u.Host == "" {
return false
}
host := strings.ToLower(u.Hostname())
for _, h := range hosts {
if host == h || strings.HasSuffix(host, "."+h) {
return true
}
}
return false
}
// Transcribe performs one audio transcription against the ASR endpoint
// (falling back to the chat endpoint's base URL/key when no ASR override is
// configured), using the OpenAI-compatible /v1/audio/transcriptions API with
// response_format=verbose_json for segment timestamps.
func (c *Client) Transcribe(ctx context.Context, req TranscribeRequest) (*Transcription, error) {
if !c.cfg.ASRConfigured() {
return nil, fmt.Errorf("transcription endpoint is not configured")
}
if IsChatOnlyGateway(c.cfg.asrBaseURL()) {
return nil, fmt.Errorf("the configured transcription endpoint (%s) cannot produce timestamped transcriptions; "+
"set a Whisper-compatible Transcription base URL under Admin Settings → AI Services", c.cfg.asrBaseURL())
}
if len(req.Audio) == 0 {
return nil, fmt.Errorf("no audio data to transcribe")
}
timeout := req.Timeout
if timeout <= 0 {
timeout = defaultTranscribeTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := endpointURL(c.cfg.asrBaseURL(), "audio/transcriptions")
var result *Transcription
doErr := c.doWithRetry(ctx, c.asrHTTP, "transcription API",
func() (*http.Request, error) {
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", req.Filename)
if err != nil {
return nil, fmt.Errorf("create multipart file: %w", err)
}
if _, err := fw.Write(req.Audio); err != nil {
return nil, fmt.Errorf("write multipart audio: %w", err)
}
fields := [][2]string{
{"model", c.cfg.ASRModel},
{"response_format", "verbose_json"},
{"temperature", "0"},
// Word timings let cue building split paragraph-length
// segments and end cues when speech actually stops; segment
// granularity must be requested alongside or OpenAI omits it.
{"timestamp_granularities[]", "segment"},
{"timestamp_granularities[]", "word"},
}
if req.Language != "" {
fields = append(fields, [2]string{"language", req.Language})
}
if !hostMatchesAny(c.cfg.asrBaseURL(), strictHostedASRHosts) {
fields = append(fields, [2]string{"vad_filter", "true"})
}
for _, f := range fields {
if err := w.WriteField(f[0], f[1]); err != nil {
return nil, fmt.Errorf("write multipart field %s: %w", f[0], err)
}
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("finalize multipart body: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf.Bytes()))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", w.FormDataContentType())
if key := c.cfg.asrAPIKey(); key != "" {
httpReq.Header.Set("Authorization", "Bearer "+key)
}
return httpReq, nil
},
func(respBody []byte) error {
var parsed transcriptionResponse
if err := json.Unmarshal(respBody, &parsed); err != nil {
return fmt.Errorf("decode transcription response: %w", err)
}
if parsed.Error != nil && parsed.Error.Message != "" {
return fmt.Errorf("transcription API error: %s", parsed.Error.Message)
}
if parsed.Segments == nil {
return &permanentError{err: fmt.Errorf(
"transcription endpoint did not return verbose_json segments (model %q); a verbose_json-capable Whisper endpoint is required", c.cfg.ASRModel)}
}
out := &Transcription{Language: parsed.Language}
for _, s := range *parsed.Segments {
out.Segments = append(out.Segments, TranscriptionSegment{
Start: s.Start, End: s.End, Text: s.Text, Words: wordsFromJSON(s.Words),
})
}
attachTopLevelWords(out.Segments, wordsFromJSON(parsed.Words))
result = out
return nil
})
if doErr != nil {
return nil, decorateTranscribeError(doErr)
}
return result, nil
}
func wordsFromJSON(words []transcriptionWordJSON) []TranscriptionWord {
out := make([]TranscriptionWord, 0, len(words))
for _, w := range words {
out = append(out, TranscriptionWord{Start: w.Start, End: w.End, Text: w.Word})
}
return out
}
// attachTopLevelWords distributes an OpenAI-style top-level word list onto
// segments by word midpoint, for endpoints that report words separately from
// segments. Segments that already carry their own words are left untouched.
func attachTopLevelWords(segments []TranscriptionSegment, words []TranscriptionWord) {
if len(segments) == 0 || len(words) == 0 {
return
}
for _, s := range segments {
if len(s.Words) > 0 {
return
}
}
si := 0
for _, w := range words {
mid := (w.Start + w.End) / 2
for si < len(segments)-1 && mid >= segments[si].End {
si++
}
segments[si].Words = append(segments[si].Words, w)
}
}
// decorateTranscribeError appends a configuration hint to the errors a
// chat-only gateway produces when it receives a transcription upload (no
// /v1/audio/transcriptions route, or multipart rejected). Operators routinely
// point the shared base URL at a chat-only provider; without the hint the raw
// 400/404 reads like a pipeline bug instead of "set a Whisper endpoint".
func decorateTranscribeError(err error) error {
msg := err.Error()
likelyUnsupported := strings.Contains(msg, "returned 400") ||
strings.Contains(msg, "returned 404") ||
strings.Contains(msg, "returned 405")
if !likelyUnsupported {
return err
}
return fmt.Errorf("%w — the configured endpoint likely does not support audio transcription; "+
"set a Whisper-compatible Transcription base URL (and model) under Admin Settings → AI Services "+
"(e.g. a self-hosted faster-whisper/speaches server, api.groq.com/openai with whisper-large-v3-turbo, or api.openai.com with whisper-1)", err)
}
@@ -1,4 +1,4 @@
package ai
package aitranslate
import "strings"
@@ -18,9 +18,25 @@ var languageNames = map[string]string{
"tr": "Turkish", "uk": "Ukrainian", "vi": "Vietnamese", "zh": "Chinese",
}
// languageDisplayName returns a human-readable language name for a code, or the
// trimmed code itself when unknown. An empty code yields an empty string.
func languageDisplayName(code string) string {
// LanguageCodeFromName maps an English language name back to its ISO 639-1
// code — Whisper endpoints report detected languages as names ("english")
// while local servers report codes. Returns "" when unknown.
func LanguageCodeFromName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
if name == "" {
return ""
}
for code, display := range languageNames {
if strings.ToLower(display) == name {
return code
}
}
return ""
}
// LanguageDisplayName returns a human-readable language name for a code, or
// the trimmed code itself when unknown. An empty code yields an empty string.
func LanguageDisplayName(code string) string {
code = strings.TrimSpace(code)
if code == "" {
return ""
+212
View File
@@ -0,0 +1,212 @@
// Package aitranslate implements generic batched text translation over an
// OpenAI-compatible chat model. Callers hand it ordered text segments and a
// domain-specific system prompt; it owns the wire protocol (indexed-JSON
// batches the model must echo back key-for-key), context carryover between
// batches, and retries on malformed model output. Subtitle cues and metadata
// descriptions both translate through this one implementation.
package aitranslate
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
)
// Segment is one unit of translatable text. ID is the caller's identifier and
// is never sent to the model (batches are keyed by position), so any stable
// string works.
type Segment struct {
ID string
Text string
}
// ChatFn performs one chat completion. Wiring this as a function keeps the
// package independent of the client and trivially testable.
type ChatFn func(ctx context.Context, system, user string) (string, error)
// Request describes one translation run.
type Request struct {
Segments []Segment
SystemPrompt string // domain-specific; must demand same-keys JSON output
// TargetName is the human-readable target language name used in the
// per-batch user message.
TargetName string
// EntryNoun names the units in the user message ("cues", "descriptions");
// empty defaults to "entries".
EntryNoun string
// BatchSize is the number of segments per chat request (required, > 0).
BatchSize int
// ContextNeighbors is how many preceding source segments are included,
// untranslated, for continuity across batch boundaries.
ContextNeighbors int
}
// maxFormatRetries is how many times a batch is re-asked after the model
// returns unparsable or incomplete JSON. Transport/API errors are retried
// inside the chat client and are not part of this count.
const maxFormatRetries = 2
// Translate translates req.Segments in order, preserving count and IDs and
// only rewriting Text. onBatch, when non-nil, is called after each batch with
// that batch's translated segments and overall progress (done/total
// segments). Implementations must honor ctx cancellation between batches.
func Translate(ctx context.Context, chat ChatFn, req Request, onBatch func(batch []Segment, done, total int)) ([]Segment, error) {
if chat == nil {
return nil, fmt.Errorf("chat function is nil")
}
if req.BatchSize <= 0 {
return nil, fmt.Errorf("invalid batch size: %d", req.BatchSize)
}
total := len(req.Segments)
if total == 0 {
return nil, fmt.Errorf("no segments to translate")
}
noun := req.EntryNoun
if noun == "" {
noun = "entries"
}
// Preserve IDs by copying input segments and only replacing Text.
out := make([]Segment, total)
copy(out, req.Segments)
for start := 0; start < total; start += req.BatchSize {
if err := ctx.Err(); err != nil {
return nil, err
}
end := min(start+req.BatchSize, total)
contextStart := max(0, start-req.ContextNeighbors)
translated, err := translateBatch(ctx, chat, req, noun, req.Segments[contextStart:start], req.Segments[start:end])
if err != nil {
return nil, fmt.Errorf("translate %s %d-%d: %w", noun, start+1, end, err)
}
for i, text := range translated {
out[start+i].Text = text
}
if onBatch != nil {
onBatch(out[start:end], end, total)
}
}
return out, nil
}
func translateBatch(ctx context.Context, chat ChatFn, req Request, noun string, contextSegs, batch []Segment) ([]string, error) {
texts := make([]string, len(batch))
for i, s := range batch {
texts[i] = s.Text
}
payload, err := buildIndexedJSON(texts)
if err != nil {
return nil, err
}
var user strings.Builder
if len(contextSegs) > 0 {
user.WriteString("Preceding lines for context only — do not translate or include them in your output:\n")
for _, s := range contextSegs {
user.WriteString(strings.ReplaceAll(s.Text, "\n", " "))
user.WriteByte('\n')
}
user.WriteByte('\n')
}
target := req.TargetName
if target == "" {
target = "the target language"
}
fmt.Fprintf(&user, "Translate these %d %s into %s. Respond with only the JSON object:\n%s", len(batch), noun, target, payload)
var lastErr error
for attempt := 0; attempt <= maxFormatRetries; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
content, err := chat(ctx, req.SystemPrompt, user.String())
if err != nil {
return nil, err // transport/API errors are already retried inside the client
}
obj, err := extractJSONObject(content)
if err != nil {
lastErr = err
continue
}
var m map[string]string
if err := json.Unmarshal([]byte(obj), &m); err != nil {
lastErr = fmt.Errorf("decode translation JSON: %w", err)
continue
}
out := make([]string, len(batch))
complete := true
for i := range batch {
v, ok := m[strconv.Itoa(i+1)]
if !ok {
complete = false
break
}
out[i] = v
}
if !complete {
lastErr = fmt.Errorf("model omitted one or more %s", noun)
continue
}
return out, nil
}
return nil, fmt.Errorf("invalid model response after %d attempts: %w", maxFormatRetries+1, lastErr)
}
// buildIndexedJSON renders texts as a JSON object {"1":..., "2":...} keyed by
// 1-based position, escaping each value safely. It is built by hand rather
// than json.Marshal'ing a map so the keys stay in numeric order — that reads
// more naturally for the model than the lexicographic order Go emits for maps
// ("1","10","11",...,"2"). Correctness doesn't depend on order (results are
// mapped back by key), but ordered input gives the model better context.
func buildIndexedJSON(texts []string) (string, error) {
var b strings.Builder
b.WriteByte('{')
for i, text := range texts {
if i > 0 {
b.WriteByte(',')
}
key, err := json.Marshal(strconv.Itoa(i + 1))
if err != nil {
return "", err
}
val, err := json.Marshal(text)
if err != nil {
return "", err
}
b.Write(key)
b.WriteByte(':')
b.Write(val)
}
b.WriteByte('}')
return b.String(), nil
}
// extractJSONObject pulls the first balanced-looking JSON object out of a
// model reply, tolerating ``` code fences and surrounding prose.
func extractJSONObject(s string) (string, error) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
if nl := strings.IndexByte(s, '\n'); nl >= 0 {
s = s[nl+1:]
}
if idx := strings.LastIndex(s, "```"); idx >= 0 {
s = s[:idx]
}
s = strings.TrimSpace(s)
}
start := strings.IndexByte(s, '{')
end := strings.LastIndexByte(s, '}')
if start < 0 || end < 0 || end < start {
return "", fmt.Errorf("no JSON object found in model response")
}
return s[start : end+1], nil
}
+153
View File
@@ -0,0 +1,153 @@
package aitranslate
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
)
// echoChat translates by upper-casing every value of the indexed JSON payload
// in the user message, mimicking a well-behaved model.
func echoChat(t *testing.T) ChatFn {
t.Helper()
return func(_ context.Context, _ string, user string) (string, error) {
obj, err := extractJSONObject(user)
if err != nil {
return "", fmt.Errorf("test chat: %w", err)
}
var m map[string]string
if err := json.Unmarshal([]byte(obj), &m); err != nil {
return "", fmt.Errorf("test chat decode: %w", err)
}
out := make(map[string]string, len(m))
for k, v := range m {
out[k] = strings.ToUpper(v)
}
b, _ := json.Marshal(out)
return string(b), nil
}
}
func segments(n int) []Segment {
out := make([]Segment, n)
for i := range out {
out[i] = Segment{ID: strconv.Itoa(i + 1), Text: fmt.Sprintf("line %d", i+1)}
}
return out
}
func TestTranslateBatchesAndPreservesIDs(t *testing.T) {
var batches []int
out, err := Translate(context.Background(), echoChat(t), Request{
Segments: segments(7),
BatchSize: 3,
}, func(batch []Segment, done, total int) {
batches = append(batches, len(batch))
if total != 7 {
t.Errorf("total = %d, want 7", total)
}
})
if err != nil {
t.Fatalf("Translate: %v", err)
}
if len(out) != 7 {
t.Fatalf("len(out) = %d", len(out))
}
for i, s := range out {
if s.ID != strconv.Itoa(i+1) {
t.Errorf("ID[%d] = %q, want %d", i, s.ID, i+1)
}
if want := strings.ToUpper(fmt.Sprintf("line %d", i+1)); s.Text != want {
t.Errorf("Text[%d] = %q, want %q", i, s.Text, want)
}
}
if fmt.Sprint(batches) != "[3 3 1]" {
t.Errorf("batch sizes = %v, want [3 3 1]", batches)
}
}
func TestTranslateSendsContextNeighborsUntranslated(t *testing.T) {
var secondBatchMsg string
call := 0
chat := func(ctx context.Context, system, user string) (string, error) {
call++
if call == 2 {
secondBatchMsg = user
}
return echoChat(t)(ctx, system, user)
}
_, err := Translate(context.Background(), chat, Request{
Segments: segments(4),
BatchSize: 2,
ContextNeighbors: 2,
TargetName: "French",
EntryNoun: "cues",
}, nil)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if !strings.Contains(secondBatchMsg, "context only") ||
!strings.Contains(secondBatchMsg, "line 1\nline 2") {
t.Errorf("second batch missing context block:\n%s", secondBatchMsg)
}
if !strings.Contains(secondBatchMsg, "Translate these 2 cues into French.") {
t.Errorf("second batch missing instruction:\n%s", secondBatchMsg)
}
}
func TestTranslateToleratesCodeFences(t *testing.T) {
chat := func(_ context.Context, _, _ string) (string, error) {
return "Sure! Here you go:\n```json\n{\"1\":\"ok\"}\n```", nil
}
out, err := Translate(context.Background(), chat, Request{
Segments: segments(1),
BatchSize: 5,
}, nil)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if out[0].Text != "ok" {
t.Errorf("Text = %q", out[0].Text)
}
}
func TestTranslateRetriesOmittedKeysThenFails(t *testing.T) {
calls := 0
chat := func(_ context.Context, _, _ string) (string, error) {
calls++
return `{"1":"only one"}`, nil // always omits key "2"
}
_, err := Translate(context.Background(), chat, Request{
Segments: segments(2),
BatchSize: 5,
}, nil)
if err == nil || !strings.Contains(err.Error(), "omitted") {
t.Fatalf("err = %v, want omission failure", err)
}
if calls != maxFormatRetries+1 {
t.Errorf("calls = %d, want %d", calls, maxFormatRetries+1)
}
}
func TestTranslateStopsOnCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
calls := 0
chat := func(_ context.Context, _, _ string) (string, error) {
calls++
cancel() // cancel after the first batch completes
return `{"1":"a"}`, nil
}
_, err := Translate(ctx, chat, Request{
Segments: segments(3),
BatchSize: 1,
}, nil)
if err == nil || !strings.Contains(err.Error(), "context canceled") {
t.Fatalf("err = %v, want context cancellation", err)
}
if calls != 1 {
t.Errorf("calls = %d, want 1", calls)
}
}
+11 -25
View File
@@ -20,12 +20,13 @@ type autoscanQueuer = autoscan.Queuer
// RequestIntegrationLookup adapts the Requests repository to the autoscan
// connection resolver's RequestIntegrationLookup: it resolves a soft-linked
// Requests integration to its base URL and (write-only) api-key ref.
// Requests integration to its base URL and api key (the repo decrypts the key
// on read, so this returns plaintext).
type RequestIntegrationLookup struct {
Repo *mediarequests.Repository
}
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKeyRef string, err error) {
func (l RequestIntegrationLookup) Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error) {
integration, err := l.Repo.GetIntegration(ctx, integrationID)
if err != nil {
return "", "", err
@@ -64,8 +65,8 @@ type PluginScanSourceAdapter struct {
Svc *plugins.Service
}
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, installationID int, capabilityID string) (autoscan.PollChangesClient, error) {
return a.Svc.ScanSourceClient(ctx, installationID, capabilityID)
func (a PluginScanSourceAdapter) ScanSourceClient(ctx context.Context, pluginID, capabilityID string) (autoscan.PollChangesClient, error) {
return a.Svc.ScanSourceClientByPluginID(ctx, pluginID, capabilityID)
}
// scanSourceCapabilityType is the plugin capability type autoscan discovery
@@ -75,15 +76,6 @@ const scanSourceCapabilityType = "scan_source.v1"
// PluginScanSourceLister adapts the plugin installation store to
// autoscan.ScanSourceLister: it enumerates every installed scan_source.v1
// capability across ALL installed plugins, regardless of enabled state.
//
// Using List (not ListEnabled) is deliberate for orphan detection: a temporarily
// DISABLED-but-installed plugin must NOT be treated as orphaned. If we dropped
// disabled plugins here, PollOnce would prune their sources and skip them with no
// last_error — they'd vanish silently. Keeping them present means PollOnce still
// attempts them; the plugin client fails to load (not running) and the source
// gets a visible RecordError instead. Only a fully UNINSTALLED plugin (gone from
// List entirely) is a true orphan. The Add-source picker shares this all-installed
// set, which is fine — operators can bind sources against an installed capability.
type PluginScanSourceLister struct {
Store *plugins.InstallationStore
}
@@ -104,10 +96,9 @@ func (l PluginScanSourceLister) ListScanSources(ctx context.Context) ([]autoscan
continue
}
out = append(out, autoscan.DiscoveredSource{
InstallationID: c.InstallationID,
CapabilityID: c.ID,
PluginID: inst.PluginID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
PluginID: inst.PluginID,
CapabilityID: c.ID,
DisplayName: scanSourceDisplayName(inst.PluginID, c),
})
}
}
@@ -135,27 +126,22 @@ func scanSourceDisplayName(pluginID string, c *plugins.Capability) string {
}
}
// AutoscanSecretResolver resolves an encrypted api-key reference to plaintext.
// It is satisfied by the server settings repo (Get(ctx, key) (string, error)).
type AutoscanSecretResolver interface {
Get(ctx context.Context, ref string) (string, error)
}
// BuildAutoscanService wires the v2 autoscan engine from its concrete
// dependencies. Both the HTTP router (manual trigger) and the background poll
// task share this constructor so the adapter wiring lives in exactly one place.
// Credentials are now decrypted inline by the autoscan/requests repos, so there
// is no separate secret resolver to thread.
func BuildAutoscanService(
repo *autoscan.Repository,
pluginService *plugins.Service,
installationStore *plugins.InstallationStore,
requestsRepo *mediarequests.Repository,
secrets AutoscanSecretResolver,
folderRepo *catalog.FolderRepository,
queue autoscanQueuer,
redisClient *redis.Client,
) *autoscan.Service {
provider := autoscan.NewPluginProvider(PluginScanSourceAdapter{pluginService})
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo}, secrets)
connRes := autoscan.NewConnectionResolver(RequestIntegrationLookup{requestsRepo})
svc := autoscan.NewService(
repo,
provider,
+33 -1
View File
@@ -1,6 +1,10 @@
package api
import "testing"
import (
"testing"
mediarequests "github.com/Silo-Server/silo-server/internal/requests"
)
// TestCheckRequestIntegrationUsable verifies the Fix 4 gate: a reused Requests
// connection is rejected when the integration is disabled or has no base_url, so
@@ -22,3 +26,31 @@ func TestCheckRequestIntegrationUsable(t *testing.T) {
}
})
}
// TestCheckRequestIntegrationUsableGatesReuse is the Task 17 regression test. It
// locks the autoscan→requests credential-reuse behavior after the
// request_integrations table was generalized into the two-tier connection
// registry (Task 9/10): autoscan must keep reusing the Sonarr/Radarr connection
// rows via the same enabled + base_url gate, regardless of the added
// capability/installation columns.
func TestCheckRequestIntegrationUsableGatesReuse(t *testing.T) {
// Enabled + non-blank base_url -> usable (autoscan can reuse the connection).
if err := checkRequestIntegrationUsable("int-1", true, "http://sonarr:8989"); err != nil {
t.Fatalf("enabled integration with base_url should be usable, got: %v", err)
}
// Disabled -> error (autoscan must not poll a disabled connection).
if err := checkRequestIntegrationUsable("int-1", false, "http://sonarr:8989"); err == nil {
t.Fatal("disabled integration must be rejected")
}
// Blank base_url -> error.
if err := checkRequestIntegrationUsable("int-1", true, " "); err == nil {
t.Fatal("blank base_url must be rejected")
}
}
// Compile-time guard: autoscan's RequestIntegrationLookup reads these fields off
// mediarequests.Integration. If any is renamed/removed, autoscan reuse breaks —
// fail the build here rather than silently at runtime.
var _ = func(in mediarequests.Integration) (string, string, bool) {
return in.BaseURL, in.APIKeyRef, in.Enabled
}
+138 -229
View File
@@ -1,7 +1,6 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
@@ -21,6 +20,7 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/ai/llm"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
@@ -29,6 +29,7 @@ import (
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
subtitleai "github.com/Silo-Server/silo-server/internal/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -101,11 +102,17 @@ func NewAdminHandler(
pool *pgxpool.Pool,
storeProv userstore.UserStoreProvider,
) *AdminHandler {
var groupResolver auth.GroupResolver
if pool != nil {
groupResolver = auth.NewGroupRepository(pool)
}
return &AdminHandler{
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv),
userRepo: userRepo,
pool: pool,
storeProv: storeProv,
// No settings dependency at construction time: nil settings makes the
// provisioner fall back to the built-in users group for defaults.
accountProvisioner: auth.NewAccountProvisioner(userRepo, storeProv, nil, groupResolver),
}
}
@@ -113,115 +120,53 @@ func NewAdminHandler(
// createUserRequest represents the JSON body for POST /admin/users.
type createUserRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
Permissions createStringSliceField `json:"permissions"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
type createStringSliceField struct {
Set bool
Value []string
}
func (f *createStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
type updateLibraryIDsField struct {
Set bool
Value []int
}
func (f *updateLibraryIDsField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateLibraryIDsField) Ptr() *[]int {
if !f.Set {
return nil
}
value := append([]int(nil), f.Value...)
return &value
}
type updateStringSliceField struct {
Set bool
Value []string
}
func (f *updateStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
func (f updateStringSliceField) Ptr() *[]string {
if !f.Set {
return nil
}
value := append([]string(nil), f.Value...)
return &value
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
CreateDefaultProfile bool `json:"create_default_profile"`
DefaultProfileName string `json:"default_profile_name,omitempty"`
// GroupIDs are the group memberships to assign. Omitted (nil) means the
// configured default groups are used.
GroupIDs []int `json:"group_ids"`
}
// updateUserRequest represents the JSON body for PUT /admin/users/{id}.
type updateUserRequest struct {
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Role *string `json:"role,omitempty"`
Permissions updateStringSliceField `json:"permissions,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
LibraryIDs updateLibraryIDsField `json:"library_ids,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
Username *string `json:"username,omitempty"`
Email *string `json:"email,omitempty"`
Password *string `json:"password,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
// GroupIDs replaces the user's group memberships. Omitted = unchanged.
GroupIDs *[]int `json:"group_ids,omitempty"`
}
// adminUserResponse represents a user in admin JSON responses.
// adminGroupRef is a lightweight group reference in admin user responses.
type adminGroupRef struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
}
// adminUserResponse represents a user in admin JSON responses. The policy
// fields are the effective values derived from the user's groups.
type adminUserResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Permissions []string `json:"permissions"`
Enabled bool `json:"enabled"`
Groups []adminGroupRef `json:"groups"`
LibraryIDs []int `json:"library_ids"`
MaxPlaybackQuality string `json:"max_playback_quality"`
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
}
type adminPlaybackHistoryRow struct {
@@ -267,15 +212,21 @@ func (h *AdminHandler) presignPosterURL(r *http.Request, path string) string {
return ""
}
// toAdminUserResponse converts a User model to an admin API response.
// toAdminUserResponse converts a User model (with its group memberships
// loaded into u.Groups at load time) to an admin API response.
func toAdminUserResponse(u *models.User) adminUserResponse {
refs := make([]adminGroupRef, 0, len(u.Groups))
for _, g := range u.Groups {
refs = append(refs, adminGroupRef{ID: g.ID, Slug: g.Slug, Name: g.Name})
}
return adminUserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role,
Role: auth.RoleForUser(u),
Permissions: append([]string{}, u.Permissions...),
Enabled: u.Enabled,
Groups: refs,
LibraryIDs: append([]int(nil), u.LibraryIDs...),
MaxPlaybackQuality: access.NormalizePlaybackQuality(u.MaxPlaybackQuality),
MaxStreams: u.MaxStreams,
@@ -338,10 +289,13 @@ func (h *AdminHandler) HandleListUsers(w http.ResponseWriter, r *http.Request) {
return
}
resp := make([]adminUserResponse, 0, len(users))
userIDs := make([]int, 0, len(users))
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
resp := make([]adminUserResponse, 0, len(users))
for _, u := range users {
resp = append(resp, toAdminUserResponse(u))
}
lastActive, err := h.loadUserLastActiveAt(r.Context(), userIDs)
@@ -391,44 +345,17 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
req.Username = auth.NormalizeUsername(req.Username)
req.Email = auth.NormalizeEmail(req.Email)
if req.Username == "" || req.Email == "" || req.Password == "" || req.Role == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, password, and role are required")
return
}
maxPlaybackQuality, ok := access.ParsePlaybackQualityPreset(req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
permissions := auth.DefaultUserPermissions()
if req.Permissions.Set {
permissions = req.Permissions.Value
}
permissions, err := auth.NormalizePermissions(permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
if req.Username == "" || req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Username, email, and password are required")
return
}
user, err := h.accountProvisioner.CreateAccount(r.Context(), auth.CreateAccountInput{
User: models.CreateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
Username: req.Username,
Email: req.Email,
Password: req.Password,
GroupIDs: req.GroupIDs,
},
DefaultProfile: auth.DefaultProfileOptions{
Enabled: req.CreateDefaultProfile,
@@ -436,6 +363,10 @@ func (h *AdminHandler) HandleCreateUser(w http.ResponseWriter, r *http.Request)
},
})
if err != nil {
if errors.Is(err, auth.ErrUnknownGroup) {
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to create user")
return
}
@@ -459,43 +390,12 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
return
}
var maxPlaybackQuality *string
if req.MaxPlaybackQuality != nil {
normalized, ok := access.ParsePlaybackQualityPreset(*req.MaxPlaybackQuality)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
maxPlaybackQuality = &normalized
}
if req.MaxProfiles != nil && *req.MaxProfiles < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "max_profiles must be at least 1")
return
}
var permissions *[]string
if req.Permissions.Set {
normalized, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
permissions = &normalized
}
updateInput := models.UpdateUserInput{
Username: req.Username,
Email: req.Email,
Password: req.Password,
Role: req.Role,
Permissions: permissions,
Enabled: req.Enabled,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxPlaybackQuality: maxPlaybackQuality,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
Username: req.Username,
Email: req.Email,
Password: req.Password,
Enabled: req.Enabled,
GroupIDs: req.GroupIDs,
}
var currentUser *models.User
@@ -517,6 +417,14 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusNotFound, "not_found", "User not found")
return
}
if errors.Is(err, auth.ErrLastAdministrator) {
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
return
}
if errors.Is(err, auth.ErrUnknownGroup) {
writeError(w, http.StatusBadRequest, "unknown_group", "One or more group IDs do not exist")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update user")
return
}
@@ -547,6 +455,10 @@ func (h *AdminHandler) HandleDeleteUser(w http.ResponseWriter, r *http.Request)
err = h.userRepo.Delete(r.Context(), id)
if err != nil {
if errors.Is(err, auth.ErrLastAdministrator) {
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete user")
return
}
@@ -790,10 +702,8 @@ func (h *AdminHandler) HandleListUserProfiles(w http.ResponseWriter, r *http.Req
func updateMayRequireSessionRevocation(input models.UpdateUserInput) bool {
return input.Password != nil ||
input.Role != nil ||
input.Enabled != nil ||
input.Permissions != nil ||
input.MaxPlaybackQuality != nil
input.GroupIDs != nil
}
func updateRequiresSessionRevocation(current *models.User, input models.UpdateUserInput) bool {
@@ -803,22 +713,28 @@ func updateRequiresSessionRevocation(current *models.User, input models.UpdateUs
if current == nil {
return updateMayRequireSessionRevocation(input)
}
if input.Role != nil && *input.Role != current.Role {
return true
}
if input.Enabled != nil && *input.Enabled != current.Enabled {
return true
}
if input.Permissions != nil && !slices.Equal(*input.Permissions, current.Permissions) {
return true
}
if input.MaxPlaybackQuality != nil &&
access.NormalizePlaybackQuality(*input.MaxPlaybackQuality) != access.NormalizePlaybackQuality(current.MaxPlaybackQuality) {
// Group membership changes alter the effective policy (permissions,
// quality ceilings, ...), so a real change revokes sessions.
if input.GroupIDs != nil && !equalIntSets(*input.GroupIDs, current.GroupIDs) {
return true
}
return false
}
// equalIntSets reports whether two int slices contain the same set of values,
// ignoring order and duplicates.
func equalIntSets(a, b []int) bool {
normalize := func(values []int) []int {
out := append([]int(nil), values...)
sort.Ints(out)
return slices.Compact(out)
}
return slices.Equal(normalize(a), normalize(b))
}
func (h *AdminHandler) revokeUserSessions(ctx context.Context, userID int) error {
if h.pool == nil {
return nil
@@ -1019,7 +935,7 @@ func (h *AdminHandler) HandleRefreshItemMetadata(w http.ResponseWriter, r *http.
publishEventJob(r.Context(), h.RealtimeHub.EventsHub(), "job.created", job)
}
writeJSON(w, http.StatusAccepted, adminJobToResponseForClaims(r, job, nil, apimw.GetClaims(r.Context())))
writeJSON(w, http.StatusAccepted, adminJobToResponseForViewer(r, job, nil, isAdminRequest(r, h.userRepo)))
}
// UpdateItemMetadataRequest contains the fields that can be updated via
@@ -1137,29 +1053,10 @@ func (h *AdminHandler) HandleUpdateItemMetadata(w http.ResponseWriter, r *http.R
// --- Server Settings endpoints ---
var sensitiveSettingKeys = map[string]bool{
"auth.jwt_secret": true,
"s3.public_access_key": true,
"s3.public_secret_key": true,
"s3.public_token_secret": true,
"s3.private_access_key": true,
"s3.private_secret_key": true,
"s3.user_db_access_key": true,
"s3.user_db_secret_key": true,
"redis.url": true,
"recommendations.openai_api_key": true,
"recommendations.embedding_auth_token": true,
"tmdb.api_key": true,
"introdb.api_key": true,
"mdblist.api_key": true,
"requests.radarr.api_key": true,
"requests.sonarr.api_key": true,
"subtitle_ai.api_key": true,
"watchsync.trakt.client_id": true,
"watchsync.trakt.client_secret": true,
"watchsync.simkl.client_id": true,
"watchsync.simkl.client_secret": true,
}
// sensitiveSettingKeys is the audited allowlist of secret-bearing settings
// keys, shared with the at-rest encryption decorator so redaction and
// encryption can never drift apart. See catalog.SensitiveSettingKeys.
var sensitiveSettingKeys = catalog.SensitiveSettingKeys
// HandleGetSettings handles GET /admin/settings.
func (h *AdminHandler) HandleGetSettings(w http.ResponseWriter, r *http.Request) {
@@ -2097,21 +1994,6 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
return
}
if key == "defaults.max_playback_quality" {
normalized, ok := access.ParsePlaybackQualityPreset(req.Value)
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid max_playback_quality")
return
}
req.Value = normalized
}
if key == "defaults.max_profiles" {
n, err := strconv.Atoi(req.Value)
if err != nil || n < 1 {
writeError(w, http.StatusBadRequest, "bad_request", "defaults.max_profiles must be an integer >= 1")
return
}
}
switch key {
case markers.SettingMode, markers.SettingLazyPlayback:
if normalized, err := markers.NormalizeSetting(key, req.Value); err != nil {
@@ -2120,6 +2002,33 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
} else {
req.Value = normalized
}
case "ai.asr_base_url":
if llm.IsChatOnlyGateway(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"This endpoint cannot produce timestamped transcriptions (chat-only gateway). "+
"Use a self-hosted Whisper server (faster-whisper/speaches), api.groq.com/openai, or api.openai.com.")
return
}
case "metadata_ai.on_view":
switch req.Value {
case "off", "button", "auto":
default:
writeError(w, http.StatusBadRequest, "bad_request",
"metadata_ai.on_view must be off, button, or auto")
return
}
case "subtitle_ai.transcribe_quota_jobs":
if n, err := strconv.Atoi(req.Value); err != nil || n < 0 {
writeError(w, http.StatusBadRequest, "bad_request",
"subtitle_ai.transcribe_quota_jobs must be an integer >= 0 (0 = unlimited)")
return
}
case "subtitle_ai.transcribe_quota_period":
if !subtitleai.ValidQuotaPeriod(req.Value) {
writeError(w, http.StatusBadRequest, "bad_request",
"subtitle_ai.transcribe_quota_period must be day, week, or month")
return
}
}
if err := h.SettingsRepo.Set(r.Context(), key, req.Value); err != nil {
+29
View File
@@ -0,0 +1,29 @@
package handlers
import (
"net/http"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
// isAdminRequest reports whether the request's authenticated user currently
// holds the admin permission. Admin authority is always derived from the
// freshly loaded user (group-derived policy), never from token contents.
// It first consults the user stashed by the RequireAdmin middleware and
// falls back to loading via the given loader.
func isAdminRequest(r *http.Request, loader auth.UserLoader) bool {
ctx := r.Context()
if user := apimw.GetUser(ctx); user != nil {
return user.Enabled && user.IsAdmin
}
claims := apimw.GetClaims(ctx)
if claims == nil || loader == nil {
return false
}
user, err := loader.GetByID(ctx, claims.UserID)
if err != nil || user == nil {
return false
}
return user.Enabled && user.IsAdmin
}
@@ -27,7 +27,6 @@ func newAdminSubtitleRequest(method, path string, body []byte) *http.Request {
req := httptest.NewRequest(method, path, reader)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
Role: "admin",
TokenType: auth.TokenTypeAccess,
})
return req.WithContext(ctx)
+379
View File
@@ -0,0 +1,379 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// GroupStore defines the group repository operations the AdminGroupsHandler
// needs, so tests can substitute a fake.
type GroupStore interface {
List(ctx context.Context) ([]auth.GroupWithMemberCount, error)
GetByID(ctx context.Context, id int) (*models.Group, error)
MemberCount(ctx context.Context, groupID int) (int, error)
Create(ctx context.Context, input models.CreateGroupInput) (*models.Group, error)
Update(ctx context.Context, id int, input models.UpdateGroupInput) (*models.Group, error)
Delete(ctx context.Context, id int) error
ListMembers(ctx context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error)
AddMember(ctx context.Context, groupID, userID int) error
RemoveMember(ctx context.Context, groupID, userID int) error
}
// AdminGroupsHandler handles admin-only group management endpoints.
type AdminGroupsHandler struct {
store GroupStore
}
// NewAdminGroupsHandler creates an AdminGroupsHandler backed by the given
// group store.
func NewAdminGroupsHandler(store GroupStore) *AdminGroupsHandler {
return &AdminGroupsHandler{store: store}
}
// --- Request/Response types ---
// createAdminGroupRequest represents the JSON body for POST /admin/groups.
type createAdminGroupRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null/absent = all libraries
MaxStreams *int `json:"max_streams"`
MaxTranscodes *int `json:"max_transcodes"`
MaxProfiles *int `json:"max_profiles"`
MaxPlaybackQuality *string `json:"max_playback_quality"`
DownloadAllowed *bool `json:"download_allowed"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed"`
}
// updateAdminGroupRequest represents the JSON body for PATCH /admin/groups/{id}.
// Absent fields are left unchanged.
type updateAdminGroupRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Permissions optionalStringSliceField `json:"permissions,omitempty"`
LibraryIDs optionalIntSliceField `json:"library_ids,omitempty"`
MaxStreams *int `json:"max_streams,omitempty"`
MaxTranscodes *int `json:"max_transcodes,omitempty"`
MaxProfiles *int `json:"max_profiles,omitempty"`
MaxPlaybackQuality *string `json:"max_playback_quality,omitempty"`
DownloadAllowed *bool `json:"download_allowed,omitempty"`
DownloadTranscodeAllowed *bool `json:"download_transcode_allowed,omitempty"`
}
// adminGroupResponse represents a group in admin JSON responses.
type adminGroupResponse struct {
ID int `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
BuiltIn bool `json:"built_in"`
Permissions []string `json:"permissions"`
LibraryIDs []int `json:"library_ids"` // null = all libraries
MaxStreams int `json:"max_streams"`
MaxTranscodes int `json:"max_transcodes"`
MaxProfiles int `json:"max_profiles"`
MaxPlaybackQuality string `json:"max_playback_quality"`
DownloadAllowed bool `json:"download_allowed"`
DownloadTranscodeAllowed bool `json:"download_transcode_allowed"`
MemberCount int `json:"member_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type adminGroupListResponse struct {
Groups []adminGroupResponse `json:"groups"`
}
type adminGroupMemberRow struct {
UserID int `json:"user_id"`
Username string `json:"username"`
Email string `json:"email"`
Enabled bool `json:"enabled"`
}
type adminGroupMembersResponse struct {
Members []adminGroupMemberRow `json:"members"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}
func toAdminGroupResponse(g *models.Group, memberCount int) adminGroupResponse {
return adminGroupResponse{
ID: g.ID,
Slug: g.Slug,
Name: g.Name,
Description: g.Description,
BuiltIn: g.BuiltIn,
Permissions: append([]string{}, g.Permissions...),
LibraryIDs: g.LibraryIDs,
MaxStreams: g.MaxStreams,
MaxTranscodes: g.MaxTranscodes,
MaxProfiles: g.MaxProfiles,
MaxPlaybackQuality: g.MaxPlaybackQuality,
DownloadAllowed: g.DownloadAllowed,
DownloadTranscodeAllowed: g.DownloadTranscodeAllowed,
MemberCount: memberCount,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
}
}
// --- Helpers ---
// writeGroupError maps group repository sentinel errors to API responses.
func writeGroupError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, auth.ErrGroupNotFound):
writeError(w, http.StatusNotFound, "not_found", "Group not found")
case errors.Is(err, auth.ErrUnknownUser):
writeError(w, http.StatusNotFound, "unknown_user", "User not found")
case errors.Is(err, auth.ErrBuiltInGroup):
writeError(w, http.StatusConflict, "built_in_group", "Built-in groups cannot be deleted")
case errors.Is(err, auth.ErrAdminPermRequired):
writeError(w, http.StatusConflict, "admin_permission_required", "The administrators group must keep the admin permission")
case errors.Is(err, auth.ErrLastAdministrator):
writeError(w, http.StatusConflict, "last_administrator", "Cannot remove the last enabled administrator")
case errors.Is(err, auth.ErrDuplicate):
writeError(w, http.StatusConflict, "duplicate", "A group with that name already exists")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Group operation failed")
}
}
func parseGroupIDParam(w http.ResponseWriter, r *http.Request, name string) (int, bool) {
id, err := strconv.Atoi(chi.URLParam(r, name))
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid "+name)
return 0, false
}
return id, true
}
// --- Handler methods ---
// HandleList handles GET /admin/groups.
func (h *AdminGroupsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
groups, err := h.store.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list groups")
return
}
resp := adminGroupListResponse{Groups: make([]adminGroupResponse, 0, len(groups))}
for i := range groups {
resp.Groups = append(resp.Groups, toAdminGroupResponse(&groups[i].Group, groups[i].MemberCount))
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreate handles POST /admin/groups.
func (h *AdminGroupsHandler) HandleCreate(w http.ResponseWriter, r *http.Request) {
var req createAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name is required")
return
}
permissions, err := auth.NormalizePermissions(req.Permissions)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
group, err := h.store.Create(r.Context(), models.CreateGroupInput{
Name: name,
Description: req.Description,
Permissions: permissions,
LibraryIDs: req.LibraryIDs,
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
})
if err != nil {
writeGroupError(w, err)
return
}
writeJSON(w, http.StatusCreated, toAdminGroupResponse(group, 0))
}
// HandleGet handles GET /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
group, err := h.store.GetByID(r.Context(), id)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleUpdate handles PATCH /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
var req updateAdminGroupRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Name != nil && strings.TrimSpace(*req.Name) == "" {
writeError(w, http.StatusBadRequest, "bad_request", "Group name cannot be empty")
return
}
input := models.UpdateGroupInput{
Name: req.Name,
Description: req.Description,
LibraryIDs: req.LibraryIDs.Ptr(),
MaxStreams: req.MaxStreams,
MaxTranscodes: req.MaxTranscodes,
MaxProfiles: req.MaxProfiles,
MaxPlaybackQuality: req.MaxPlaybackQuality,
DownloadAllowed: req.DownloadAllowed,
DownloadTranscodeAllowed: req.DownloadTranscodeAllowed,
}
if req.Permissions.Set {
permissions, err := auth.NormalizePermissions(req.Permissions.Value)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
input.Permissions = &permissions
}
group, err := h.store.Update(r.Context(), id, input)
if err != nil {
writeGroupError(w, err)
return
}
memberCount, err := h.store.MemberCount(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to count group members")
return
}
writeJSON(w, http.StatusOK, toAdminGroupResponse(group, memberCount))
}
// HandleDelete handles DELETE /admin/groups/{id}.
func (h *AdminGroupsHandler) HandleDelete(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if err := h.store.Delete(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListMembers handles GET /admin/groups/{id}/members.
func (h *AdminGroupsHandler) HandleListMembers(w http.ResponseWriter, r *http.Request) {
id, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
if _, err := h.store.GetByID(r.Context(), id); err != nil {
writeGroupError(w, err)
return
}
q := r.URL.Query()
offset := 0
if v := q.Get("offset"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
offset = n
}
}
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
limit = n
}
}
members, total, err := h.store.ListMembers(r.Context(), id, offset, limit)
if err != nil {
writeGroupError(w, err)
return
}
resp := adminGroupMembersResponse{
Members: make([]adminGroupMemberRow, 0, len(members)),
Total: total,
Offset: offset,
Limit: limit,
}
for _, m := range members {
resp.Members = append(resp.Members, adminGroupMemberRow{
UserID: m.UserID,
Username: m.Username,
Email: m.Email,
Enabled: m.Enabled,
})
}
writeJSON(w, http.StatusOK, resp)
}
// HandleAddMember handles PUT /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleAddMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.AddMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRemoveMember handles DELETE /admin/groups/{id}/members/{userID}.
func (h *AdminGroupsHandler) HandleRemoveMember(w http.ResponseWriter, r *http.Request) {
groupID, ok := parseGroupIDParam(w, r, "id")
if !ok {
return
}
userID, ok := parseGroupIDParam(w, r, "userID")
if !ok {
return
}
if err := h.store.RemoveMember(r.Context(), groupID, userID); err != nil {
writeGroupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+550
View File
@@ -0,0 +1,550 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeGroupStore is a configurable in-memory GroupStore that records the
// inputs handlers pass to it.
type fakeGroupStore struct {
listGroups []auth.GroupWithMemberCount
listErr error
getGroup *models.Group
getErr error
memberCount int
countErr error
createInput *models.CreateGroupInput
createGroup *models.Group
createErr error
updateID int
updateInput *models.UpdateGroupInput
updateGroup *models.Group
updateErr error
deleteID int
deleteErr error
membersGroupID int
membersOffset int
membersLimit int
members []auth.GroupMember
membersTotal int
membersErr error
addGroupID int
addUserID int
addErr error
removeGroupID int
removeUserID int
removeErr error
}
func (f *fakeGroupStore) List(context.Context) ([]auth.GroupWithMemberCount, error) {
return f.listGroups, f.listErr
}
func (f *fakeGroupStore) GetByID(_ context.Context, id int) (*models.Group, error) {
return f.getGroup, f.getErr
}
func (f *fakeGroupStore) MemberCount(_ context.Context, groupID int) (int, error) {
return f.memberCount, f.countErr
}
func (f *fakeGroupStore) Create(_ context.Context, input models.CreateGroupInput) (*models.Group, error) {
f.createInput = &input
return f.createGroup, f.createErr
}
func (f *fakeGroupStore) Update(_ context.Context, id int, input models.UpdateGroupInput) (*models.Group, error) {
f.updateID = id
f.updateInput = &input
return f.updateGroup, f.updateErr
}
func (f *fakeGroupStore) Delete(_ context.Context, id int) error {
f.deleteID = id
return f.deleteErr
}
func (f *fakeGroupStore) ListMembers(_ context.Context, groupID, offset, limit int) ([]auth.GroupMember, int, error) {
f.membersGroupID = groupID
f.membersOffset = offset
f.membersLimit = limit
return f.members, f.membersTotal, f.membersErr
}
func (f *fakeGroupStore) AddMember(_ context.Context, groupID, userID int) error {
f.addGroupID = groupID
f.addUserID = userID
return f.addErr
}
func (f *fakeGroupStore) RemoveMember(_ context.Context, groupID, userID int) error {
f.removeGroupID = groupID
f.removeUserID = userID
return f.removeErr
}
// newGroupsTestRouter mounts the handler routes the same way the API router
// does so tests exercise real chi URL params.
func newGroupsTestRouter(store *fakeGroupStore) chi.Router {
h := NewAdminGroupsHandler(store)
r := chi.NewRouter()
r.Get("/groups", h.HandleList)
r.Post("/groups", h.HandleCreate)
r.Get("/groups/{id}", h.HandleGet)
r.Patch("/groups/{id}", h.HandleUpdate)
r.Delete("/groups/{id}", h.HandleDelete)
r.Get("/groups/{id}/members", h.HandleListMembers)
r.Put("/groups/{id}/members/{userID}", h.HandleAddMember)
r.Delete("/groups/{id}/members/{userID}", h.HandleRemoveMember)
return r
}
func doGroupsRequest(t *testing.T, router chi.Router, method, target string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshaling request body: %v", err)
}
reader = bytes.NewReader(payload)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, target, reader)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeGroupsJSON(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decoding response %q: %v", rec.Body.String(), err)
}
return out
}
func assertGroupsError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) {
t.Helper()
if rec.Code != status {
t.Fatalf("status = %d, want %d (body %q)", rec.Code, status, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
if body["error"] != code {
t.Fatalf("error code = %v, want %q (body %q)", body["error"], code, rec.Body.String())
}
}
func testGroup() *models.Group {
return &models.Group{
ID: 7,
Slug: "film-club",
Name: "Film Club",
Description: "Movie nights",
Permissions: []string{"marker_edit"},
LibraryIDs: nil,
MaxStreams: 2,
MaxTranscodes: 1,
MaxProfiles: 4,
MaxPlaybackQuality: "1080p",
CreatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
UpdatedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}
}
func TestAdminGroupsList(t *testing.T) {
store := &fakeGroupStore{
listGroups: []auth.GroupWithMemberCount{
{Group: models.Group{ID: 1, Slug: "administrators", Name: "Administrators", BuiltIn: true, Permissions: []string{"admin"}}, MemberCount: 1},
{Group: models.Group{ID: 2, Slug: "users", Name: "Users", BuiltIn: true, LibraryIDs: []int{3}}, MemberCount: 12},
},
}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
groups, ok := body["groups"].([]any)
if !ok || len(groups) != 2 {
t.Fatalf("groups = %v, want 2 entries", body["groups"])
}
first := groups[0].(map[string]any)
if first["member_count"] != float64(1) {
t.Fatalf("member_count = %v, want 1", first["member_count"])
}
if first["library_ids"] != nil {
t.Fatalf("library_ids = %v, want null for all-libraries", first["library_ids"])
}
second := groups[1].(map[string]any)
if second["member_count"] != float64(12) {
t.Fatalf("member_count = %v, want 12", second["member_count"])
}
if ids, ok := second["library_ids"].([]any); !ok || len(ids) != 1 || ids[0] != float64(3) {
t.Fatalf("library_ids = %v, want [3]", second["library_ids"])
}
}
func TestAdminGroupsCreate(t *testing.T) {
store := &fakeGroupStore{createGroup: testGroup()}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": " Film Club ",
"description": "Movie nights",
"permissions": []string{"marker_edit"},
"library_ids": []int{1, 2},
"max_streams": 2,
})
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body %q)", rec.Code, rec.Body.String())
}
if store.createInput == nil {
t.Fatal("store.Create was not called")
}
if store.createInput.Name != "Film Club" {
t.Fatalf("Name = %q, want trimmed name", store.createInput.Name)
}
if len(store.createInput.LibraryIDs) != 2 {
t.Fatalf("LibraryIDs = %v, want [1 2]", store.createInput.LibraryIDs)
}
if store.createInput.MaxStreams == nil || *store.createInput.MaxStreams != 2 {
t.Fatalf("MaxStreams = %v, want 2", store.createInput.MaxStreams)
}
if store.createInput.MaxTranscodes != nil {
t.Fatalf("MaxTranscodes = %v, want nil (omitted)", store.createInput.MaxTranscodes)
}
body := decodeGroupsJSON(t, rec)
if body["slug"] != "film-club" {
t.Fatalf("slug = %v, want film-club", body["slug"])
}
if body["member_count"] != float64(0) {
t.Fatalf("member_count = %v, want 0 for new group", body["member_count"])
}
}
func TestAdminGroupsCreateEmptyName(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": " ",
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.createInput != nil {
t.Fatal("store.Create should not be called for empty name")
}
}
func TestAdminGroupsCreateUnknownPermission(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": "Film Club",
"permissions": []string{"server_owner"},
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.createInput != nil {
t.Fatal("store.Create should not be called for unknown permission")
}
}
func TestAdminGroupsCreateDuplicate(t *testing.T) {
store := &fakeGroupStore{createErr: auth.ErrDuplicate}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPost, "/groups", map[string]any{
"name": "Film Club",
})
assertGroupsError(t, rec, http.StatusConflict, "duplicate")
}
func TestAdminGroupsGet(t *testing.T) {
store := &fakeGroupStore{getGroup: testGroup(), memberCount: 5}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
body := decodeGroupsJSON(t, rec)
if body["id"] != float64(7) {
t.Fatalf("id = %v, want 7", body["id"])
}
if body["member_count"] != float64(5) {
t.Fatalf("member_count = %v, want 5", body["member_count"])
}
}
func TestAdminGroupsGetNotFound(t *testing.T) {
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999", nil)
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
}
func TestAdminGroupsGetInvalidID(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/abc", nil)
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
}
func TestAdminGroupsPatchStripAdminPermission(t *testing.T) {
store := &fakeGroupStore{updateErr: auth.ErrAdminPermRequired}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/1", map[string]any{
"permissions": []string{"marker_edit"},
})
assertGroupsError(t, rec, http.StatusConflict, "admin_permission_required")
}
func TestAdminGroupsPatchUnknownPermission(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPatch, "/groups/7", map[string]any{
"permissions": []string{"server_owner"},
})
assertGroupsError(t, rec, http.StatusBadRequest, "bad_request")
if store.updateInput != nil {
t.Fatal("store.Update should not be called for unknown permission")
}
}
func TestAdminGroupsPatchLibraryIDsSemantics(t *testing.T) {
tests := []struct {
name string
body string
want func(t *testing.T, input *models.UpdateGroupInput)
}{
{
name: "absent means no change",
body: `{"name":"Renamed"}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs != nil {
t.Fatalf("LibraryIDs = %v, want nil (no change)", input.LibraryIDs)
}
},
},
{
name: "null means all libraries",
body: `{"library_ids":null}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to nil slice (all libraries)")
}
if *input.LibraryIDs != nil {
t.Fatalf("*LibraryIDs = %v, want nil slice (all libraries)", *input.LibraryIDs)
}
},
},
{
name: "empty array means no libraries",
body: `{"library_ids":[]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to empty slice (no libraries)")
}
if *input.LibraryIDs == nil || len(*input.LibraryIDs) != 0 {
t.Fatalf("*LibraryIDs = %v, want non-nil empty slice", *input.LibraryIDs)
}
},
},
{
name: "explicit ids",
body: `{"library_ids":[1,2]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.LibraryIDs == nil {
t.Fatal("LibraryIDs = nil, want pointer to [1 2]")
}
ids := *input.LibraryIDs
if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 {
t.Fatalf("*LibraryIDs = %v, want [1 2]", ids)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeGroupStore{updateGroup: testGroup()}
router := newGroupsTestRouter(store)
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.updateInput == nil {
t.Fatal("store.Update was not called")
}
tt.want(t, store.updateInput)
})
}
}
func TestAdminGroupsDeleteBuiltIn(t *testing.T) {
store := &fakeGroupStore{deleteErr: auth.ErrBuiltInGroup}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1", nil)
assertGroupsError(t, rec, http.StatusConflict, "built_in_group")
}
func TestAdminGroupsDelete(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.deleteID != 7 {
t.Fatalf("deleted id = %d, want 7", store.deleteID)
}
}
func TestAdminGroupsListMembersPagination(t *testing.T) {
store := &fakeGroupStore{
members: []auth.GroupMember{
{UserID: 3, Username: "alice", Email: "alice@example.com", Enabled: true},
},
membersTotal: 42,
}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members?offset=10&limit=5", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.membersGroupID != 7 || store.membersOffset != 10 || store.membersLimit != 5 {
t.Fatalf("ListMembers(%d, %d, %d), want (7, 10, 5)",
store.membersGroupID, store.membersOffset, store.membersLimit)
}
body := decodeGroupsJSON(t, rec)
if body["total"] != float64(42) || body["offset"] != float64(10) || body["limit"] != float64(5) {
t.Fatalf("pagination = total %v offset %v limit %v, want 42/10/5",
body["total"], body["offset"], body["limit"])
}
members, ok := body["members"].([]any)
if !ok || len(members) != 1 {
t.Fatalf("members = %v, want 1 entry", body["members"])
}
member := members[0].(map[string]any)
if member["user_id"] != float64(3) || member["username"] != "alice" ||
member["email"] != "alice@example.com" || member["enabled"] != true {
t.Fatalf("member = %v, want alice row", member)
}
}
func TestAdminGroupsListMembersDefaults(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/7/members", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.membersOffset != 0 || store.membersLimit != 50 {
t.Fatalf("ListMembers offset %d limit %d, want defaults 0/50",
store.membersOffset, store.membersLimit)
}
body := decodeGroupsJSON(t, rec)
if members, ok := body["members"].([]any); !ok || len(members) != 0 {
t.Fatalf("members = %v, want empty array (not null)", body["members"])
}
}
func TestAdminGroupsAddMember(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/12", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.addGroupID != 7 || store.addUserID != 12 {
t.Fatalf("AddMember(%d, %d), want (7, 12)", store.addGroupID, store.addUserID)
}
}
func TestAdminGroupsRemoveLastAdministrator(t *testing.T) {
store := &fakeGroupStore{removeErr: auth.ErrLastAdministrator}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/1/members/12", nil)
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestAdminGroupsRemoveMember(t *testing.T) {
store := &fakeGroupStore{}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodDelete, "/groups/7/members/12", nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204 (body %q)", rec.Code, rec.Body.String())
}
if store.removeGroupID != 7 || store.removeUserID != 12 {
t.Fatalf("RemoveMember(%d, %d), want (7, 12)", store.removeGroupID, store.removeUserID)
}
}
func TestAdminGroupsAddMemberUnknownUser(t *testing.T) {
store := &fakeGroupStore{addErr: auth.ErrUnknownUser}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodPut, "/groups/7/members/999", nil)
assertGroupsError(t, rec, http.StatusNotFound, "unknown_user")
}
func TestAdminGroupsListMembersNotFound(t *testing.T) {
store := &fakeGroupStore{getErr: auth.ErrGroupNotFound}
rec := doGroupsRequest(t, newGroupsTestRouter(store), http.MethodGet, "/groups/999/members", nil)
assertGroupsError(t, rec, http.StatusNotFound, "not_found")
}
func TestAdminGroupsPatchPermissionsSemantics(t *testing.T) {
tests := []struct {
name string
body string
want func(t *testing.T, input *models.UpdateGroupInput)
}{
{
name: "explicit permissions reach store as pointer to slice",
body: `{"permissions":["marker_edit"]}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions == nil {
t.Fatal("Permissions = nil, want pointer to [\"marker_edit\"]")
}
perms := *input.Permissions
if len(perms) != 1 || perms[0] != "marker_edit" {
t.Fatalf("*Permissions = %v, want [\"marker_edit\"]", perms)
}
},
},
{
name: "null permissions reaches store as pointer to empty slice",
body: `{"permissions":null}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions == nil {
t.Fatal("Permissions = nil, want pointer to empty slice")
}
if len(*input.Permissions) != 0 {
t.Fatalf("*Permissions = %v, want empty slice", *input.Permissions)
}
},
},
{
name: "absent permissions reaches store as nil (no change)",
body: `{"name":"Renamed"}`,
want: func(t *testing.T, input *models.UpdateGroupInput) {
if input.Permissions != nil {
t.Fatalf("Permissions = %v, want nil (no change)", input.Permissions)
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeGroupStore{updateGroup: testGroup()}
router := newGroupsTestRouter(store)
req := httptest.NewRequest(http.MethodPatch, "/groups/7", bytes.NewReader([]byte(tt.body)))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %q)", rec.Code, rec.Body.String())
}
if store.updateInput == nil {
t.Fatal("store.Update was not called")
}
tt.want(t, store.updateInput)
})
}
}
+14 -12
View File
@@ -37,6 +37,7 @@ type AdminJobsHandler struct {
store AdminJobArtifactStore
CancelRegistry *adminjob.CancelRegistry
RealtimeHub *notifications.Hub
Users auth.UserLoader // for server-side admin checks on non-admin-gated routes
}
func NewAdminJobsHandler(repo adminJobRepository, store AdminJobArtifactStore) *AdminJobsHandler {
@@ -122,13 +123,14 @@ func (h *AdminJobsHandler) HandleGet(w http.ResponseWriter, r *http.Request) {
return
}
claims := apimw.GetClaims(r.Context())
if !canReadAdminJob(claims, job) {
viewerUserID := apimw.GetUserID(r.Context())
viewerIsAdmin := isAdminRequest(r, h.Users)
if !canReadAdminJob(viewerUserID, viewerIsAdmin, job) {
writeError(w, http.StatusForbidden, "forbidden", "Admin access required")
return
}
writeJSON(w, http.StatusOK, adminJobToResponseForClaims(r, job, h.store, claims))
writeJSON(w, http.StatusOK, adminJobToResponseForViewer(r, job, h.store, viewerIsAdmin))
}
func (h *AdminJobsHandler) HandleCancel(w http.ResponseWriter, r *http.Request) {
@@ -264,19 +266,19 @@ func adminJobToResponse(r *http.Request, job *models.AdminJob, store AdminJobArt
return resp
}
func adminJobToResponseForClaims(
func adminJobToResponseForViewer(
r *http.Request,
job *models.AdminJob,
store AdminJobArtifactStore,
claims *auth.Claims,
viewerIsAdmin bool,
) adminJobResponse {
response := adminJobToResponse(r, job, store)
sanitizeAdminJobResponseForClaims(&response, claims)
sanitizeAdminJobResponseForViewer(&response, viewerIsAdmin)
return response
}
func sanitizeAdminJobResponseForClaims(response *adminJobResponse, claims *auth.Claims) {
if response == nil || (claims != nil && claims.Role == "admin") {
func sanitizeAdminJobResponseForViewer(response *adminJobResponse, viewerIsAdmin bool) {
if response == nil || viewerIsAdmin {
return
}
response.RequestPayload = json.RawMessage(`{}`)
@@ -384,12 +386,12 @@ func currentAdminUserID(r *http.Request) int {
return claims.UserID
}
func canReadAdminJob(claims *auth.Claims, job *models.AdminJob) bool {
if claims == nil || job == nil {
func canReadAdminJob(viewerUserID int, viewerIsAdmin bool, job *models.AdminJob) bool {
if viewerUserID == 0 || job == nil {
return false
}
if claims.Role == "admin" {
if viewerIsAdmin {
return true
}
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == claims.UserID
return job.JobType == adminjob.JobTypeItemRefresh && job.CreatedByUserID == viewerUserID
}
+6 -12
View File
@@ -13,46 +13,40 @@ import (
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
)
func TestCanReadAdminJob_AdminCanReadAnyJob(t *testing.T) {
claims := &auth.Claims{UserID: 1, Role: "admin"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
if !canReadAdminJob(claims, job) {
if !canReadAdminJob(1, true, job) {
t.Fatal("admin should be allowed to read any job")
}
}
func TestCanReadAdminJob_CreatorCanReadOwnItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
if !canReadAdminJob(claims, job) {
if !canReadAdminJob(2, false, job) {
t.Fatal("creator should be allowed to read own item refresh job")
}
}
func TestCanReadAdminJob_CreatorCannotReadOwnNonItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeCatalogExport}
if canReadAdminJob(claims, job) {
if canReadAdminJob(2, false, job) {
t.Fatal("non-admin should not read non-item-refresh jobs")
}
}
func TestCanReadAdminJob_OtherUserCannotReadItemRefreshJob(t *testing.T) {
claims := &auth.Claims{UserID: 3, Role: "user"}
job := &models.AdminJob{CreatedByUserID: 2, JobType: adminjob.JobTypeItemRefresh}
if canReadAdminJob(claims, job) {
if canReadAdminJob(3, false, job) {
t.Fatal("non-admin should not read another user's item refresh job")
}
}
func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
claims := &auth.Claims{UserID: 2, Role: "user"}
func TestAdminJobToResponseForViewer_NonAdminSanitizesItemRefreshPayloads(t *testing.T) {
job := &models.AdminJob{
JobType: adminjob.JobTypeItemRefresh,
CreatedByUserID: 2,
@@ -66,7 +60,7 @@ func TestAdminJobToResponseForClaims_NonAdminSanitizesItemRefreshPayloads(t *tes
PublicURL: "https://example.test/public",
}
resp := adminJobToResponseForClaims(nil, job, nil, claims)
resp := adminJobToResponseForViewer(nil, job, nil, false)
if string(resp.RequestPayload) != `{}` {
t.Fatalf("RequestPayload = %s, want sanitized empty object", resp.RequestPayload)
@@ -6,8 +6,6 @@ import (
"net/http"
"sort"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/cache"
"github.com/Silo-Server/silo-server/internal/markers"
)
@@ -122,7 +120,11 @@ func (h *AdminMarkerProvidersHandler) HandleUpdateProvider(w http.ResponseWriter
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
return
}
provider := chi.URLParam(r, "provider")
provider, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
return
}
existing, ok := h.Config.Get(provider)
if !ok {
writeError(w, http.StatusNotFound, "not_found", "Unknown marker provider")
@@ -179,7 +181,11 @@ func (h *AdminMarkerProvidersHandler) HandleValidateProvider(w http.ResponseWrit
writeError(w, http.StatusServiceUnavailable, "unavailable", "Marker providers are not configured")
return
}
provider := chi.URLParam(r, "provider")
provider, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider ID")
return
}
var submitter markers.Submitter
for _, p := range h.Registry.Providers() {
if p.ID() == provider {
@@ -49,6 +49,34 @@ func TestValidateMarkerProviderUsesSnakeCaseStats(t *testing.T) {
}
}
type fakePluginMarkerSubmitter struct{ fakeMarkerStatsSubmitter }
func (fakePluginMarkerSubmitter) ID() string { return "plugin:6:introdb" }
// Browsers send encodeURIComponent-escaped provider IDs (plugin%3A6%3Aintrodb),
// and chi matches the raw path, so the handler must decode the route param.
func TestValidateMarkerProviderDecodesEncodedID(t *testing.T) {
reg := markers.NewRegistry(nil)
if err := reg.Register(fakePluginMarkerSubmitter{}); err != nil {
t.Fatalf("register provider: %v", err)
}
h := NewAdminMarkerProvidersHandler(reg, nil, nil, nil)
router := chi.NewRouter()
router.Post("/admin/markers/providers/{provider}/validate", h.HandleValidateProvider)
req := httptest.NewRequest(http.MethodPost, "/admin/markers/providers/plugin%3A6%3Aintrodb/validate", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"valid":true`) {
t.Fatalf("unexpected response: %s", rec.Body.String())
}
}
func TestMarkerProviderResponseIncludesPluginMetadata(t *testing.T) {
resp := toProviderConfigResponse(
markers.ProviderConfig{Provider: "plugin:4:markers", FetchEnabled: true, FetchPriority: 25},
@@ -111,7 +111,7 @@ func (h *AdminPlaybackControlHandler) HandleMessageSession(w http.ResponseWriter
return
}
command.Reason = req.Reason
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
result := h.playback.CommandDispatcher.DispatchToSession(command, 0, nil)
if result.DispatchErr != nil {
@@ -173,7 +173,7 @@ func (h *AdminPlaybackControlHandler) handleSessionCommand(w http.ResponseWriter
return
}
command.Reason = req.Reason
command.IssuedBy = &playback.CommandIssuedBy{Kind: "admin"}
command.IssuedBy = &playback.CommandIssuedBy{Kind: playback.IssuedByKindAdmin}
deadline := boundedPlaybackControlDeadline(req.DeadlineMS)
command.DeadlineMS = int(deadline / time.Millisecond)
+44 -8
View File
@@ -22,7 +22,9 @@ type AdminStats struct {
TotalFiles int `json:"total_files"`
TotalUsers int `json:"total_users"`
TotalMovies int `json:"total_movies"`
TotalMovieFiles int `json:"total_movie_files"`
TotalShows int `json:"total_shows"`
TotalShowFiles int `json:"total_show_files"`
ActiveStreams int `json:"active_streams"`
TotalStorageBytes int64 `json:"total_storage_bytes"`
WatchProviderActivity WatchProviderActivity `json:"watch_provider_activity"`
@@ -127,13 +129,15 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
}
var (
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalShows int64
activeStreams int64
totalStorage int64
totalUsers int64
totalItems int64
totalFiles int64
totalMovies int64
totalMovieFiles int64
totalShows int64
totalShowFiles int64
activeStreams int64
totalStorage int64
)
row := pool.QueryRow(ctx, `
@@ -151,8 +155,34 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
file_stats AS (
SELECT
COUNT(*)::bigint AS total_files,
COUNT(*) FILTER (WHERE file_kind = 'movie')::bigint AS total_movie_files,
COUNT(*) FILTER (WHERE file_kind = 'series')::bigint AS total_show_files,
COALESCE(SUM(file_size), 0)::bigint AS total_storage_bytes
FROM media_files
FROM (
SELECT
media_files.file_size,
CASE
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'movie'
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_items.type, ''), ''))) = 'series'
THEN 'series'
WHEN episodes.content_id IS NOT NULL
THEN 'series'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(COALESCE(NULLIF(media_files.base_type, ''), ''))) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
WHEN lower(trim(media_folders.type)) IN ('movie', 'movies')
THEN 'movie'
WHEN lower(trim(media_folders.type)) IN ('series', 'tv', 'show', 'shows', 'tvshows')
THEN 'series'
ELSE ''
END AS file_kind
FROM media_files
JOIN media_folders ON media_folders.id = media_files.media_folder_id
LEFT JOIN media_items ON media_items.content_id = media_files.content_id
LEFT JOIN episodes ON episodes.content_id = media_files.episode_id
) classified_files
),
session_stats AS (
SELECT COUNT(*)::bigint AS active_streams
@@ -163,7 +193,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
item_stats.total_items,
file_stats.total_files,
item_stats.total_movies,
file_stats.total_movie_files,
item_stats.total_shows,
file_stats.total_show_files,
session_stats.active_streams,
file_stats.total_storage_bytes
FROM user_stats
@@ -176,7 +208,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
&totalItems,
&totalFiles,
&totalMovies,
&totalMovieFiles,
&totalShows,
&totalShowFiles,
&activeStreams,
&totalStorage,
); err != nil {
@@ -194,7 +228,9 @@ func queryAdminStats(ctx context.Context, pool *pgxpool.Pool) (AdminStats, error
TotalItems: int(totalItems),
TotalFiles: int(totalFiles),
TotalMovies: int(totalMovies),
TotalMovieFiles: int(totalMovieFiles),
TotalShows: int(totalShows),
TotalShowFiles: int(totalShowFiles),
ActiveStreams: int(activeStreams),
TotalStorageBytes: totalStorage,
WatchProviderActivity: activity,
+10 -3
View File
@@ -12,7 +12,6 @@ import (
"github.com/Silo-Server/silo-server/internal/subtitles/opensubtitles"
"github.com/Silo-Server/silo-server/internal/subtitles/subdl"
"github.com/Silo-Server/silo-server/internal/subtitles/subsource"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -57,7 +56,11 @@ func (h *AdminSubtitleHandler) HandleListProviders(w http.ResponseWriter, r *htt
// HandleUpdateProvider handles PUT /api/v1/admin/subtitle-providers/{provider}
func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
providerName, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
return
}
var req updateSubtitleProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -83,7 +86,11 @@ func (h *AdminSubtitleHandler) HandleUpdateProvider(w http.ResponseWriter, r *ht
// HandleTestProvider handles POST /api/v1/admin/subtitle-providers/{provider}/test
func (h *AdminSubtitleHandler) HandleTestProvider(w http.ResponseWriter, r *http.Request) {
providerName := chi.URLParam(r, "provider")
providerName, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
return
}
cfg, err := h.repo.GetProviderConfig(r.Context(), providerName)
if err != nil {
+78 -63
View File
@@ -1,34 +1,82 @@
package handlers
import (
"context"
"net/http"
"testing"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeAdminUserRepo is a configurable in-memory UserRepository for handler
// tests.
type fakeAdminUserRepo struct {
user *models.User
updateErr error
deleteErr error
}
func (f *fakeAdminUserRepo) List(context.Context) ([]*models.User, error) { return nil, nil }
func (f *fakeAdminUserRepo) Create(context.Context, models.CreateUserInput) (*models.User, error) {
return f.user, nil
}
func (f *fakeAdminUserRepo) Update(context.Context, int, models.UpdateUserInput) error {
return f.updateErr
}
func (f *fakeAdminUserRepo) Delete(context.Context, int) error { return f.deleteErr }
func (f *fakeAdminUserRepo) GetByID(context.Context, int) (*models.User, error) {
if f.user == nil {
return nil, auth.ErrNotFound
}
return f.user, nil
}
// newAdminUsersTestRouter mounts the user-management routes the same way the
// API router does so tests exercise real chi URL params.
func newAdminUsersTestRouter(repo UserRepository) chi.Router {
h := NewAdminHandler(repo, nil, nil)
r := chi.NewRouter()
r.Put("/admin/users/{id}", h.HandleUpdateUser)
r.Delete("/admin/users/{id}", h.HandleDeleteUser)
return r
}
func TestAdminUpdateUserDisableLastAdministrator(t *testing.T) {
repo := &fakeAdminUserRepo{
user: &models.User{ID: 2, Enabled: true},
updateErr: auth.ErrLastAdministrator,
}
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodPut,
"/admin/users/2", map[string]any{"enabled": false})
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestAdminDeleteUserLastAdministrator(t *testing.T) {
repo := &fakeAdminUserRepo{deleteErr: auth.ErrLastAdministrator}
rec := doGroupsRequest(t, newAdminUsersTestRouter(repo), http.MethodDelete,
"/admin/users/2", nil)
assertGroupsError(t, rec, http.StatusConflict, "last_administrator")
}
func TestUpdateRequiresSessionRevocation(t *testing.T) {
role := "admin"
sameRole := "user"
enabled := true
disabled := false
libraryIDs := []int{1, 2}
sameLibraryIDs := []int{1}
emptyLibraryIDs := []int{}
var allLibraryIDs []int
maxPlaybackQuality := "1080p"
sameMaxPlaybackQuality := "original"
password := "new-password"
username := "renamed"
maxStreams := 4
permissions := []string{"metadata_curation"}
samePermissions := []string{"download"}
newGroups := []int{1, 2}
sameGroups := []int{2, 1, 1} // order and duplicates must not matter
emptyGroups := []int{}
current := &models.User{
Role: "user",
Permissions: []string{"download"},
Enabled: false,
LibraryIDs: []int{1},
MaxPlaybackQuality: "original",
Enabled: false,
GroupIDs: []int{1, 2},
}
tests := []struct {
@@ -36,26 +84,6 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
in models.UpdateUserInput
want bool
}{
{
name: "permissions set",
in: models.UpdateUserInput{Permissions: &permissions},
want: true,
},
{
name: "permissions unchanged",
in: models.UpdateUserInput{Permissions: &samePermissions},
want: false,
},
{
name: "role",
in: models.UpdateUserInput{Role: &role},
want: true,
},
{
name: "role unchanged",
in: models.UpdateUserInput{Role: &sameRole},
want: false,
},
{
name: "enabled",
in: models.UpdateUserInput{Enabled: &enabled},
@@ -67,30 +95,15 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
want: false,
},
{
name: "library ids does not revoke session",
in: models.UpdateUserInput{LibraryIDs: &libraryIDs},
name: "groups unchanged",
in: models.UpdateUserInput{GroupIDs: &sameGroups},
want: false,
},
{
name: "library ids unchanged",
in: models.UpdateUserInput{LibraryIDs: &sameLibraryIDs},
want: false,
},
{
name: "library ids nil does not revoke session",
in: models.UpdateUserInput{LibraryIDs: &allLibraryIDs},
want: false,
},
{
name: "max playback quality",
in: models.UpdateUserInput{MaxPlaybackQuality: &maxPlaybackQuality},
name: "groups cleared",
in: models.UpdateUserInput{GroupIDs: &emptyGroups},
want: true,
},
{
name: "max playback quality unchanged",
in: models.UpdateUserInput{MaxPlaybackQuality: &sameMaxPlaybackQuality},
want: false,
},
{
name: "password",
in: models.UpdateUserInput{Password: &password},
@@ -98,7 +111,7 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
},
{
name: "non access fields",
in: models.UpdateUserInput{Username: &username, MaxStreams: &maxStreams},
in: models.UpdateUserInput{Username: &username},
want: false,
},
{
@@ -116,16 +129,18 @@ func TestUpdateRequiresSessionRevocation(t *testing.T) {
})
}
unrestrictedCurrent := *current
unrestrictedCurrent.LibraryIDs = nil
t.Run("library ids empty does not revoke session", func(t *testing.T) {
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &emptyLibraryIDs}); got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
memberless := &models.User{Enabled: true, GroupIDs: nil}
t.Run("groups assigned to memberless user", func(t *testing.T) {
if got := updateRequiresSessionRevocation(memberless, models.UpdateUserInput{GroupIDs: &newGroups}); !got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
}
})
t.Run("library ids nil unchanged", func(t *testing.T) {
if got := updateRequiresSessionRevocation(&unrestrictedCurrent, models.UpdateUserInput{LibraryIDs: &allLibraryIDs}); got {
t.Run("unknown current user falls back to may-require", func(t *testing.T) {
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{GroupIDs: &sameGroups}); !got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want true", got)
}
if got := updateRequiresSessionRevocation(nil, models.UpdateUserInput{Username: &username}); got {
t.Fatalf("updateRequiresSessionRevocation() = %v, want false", got)
}
})
+108
View File
@@ -0,0 +1,108 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/catalog"
)
type audiobookGroupResponse struct {
Name string `json:"name"`
ItemCount int `json:"item_count"`
TotalDurationSeconds int64 `json:"total_duration_seconds"`
InProgressCount int `json:"in_progress_count"`
FinishedCount int `json:"finished_count"`
PosterURLs []string `json:"poster_urls"`
}
type audiobookGroupsResponse struct {
Total int `json:"total"`
Groups []audiobookGroupResponse `json:"groups"`
}
// HandleGetAudiobookGroups — GET /api/v1/catalog/audiobook-groups
//
// Grouped browse for audiobook libraries: authors, narrators, or series with
// aggregate stats (book count, total duration, per-profile progress counts)
// and up to four poster URLs for cover stacks. Query parameters:
// library_id=<id> (required), group_by=author|narrator|series (required),
// sort=name|count|duration (default name), limit, offset.
//
// Group names round-trip into the corresponding catalog filter fields
// (author / narrator / series), which match case-insensitively.
func (h *CatalogHandler) HandleGetAudiobookGroups(w http.ResponseWriter, r *http.Request) {
if h == nil || h.itemsH == nil || h.itemsH.browseRepo == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
return
}
libraryID, err := strconv.Atoi(strings.TrimSpace(r.URL.Query().Get("library_id")))
if err != nil || libraryID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "library_id must be a positive integer")
return
}
groupBy, ok := catalog.ParseAudiobookGroupBy(r.URL.Query().Get("group_by"))
if !ok {
writeError(w, http.StatusBadRequest, "bad_request", "group_by must be one of author, narrator, series")
return
}
sort := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("sort")))
switch sort {
case "", "name", "count", "duration":
default:
writeError(w, http.StatusBadRequest, "bad_request", "sort must be one of name, count, duration")
return
}
limit := 200
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
n, parseErr := strconv.Atoi(raw)
if parseErr != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
limit = n
}
offset := max(catalog.ParseIntParam(r.URL.Query().Get("offset")), 0)
groups, total, err := catalog.ListAudiobookGroups(
r.Context(),
h.itemsH.browseRepo.Pool(),
catalog.AudiobookGroupsQuery{
LibraryID: libraryID,
GroupBy: groupBy,
Sort: sort,
Limit: limit,
Offset: offset,
},
h.itemsH.accessFilter(r),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list audiobook groups")
return
}
resp := audiobookGroupsResponse{Total: total, Groups: make([]audiobookGroupResponse, 0, len(groups))}
for _, g := range groups {
posterURLs := make([]string, 0, len(g.PosterPaths))
for _, path := range g.PosterPaths {
if url := h.itemsH.presignURL(r, cardThumbnailPath(path), "card"); url != "" {
posterURLs = append(posterURLs, url)
}
}
resp.Groups = append(resp.Groups, audiobookGroupResponse{
Name: g.Name,
ItemCount: g.ItemCount,
TotalDurationSeconds: g.TotalDurationSeconds,
InProgressCount: g.InProgressCount,
FinishedCount: g.FinishedCount,
PosterURLs: posterURLs,
})
}
writeJSON(w, http.StatusOK, resp)
}
+2 -2
View File
@@ -344,7 +344,7 @@ func (h *AuthHandler) HandlePluginLaunch(w http.ResponseWriter, r *http.Request)
}
const ttl = 5 * time.Minute
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.Role, claims.SessionID, ttl)
token, err := h.jwt.GeneratePluginAccessToken(claims.UserID, claims.SessionID, ttl)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare plugin access")
return
@@ -527,7 +527,7 @@ func buildUserResponse(user *models.User, impersonatorUserID *int, impersonator
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
Role: auth.RoleForUser(user),
Permissions: auth.EffectivePermissions(user),
DownloadAllowed: user.DownloadAllowed,
}
+94 -57
View File
@@ -36,6 +36,7 @@ type autoscanStore interface {
CountAutoscanScans(ctx context.Context, filter autoscan.ScanListFilter) (int, error)
ListEvents(ctx context.Context, filter autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
CountEvents(ctx context.Context, filter autoscan.EventListFilter) (int, error)
ListRunningEvents(ctx context.Context) ([]autoscan.Event, error)
GetQueueSummary(ctx context.Context) (autoscan.QueueSummary, error)
LatestEventAt(ctx context.Context) (*time.Time, error)
}
@@ -309,7 +310,7 @@ func (h *AutoscanHandler) HandleDeleteConnection(w http.ResponseWriter, r *http.
// credentials: the connection link is by id only.
type autoscanSourceResponse struct {
ID string `json:"id"`
InstallationID int `json:"installation_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
@@ -331,7 +332,7 @@ func sourceResponse(s autoscan.Source) autoscanSourceResponse {
config := normalizeSourceConfig(s.SourceConfig)
return autoscanSourceResponse{
ID: s.ID,
InstallationID: s.InstallationID,
PluginID: s.PluginID,
CapabilityID: s.CapabilityID,
ConnectionID: s.ConnectionID,
Enabled: s.Enabled,
@@ -364,10 +365,9 @@ func (h *AutoscanHandler) HandleListSources(w http.ResponseWriter, r *http.Reque
// --- Available scan-source plugins (Add-source picker) ---
type autoscanScanSourcePluginResponse struct {
InstallationID int `json:"installation_id"`
CapabilityID string `json:"capability_id"`
PluginID string `json:"plugin_id"`
DisplayName string `json:"display_name"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
DisplayName string `json:"display_name"`
}
// HandleListAvailableScanSources returns every installed scan_source capability
@@ -381,10 +381,9 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
out := make([]autoscanScanSourcePluginResponse, 0, len(available))
for _, a := range available {
out = append(out, autoscanScanSourcePluginResponse{
InstallationID: a.InstallationID,
CapabilityID: a.CapabilityID,
PluginID: a.PluginID,
DisplayName: a.DisplayName,
PluginID: a.PluginID,
CapabilityID: a.CapabilityID,
DisplayName: a.DisplayName,
})
}
writeJSON(w, http.StatusOK, struct {
@@ -394,12 +393,12 @@ func (h *AutoscanHandler) HandleListAvailableScanSources(w http.ResponseWriter,
// --- Create source ---
// autoscanCreateSourceInput is the create payload: it names the installed
// scan_source capability to bind (installation_id + capability_id) plus the
// initial binding/scheduling fields. Unlike the update payload, identity is
// supplied here rather than read from an existing row.
// autoscanCreateSourceInput is the create payload: it names the scan_source
// capability to bind (plugin_id + capability_id) plus the initial
// binding/scheduling fields. Unlike the update payload, identity is supplied
// here rather than read from an existing row.
type autoscanCreateSourceInput struct {
InstallationID int `json:"installation_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
@@ -420,6 +419,11 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
return
}
capID := strings.TrimSpace(in.CapabilityID)
pluginID := strings.TrimSpace(in.PluginID)
if pluginID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id is required")
return
}
if capID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "capability_id is required")
return
@@ -432,7 +436,7 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
// Validate the (installation_id, capability_id) is a currently-installed
// Validate the (plugin_id, capability_id) is a currently-installed
// scan_source capability — a source may only be created against an installed
// plugin capability.
available, err := h.svc.ListAvailableScanSources(r.Context())
@@ -440,13 +444,13 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
writeAutoscanError(w, err)
return
}
if !scanSourceInstalled(available, in.InstallationID, capID) {
writeError(w, http.StatusBadRequest, "bad_request", "installation_id + capability_id is not a currently-installed scan_source capability")
if !scanSourceInstalled(available, pluginID, capID) {
writeError(w, http.StatusBadRequest, "bad_request", "plugin_id + capability_id is not a currently-installed scan_source capability")
return
}
connArg := normalizeConnectionID(in.ConnectionID)
created, err := h.repo.CreateSource(r.Context(), autoscan.Source{
InstallationID: in.InstallationID,
PluginID: pluginID,
CapabilityID: capID,
ConnectionID: connArg,
Enabled: in.Enabled,
@@ -462,18 +466,18 @@ func (h *AutoscanHandler) HandleCreateSource(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusCreated, sourceResponse(created))
}
// scanSourceInstalled reports whether (installationID, capabilityID) is in the
// scanSourceInstalled reports whether (pluginID, capabilityID) is in the
// set of currently-installed scan_source capabilities.
func scanSourceInstalled(available []autoscan.AvailableScanSource, installationID int, capabilityID string) bool {
func scanSourceInstalled(available []autoscan.AvailableScanSource, pluginID, capabilityID string) bool {
for _, a := range available {
if a.InstallationID == installationID && a.CapabilityID == capabilityID {
if a.PluginID == pluginID && a.CapabilityID == capabilityID {
return true
}
}
return false
}
// autoscanSourceInput is the source write payload. The (installation_id,
// autoscanSourceInput is the source write payload. The (plugin_id,
// capability_id) identity is read from the existing source row, so only the
// schedulable/binding fields are accepted.
//
@@ -548,7 +552,7 @@ func (h *AutoscanHandler) HandleUpdateSource(w http.ResponseWriter, r *http.Requ
// connection_id is a full-state field: nil means unbind, a UUID string
// means bind. A whitespace-only string is normalised to nil (unbound).
connArg := normalizeConnectionID(in.ConnectionID)
// Update by id; identity (installation_id, capability_id) is immutable and
// Update by id; identity (plugin_id, capability_id) is immutable and
// preserved by the repo. A missing source maps to 404.
updated, err := h.repo.UpdateSource(r.Context(), autoscan.Source{
ID: id,
@@ -581,9 +585,8 @@ func normalizeConnectionID(in *string) *string {
}
// HandleDeleteSource removes a source row. This is the operator's escape hatch
// for an orphaned source (one whose scan_source plugin was uninstalled): the
// poll loop skips such rows quietly, and this endpoint clears them. An unknown
// id maps to 404 via autoscan.ErrNotFound.
// for an orphaned source (one whose scan_source plugin was uninstalled or no
// longer resolves cleanly). An unknown id maps to 404 via autoscan.ErrNotFound.
func (h *AutoscanHandler) HandleDeleteSource(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if err := h.repo.DeleteSource(r.Context(), id); err != nil {
@@ -710,7 +713,7 @@ type autoscanEventScanRunResponse struct {
type autoscanEventResponse struct {
ID int64 `json:"id"`
SourceID *string `json:"source_id"`
InstallationID int `json:"installation_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
StartedAt time.Time `json:"started_at"`
CompletedAt time.Time `json:"completed_at"`
@@ -753,7 +756,7 @@ func eventResponse(event autoscan.EventWithRuns) autoscanEventResponse {
return autoscanEventResponse{
ID: e.ID,
SourceID: e.SourceID,
InstallationID: e.InstallationID,
PluginID: e.PluginID,
CapabilityID: e.CapabilityID,
StartedAt: e.StartedAt,
CompletedAt: e.CompletedAt,
@@ -794,9 +797,9 @@ func (h *AutoscanHandler) HandleListEvents(w http.ResponseWriter, r *http.Reques
filter.Offset = offset
}
switch filter.Status {
case "", autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
case "", autoscan.EventStatusRunning, autoscan.EventStatusSuccess, autoscan.EventStatusError, autoscan.EventStatusUnresolved:
default:
writeError(w, http.StatusBadRequest, "bad_request", "status must be success, error, or unresolved")
writeError(w, http.StatusBadRequest, "bad_request", "status must be running, success, error, or unresolved")
return
}
@@ -835,7 +838,7 @@ type autoscanScanResponse struct {
CompletedAt *time.Time `json:"completed_at,omitempty"`
AutoscanEventID *int64 `json:"autoscan_event_id,omitempty"`
SourceID *string `json:"source_id,omitempty"`
InstallationID *int `json:"installation_id,omitempty"`
PluginID string `json:"plugin_id,omitempty"`
CapabilityID string `json:"capability_id,omitempty"`
EventStatus string `json:"event_status,omitempty"`
EventCompletedAt *time.Time `json:"event_completed_at,omitempty"`
@@ -862,7 +865,7 @@ func autoscanScanRunResponse(scan autoscan.ScanWithEvent) autoscanScanResponse {
CompletedAt: scan.CompletedAt,
AutoscanEventID: scan.AutoscanEventID,
SourceID: scan.SourceID,
InstallationID: scan.InstallationID,
PluginID: scan.PluginID,
CapabilityID: scan.CapabilityID,
EventStatus: string(scan.EventStatus),
EventCompletedAt: scan.EventCompletedAt,
@@ -923,24 +926,35 @@ func (h *AutoscanHandler) HandleListScans(w http.ResponseWriter, r *http.Request
// --- Status ---
type autoscanStatusSource struct {
ID string `json:"id"`
InstallationID int `json:"installation_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
Label string `json:"label"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastError *string `json:"last_error,omitempty"`
ID string `json:"id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
ConnectionID *string `json:"connection_id"`
Enabled bool `json:"enabled"`
Label string `json:"label"`
PathRewrites []autoscan.PathRewrite `json:"path_rewrites"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastError *string `json:"last_error,omitempty"`
}
type autoscanRunningPollResponse struct {
ID int64 `json:"id"`
SourceID *string `json:"source_id"`
PluginID string `json:"plugin_id"`
CapabilityID string `json:"capability_id"`
StartedAt time.Time `json:"started_at"`
ElapsedMS int64 `json:"elapsed_ms"`
MarkerBefore *string `json:"marker_before,omitempty"`
}
type autoscanStatusResponse struct {
Enabled bool `json:"enabled"`
Sources []autoscanStatusSource `json:"sources"`
ActiveScans int `json:"active_scans"`
AcceptedScans int `json:"accepted_scans"`
RunningScans int `json:"running_scans"`
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
Enabled bool `json:"enabled"`
Sources []autoscanStatusSource `json:"sources"`
RunningPolls []autoscanRunningPollResponse `json:"running_polls"`
ActiveScans int `json:"active_scans"`
AcceptedScans int `json:"accepted_scans"`
RunningScans int `json:"running_scans"`
LatestEventAt *time.Time `json:"latest_event_at,omitempty"`
}
func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
@@ -960,6 +974,11 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
writeAutoscanError(w, err)
return
}
runningEvents, err := h.repo.ListRunningEvents(ctx)
if err != nil {
writeAutoscanError(w, err)
return
}
latestEventAt, err := h.repo.LatestEventAt(ctx)
if err != nil {
writeAutoscanError(w, err)
@@ -972,20 +991,38 @@ func (h *AutoscanHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
rewrites = []autoscan.PathRewrite{}
}
trimmed = append(trimmed, autoscanStatusSource{
ID: src.ID,
InstallationID: src.InstallationID,
CapabilityID: src.CapabilityID,
ConnectionID: src.ConnectionID,
Enabled: src.Enabled,
Label: src.Label,
PathRewrites: rewrites,
LastRunAt: src.LastRunAt,
LastError: src.LastError,
ID: src.ID,
PluginID: src.PluginID,
CapabilityID: src.CapabilityID,
ConnectionID: src.ConnectionID,
Enabled: src.Enabled,
Label: src.Label,
PathRewrites: rewrites,
LastRunAt: src.LastRunAt,
LastError: src.LastError,
})
}
now := time.Now()
runningPolls := make([]autoscanRunningPollResponse, 0, len(runningEvents))
for _, event := range runningEvents {
elapsed := now.Sub(event.StartedAt)
if elapsed < 0 {
elapsed = 0
}
runningPolls = append(runningPolls, autoscanRunningPollResponse{
ID: event.ID,
SourceID: event.SourceID,
PluginID: event.PluginID,
CapabilityID: event.CapabilityID,
StartedAt: event.StartedAt,
ElapsedMS: elapsed.Milliseconds(),
MarkerBefore: event.MarkerBefore,
})
}
writeJSON(w, http.StatusOK, autoscanStatusResponse{
Enabled: s.Enabled,
Sources: trimmed,
RunningPolls: runningPolls,
ActiveScans: queue.Active,
AcceptedScans: queue.Accepted,
RunningScans: queue.Running,
+52 -22
View File
@@ -31,6 +31,7 @@ type fakeAutoscanStore struct {
countScansFn func(autoscan.ScanListFilter) (int, error)
listEventsFn func(autoscan.EventListFilter) ([]autoscan.EventWithRuns, error)
countEventsFn func(autoscan.EventListFilter) (int, error)
listRunningFn func() ([]autoscan.Event, error)
queueSummaryFn func() (autoscan.QueueSummary, error)
latestEventAtFn func() (*time.Time, error)
}
@@ -140,6 +141,13 @@ func (f *fakeAutoscanStore) CountEvents(_ context.Context, filter autoscan.Event
return 0, nil
}
func (f *fakeAutoscanStore) ListRunningEvents(context.Context) ([]autoscan.Event, error) {
if f.listRunningFn != nil {
return f.listRunningFn()
}
return nil, nil
}
func (f *fakeAutoscanStore) GetQueueSummary(context.Context) (autoscan.QueueSummary, error) {
if f.queueSummaryFn != nil {
return f.queueSummaryFn()
@@ -273,7 +281,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
store := &fakeAutoscanStore{
listSourcesFn: func() ([]autoscan.Source, error) {
listed = true
return []autoscan.Source{{ID: "src-1", InstallationID: 1, CapabilityID: "arr"}}, nil
return []autoscan.Source{{ID: "src-1", PluginID: "silo.autoscan.arr", CapabilityID: "arr"}}, nil
},
}
h := NewAutoscanHandler(store, &fakeAutoscanTriggerer{})
@@ -300,7 +308,7 @@ func TestAutoscanHandleListSourcesListsOnly(t *testing.T) {
func TestAutoscanHandleListAvailableScanSources(t *testing.T) {
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 1, CapabilityID: "arr", PluginID: "sonarr", DisplayName: "Sonarr"},
{PluginID: "sonarr", CapabilityID: "arr", DisplayName: "Sonarr"},
}}
h := NewAutoscanHandler(&fakeAutoscanStore{}, trig)
@@ -331,19 +339,19 @@ func TestAutoscanHandleCreateSourceSucceeds(t *testing.T) {
},
}
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 1, CapabilityID: "arr"},
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"installation_id":1,"capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","connection_id":"conn-1","enabled":true}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if got.InstallationID != 1 || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
if got.PluginID != "silo.autoscan.arr" || got.CapabilityID != "arr" || got.ConnectionID == nil || *got.ConnectionID != "conn-1" {
t.Fatalf("unexpected created source: %+v", got)
}
var body autoscanSourceResponse
@@ -363,14 +371,14 @@ func TestAutoscanHandleCreateSourceRejectsUnknownCapability(t *testing.T) {
return s, nil
},
}
// Installed set does NOT include (1, "arr").
// Installed set does NOT include ("silo.autoscan.arr", "arr").
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 2, CapabilityID: "other"},
{PluginID: "silo.autoscan.other", CapabilityID: "other"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"installation_id":1,"capability_id":"arr","enabled":false}`, "")
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":false}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
@@ -394,12 +402,12 @@ func TestAutoscanHandleCreateSourceEnableWithoutConnectionSucceeds(t *testing.T)
},
}
trig := &fakeAutoscanTriggerer{available: []autoscan.AvailableScanSource{
{InstallationID: 1, CapabilityID: "arr"},
{PluginID: "silo.autoscan.arr", CapabilityID: "arr"},
}}
h := NewAutoscanHandler(store, trig)
req := newAutoscanRequest("POST", "/api/v1/admin/autoscan/sources",
`{"installation_id":1,"capability_id":"arr","enabled":true}`, "")
`{"plugin_id":"silo.autoscan.arr","capability_id":"arr","enabled":true}`, "")
rec := httptest.NewRecorder()
h.HandleCreateSource(rec, req)
@@ -615,7 +623,7 @@ func TestAutoscanHandleUpdateSourceEnableWithoutConnectionSucceeds(t *testing.T)
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -641,7 +649,7 @@ func TestAutoscanHandleUpdateSourceEnableWithConnectionSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -669,7 +677,7 @@ func TestAutoscanHandleUpdateSourceBindConnectionSucceeds(t *testing.T) {
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
// Source starts with no connection.
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: nil}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: nil}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -697,7 +705,7 @@ func TestAutoscanHandleUpdateSourceUnbindConnectionSucceeds(t *testing.T) {
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
// Source starts with a connection already bound.
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -727,7 +735,7 @@ func TestAutoscanHandleUpdateSourceUnbindWhileEnabledSucceeds(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -925,7 +933,7 @@ func TestAutoscanHandleUpdateSourceRoundTripsPathRewrites(t *testing.T) {
var got autoscan.Source
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
got = s
@@ -990,7 +998,7 @@ func TestAutoscanHandleUpdateSourceRejectsBlankRewrite(t *testing.T) {
upserted := false
store := &fakeAutoscanStore{
getSourceFn: func(id string) (autoscan.Source, error) {
return autoscan.Source{ID: id, InstallationID: 1, CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
return autoscan.Source{ID: id, PluginID: "silo.autoscan.arr", CapabilityID: "arr", ConnectionID: ptr("conn-1")}, nil
},
updateSourceFn: func(s autoscan.Source) (autoscan.Source, error) {
upserted = true
@@ -1020,7 +1028,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
eventCompleted := completed.Add(-1 * time.Second)
eventID := int64(42)
sourceID := "src-1"
installationID := 7
pluginID := "silo.autoscan.cephfs"
var gotFilter autoscan.ScanListFilter
var gotCountFilter autoscan.ScanListFilter
store := &fakeAutoscanStore{
@@ -1040,7 +1048,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
},
AutoscanEventID: &eventID,
SourceID: &sourceID,
InstallationID: &installationID,
PluginID: pluginID,
CapabilityID: "cephfs",
EventStatus: autoscan.EventStatusSuccess,
EventCompletedAt: &eventCompleted,
@@ -1084,7 +1092,7 @@ func TestAutoscanHandleListScansSerializesFiltersAndEventContext(t *testing.T) {
if scan.AutoscanEventID == nil || *scan.AutoscanEventID != eventID || scan.EventStatus != "success" {
t.Fatalf("event context = %+v", scan)
}
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.InstallationID == nil || *scan.InstallationID != installationID || scan.CapabilityID != "cephfs" {
if scan.SourceID == nil || *scan.SourceID != sourceID || scan.PluginID != pluginID || scan.CapabilityID != "cephfs" {
t.Fatalf("source context = %+v", scan)
}
}
@@ -1128,7 +1136,7 @@ func TestAutoscanHandleListEventsSerializesFiltersAndRuns(t *testing.T) {
Event: autoscan.Event{
ID: 42,
SourceID: &sourceID,
InstallationID: 7,
PluginID: "silo.autoscan.cephfs",
CapabilityID: "cephfs",
StartedAt: started,
CompletedAt: completed,
@@ -1258,18 +1266,30 @@ func TestAutoscanHandleUpdateSourceNormalizesLabel(t *testing.T) {
func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
latestEventAt := time.Date(2026, 6, 4, 14, 30, 0, 0, time.UTC)
runningStartedAt := time.Now().Add(-90 * time.Second)
sourceID := "src-1"
store := &fakeAutoscanStore{
getSettingsFn: func() (autoscan.Settings, error) {
return autoscan.Settings{Enabled: true}, nil
},
listSourcesFn: func() ([]autoscan.Source, error) {
return []autoscan.Source{
{ID: "src-1", InstallationID: 7, CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
{ID: "src-1", PluginID: "silo.autoscan.cephfs", CapabilityID: "scan_source", ConnectionID: ptr("conn-1"), Enabled: true},
}, nil
},
queueSummaryFn: func() (autoscan.QueueSummary, error) {
return autoscan.QueueSummary{Active: 3, Accepted: 2, Running: 1}, nil
},
listRunningFn: func() ([]autoscan.Event, error) {
return []autoscan.Event{{
ID: 44,
SourceID: &sourceID,
PluginID: "silo.autoscan.cephfs",
CapabilityID: "cephfs",
StartedAt: runningStartedAt,
Status: autoscan.EventStatusRunning,
}}, nil
},
latestEventAtFn: func() (*time.Time, error) {
return &latestEventAt, nil
},
@@ -1296,6 +1316,16 @@ func TestAutoscanHandleStatusReturnsTrimmedSources(t *testing.T) {
if body.ActiveScans != 3 || body.AcceptedScans != 2 || body.RunningScans != 1 {
t.Errorf("queue summary = %+v", body)
}
if len(body.RunningPolls) != 1 {
t.Fatalf("running_polls = %+v", body.RunningPolls)
}
poll := body.RunningPolls[0]
if poll.ID != 44 || poll.SourceID == nil || *poll.SourceID != sourceID || poll.PluginID != "silo.autoscan.cephfs" || poll.CapabilityID != "cephfs" {
t.Fatalf("running poll identity = %+v", poll)
}
if poll.ElapsedMS <= 0 {
t.Fatalf("running poll elapsed_ms = %d, want > 0", poll.ElapsedMS)
}
if body.LatestEventAt == nil || !body.LatestEventAt.Equal(latestEventAt) {
t.Errorf("latest_event_at = %v, want %v", body.LatestEventAt, latestEventAt)
}
+87
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/sections"
@@ -42,6 +43,9 @@ type catalogFiltersResponse struct {
Countries []string `json:"countries"`
OriginalLanguages []string `json:"original_languages"`
ContentRatings []string `json:"content_ratings"`
Authors []string `json:"authors"`
Narrators []string `json:"narrators"`
Series []string `json:"series"`
Resolutions *[]string `json:"resolutions,omitempty"`
AudioLanguages *[]string `json:"audio_languages,omitempty"`
SubtitleLanguages *[]string `json:"subtitle_languages,omitempty"`
@@ -86,6 +90,7 @@ func (h *CatalogHandler) HandleGetCatalog(w http.ResponseWriter, r *http.Request
h.itemsH.accessFilter(r),
overlaySummaries,
store,
apimw.GetUserID(r.Context()),
profileID,
)
items := make([]itemListResponse, 0, len(result.Items))
@@ -163,12 +168,94 @@ func (h *CatalogHandler) HandleGetCatalogFilters(w http.ResponseWriter, r *http.
Countries: filters.Countries,
OriginalLanguages: filters.OriginalLanguages,
ContentRatings: filters.ContentRatings,
Authors: filters.Authors,
Narrators: filters.Narrators,
Series: filters.Series,
Resolutions: resolutions,
AudioLanguages: audioLanguages,
SubtitleLanguages: subtitleLanguages,
})
}
// catalogFacetSearchResponse mirrors catalog.CatalogFacetSearchResult on
// the wire. matches[] is always present (empty when no hits); has_more
// is true when the underlying result set held more entries than the
// requested limit.
type catalogFacetSearchResponse struct {
Matches []string `json:"matches"`
HasMore bool `json:"has_more"`
}
// HandleGetCatalogFacetSearch — GET /api/v1/catalog/filters/search
//
// Prefix-typeahead for the high-cardinality filter facets (authors /
// narrators / series, plus genre / studio / network / country /
// original_language / content_rating for consistency). Query
// parameters: same as /api/v1/catalog/filters for scope (source,
// library_id, etc.), plus facet=<name>, q=<prefix>, limit=<N>.
//
// The bulk /api/v1/catalog/filters endpoint stays as the source for
// the initial dropdown render (top 1000 alphabetical); this endpoint
// takes over once the user starts typing.
func (h *CatalogHandler) HandleGetCatalogFacetSearch(w http.ResponseWriter, r *http.Request) {
if h == nil || h.resolver == nil || h.itemsH == nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Catalog is not configured")
return
}
req, err := catalog.ParseCatalogRequest(r.URL.Query())
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
facet := strings.TrimSpace(r.URL.Query().Get("facet"))
if facet == "" {
writeError(w, http.StatusBadRequest, "bad_request", "facet parameter is required")
return
}
prefix := r.URL.Query().Get("q")
limit := 20
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
n, parseErr := strconv.Atoi(raw)
if parseErr != nil || n <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "limit must be a positive integer")
return
}
if n > 100 {
n = 100
}
limit = n
}
result, err := h.resolver.SearchFacet(
r.Context(),
req,
h.itemsH.accessFilter(r),
facet,
prefix,
limit,
)
if err != nil {
if errors.Is(err, catalog.ErrInvalidCatalogRequest) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search catalog facet")
return
}
matches := result.Matches
if matches == nil {
matches = []string{}
}
writeJSON(w, http.StatusOK, catalogFacetSearchResponse{
Matches: matches,
HasMore: result.HasMore,
})
}
func parseIncludeTechnical(raw string) bool {
if strings.TrimSpace(raw) == "" {
return true
+4 -59
View File
@@ -161,35 +161,7 @@ func (h *CatalogResourceHandler) HandleGetItemEpisodes(w http.ResponseWriter, r
return
}
h.items.maybeRequestStaleSeasonMetadataRefresh(r.Context(), season.ContentID, episodes)
resp := make([]episodeResponse, 0, len(episodes))
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
for _, ep := range episodes {
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
if h.items.fileRepo != nil {
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
if fileErr == nil {
for _, f := range files {
if !catalog.FileAllowedByAccess(f, filter) {
continue
}
epResp.Files = append(epResp.Files, episodeFileResponse{
FileID: f.ID,
Resolution: f.Resolution,
CodecVideo: f.CodecVideo,
HDR: f.HDR,
AudioChannels: f.AudioChannels,
Container: f.Container,
FileSize: f.FileSize,
})
}
}
}
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
resp = append(resp, epResp)
}
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
h.writeEpisodeResponses(w, r, episodes)
}
func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http.Request) {
@@ -439,34 +411,7 @@ func (h *CatalogResourceHandler) syntheticSeasonDetail(r *http.Request, seasonID
}
func (h *CatalogResourceHandler) writeEpisodeResponses(w http.ResponseWriter, r *http.Request, episodes []*models.Episode) {
filter := h.items.accessFilter(r)
resp := make([]episodeResponse, 0, len(episodes))
fallbacks := h.items.episodeImageFallbacks(r.Context(), episodes)
for _, ep := range episodes {
epResp := h.items.toEpisodeResponseWithFallback(r, ep, fallbacks[ep.SeriesID])
if h.items.fileRepo != nil {
files, fileErr := h.items.fileRepo.GetByEpisodeID(r.Context(), ep.ContentID)
if fileErr == nil {
for _, f := range files {
if !catalog.FileAllowedByAccess(f, filter) {
continue
}
epResp.Files = append(epResp.Files, episodeFileResponse{
FileID: f.ID,
Resolution: f.Resolution,
CodecVideo: f.CodecVideo,
HDR: f.HDR,
AudioChannels: f.AudioChannels,
Container: f.Container,
FileSize: f.FileSize,
})
}
}
}
epResp.UserData = h.items.getLeafUserData(r, ep.ContentID)
resp = append(resp, epResp)
}
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: resp})
writeJSON(w, http.StatusOK, episodesListResponse{Episodes: h.items.buildEpisodeResponses(r, episodes)})
}
func parseSyntheticSeasonID(contentID string) (string, int, bool) {
@@ -497,8 +442,8 @@ func (h *CatalogResourceHandler) enrichItemDetail(r *http.Request, detail *catal
detail.SeasonUserData = h.items.getAggregateUserData(r, episodes)
}
}
case "movie", "episode":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID)
case "movie", "episode", "audiobook", "ebook":
detail.SeasonUserData = h.items.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.SeasonUserData, &detail.EffectiveVersionEditionKey)
}
+150
View File
@@ -0,0 +1,150 @@
package handlers
import (
"context"
"fmt"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// Ebook read state lives exclusively in ebook_reader_progress: every consumer
// (user-state Played, catalog watched/in_progress filters, Continue Reading,
// sort metrics, recommendations) keys off progress crossing
// models.EbookFinishedProgressThreshold. The native watched endpoints therefore
// write that table instead of user_watch_progress/user_watch_history. The
// helpers live in this package — next to the ebook progress store and types —
// because the handler layer is the only producer today: the webhook-import
// matcher (internal/historyimport) only matches movie/series/episode kinds, so
// imported mark-played events can never resolve to an ebook content ID.
// EbookReadStateStore is the ebook_reader_progress surface needed to set or
// clear an ebook's read state from the watched endpoints.
// *PGEbookReaderProgressStore implements it.
type EbookReadStateStore interface {
Get(ctx context.Context, userID int, profileID string, contentID string) (*EbookReaderProgress, error)
Upsert(ctx context.Context, progress EbookReaderProgress) error
Delete(ctx context.Context, userID int, profileID string, contentID string) error
}
// EbookReaderProgressReadWriter is the full progress-store surface the items
// handler needs: batch reads for list/user-state responses plus read-state
// writes for the watched endpoints.
type EbookReaderProgressReadWriter interface {
EbookReaderProgressLister
EbookReadStateStore
}
// markEbookRead marks an ebook as finished by upserting its reader-progress
// row with progress = 1.0 (>= models.EbookFinishedProgressThreshold). An
// existing row keeps its file_id and location so the reader still reopens at
// the user's last position even though the book is marked read; a missing row
// references the item's preferred reader file (resolved lazily via
// defaultFileID, since ebook_reader_progress.file_id is NOT NULL) with an
// empty location meaning "start at the beginning". A fresh updated_at also
// resurfaces a previously hidden book, mirroring how re-watching resurfaces
// hidden video history.
func markEbookRead(
ctx context.Context,
store EbookReadStateStore,
userID int,
profileID, contentID string,
now time.Time,
defaultFileID func(ctx context.Context) (int, error),
) error {
existing, err := store.Get(ctx, userID, profileID, contentID)
if err != nil {
return err
}
progress := EbookReaderProgress{
UserID: userID,
ProfileID: profileID,
ContentID: contentID,
Progress: 1.0,
UpdatedAt: now,
}
if existing != nil {
progress.FileID = existing.FileID
progress.Location = existing.Location
} else {
fileID, err := defaultFileID(ctx)
if err != nil {
return err
}
progress.FileID = fileID
}
return store.Upsert(ctx, progress)
}
// markEbookUnread clears an ebook's read state by deleting its reader-progress
// row — the direct analog of the video unwatch path, where
// userstore.ClearProgress DELETEs the user_watch_progress row (dropping the
// resume position along with the watched flag).
func markEbookUnread(
ctx context.Context,
store EbookReadStateStore,
userID int,
profileID, contentID string,
) error {
return store.Delete(ctx, userID, profileID, contentID)
}
// setEbookReadState applies the watched toggle for an ebook target resolved by
// resolveWatchedTargets, which has already enforced the access filter on the
// item. Scoping matches the rest of the ebook progress code: user_id +
// profile_id.
func (h *ItemsHandler) setEbookReadState(
ctx context.Context,
userID int,
profileID, contentID string,
read bool,
filter catalog.AccessFilter,
) error {
if h.ebookReadStateStore == nil {
return fmt.Errorf("ebook reader progress store is not configured")
}
if !read {
return markEbookUnread(ctx, h.ebookReadStateStore, userID, profileID, contentID)
}
return markEbookRead(ctx, h.ebookReadStateStore, userID, profileID, contentID, time.Now().UTC(), func(ctx context.Context) (int, error) {
return h.defaultEbookFileID(ctx, contentID, filter)
})
}
// defaultEbookFileID resolves the file a fresh mark-read progress row should
// reference, respecting the caller's access filter.
func (h *ItemsHandler) defaultEbookFileID(ctx context.Context, contentID string, filter catalog.AccessFilter) (int, error) {
if h.fileRepo == nil {
return 0, catalog.ErrItemNotFound
}
files, err := h.fileRepo.GetByContentID(ctx, contentID)
if err != nil {
return 0, err
}
file := preferredEbookReadFile(catalog.FilterMediaFilesByAccess(files, filter))
if file == nil {
return 0, catalog.ErrItemNotFound
}
return file.ID, nil
}
// preferredEbookReadFile mirrors the web reader's default file choice
// (web/src/pages/ItemDetail/EbookContent.tsx preferredReadVersion): prefer an
// EPUB version, otherwise the first reader-supported ebook file. Callers must
// pass access-filtered files.
func preferredEbookReadFile(files []*models.MediaFile) *models.MediaFile {
var first *models.MediaFile
for _, file := range files {
if !isEbookFile(file) {
continue
}
if ebookReaderFormat(file.FilePath, file.Container) == "epub" {
return file
}
if first == nil {
first = file
}
}
return first
}
@@ -0,0 +1,230 @@
package handlers
import (
"context"
"errors"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
// fakeEbookReadStateStore is an in-memory EbookReaderProgressReadWriter keyed
// by content ID for a single (user, profile) scope.
type fakeEbookReadStateStore struct {
rows map[string]EbookReaderProgress
}
func newFakeEbookReadStateStore() *fakeEbookReadStateStore {
return &fakeEbookReadStateStore{rows: map[string]EbookReaderProgress{}}
}
func (f *fakeEbookReadStateStore) Get(_ context.Context, _ int, _ string, contentID string) (*EbookReaderProgress, error) {
row, ok := f.rows[contentID]
if !ok {
return nil, nil
}
return &row, nil
}
func (f *fakeEbookReadStateStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
f.rows[progress.ContentID] = progress
return nil
}
func (f *fakeEbookReadStateStore) Delete(_ context.Context, _ int, _ string, contentID string) error {
delete(f.rows, contentID)
return nil
}
func (f *fakeEbookReadStateStore) ListByContentIDs(_ context.Context, _ int, _ string, contentIDs []string) (map[string]EbookReaderProgress, error) {
result := make(map[string]EbookReaderProgress, len(contentIDs))
for _, contentID := range contentIDs {
if row, ok := f.rows[contentID]; ok {
result[contentID] = row
}
}
return result, nil
}
// fakeEbookFileProvider implements EpisodeFileProvider for ebook file lookups.
type fakeEbookFileProvider struct {
files map[string][]*models.MediaFile
err error
}
func (f *fakeEbookFileProvider) GetByContentID(_ context.Context, contentID string) ([]*models.MediaFile, error) {
if f.err != nil {
return nil, f.err
}
return f.files[contentID], nil
}
func (f *fakeEbookFileProvider) GetByEpisodeID(context.Context, string) ([]*models.MediaFile, error) {
return nil, nil
}
func (f *fakeEbookFileProvider) ListByContentIDs(context.Context, []string) (map[string][]*models.MediaFile, error) {
return nil, nil
}
func TestMarkEbookReadPreservesExistingFileAndLocation(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
store.rows["ebook-1"] = EbookReaderProgress{
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-1",
FileID: 7,
Location: "epubcfi(/6/14!/4/2/14)",
Progress: 0.42,
UpdatedAt: time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC),
}
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
t.Fatal("defaultFileID must not be called when a progress row exists")
return 0, nil
})
if err != nil {
t.Fatalf("markEbookRead: %v", err)
}
row := store.rows["ebook-1"]
if row.Progress != 1.0 {
t.Fatalf("progress = %v, want 1.0", row.Progress)
}
if row.FileID != 7 || row.Location != "epubcfi(/6/14!/4/2/14)" {
t.Fatalf("file/location not preserved: %+v", row)
}
if !row.UpdatedAt.Equal(now) {
t.Fatalf("updated_at = %v, want %v", row.UpdatedAt, now)
}
}
func TestMarkEbookReadUsesDefaultFileWhenNoProgressExists(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
now := time.Date(2026, 6, 9, 12, 0, 0, 0, time.UTC)
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", now, func(context.Context) (int, error) {
return 12, nil
})
if err != nil {
t.Fatalf("markEbookRead: %v", err)
}
row, ok := store.rows["ebook-1"]
if !ok {
t.Fatal("expected a progress row to be created")
}
if row.Progress != 1.0 || row.FileID != 12 || row.Location != "" {
t.Fatalf("row = %+v, want progress 1.0 with file 12 and empty location", row)
}
if row.UserID != 42 || row.ProfileID != "profile-1" {
t.Fatalf("row scope = user %d profile %q, want user 42 profile-1", row.UserID, row.ProfileID)
}
}
func TestMarkEbookReadPropagatesDefaultFileError(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
err := markEbookRead(ctx, store, 42, "profile-1", "ebook-1", time.Now().UTC(), func(context.Context) (int, error) {
return 0, catalog.ErrItemNotFound
})
if !errors.Is(err, catalog.ErrItemNotFound) {
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
}
if len(store.rows) != 0 {
t.Fatalf("no row must be written on error, got %+v", store.rows)
}
}
func TestMarkEbookUnreadDeletesProgressRow(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
store.rows["ebook-1"] = EbookReaderProgress{ContentID: "ebook-1", FileID: 7, Progress: 1.0}
if err := markEbookUnread(ctx, store, 42, "profile-1", "ebook-1"); err != nil {
t.Fatalf("markEbookUnread: %v", err)
}
if _, ok := store.rows["ebook-1"]; ok {
t.Fatal("progress row must be deleted on mark unread")
}
}
func TestPreferredEbookReadFilePrefersEpub(t *testing.T) {
pdf := &models.MediaFile{ID: 1, BaseType: "ebook", FilePath: "/books/a.pdf", Container: "pdf"}
epub := &models.MediaFile{ID: 2, BaseType: "ebook", FilePath: "/books/a.epub", Container: "epub"}
video := &models.MediaFile{ID: 3, BaseType: "movie", FilePath: "/movies/a.mkv", Container: "mkv"}
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf, epub}); got == nil || got.ID != 2 {
t.Fatalf("preferredEbookReadFile = %+v, want epub file 2", got)
}
if got := preferredEbookReadFile([]*models.MediaFile{video, pdf}); got == nil || got.ID != 1 {
t.Fatalf("preferredEbookReadFile = %+v, want first reader-supported file 1", got)
}
if got := preferredEbookReadFile([]*models.MediaFile{video}); got != nil {
t.Fatalf("preferredEbookReadFile = %+v, want nil without ebook files", got)
}
}
func TestSetEbookReadStateMarkReadShowsPlayedUserState(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
handler := &ItemsHandler{
ebookProgressStore: store,
ebookReadStateStore: store,
fileRepo: &fakeEbookFileProvider{files: map[string][]*models.MediaFile{
"ebook-1": {{ID: 5, BaseType: "ebook", FilePath: "/books/b.epub", Container: "epub"}},
}},
}
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{}); err != nil {
t.Fatalf("setEbookReadState(read): %v", err)
}
userStore := newProfileTestStore(t)
items := []*models.MediaItem{{ContentID: "ebook-1", Type: "ebook", Title: "Book"}}
states, err := resolveItemUserStatesWithOptions(ctx, userStore, "profile-1", nil, items, itemUserStateOptions{
UserID: 42,
EbookProgressStore: store,
})
if err != nil {
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
}
if states["ebook-1"] == nil || !states["ebook-1"].Played {
t.Fatalf("user state = %+v, want played after mark read", states["ebook-1"])
}
// A marked-read book is finished, not in progress: the reader-progress
// response derived from the row must exclude it from Continue Reading.
row := store.rows["ebook-1"]
if row.Progress < models.EbookFinishedProgressThreshold {
t.Fatalf("progress %v must cross the finished threshold", row.Progress)
}
if err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", false, catalog.AccessFilter{}); err != nil {
t.Fatalf("setEbookReadState(unread): %v", err)
}
if _, ok := store.rows["ebook-1"]; ok {
t.Fatal("mark unread must delete the reader progress row")
}
}
func TestSetEbookReadStateFailsWithoutAccessibleFile(t *testing.T) {
ctx := context.Background()
store := newFakeEbookReadStateStore()
handler := &ItemsHandler{
ebookProgressStore: store,
ebookReadStateStore: store,
fileRepo: &fakeEbookFileProvider{},
}
err := handler.setEbookReadState(ctx, 42, "profile-1", "ebook-1", true, catalog.AccessFilter{})
if !errors.Is(err, catalog.ErrItemNotFound) {
t.Fatalf("err = %v, want catalog.ErrItemNotFound", err)
}
}
File diff suppressed because it is too large Load Diff
+897
View File
@@ -0,0 +1,897 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"mime"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
)
func newEbookReaderAuthRequest(method, path string) *http.Request {
req := httptest.NewRequest(method, path, nil)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
})
ctx = apimw.SetProfileID(ctx, "profile-1")
return req.WithContext(ctx)
}
func withEbookReaderRouteParams(req *http.Request, contentID, fileID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
routeCtx.URLParams.Add("file_id", fileID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func withEbookReaderContentRouteParam(req *http.Request, contentID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
func withEbookReaderAnnotationRouteParams(req *http.Request, contentID, annotationID string) *http.Request {
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("content_id", contentID)
routeCtx.URLParams.Add("annotation_id", annotationID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
}
type fakeEbookProgressStore struct {
progress *EbookReaderProgress
upserted *EbookReaderProgress
err error
}
func (s *fakeEbookProgressStore) Get(
context.Context,
int,
string,
string,
) (*EbookReaderProgress, error) {
return s.progress, s.err
}
func (s *fakeEbookProgressStore) Upsert(_ context.Context, progress EbookReaderProgress) error {
if s.err != nil {
return s.err
}
s.upserted = &progress
return nil
}
type fakeEbookReaderConfigStore struct {
config *EbookReaderConfig
saved *EbookReaderConfig
getErr error
saveErr error
}
func (s *fakeEbookReaderConfigStore) Get(
context.Context,
int,
string,
string,
) (*EbookReaderConfig, error) {
return s.config, s.getErr
}
func (s *fakeEbookReaderConfigStore) Upsert(_ context.Context, config EbookReaderConfig) error {
if s.saveErr != nil {
return s.saveErr
}
s.saved = &config
return nil
}
type fakeEbookReaderAnnotationStore struct {
items []EbookReaderAnnotation
existing *EbookReaderAnnotation
created *EbookReaderAnnotation
updated *EbookReaderAnnotation
mergedFrom *EbookReaderAnnotation
mergeCalls int
deleted string
err error
}
func (s *fakeEbookReaderAnnotationStore) List(context.Context, int, string, string) ([]EbookReaderAnnotation, error) {
return s.items, s.err
}
func (s *fakeEbookReaderAnnotationStore) Create(_ context.Context, annotation EbookReaderAnnotation) error {
if s.err != nil {
return s.err
}
s.created = &annotation
s.items = append(s.items, annotation)
return nil
}
// Update models the PG store contract: read the stored row, invoke merge with
// it, and persist the merged result, all as one atomic operation.
func (s *fakeEbookReaderAnnotationStore) Update(
_ context.Context,
_ int,
_ string,
_ string,
annotationID string,
merge func(existing EbookReaderAnnotation) (EbookReaderAnnotation, error),
) (*EbookReaderAnnotation, error) {
if s.err != nil {
return nil, s.err
}
if s.existing == nil || s.existing.ID != annotationID {
return nil, nil
}
existing := *s.existing
s.mergedFrom = &existing
s.mergeCalls++
merged, err := merge(existing)
if err != nil {
return nil, err
}
s.updated = &merged
stored := merged
s.existing = &stored
return &merged, nil
}
func (s *fakeEbookReaderAnnotationStore) Delete(_ context.Context, userID int, profileID, contentID, annotationID string) error {
if s.err != nil {
return s.err
}
s.deleted = annotationID
return nil
}
func TestEbookReaderServesEbookInlineWithRangeSupport(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "book.epub")
if err := os.WriteFile(filePath, []byte("0123456789"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: filePath,
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req.Header.Set("Range", "bytes=2-5")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusPartialContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("content-type = %q", got)
}
if got := rr.Header().Get("Content-Disposition"); got != "inline; filename=book.epub" {
t.Fatalf("content-disposition = %q", got)
}
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("x-content-type-options = %q, want nosniff", got)
}
if rr.Body.String() != "2345" {
t.Fatalf("body = %q", rr.Body.String())
}
}
func TestEbookReaderServesContainerMimeForMismatchedExtension(t *testing.T) {
filePath := writePlaybackTestMediaFile(t, "book.txt")
if err := os.WriteFile(filePath, []byte("<html>not really</html>"), 0o644); err != nil {
t.Fatalf("write ebook: %v", err)
}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: filePath,
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
// The file is admitted via its validated container, so the response must
// carry that container's MIME type, never application/octet-stream.
if got := rr.Header().Get("Content-Type"); got != "application/epub+zip" {
t.Fatalf("content-type = %q, want application/epub+zip", got)
}
if got := rr.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("x-content-type-options = %q, want nosniff", got)
}
}
func TestEbookMimeTypeNeverReturnsOctetStreamForAdmittedFiles(t *testing.T) {
cases := []struct {
path string
container string
wantMime string
}{
{"/library/book.txt", "epub", "application/epub+zip"},
{"/library/book", "pdf", "application/pdf"},
{"/library/book.epub", "pdf", "application/epub+zip"}, // known extension wins
}
for _, tc := range cases {
file := &models.MediaFile{FilePath: tc.path, Container: tc.container, BaseType: "ebook"}
if !isEbookFile(file) {
t.Fatalf("isEbookFile(%q, %q) = false, want admitted", tc.path, tc.container)
}
if got := ebookMimeType(tc.path, tc.container); got != tc.wantMime {
t.Fatalf("ebookMimeType(%q, %q) = %q, want %q", tc.path, tc.container, got, tc.wantMime)
}
}
}
func TestEbookReaderRejectsNonEbookFile(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: "/tmp/movie.mkv",
Container: "mkv",
BaseType: "movie",
},
},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/movie-1/files/42/read")
req = withEbookReaderRouteParams(req, "movie-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderRecognizesReadestFormats(t *testing.T) {
cases := map[string]string{
"book.epub": "application/epub+zip",
"book.pdf": "application/pdf",
"book.mobi": "application/x-mobipocket-ebook",
"book.azw": "application/vnd.amazon.ebook",
"book.azw3": "application/vnd.amazon.mobi8-ebook",
"book.cbz": "application/vnd.comicbook+zip",
"book.cbr": "application/vnd.comicbook-rar",
"book.fb2": "application/x-fictionbook+xml",
"book.fbz": "application/x-zip-compressed-fb2",
"book.fb2.zip": "application/x-zip-compressed-fb2",
"book.md": "application/octet-stream",
"book.unknown": "application/octet-stream",
}
rejected := map[string]bool{"book.md": true, "book.unknown": true}
for name, wantMime := range cases {
t.Run(name, func(t *testing.T) {
container := name
if name == "book.fb2.zip" {
container = "fbz"
}
file := &models.MediaFile{FilePath: "/library/" + name, Container: container}
if rejected[name] {
file.BaseType = "ebook"
if isEbookFile(file) {
t.Fatalf("%s should not be treated as an ebook reader format", name)
}
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
}
return
}
file.BaseType = "ebook"
if !isEbookFile(file) {
t.Fatal("expected ebook reader format")
}
if got := ebookMimeType(file.FilePath, file.Container); got != wantMime {
t.Fatalf("ebookMimeType() = %q, want %q", got, wantMime)
}
})
}
}
func TestEbookReaderRejectsPlainText(t *testing.T) {
file := &models.MediaFile{
FilePath: "/library/book.txt",
BaseType: "ebook",
}
if isEbookFile(file) {
t.Fatal("plain text should not be treated as an ebook reader format")
}
if got := ebookMimeType(file.FilePath, file.Container); got == "text/plain; charset=utf-8" {
t.Fatal("plain text should not have an ebook reader MIME type")
}
}
func TestEbookReaderRecognizesFBZFromCompoundFilenameWithoutContainer(t *testing.T) {
file := &models.MediaFile{
FilePath: "/library/book.fb2.zip",
BaseType: "ebook",
}
if !isEbookFile(file) {
t.Fatal("expected .fb2.zip path to be treated as an ebook reader format")
}
}
func TestEbookReaderMapsAccessFailureToNotFound(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: "/tmp/book.epub",
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderReturnsInternalErrorForUnexpectedAuthorizeFailure(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{err: errors.New("db down")},
ItemAccess: stubItemAccessChecker{},
})
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/files/42/read")
req = withEbookReaderRouteParams(req, "ebook-1", "42")
rr := httptest.NewRecorder()
handler.HandleReadFile(rr, req)
if rr.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderSavesProgressForAuthorizedEbookFile(t *testing.T) {
store := &fakeEbookProgressStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{
file: &models.MediaFile{
ID: 42,
ContentID: "ebook-1",
FilePath: "/tmp/book.epub",
Container: "epub",
BaseType: "ebook",
},
},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = store
req := httptest.NewRequest(
http.MethodPut,
"/ebooks/ebook-1/progress",
strings.NewReader(`{"file_id":42,"location":"epubcfi(/6/4)","progress":0.42}`),
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveProgress(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.upserted == nil {
t.Fatal("expected progress to be saved")
}
if store.upserted.UserID != 1 || store.upserted.ProfileID != "profile-9" {
t.Fatalf("scope = user %d profile %q", store.upserted.UserID, store.upserted.ProfileID)
}
if store.upserted.ContentID != "ebook-1" || store.upserted.FileID != 42 {
t.Fatalf("target = content %q file %d", store.upserted.ContentID, store.upserted.FileID)
}
if store.upserted.Location != "epubcfi(/6/4)" || store.upserted.Progress != 0.42 {
t.Fatalf("progress = %+v", store.upserted)
}
}
func TestEbookReaderReturnsSavedProgress(t *testing.T) {
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = &fakeEbookProgressStore{
progress: &EbookReaderProgress{
UserID: 1,
ProfileID: "profile-1",
ContentID: "ebook-1",
FileID: 42,
Location: "epubcfi(/6/8)",
Progress: 0.75,
UpdatedAt: updatedAt,
},
}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/progress")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleGetProgress(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
`"content_id":"ebook-1"`,
`"file_id":42`,
`"location":"epubcfi(/6/8)"`,
`"progress":0.75`,
`"updated_at":"2026-06-08T12:00:00Z"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("response %s missing %s", body, want)
}
}
}
func TestEbookReaderReturnsEmptyConfigWhenNoneSaved(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ConfigStore = &fakeEbookReaderConfigStore{}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleGetConfig(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if got := strings.TrimSpace(rr.Body.String()); got != `{"config":{}}` {
t.Fatalf("body = %s", got)
}
}
func TestEbookReaderSavesConfigForAccessibleEbook(t *testing.T) {
store := &fakeEbookReaderConfigStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ConfigStore = store
req := httptest.NewRequest(
http.MethodPut,
"/ebooks/ebook-1/reader-config",
strings.NewReader(`{"config":{"theme":"dark","flow":"scrolled","fontSize":120}}`),
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveConfig(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.saved == nil {
t.Fatal("expected config to be saved")
}
if store.saved.UserID != 1 || store.saved.ProfileID != "profile-9" || store.saved.ContentID != "ebook-1" {
t.Fatalf("scope = %+v", store.saved)
}
var config map[string]any
if err := json.Unmarshal(store.saved.Config, &config); err != nil {
t.Fatalf("config json: %v", err)
}
if config["theme"] != "dark" || config["flow"] != "scrolled" || config["fontSize"].(float64) != 120 {
t.Fatalf("config = %#v", config)
}
}
func TestEbookReaderRejectsNonObjectConfig(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ConfigStore = &fakeEbookReaderConfigStore{}
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/reader-config")
req.Body = http.NoBody
req = httptest.NewRequest(
http.MethodPut,
"/ebooks/ebook-1/reader-config",
strings.NewReader(`{"config":["bad"]}`),
).WithContext(req.Context())
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveConfig(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderConfigRequiresAccessibleContent(t *testing.T) {
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{err: catalog.ErrItemNotFound},
})
handler.ConfigStore = &fakeEbookReaderConfigStore{}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/reader-config")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleGetConfig(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderCreatesAnnotationForAccessibleEbook(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = store
req := httptest.NewRequest(
http.MethodPost,
"/ebooks/ebook-1/annotations",
strings.NewReader(`{"kind":"highlight","cfi_range":"epubcfi(/6/4,/1:0,/1:12)","selected_text":"sample text","note":"note text","style":"highlight","color":"#facc15"}`),
)
ctx := apimw.SetClaims(context.Background(), &auth.Claims{
UserID: 1,
TokenType: auth.TokenTypeAccess,
})
req = req.WithContext(apimw.SetProfileID(ctx, "profile-9"))
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleCreateAnnotation(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.created == nil {
t.Fatal("expected annotation to be created")
}
if store.created.UserID != 1 || store.created.ProfileID != "profile-9" || store.created.ContentID != "ebook-1" {
t.Fatalf("scope = %+v", store.created)
}
if store.created.Kind != "highlight" || store.created.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
t.Fatalf("annotation = %+v", store.created)
}
if store.created.SelectedText != "sample text" || store.created.Note != "note text" {
t.Fatalf("annotation text = %+v", store.created)
}
if store.created.ID == "" {
t.Fatal("expected generated annotation id")
}
}
func TestEbookReaderReturnsAnnotations(t *testing.T) {
updatedAt := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = &fakeEbookReaderAnnotationStore{
items: []EbookReaderAnnotation{
{
ID: "ann-1",
UserID: 1,
ProfileID: "profile-1",
ContentID: "ebook-1",
Kind: "bookmark",
Location: "epubcfi(/6/8)",
SelectedText: "",
Note: "Saved spot",
Style: "highlight",
Color: "#facc15",
Metadata: json.RawMessage(`{}`),
CreatedAt: updatedAt,
UpdatedAt: updatedAt,
},
},
}
req := newEbookReaderAuthRequest(http.MethodGet, "/ebooks/ebook-1/annotations")
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleListAnnotations(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{`"items":[`, `"id":"ann-1"`, `"kind":"bookmark"`, `"location":"epubcfi(/6/8)"`} {
if !strings.Contains(body, want) {
t.Fatalf("response %s missing %s", body, want)
}
}
}
func existingEbookReaderAnnotation() *EbookReaderAnnotation {
createdAt := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
return &EbookReaderAnnotation{
ID: "ann-1",
UserID: 1,
ProfileID: "profile-1",
ContentID: "ebook-1",
Kind: "highlight",
CFIRange: "epubcfi(/6/4,/1:0,/1:12)",
SelectedText: "sample text",
Note: "original note",
Style: "highlight",
Color: "#facc15",
Metadata: json.RawMessage(`{"chapter":"one"}`),
CreatedAt: createdAt,
UpdatedAt: createdAt,
}
}
func patchEbookReaderAnnotation(t *testing.T, store *fakeEbookReaderAnnotationStore, body string) *httptest.ResponseRecorder {
t.Helper()
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = store
req := newEbookReaderAuthRequest(http.MethodPatch, "/ebooks/ebook-1/annotations/ann-1")
req.Body = ioNopCloser{strings.NewReader(body)}
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
rr := httptest.NewRecorder()
handler.HandleUpdateAnnotation(rr, req)
return rr
}
func TestEbookReaderPatchKeepsAbsentFields(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated","color":"#bfdbfe"}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated == nil || store.updated.ID != "ann-1" || store.updated.Note != "updated" || store.updated.Color != "#bfdbfe" {
t.Fatalf("updated = %+v", store.updated)
}
if string(store.updated.Metadata) != `{"chapter":"one"}` {
t.Fatalf("metadata should be preserved when absent, got %s", store.updated.Metadata)
}
if store.updated.Kind != "highlight" || store.updated.CFIRange != "epubcfi(/6/4,/1:0,/1:12)" {
t.Fatalf("absent fields should keep stored values, got %+v", store.updated)
}
}
func TestEbookReaderPatchClearsExplicitlyEmptyFields(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"note":"","metadata":{}}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated == nil || store.updated.Note != "" {
t.Fatalf("note should be cleared, got %+v", store.updated)
}
if string(store.updated.Metadata) != `{}` {
t.Fatalf("metadata should be cleared to {}, got %s", store.updated.Metadata)
}
}
func TestEbookReaderPatchValidatesResultingState(t *testing.T) {
cases := map[string]string{
"kind change without location": `{"kind":"bookmark"}`,
"clearing required cfi_range": `{"cfi_range":""}`,
"unknown kind": `{"kind":"doodle"}`,
"non-object metadata": `{"metadata":["bad"]}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated != nil {
t.Fatalf("invalid patch must not reach the store, got %+v", store.updated)
}
})
}
}
func TestEbookReaderPatchAllowsKindChangeWithRequiredFields(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"kind":"bookmark","location":"epubcfi(/6/8)"}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.updated == nil || store.updated.Kind != "bookmark" || store.updated.Location != "epubcfi(/6/8)" {
t.Fatalf("updated = %+v", store.updated)
}
}
func TestEbookReaderPatchMergesOntoRowReadInsideStoreUpdate(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{existing: existingEbookReaderAnnotation()}
rr := patchEbookReaderAnnotation(t, store, `{"note":"merged note"}`)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
// The handler must not pre-read and merge outside the store: the merge
// runs exactly once, on the row handed over by the (locked) store read.
if store.mergeCalls != 1 {
t.Fatalf("merge calls = %d, want 1", store.mergeCalls)
}
if store.mergedFrom == nil || store.mergedFrom.Note != "original note" {
t.Fatalf("merge input = %+v, want the stored row", store.mergedFrom)
}
if store.updated == nil || store.updated.Note != "merged note" {
t.Fatalf("written row = %+v, want merged note", store.updated)
}
if store.updated.CFIRange != store.mergedFrom.CFIRange || store.updated.Kind != store.mergedFrom.Kind {
t.Fatalf("written row %+v must keep unpatched fields from merge input %+v", store.updated, store.mergedFrom)
}
if !store.updated.UpdatedAt.After(store.mergedFrom.UpdatedAt) {
t.Fatalf("updated_at %v must advance past %v", store.updated.UpdatedAt, store.mergedFrom.UpdatedAt)
}
}
func TestEbookReaderPatchReturnsNotFoundForMissingAnnotation(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{}
rr := patchEbookReaderAnnotation(t, store, `{"note":"updated"}`)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
func TestEbookReaderRejectsOversizedProgressBody(t *testing.T) {
store := &fakeEbookProgressStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.ProgressStore = store
body := `{"file_id":42,"location":"` + strings.Repeat("x", int(ebookReaderProgressMaxBodySize)) + `","progress":0.5}`
req := newEbookReaderAuthRequest(http.MethodPut, "/ebooks/ebook-1/progress")
req.Body = ioNopCloser{strings.NewReader(body)}
req = withEbookReaderContentRouteParam(req, "ebook-1")
rr := httptest.NewRecorder()
handler.HandleSaveProgress(rr, req)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.upserted != nil {
t.Fatalf("oversized body must not be saved, got %+v", store.upserted)
}
}
func TestInlineContentDispositionProducesParseableHeaders(t *testing.T) {
cases := map[string]string{
"book.epub": "book.epub",
`bo"ok\.epub`: `bo"ok\.epub`,
`trailing-slash\`: `trailing-slash\`,
"börk \U0001F4DA.epub": "börk \U0001F4DA.epub",
"": "ebook",
".": "ebook",
}
for name, want := range cases {
t.Run(name, func(t *testing.T) {
got := inlineContentDisposition(name)
mediaType, params, err := mime.ParseMediaType(got)
if err != nil {
t.Fatalf("header %q is malformed: %v", got, err)
}
if mediaType != "inline" {
t.Fatalf("media type = %q", mediaType)
}
if params["filename"] != want {
t.Fatalf("filename = %q, want %q", params["filename"], want)
}
})
}
}
func TestEbookReaderDeletesAnnotation(t *testing.T) {
store := &fakeEbookReaderAnnotationStore{}
handler := NewEbookReaderHandler(&MediaFileAuthorizer{
FileResolver: stubMediaFileResolver{},
ItemAccess: stubItemAccessChecker{},
})
handler.AnnotationStore = store
req := newEbookReaderAuthRequest(http.MethodDelete, "/ebooks/ebook-1/annotations/ann-1")
req = withEbookReaderAnnotationRouteParams(req, "ebook-1", "ann-1")
rr := httptest.NewRecorder()
handler.HandleDeleteAnnotation(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
if store.deleted != "ann-1" {
t.Fatalf("deleted = %q", store.deleted)
}
}
type ioNopCloser struct {
*strings.Reader
}
func (c ioNopCloser) Close() error { return nil }
+91 -14
View File
@@ -12,6 +12,7 @@ import (
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/gorilla/websocket"
@@ -31,6 +32,10 @@ type activeScanLister interface {
ListActive(ctx context.Context) ([]evt.ScanRun, error)
}
// viewerRevalidateInterval is how often an open events WebSocket re-loads its
// user to confirm the account is still enabled and admin status is unchanged.
const viewerRevalidateInterval = 60 * time.Second
type EventsHandler struct {
hub *evt.Hub
jobs *AdminJobsHandler
@@ -39,6 +44,11 @@ type EventsHandler struct {
scans *evt.ScanRegistry
persistedScans activeScanLister
historyImports historyImportActiveLister
users auth.UserLoader
// revalidateInterval overrides viewerRevalidateInterval in tests; zero
// means the default.
revalidateInterval time.Duration
}
func NewEventsHandler(
@@ -49,6 +59,7 @@ func NewEventsHandler(
scans *evt.ScanRegistry,
persistedScans *scanqueue.Service,
historyImports historyImportActiveLister,
users auth.UserLoader,
) *EventsHandler {
return &EventsHandler{
hub: hub,
@@ -58,6 +69,7 @@ func NewEventsHandler(
scans: scans,
persistedScans: persistedScans,
historyImports: historyImports,
users: users,
}
}
@@ -73,6 +85,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
}
// Admin status is checked server-side once per connection; it never comes
// from token contents.
viewerIsAdmin := isAdminRequest(r, h.users)
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
return
@@ -89,7 +105,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return writeWebSocketControl(conn, websocket.PingMessage, nil)
})
allowedChannels := allowedChannelsForRole(claims.Role)
allowedChannels := allowedChannelsForViewer(viewerIsAdmin)
connectionID := ulid.Make().String()
if err := writeWebSocketJSON(conn, evt.EventsHelloMessage{
Type: "hello",
@@ -121,6 +137,17 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
// Channel authorization is decided once at upgrade, so a long-lived
// connection must periodically re-load its user: a disabled account or a
// changed admin role would otherwise keep its channel set until the
// client disconnects on its own.
revalidateEvery := h.revalidateInterval
if revalidateEvery <= 0 {
revalidateEvery = viewerRevalidateInterval
}
revalidate := time.NewTicker(revalidateEvery)
defer revalidate.Stop()
subscriptions := make(map[evt.EventChannel]struct{})
subscribedOnce := false
@@ -130,6 +157,33 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
return
case <-readDone:
return
case <-revalidate.C:
if h.users == nil {
// No loader wired: the viewer was never granted admin
// channels (isAdminRequest fails closed), so there is no
// stale privilege to revoke.
continue
}
user, err := h.users.GetByID(ctx, claims.UserID)
if err != nil && !auth.IsNotFound(err) {
// Transient lookup failure: keep the connection and retry on
// the next tick rather than dropping every viewer on a DB
// blip. A definitive miss (ErrNotFound) leaves user nil and
// closes below.
slog.Warn("events: viewer revalidation failed; retrying next tick",
"user_id", claims.UserID,
"error", err,
)
continue
}
if closeConn, reason := revalidateViewer(user, viewerIsAdmin); closeConn {
_ = writeWebSocketControl(
conn,
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason),
)
return
}
case <-deadline.C:
if subscribedOnce {
continue
@@ -142,7 +196,7 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
)
return
case data := <-readMessages:
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, data, allowedChannels)
nextSubs, handled, ok := h.handleEventsClientMessage(conn, r, claims, viewerIsAdmin, data, allowedChannels)
if !ok {
return
}
@@ -164,10 +218,10 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
if _, subscribed := subscriptions[env.Channel]; !subscribed {
continue
}
if !allowsEventForClaims(claims, env) {
if !allowsEventForViewer(claims.UserID, viewerIsAdmin, env) {
continue
}
if err := h.writeEventFrame(conn, r, claims, env); err != nil {
if err := h.writeEventFrame(conn, r, claims, viewerIsAdmin, env); err != nil {
return
}
}
@@ -178,6 +232,7 @@ func (h *EventsHandler) handleEventsClientMessage(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
data []byte,
allowed []evt.EventChannel,
) (map[evt.EventChannel]struct{}, bool, bool) {
@@ -262,7 +317,7 @@ func (h *EventsHandler) handleEventsClientMessage(
}
for _, channel := range accepted {
if err := h.writeSnapshotFrame(conn, r, claims, channel); err != nil {
if err := h.writeSnapshotFrame(conn, r, claims, viewerIsAdmin, channel); err != nil {
return nil, false, false
}
}
@@ -270,13 +325,32 @@ func (h *EventsHandler) handleEventsClientMessage(
return nextSubs, true, true
}
func allowedChannelsForRole(role string) []evt.EventChannel {
// revalidateViewer decides whether an open events WebSocket must be closed
// after its user is re-loaded. The connection closes when the account is gone
// or disabled, and when admin status changed in either direction — the
// allowed channel set is fixed at upgrade, so the client must reconnect to
// receive the correct set; closing on change is simpler and safer than
// re-assigning channels mid-stream. Other policy changes (AccessPolicyRevision
// bumps from group/library edits) deliberately keep the connection: event
// authorization (allowedChannelsForViewer, allowsEventForViewer) depends only
// on admin status and user ID, never on per-user library or rating policy.
func revalidateViewer(user *models.User, wasAdmin bool) (closeConn bool, reason string) {
if user == nil || !user.Enabled {
return true, "account disabled"
}
if user.IsAdmin != wasAdmin {
return true, "permissions changed"
}
return false, ""
}
func allowedChannelsForViewer(viewerIsAdmin bool) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelUserState,
}
if role == "admin" {
if viewerIsAdmin {
channels = append(channels,
evt.ChannelJobs,
evt.ChannelSessions,
@@ -287,14 +361,14 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
return channels
}
func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
if claims == nil {
func allowsEventForViewer(viewerUserID int, viewerIsAdmin bool, env evt.Envelope) bool {
if viewerUserID == 0 {
return false
}
if env.AdminOnly && claims.Role != "admin" {
if env.AdminOnly && !viewerIsAdmin {
return false
}
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
if env.UserID > 0 && !viewerIsAdmin && env.UserID != viewerUserID {
return false
}
return true
@@ -314,6 +388,7 @@ func marshalJSON(value any) json.RawMessage {
func (h *EventsHandler) snapshotForChannel(
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
channel evt.EventChannel,
) (json.RawMessage, error) {
switch channel {
@@ -366,7 +441,7 @@ func (h *EventsHandler) snapshotForChannel(
if h == nil || h.historyImports == nil {
return json.RawMessage("[]"), nil
}
if claims != nil && claims.Role == "admin" {
if viewerIsAdmin {
runs, err := h.historyImports.ListAdminActiveRuns(r.Context(), nil)
if err != nil {
return nil, err
@@ -387,9 +462,10 @@ func (h *EventsHandler) writeSnapshotFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
channel evt.EventChannel,
) error {
data, err := h.snapshotForChannel(r, claims, channel)
data, err := h.snapshotForChannel(r, claims, viewerIsAdmin, channel)
if err != nil {
slog.Error(
"events: failed to build initial snapshot",
@@ -415,11 +491,12 @@ func (h *EventsHandler) writeEventFrame(
conn *websocket.Conn,
r *http.Request,
claims *auth.Claims,
viewerIsAdmin bool,
env evt.Envelope,
) error {
data := env.Data
if len(data) == 0 || (env.Channel == evt.ChannelSessions && env.Event == "sessions.replaced") {
snapshot, err := h.snapshotForChannel(r, claims, env.Channel)
snapshot, err := h.snapshotForChannel(r, claims, viewerIsAdmin, env.Channel)
if err != nil {
slog.Error("events: failed to build event payload", "channel", env.Channel, "event", env.Event, "error", err)
return err
+170
View File
@@ -0,0 +1,170 @@
package handlers
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestRevalidateViewer(t *testing.T) {
cases := []struct {
name string
user *models.User
wasAdmin bool
wantClose bool
}{
{
name: "user deleted",
user: nil,
wasAdmin: false,
wantClose: true,
},
{
name: "user disabled",
user: &models.User{ID: 1, Enabled: false},
wasAdmin: false,
wantClose: true,
},
{
name: "admin disabled keeps no access even with admin flag",
user: &models.User{ID: 1, Enabled: false, IsAdmin: true},
wasAdmin: true,
wantClose: true,
},
{
name: "admin demoted",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
wasAdmin: true,
wantClose: true,
},
{
name: "viewer promoted to admin",
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
wasAdmin: false,
wantClose: true,
},
{
name: "enabled non-admin unchanged",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false},
wasAdmin: false,
wantClose: false,
},
{
name: "enabled admin unchanged",
user: &models.User{ID: 1, Enabled: true, IsAdmin: true},
wasAdmin: true,
wantClose: false,
},
{
// Policy revision bumps (group/library edits) do not affect event
// authorization, which depends only on admin status and user ID.
name: "policy revision change without admin change",
user: &models.User{ID: 1, Enabled: true, IsAdmin: false, AccessPolicyRevision: 42},
wasAdmin: false,
wantClose: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
closeConn, reason := revalidateViewer(tc.user, tc.wasAdmin)
if closeConn != tc.wantClose {
t.Fatalf("revalidateViewer() close = %v, want %v", closeConn, tc.wantClose)
}
if closeConn && reason == "" {
t.Fatal("expected a close reason")
}
if !closeConn && reason != "" {
t.Fatalf("expected empty reason for kept connection, got %q", reason)
}
})
}
}
// mutableUserLoader is a swappable auth.UserLoader for revalidation tests.
type mutableUserLoader struct {
mu sync.Mutex
user *models.User
err error
}
func (l *mutableUserLoader) GetByID(_ context.Context, id int) (*models.User, error) {
l.mu.Lock()
defer l.mu.Unlock()
if l.err != nil {
return nil, l.err
}
if l.user == nil || l.user.ID != id {
return nil, auth.ErrNotFound
}
userCopy := *l.user
return &userCopy, nil
}
func (l *mutableUserLoader) set(user *models.User, err error) {
l.mu.Lock()
l.user = user
l.err = err
l.mu.Unlock()
}
// TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection covers the
// revalidation ticker end to end: an open connection is closed by the server
// once the periodic user re-load reports the account disabled.
func TestHandleEventsWebSocket_ClosesWhenUserDisabledMidConnection(t *testing.T) {
loader := &mutableUserLoader{user: &models.User{ID: 1, Enabled: true}}
handler := NewEventsHandler(evt.NewHub("test", nil), nil, nil, nil, nil, nil, nil, loader)
handler.revalidateInterval = 20 * time.Millisecond
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := apimw.SetClaims(r.Context(), &auth.Claims{UserID: 1, TokenType: auth.TokenTypeAccess})
handler.HandleWebSocket(w, r.WithContext(ctx))
}))
defer server.Close()
conn, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(server.URL, "http"), nil)
if err != nil {
t.Fatalf("Dial websocket: %v", err)
}
defer conn.Close()
// Complete the handshake: hello, subscribe, subscribed + snapshot.
var hello evt.EventsHelloMessage
if err := conn.ReadJSON(&hello); err != nil {
t.Fatalf("read hello: %v", err)
}
if err := conn.WriteJSON(evt.EventsSubscribeMessage{
Type: "subscribe",
Channels: []evt.EventChannel{evt.ChannelCatalog},
}); err != nil {
t.Fatalf("write subscribe: %v", err)
}
// The account is disabled while the connection is open; the next
// revalidation tick must close it with a normal close frame.
loader.set(&models.User{ID: 1, Enabled: false}, nil)
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
for {
var frame json.RawMessage
if err := conn.ReadJSON(&frame); err != nil {
var closeErr *websocket.CloseError
if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
return // server closed the connection as required
}
t.Fatalf("expected normal close, got %v (%T %v)", err, err, closeErr)
}
// Drain subscribed/snapshot frames until the close arrives.
}
}
+14 -2
View File
@@ -40,6 +40,7 @@ type PersonalDataHandler struct {
localFavoriteDispatcher LocalFavoriteEventDispatcher
profileStaler ProfileStaler
profileRefreshRequester ProfileRefreshRequester
ebookProgressStore EbookReaderProgressLister
}
// NewPersonalDataHandler creates a new PersonalDataHandler.
@@ -55,6 +56,10 @@ func (h *PersonalDataHandler) SetDetailService(svc *catalog.DetailService) {
h.detailSvc = svc
}
func (h *PersonalDataHandler) SetEbookReaderProgressStore(store EbookReaderProgressLister) {
h.ebookProgressStore = store
}
// SetProfileStaler configures an optional staleness trigger for taste profiles.
func (h *PersonalDataHandler) SetProfileStaler(ps ProfileStaler) {
h.profileStaler = ps
@@ -552,7 +557,10 @@ func resolveItemsByIDs(h *PersonalDataHandler, r *http.Request, ids []string) ([
userStates := map[string]*itemUserStateResponse{}
if store, profileID, ok := h.userStoreForRequest(r); ok {
if resolvedStates, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, accessibleItems); err == nil {
if resolvedStates, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, accessibleItems, itemUserStateOptions{
UserID: apimw.GetUserID(r.Context()),
EbookProgressStore: h.ebookProgressStore,
}); err == nil {
userStates = resolvedStates
}
}
@@ -669,7 +677,11 @@ func (h *PersonalDataHandler) resolveHistoryRemovalMediaItemIDs(
return nil, err
}
switch item.Type {
case "movie":
case "movie", "ebook":
// Hiding an ebook gates ebook_reader_progress reads via
// user_history_hidden_items (updated_at <= hidden_before)
// without deleting the progress row, so the reader position
// survives: hidden is not the same as unread.
return []string{item.ContentID}, nil
case "series":
return h.seriesEpisodeIDs(ctx, item.ContentID)
@@ -0,0 +1,113 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/userstore"
)
type fakeHistoryItemRepo struct {
items map[string]*models.MediaItem
inaccessible map[string]bool
}
func (r *fakeHistoryItemRepo) GetByID(_ context.Context, contentID string) (*models.MediaItem, error) {
item, ok := r.items[contentID]
if !ok {
return nil, catalog.ErrItemNotFound
}
return item, nil
}
func (r *fakeHistoryItemRepo) GetByIDs(_ context.Context, contentIDs []string) ([]*models.MediaItem, error) {
result := make([]*models.MediaItem, 0, len(contentIDs))
for _, contentID := range contentIDs {
if item, ok := r.items[contentID]; ok {
result = append(result, item)
}
}
return result, nil
}
func (r *fakeHistoryItemRepo) EnsureAccessible(_ context.Context, contentID string, _ catalog.AccessFilter) error {
if r.inaccessible[contentID] {
return catalog.ErrItemNotFound
}
return nil
}
func TestHandleRemoveHistoryAcceptsEbookTargets(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
items: map[string]*models.MediaItem{
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
},
})
// Seed a history entry keyed by the ebook content ID so the removal has
// something to hide. RemoveHistoryItems only touches watch history,
// watch progress, and the hidden-items gate — PersonalDataHandler has no
// write access to ebook_reader_progress (its store interface is read-only),
// so the reader position survives: hidden is not the same as unread.
if err := store.AddHistory(ctx, userstore.WatchHistoryEntry{
ProfileID: "profile-1",
MediaItemID: "ebook-1",
WatchedAt: "2026-06-01T10:00:00Z",
Completed: true,
Source: userstore.WatchHistorySourceManual,
}); err != nil {
t.Fatalf("AddHistory: %v", err)
}
req := newAuthorizedProfileRequestWithRole(
http.MethodPost,
"/history/remove",
`{"targets":[{"content_id":"ebook-1"}]}`,
"user",
"profile-1",
)
rr := httptest.NewRecorder()
handler.HandleRemoveHistory(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
entries, err := store.ListHistory(ctx, "profile-1", 10, 0)
if err != nil {
t.Fatalf("ListHistory: %v", err)
}
if len(entries) != 0 {
t.Fatalf("history entries = %+v, want hidden after removal", entries)
}
}
func TestHandleRemoveHistoryRejectsInaccessibleEbook(t *testing.T) {
store := newProfileTestStore(t)
handler := NewPersonalDataHandler(testUserStoreProvider{store: store}, &fakeHistoryItemRepo{
items: map[string]*models.MediaItem{
"ebook-1": {ContentID: "ebook-1", Type: "ebook", Title: "Book"},
},
inaccessible: map[string]bool{"ebook-1": true},
})
req := newAuthorizedProfileRequestWithRole(
http.MethodPost,
"/history/remove",
`{"targets":[{"content_id":"ebook-1"}]}`,
"user",
"profile-1",
)
rr := httptest.NewRecorder()
handler.HandleRemoveHistory(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
}
+332 -30
View File
@@ -46,6 +46,10 @@ type LocalWatchEventDispatcher interface {
HandleLocalWatchEvent(ctx context.Context, event watchsync.LocalWatchEvent) error
}
type EbookReaderProgressLister interface {
ListByContentIDs(ctx context.Context, userID int, profileID string, contentIDs []string) (map[string]EbookReaderProgress, error)
}
// ItemsHandler handles browse, search, item detail, and series endpoints.
type ItemsHandler struct {
browseRepo *catalog.BrowseRepository
@@ -62,6 +66,8 @@ type ItemsHandler struct {
profileRefreshRequester ProfileRefreshRequester
metadataRefreshRequester MetadataRefreshRequester
localWatchDispatcher LocalWatchEventDispatcher
ebookProgressStore EbookReaderProgressLister
ebookReadStateStore EbookReadStateStore
EventsHub *evt.Hub
UserRepo *auth.UserRepository
}
@@ -114,6 +120,11 @@ func (h *ItemsHandler) SetLocalWatchEventDispatcher(dispatcher LocalWatchEventDi
h.localWatchDispatcher = dispatcher
}
func (h *ItemsHandler) SetEbookReaderProgressStore(store EbookReaderProgressReadWriter) {
h.ebookProgressStore = store
h.ebookReadStateStore = store
}
func (h *ItemsHandler) maybeRequestStaleDetailMetadataRefresh(ctx context.Context, detail *catalog.ItemDetail) {
if h == nil || detail == nil || h.metadataRefreshRequester == nil {
return
@@ -225,6 +236,9 @@ type sortMetricsResponse struct {
ProgressRatio *float64 `json:"progress_ratio,omitempty"`
ViewedAt string `json:"viewed_at,omitempty"`
PlayCount *int `json:"play_count,omitempty"`
Author string `json:"author,omitempty"`
Narrator string `json:"narrator,omitempty"`
SeriesName string `json:"series_name,omitempty"`
}
// browseResponse is the paginated response for the /items endpoint.
@@ -373,8 +387,8 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque
}
}
if detail.Type == "movie" || detail.Type == "episode" {
detail.UserData = h.getLeafUserData(r, detail.ContentID)
if detail.Type == "movie" || detail.Type == "episode" || detail.Type == "ebook" {
detail.UserData = h.getLeafUserData(r, detail.ContentID, detail.Type)
applyEffectiveEditionPreference(detail.UserData, &detail.EffectiveVersionEditionKey)
}
@@ -429,7 +443,8 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
return
}
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, h.accessFilter(r))
filter := h.accessFilter(r)
targetType, targets, err := h.resolveWatchedTargets(r.Context(), id, filter)
if err != nil {
switch {
case isNotFound(err):
@@ -440,12 +455,16 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
return
}
if h.watchState == nil {
switch {
case targetType == "ebook":
// Ebook read state lives in ebook_reader_progress, not in
// user_watch_progress/user_watch_history; watch providers do not sync
// books, so no local watch event is dispatched.
err = h.setEbookReadState(r.Context(), userID, profileID, id, played, filter)
case h.watchState == nil:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to access user store")
return
}
if played {
case played:
leafTargets := make([]watchstate.LeafWatchTarget, 0, len(targets))
for _, target := range targets {
leafTargets = append(leafTargets, watchstate.LeafWatchTarget{
@@ -459,7 +478,7 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
if err == nil {
h.dispatchLocalWatchEvent(r.Context(), watchsync.LocalWatchEventMarkedWatched, userID, profileID, result)
}
} else {
default:
targetIDs := make([]string, 0, len(targets))
for _, target := range targets {
targetIDs = append(targetIDs, target.ContentID)
@@ -471,6 +490,10 @@ func (h *ItemsHandler) handleSetWatchedState(w http.ResponseWriter, r *http.Requ
}
}
if err != nil {
if isNotFound(err) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update watched state")
return
}
@@ -739,7 +762,10 @@ func (h *ItemsHandler) listItemUserStates(r *http.Request, items []*models.Media
if !ok {
return map[string]*itemUserStateResponse{}
}
states, err := resolveItemUserStates(r.Context(), store, profileID, h.episodeRepo, items)
states, err := resolveItemUserStatesWithOptions(r.Context(), store, profileID, h.episodeRepo, items, itemUserStateOptions{
UserID: apimw.GetUserID(r.Context()),
EbookProgressStore: h.ebookProgressStore,
})
if err != nil {
return map[string]*itemUserStateResponse{}
}
@@ -757,6 +783,14 @@ func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models
ep = localized
}
}
resp, stillPath := episodeResponseShell(ep, fallback)
resp.StillURL = h.presignURL(r, stillPath, "card")
return resp
}
// episodeResponseShell maps an already-localized episode onto the response
// shape, returning the card-variant still path for the caller to presign.
func episodeResponseShell(ep *models.Episode, fallback episodeImageFallback) (episodeResponse, string) {
stillPath := ep.StillPath
stillThumbhash := ep.StillThumbhash
if strings.TrimSpace(stillPath) == "" && strings.TrimSpace(fallback.Path) != "" {
@@ -780,8 +814,94 @@ func (h *ItemsHandler) toEpisodeResponseWithFallback(r *http.Request, ep *models
resp.AirDate = ep.AirDate.Format("2006-01-02")
}
resp.StillURL = h.presignURL(r, cardThumbnailPath(stillPath), "card")
return resp, cardThumbnailPath(stillPath)
}
// buildEpisodeResponses converts episodes to API responses using batched
// lookups — localization, media files, watch progress, and image presigning
// each resolve in one round-trip for the whole list instead of per episode.
func (h *ItemsHandler) buildEpisodeResponses(r *http.Request, episodes []*models.Episode) []episodeResponse {
ctx := r.Context()
filter := h.accessFilter(r)
if h.detailSvc != nil {
if localized, err := h.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); err == nil && len(localized) == len(episodes) {
episodes = localized
}
}
fallbacks := h.episodeImageFallbacks(ctx, episodes)
episodeIDs := make([]string, 0, len(episodes))
for _, ep := range episodes {
if ep != nil && ep.ContentID != "" {
episodeIDs = append(episodeIDs, ep.ContentID)
}
}
filesByEpisode := h.listEpisodeFiles(ctx, episodeIDs)
userData := h.listLeafUserData(r, episodeIDs)
resp := make([]episodeResponse, 0, len(episodes))
stillPaths := make([]string, 0, len(episodes))
for _, ep := range episodes {
if ep == nil {
continue
}
shell, stillPath := episodeResponseShell(ep, fallbacks[ep.SeriesID])
resp = append(resp, shell)
stillPaths = append(stillPaths, stillPath)
}
stillURLs := map[string]catalog.ResolvedImageURL{}
if h.detailSvc != nil {
stillURLs = h.detailSvc.PresignURLsWithExpiry(ctx, stillPaths, "card")
}
for i := range resp {
resp[i].StillURL = stillURLs[stillPaths[i]].URL
resp[i].Files = episodeFileResponses(filesByEpisode[resp[i].ContentID], filter)
resp[i].UserData = userData[resp[i].ContentID]
}
return resp
}
// listEpisodeFiles batch-fetches media files keyed by episode ID, falling back
// to per-episode lookups when the repository lacks batch support.
func (h *ItemsHandler) listEpisodeFiles(ctx context.Context, episodeIDs []string) map[string][]*models.MediaFile {
if h.fileRepo == nil || len(episodeIDs) == 0 {
return nil
}
if batchProvider, ok := h.fileRepo.(batchEpisodeFileProvider); ok {
files, err := batchProvider.ListByEpisodeIDs(ctx, episodeIDs)
if err != nil {
return nil
}
return files
}
files := make(map[string][]*models.MediaFile, len(episodeIDs))
for _, id := range episodeIDs {
if list, err := h.fileRepo.GetByEpisodeID(ctx, id); err == nil {
files[id] = list
}
}
return files
}
func episodeFileResponses(files []*models.MediaFile, filter catalog.AccessFilter) []episodeFileResponse {
var resp []episodeFileResponse
for _, f := range files {
if !catalog.FileAllowedByAccess(f, filter) {
continue
}
resp = append(resp, episodeFileResponse{
FileID: f.ID,
Resolution: f.Resolution,
CodecVideo: f.CodecVideo,
HDR: f.HDR,
AudioChannels: f.AudioChannels,
Container: f.Container,
FileSize: f.FileSize,
})
}
return resp
}
@@ -850,6 +970,7 @@ func (h *ItemsHandler) listSortMetrics(
filter catalog.AccessFilter,
overlaySummaries map[string]*models.OverlaySummary,
store userstore.UserStore,
userID int,
profileID string,
) map[string]*sortMetricsResponse {
metrics := make(map[string]*sortMetricsResponse, len(items))
@@ -890,16 +1011,97 @@ func (h *ItemsHandler) listSortMetrics(
}
}
case "progress", "date_viewed", "plays":
h.listUserSortMetrics(ctx, items, sortField, store, profileID, metrics)
h.listUserSortMetrics(ctx, items, sortField, store, userID, profileID, metrics)
case "author", "narrator", "series":
h.listAudiobookSortMetrics(ctx, items, sortField, metrics)
}
return metrics
}
func (h *ItemsHandler) listAudiobookSortMetrics(
ctx context.Context,
items []*models.MediaItem,
sortField string,
metrics map[string]*sortMetricsResponse,
) {
if h == nil || h.browseRepo == nil || h.browseRepo.Pool() == nil || len(items) == 0 {
return
}
contentIDs := uniqueItemContentIDs(items)
if len(contentIDs) == 0 {
return
}
var query string
switch sortField {
case "author":
query = audiobookPersonSortMetricQuery(int(models.PersonKindAuthor))
case "narrator":
query = audiobookPersonSortMetricQuery(int(models.PersonKindNarrator))
case "series":
query = `
SELECT target.content_id, BTRIM(s.series_name) AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN audiobook_series s ON s.content_id = target.content_id
WHERE BTRIM(s.series_name) <> ''`
default:
return
}
rows, err := h.browseRepo.Pool().Query(ctx, query, contentIDs)
if err != nil {
return
}
defer rows.Close()
for rows.Next() {
var contentID, value string
if err := rows.Scan(&contentID, &value); err != nil {
return
}
value = strings.TrimSpace(value)
if contentID == "" || value == "" {
continue
}
resp := metrics[contentID]
if resp == nil {
resp = &sortMetricsResponse{}
metrics[contentID] = resp
}
switch sortField {
case "author":
resp.Author = value
case "narrator":
resp.Narrator = value
case "series":
resp.SeriesName = value
}
}
}
func audiobookPersonSortMetricQuery(kind int) string {
return fmt.Sprintf(`
SELECT target.content_id, person.name AS value
FROM unnest($1::text[]) WITH ORDINALITY AS target(content_id, ord)
JOIN LATERAL (
SELECT BTRIM(p.name) AS name
FROM item_people ip
JOIN people p ON p.id = ip.person_id
WHERE ip.content_id = target.content_id
AND ip.kind = %d
AND p.name IS NOT NULL
AND BTRIM(p.name) <> ''
ORDER BY p.name ASC
LIMIT 1
) person ON TRUE`, kind)
}
func (h *ItemsHandler) listUserSortMetrics(
ctx context.Context,
items []*models.MediaItem,
sortField string,
store userstore.UserStore,
userID int,
profileID string,
metrics map[string]*sortMetricsResponse,
) {
@@ -915,6 +1117,10 @@ func (h *ItemsHandler) listUserSortMetrics(
if err != nil {
return
}
ebookProgressMap, err := h.listEbookReaderProgressByContentIDs(ctx, userID, profileID, contentIDs)
if err != nil {
return
}
switch sortField {
case "progress":
@@ -923,10 +1129,16 @@ func (h *ItemsHandler) listUserSortMetrics(
continue
}
progress, ok := progressMap[item.ContentID]
if !ok || progress.Completed || progress.PositionSeconds <= 0 || progress.DurationSeconds <= 0 {
if ok && !progress.Completed && progress.PositionSeconds > 0 && progress.DurationSeconds > 0 {
ratio := progress.PositionSeconds / progress.DurationSeconds
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
continue
}
ratio := progress.PositionSeconds / progress.DurationSeconds
ebookProgress, ok := ebookProgressMap[item.ContentID]
if !ok || ebookProgress.Progress <= 0 || ebookProgress.Progress >= models.EbookFinishedProgressThreshold {
continue
}
ratio := ebookProgress.Progress
metrics[item.ContentID] = &sortMetricsResponse{ProgressRatio: &ratio}
}
case "date_viewed", "plays":
@@ -955,6 +1167,12 @@ func (h *ItemsHandler) listUserSortMetrics(
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && progress.UpdatedAt > viewedAt {
viewedAt = progress.UpdatedAt
}
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold {
ebookViewedAt := progress.UpdatedAt.UTC().Format(time.RFC3339)
if ebookViewedAt > viewedAt {
viewedAt = ebookViewedAt
}
}
if viewedAt == "" {
continue
}
@@ -964,6 +1182,9 @@ func (h *ItemsHandler) listUserSortMetrics(
if progress, ok := progressMap[item.ContentID]; ok && progress.Completed && playCount < 1 {
playCount = 1
}
if progress, ok := ebookProgressMap[item.ContentID]; ok && progress.Progress >= models.EbookFinishedProgressThreshold && playCount < 1 {
playCount = 1
}
if playCount <= 0 {
continue
}
@@ -974,6 +1195,18 @@ func (h *ItemsHandler) listUserSortMetrics(
}
}
func (h *ItemsHandler) listEbookReaderProgressByContentIDs(
ctx context.Context,
userID int,
profileID string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
if h == nil || h.ebookProgressStore == nil || userID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
return h.ebookProgressStore.ListByContentIDs(ctx, userID, profileID, contentIDs)
}
func uniqueItemContentIDs(items []*models.MediaItem) []string {
contentIDs := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
@@ -1110,7 +1343,11 @@ func (h *ItemsHandler) toSeasonResponse(r *http.Request, seriesID string, s *mod
return resp
}
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string, itemType ...string) *catalog.SeasonUserData {
if len(itemType) > 0 && itemType[0] == "ebook" {
return h.getEbookLeafUserData(r, contentID)
}
store, profileID, ok := h.userStoreForRequest(r)
if !ok {
return nil
@@ -1121,10 +1358,34 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
return nil
}
return leafUserDataFromProgress(*progress)
}
// listLeafUserData batch-fetches watch progress for the given content IDs in a
// single query; the result only holds entries for items with progress rows.
func (h *ItemsHandler) listLeafUserData(r *http.Request, contentIDs []string) map[string]*catalog.SeasonUserData {
store, profileID, ok := h.userStoreForRequest(r)
if !ok || len(contentIDs) == 0 {
return nil
}
progressMap, err := store.ListProgressByMediaItems(r.Context(), profileID, contentIDs)
if err != nil {
return nil
}
result := make(map[string]*catalog.SeasonUserData, len(progressMap))
for contentID, progress := range progressMap {
result[contentID] = leafUserDataFromProgress(progress)
}
return result
}
func leafUserDataFromProgress(progress userstore.WatchProgress) *catalog.SeasonUserData {
return &catalog.SeasonUserData{
PositionSeconds: progress.PositionSeconds,
DurationSeconds: progress.DurationSeconds,
IsInProgress: !progress.Completed && progress.PositionSeconds > 0,
IsInProgress: progress.PositionSeconds > 0,
Played: progress.Completed,
LastFileID: progress.LastFileID,
LastResolution: progress.LastResolution,
@@ -1134,6 +1395,38 @@ func (h *ItemsHandler) getLeafUserData(r *http.Request, contentID string) *catal
}
}
func (h *ItemsHandler) getEbookLeafUserData(r *http.Request, contentID string) *catalog.SeasonUserData {
if h == nil || h.ebookProgressStore == nil {
return nil
}
userID := apimw.GetUserID(r.Context())
profileID := requestProfileID(r)
if userID <= 0 || profileID == "" || contentID == "" {
return nil
}
progress, err := h.ebookProgressStore.ListByContentIDs(r.Context(), userID, profileID, []string{contentID})
if err != nil {
return nil
}
row, ok := progress[contentID]
if !ok || row.Progress <= 0 {
return nil
}
// Ebooks have no playback duration, so PositionSeconds/DurationSeconds
// encode the 0..1 reading ratio (position=ratio, duration=1). Clients can
// derive percent-complete from the pair as usual, but rendering them as
// absolute times is meaningless for ebooks. Changing the wire shape needs
// coordinated Android/Apple client updates.
return &catalog.SeasonUserData{
PositionSeconds: row.Progress,
DurationSeconds: 1,
IsInProgress: row.Progress < models.EbookFinishedProgressThreshold,
Played: row.Progress >= models.EbookFinishedProgressThreshold,
}
}
func applyEffectiveEditionPreference(userData *catalog.SeasonUserData, target **string) {
if userData == nil || userData.LastEditionKey == nil || *userData.LastEditionKey == "" {
return
@@ -1178,6 +1471,16 @@ func (h *ItemsHandler) getAggregateUserData(r *http.Request, episodes []*models.
}
}
// requestProfileID resolves the active profile for a request, falling back to
// the X-Profile-Id header when the auth context does not carry one.
func requestProfileID(r *http.Request) string {
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
return profileID
}
func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore, string, bool) {
if h.storeProvider == nil {
return nil, "", false
@@ -1188,10 +1491,7 @@ func (h *ItemsHandler) userStoreForRequest(r *http.Request) (userstore.UserStore
return nil, "", false
}
profileID := apimw.GetProfileID(r.Context())
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
profileID := requestProfileID(r)
if profileID == "" {
return nil, "", false
}
@@ -1315,14 +1615,15 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
}
}
@@ -1385,6 +1686,10 @@ func (h *ItemsHandler) resolveWatchedTargets(ctx context.Context, contentID stri
ContentID: item.ContentID,
DurationSeconds: h.contentDurationSeconds(ctx, item.ContentID, "", item.Runtime),
}}, nil
case "ebook":
// Ebooks have no playback duration; read state is keyed off
// ebook_reader_progress, so the leaf target only carries the ID.
return "ebook", []watchedLeafTarget{{ContentID: item.ContentID}}, nil
case "series":
if h.episodeRepo == nil {
return "", nil, catalog.ErrItemNotFound
@@ -1495,9 +1800,6 @@ func (h *ItemsHandler) requestCanViewFilePaths(r *http.Request) bool {
if claims == nil {
return false
}
if claims.Role == "admin" {
return true
}
if h == nil || h.UserRepo == nil {
return false
}
@@ -40,20 +40,145 @@ func TestListSortMetricsIncludesUserStateSorts(t *testing.T) {
}
}
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, "profile-1")
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 0, "profile-1")
progress := progressMetrics["movie-progress"]
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.25 {
t.Fatalf("progress metrics = %#v", progress)
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, "profile-1")
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 0, "profile-1")
if got := viewedMetrics["movie-viewed"]; got == nil || got.ViewedAt != "2026-05-29T18:30:00Z" {
t.Fatalf("viewed metrics = %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, "profile-1")
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 0, "profile-1")
plays := playsMetrics["movie-plays"]
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 2 {
t.Fatalf("plays metrics = %#v", plays)
}
}
func TestListSortMetricsIncludesEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-progress": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.37,
UpdatedAt: updatedAt,
},
"ebook-complete": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.95,
UpdatedAt: updatedAt.Add(time.Hour),
},
},
},
}
items := []*models.MediaItem{
{ContentID: "ebook-progress", Type: "ebook", Title: "Progress"},
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete"},
}
progressMetrics := handler.listSortMetrics(ctx, items, "progress", catalog.AccessFilter{}, nil, store, 42, "profile-1")
progress := progressMetrics["ebook-progress"]
if progress == nil || progress.ProgressRatio == nil || *progress.ProgressRatio != 0.37 {
t.Fatalf("progress metrics = %#v", progress)
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
if got := viewedMetrics["ebook-complete"]; got == nil || got.ViewedAt != "2026-06-03T10:15:00Z" {
t.Fatalf("viewed metrics = %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
plays := playsMetrics["ebook-complete"]
if plays == nil || plays.PlayCount == nil || *plays.PlayCount != 1 {
t.Fatalf("plays metrics = %#v", plays)
}
}
func TestListSortMetricsExcludesHiddenEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
updatedAt := time.Date(2026, 6, 3, 9, 15, 0, 0, time.UTC)
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-hidden": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-hidden",
Progress: 0.95,
UpdatedAt: updatedAt,
},
"ebook-reread": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-reread",
Progress: 0.95,
UpdatedAt: updatedAt.Add(2 * time.Hour),
},
},
hiddenBefore: map[string]time.Time{
"ebook-hidden": updatedAt.Add(time.Hour),
"ebook-reread": updatedAt.Add(time.Hour),
},
},
}
items := []*models.MediaItem{
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden"},
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread"},
}
viewedMetrics := handler.listSortMetrics(ctx, items, "date_viewed", catalog.AccessFilter{}, nil, store, 42, "profile-1")
if got := viewedMetrics["ebook-hidden"]; got != nil {
t.Fatalf("hidden ebook must have no viewed metrics, got %#v", got)
}
if got := viewedMetrics["ebook-reread"]; got == nil || got.ViewedAt != "2026-06-03T11:15:00Z" {
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
}
playsMetrics := handler.listSortMetrics(ctx, items, "plays", catalog.AccessFilter{}, nil, store, 42, "profile-1")
if got := playsMetrics["ebook-hidden"]; got != nil {
t.Fatalf("hidden ebook must have no play count, got %#v", got)
}
if got := playsMetrics["ebook-reread"]; got == nil || got.PlayCount == nil || *got.PlayCount != 1 {
t.Fatalf("progress updated after hidden_before must count again, got %#v", got)
}
}
type fakeEbookReaderProgressLister struct {
progress map[string]EbookReaderProgress
// hiddenBefore models user_history_hidden_items: like the PG store, a
// row is excluded while progress.UpdatedAt <= hidden_before and counts
// again once the progress row is updated after the hide.
hiddenBefore map[string]time.Time
}
func (f *fakeEbookReaderProgressLister) ListByContentIDs(
_ context.Context,
_ int,
_ string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
result := make(map[string]EbookReaderProgress, len(contentIDs))
for _, contentID := range contentIDs {
progress, ok := f.progress[contentID]
if !ok {
continue
}
if hiddenBefore, hidden := f.hiddenBefore[contentID]; hidden && !progress.UpdatedAt.After(hiddenBefore) {
continue
}
result[contentID] = progress
}
return result, nil
}
@@ -0,0 +1,47 @@
package handlers
import (
"net/http/httptest"
"testing"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
)
func TestGetLeafUserDataUsesEbookReaderProgress(t *testing.T) {
handler := &ItemsHandler{
ebookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-progress": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.42,
UpdatedAt: time.Date(2026, 6, 5, 10, 0, 0, 0, time.UTC),
},
"ebook-complete": {
UserID: 7,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.93,
UpdatedAt: time.Date(2026, 6, 5, 11, 0, 0, 0, time.UTC),
},
},
},
}
req := httptest.NewRequest("GET", "/items/ebook-progress", nil)
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 7})
ctx = apimw.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
progress := handler.getLeafUserData(req, "ebook-progress", "ebook")
if progress == nil || progress.Played || !progress.IsInProgress || progress.PositionSeconds != 0.42 || progress.DurationSeconds != 1 {
t.Fatalf("partial ebook user data = %#v", progress)
}
complete := handler.getLeafUserData(req, "ebook-complete", "ebook")
if complete == nil || !complete.Played || complete.IsInProgress {
t.Fatalf("completed ebook user data = %#v", complete)
}
}
+62
View File
@@ -0,0 +1,62 @@
package handlers
// This file holds tri-state JSON field wrappers for PATCH-style request
// bodies. Plain Go pointers can only distinguish two states (absent/nil vs.
// value); these wrappers track three:
//
// - absent: the key was not present in the JSON body (Set = false) —
// the caller should leave the current value unchanged
// - null: the key was present with an explicit JSON null (Set = true)
// - value: the key was present with a concrete value (Set = true)
//
// How null is decoded depends on the wrapper: optionalIntSliceField keeps a
// nil slice (e.g. "all libraries"), optionalStringSliceField normalizes null
// to an empty slice.
import (
"bytes"
"encoding/json"
)
// optionalIntSliceField distinguishes an absent JSON field from an explicit
// null or array value. Absent = don't update; null = nil slice ("all
// libraries"); [] = empty slice ("none").
type optionalIntSliceField struct {
Set bool
Value []int
}
func (f *optionalIntSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = nil
return nil
}
return json.Unmarshal(data, &f.Value)
}
// Ptr returns nil when the field was absent, otherwise a pointer to the
// decoded slice (which may itself be nil for JSON null).
func (f optionalIntSliceField) Ptr() *[]int {
if !f.Set {
return nil
}
value := f.Value
return &value
}
// optionalStringSliceField distinguishes an absent JSON field from an
// explicit null or array value. JSON null decodes to an empty slice.
type optionalStringSliceField struct {
Set bool
Value []string
}
func (f *optionalStringSliceField) UnmarshalJSON(data []byte) error {
f.Set = true
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
f.Value = []string{}
return nil
}
return json.Unmarshal(data, &f.Value)
}
+29 -11
View File
@@ -192,6 +192,7 @@ type updateLibraryRequest struct {
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
MetadataLanguage *string `json:"metadata_language,omitempty"`
AutoTranslateMetadata *bool `json:"auto_translate_metadata,omitempty"`
ChapterThumbnailsEnabled *bool `json:"chapter_thumbnails_enabled,omitempty"`
IntroDetectionEnabled *bool `json:"intro_detection_enabled,omitempty"`
}
@@ -226,6 +227,7 @@ type libraryResponse struct {
Name string `json:"name"`
Enabled bool `json:"enabled"`
MetadataLanguage string `json:"metadata_language"`
AutoTranslateMetadata bool `json:"auto_translate_metadata"`
ChapterThumbnailsEnabled bool `json:"chapter_thumbnails_enabled"`
ChapterThumbnailsSupported bool `json:"chapter_thumbnails_supported"`
IntroDetectionEnabled bool `json:"intro_detection_enabled"`
@@ -348,6 +350,7 @@ func toLibraryResponse(f *models.MediaFolder) libraryResponse {
Name: f.Name,
Enabled: f.Enabled,
MetadataLanguage: f.MetadataLanguage,
AutoTranslateMetadata: f.AutoTranslateMetadata,
ChapterThumbnailsEnabled: f.ChapterThumbnailsEnabled,
ChapterThumbnailsSupported: false,
IntroDetectionEnabled: f.IntroDetectionEnabled,
@@ -574,6 +577,11 @@ func (h *LibraryHandler) HandleCreateLibrary(w http.ResponseWriter, r *http.Requ
if seedErr := h.SectionRepo.SeedDefaults(r.Context(), "library", &folder.ID, sections.DefaultLibrarySectionsForType(&folder.ID, folder.Type)); seedErr != nil {
slog.Warn("seed default sections for new library", "library_id", folder.ID, "error", seedErr)
}
if sections.IsAudiobookLibraryType(folder.Type) {
if _, seedErr := h.SectionRepo.EnsureHomeContinueListeningSection(r.Context()); seedErr != nil {
slog.Warn("ensure home continue listening section", "library_id", folder.ID, "error", seedErr)
}
}
if _, seedErr := h.SectionRepo.CreateGeneratedHomeLibraryRecentSections(r.Context(), folder.ID, folder.Name, folder.Type); seedErr != nil {
slog.Warn("seed generated home sections for new library", "library_id", folder.ID, "error", seedErr)
}
@@ -647,6 +655,7 @@ func (h *LibraryHandler) HandleUpdateLibrary(w http.ResponseWriter, r *http.Requ
Name: req.Name,
Enabled: req.Enabled,
MetadataLanguage: req.MetadataLanguage,
AutoTranslateMetadata: req.AutoTranslateMetadata,
ChapterThumbnailsEnabled: req.ChapterThumbnailsEnabled,
IntroDetectionEnabled: req.IntroDetectionEnabled,
})
@@ -2029,16 +2038,8 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
return nil
}
// Determine which content levels apply.
var levels []string
switch libraryType {
case "series":
levels = []string{"series", "season", "episode"}
case "movies", "movie":
levels = []string{"movie"}
case "mixed":
levels = []string{"movie", "series", "season", "episode"}
default:
levels := metadataContentLevelsForLibraryType(libraryType)
if len(levels) == 0 {
return nil
}
@@ -2053,7 +2054,7 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
var candidates []candidate
for _, c := range caps {
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, level)
defaultPriority := metadata.LookupDefaultPriority(ctx, h.ChainRepo.Pool(), c.PluginInstallationID, c.CapabilityID, level)
if defaultPriority > 0 {
candidates = append(candidates, candidate{
installationID: c.PluginInstallationID,
@@ -2091,6 +2092,23 @@ func (h *LibraryHandler) seedDefaultChain(ctx context.Context, libraryType strin
return entries
}
func metadataContentLevelsForLibraryType(libraryType string) []string {
switch libraryType {
case "series":
return []string{"series", "season", "episode"}
case "movies", "movie":
return []string{"movie"}
case "audiobooks", "audiobook":
return []string{"audiobook"}
case "ebooks", "ebook":
return []string{"ebook"}
case "mixed":
return []string{"movie", "series", "season", "episode", "audiobook", "ebook"}
default:
return nil
}
}
// HandleListStaleIDs handles GET /libraries/stale-ids.
func (h *LibraryHandler) HandleListStaleIDs(w http.ResponseWriter, r *http.Request) {
if h.StaleIDRepo == nil {
@@ -0,0 +1,26 @@
package handlers
import (
"reflect"
"testing"
)
func TestMetadataContentLevelsForLibraryTypeIncludesEbooks(t *testing.T) {
cases := []struct {
name string
libraryType string
want []string
}{
{name: "plural ebooks", libraryType: "ebooks", want: []string{"ebook"}},
{name: "singular ebook", libraryType: "ebook", want: []string{"ebook"}},
{name: "mixed includes ebook", libraryType: "mixed", want: []string{"movie", "series", "season", "episode", "audiobook", "ebook"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := metadataContentLevelsForLibraryType(tc.libraryType); !reflect.DeepEqual(got, tc.want) {
t.Fatalf("metadataContentLevelsForLibraryType(%q) = %#v, want %#v", tc.libraryType, got, tc.want)
}
})
}
}

Some files were not shown because too many files have changed in this diff Show More