Compare commits

...
Author SHA1 Message Date
Claude Code 67209e38e0 fix(notifications): make Materializer.Start race-free single-shot
The previous `if m.unsub != nil` check-and-set was unsynchronized, so concurrent
Start calls could double-subscribe and leak an unsubscribe handle. Use a
sync.Once (mirroring stopOnce) so only the first call subscribes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:58:27 +02:00
Claude Code 35b8b9e44e fix(notifications): address PR #132 review feedback
Security / correctness:
- push: HandleToggleDevice now requires an explicit `enabled` (pointer bool) so
  `{}` can't silently disable a device.
- push: ClaimDue only claims deliveries for enabled devices with a token
  (push_enabled = true AND push_token IS NOT NULL).
- push: WebPushConfig.Configured() also requires Subject (avoids empty VAPID sub).
- notifications: SetPreferences persists the whole validated set in one
  transaction and returns a typed ErrInvalidCategory so the handler can 400 on
  invalid input vs 500 on a store failure (no more partial writes / mis-coded
  errors).
- requests: on a hard submission error after approval, publish request.approved
  so consumers aren't left with only request.submitted for an approved row
  (soft-fail behaviour unchanged).
- migration: add push_deliveries (user_id, profile_id) FK to user_profiles.

Robustness / hygiene:
- presence: log a warning when Redis is unavailable before falling back to the
  in-memory registry.
- router: mount push routes only when both PushStore and PushConfig are present
  (avoids nil-config panics).
- notifications: validate request_id before deriving a request DedupRef; make
  Materializer.Start single-shot.
- push: parseRetryAfter accepts the HTTP-date form of Retry-After.
- requests: populate Quality in published lifecycle payloads.
- docs: use repository-relative example paths in the push plans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:17:32 +02:00
Claude Code be704c3a06 refactor(web): dedupe notification components from code review
- Extract a shared NotificationStripBar presentational component; AnnouncementBar
  and NotificationStrip become thin data-fetch wrappers.
- Extract partitionAnnouncementsFirst() (used by the inbox and bell) and a
  notificationTimeAgo() helper.
- Revert the shared timeAgo util to upstream (format relative time in the
  notifications module instead of parameterizing the shared util).
- Remove the no-op useInfiniteList wrapper in the inbox page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:32:24 +02:00
Claude Code dcce156d4a refactor(notifications): security, decoupling, and cleanup from code review
Security:
- WS: never deliver another user's notification/request events to admins
  (personal channels drop the admin cross-user monitoring bypass).
- Scope MarkRead/MarkAllRead/Dismiss to the active profile so one profile
  cannot read/dismiss a sibling profile's notifications.
- Bound announcement title/body and cap the request body; cap push token size.

Decoupling / footprint:
- Password-changed notification is now event-driven: the admin handler publishes
  a user.password_changed event (shared EventUserPasswordChanged constant) and a
  new system matcher materializes it, removing the notifications dependency from
  the admin handler.
- Revert a no-op control-flow rewrite in requests reconcileRequest.

Refactors:
- Promote presence.Registry.Refresh onto the interface (no-op for the memory
  registry) and drop the runtime type-assertion in the WS handler.
- Extract a deviceContext helper for the push register/revoke handlers.
- Thread the claim context into the push worker's outcome writes instead of
  context.Background().

Note: per-profile / child-safe live filtering stays client-side and reactive
(the active profile is not sent on the WS, which never reconnects on switch);
the REST inbox remains the authoritative child-safe boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:32:14 +02:00
Claude Code dcb8ce8afe refactor(push): send FCM via HTTP v1 instead of the firebase SDK
Replace firebase.google.com/go/v4 with direct FCM HTTP v1 REST calls
authenticated by golang.org/x/oauth2/google (already a dependency). This drops
firebase + google.golang.org/api and their large transitive tree
(cloud.google.com/go/*, OpenTelemetry, spiffe, envoyproxy) — go.sum shrinks by
~84 lines — with no behaviour change. Result classification (UNREGISTERED /
INVALID_ARGUMENT → dead; QUOTA_EXCEEDED / UNAVAILABLE → backoff) is preserved as
a pure, unit-tested helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:31:55 +02:00
Claude Code 93eb23d2ec fix(notifications): restore admin announcements list refresh on add/delete
Regression from switching the create/delete invalidation to notificationKeys.all
(["notifications"]). The admin announcements list is keyed under
["admin","announcements"], which that prefix does not match — so the admin page
stopped refreshing on publish/delete (only updating once the 2-min staleTime
lapsed). Invalidate both keys: the admin list and the user-facing notification
tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 20b75732ac fix(notifications): home bars always refetch on mount
Relying on cache invalidation alone left the home announcement bar showing
stale (empty) data when landing on the app after publishing — the cached query
could still be within staleTime and not refetch on remount. Give the home
AnnouncementBar and NotificationStrip staleTime:0 + refetchOnMount:"always" via
a new options arg on useNotificationsList, so navigating to "/" (logo / Back to
App) always pulls current announcements without a manual reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code c47c3dc621 fix(notifications): refresh notification views when an announcement is published/deleted
useCreateAnnouncement/useDeleteAnnouncement only invalidated the admin
announcements list, so after publishing an announcement the admin's own app
(home bar, inbox, bell, unread count) kept showing stale cached data and needed
a manual reload to surface it. Invalidate the whole notifications tree instead,
so the publisher's session refreshes without relying on the live WS event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 84704a073c fix(notifications): refresh per-profile data on profile switch and announcement push
Two stale-cache bugs made announcements look account-wide in the UI even though
each profile now has its own row:

- Switching profiles (selectProfile) swapped the active-profile header but never
  reset the React Query cache, so the new profile kept showing the previous
  profile's cached notifications (a dismiss on one profile appeared to clear it
  for all). Clear the cache on switch, mirroring logout.
- The live WS hook invalidated notification lists with refetchType:"none", so a
  newly pushed announcement only moved the unread badge — the home bar/inbox/bell
  didn't update until a manual reload. Refetch active lists when the arriving
  notification is an announcement (routine notifications keep the lighter
  mark-stale behavior to avoid disrupting a list being read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e2865c070b feat(notifications): pin announcements to the top of the inbox and bell
System announcements now sort above other notifications in the inbox "All" view
and the bell dropdown, so they're seen first. Sourced from the already-loaded
list (announcements are few and recent, so they're in the loaded window) via a
stable partition — no extra query, no pagination/cursor changes, and mark-on-view
and dismiss are unchanged. The dedicated "Announcements" tab is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code fd6797d907 fix(notifications): clear hidden dismiss X and add bell dropdown dismiss
The home announcement bar / notification strip dismiss X was covered on admin
accounts by the ServerActivity widget (fixed top-5 right-5, z-40), which floats
over the top-right of the page while a scan/stream is active. On the home page
for admins, shift the dismiss button left on large screens so it clears the
widget; the bar still spans full width.

Also add a per-row dismiss X to the bell dropdown (previously only the inbox and
home bars had one) — always visible since the bell is mobile-primary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code fede2a986f feat(notifications): fan out announcements per profile
Announcements were stored as one account-wide row (profile_id NULL) with a
shared read_at, so when any profile on a household account read or dismissed an
announcement it vanished for every profile on that account.

Fan out one row per profile instead — mirroring how content and request
notifications already work — with a profile-scoped dedup ref
(announcement-<id>-<profileID>) so the unique (user_id, type, dedup_ref) index
is satisfied across a single account's profiles. Each profile now reads and
dismisses independently. Accounts with no profiles fall back to a single
account-wide row.

- Add Store.ProfileIDsForUsers; replace DismissUnreadByTypeAndRef with
  DismissAnnouncementNotifications (matches both the per-profile refs and the
  legacy account-wide ref on delete).
- Update both fake stores and the announcement fan-out / delete tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code c2a33c7e35 fix(notifications): announcement audience list overlapping the Expires field
The shadcn/Radix ScrollArea collapses to near-zero layout height when given
only a max-height (its size-full viewport can't resolve against an indefinite
height), so the user/library selection list painted outside its box and the
Expires datetime field rendered on top of it. Replace both lists with a native
max-h-40 overflow-y-auto div, which reserves correct layout height and scrolls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0506336625 feat(notifications): in-app only — remove push config UI and frontend push code
Notifications are delivered entirely over the WebSocket (bell, inbox, live
toasts, home announcement bar), so the push subsystem — which only matters for
OS banners when the app is closed — isn't needed for the current product.

- Remove the admin Push Notifications settings page + its nav entry.
- Remove the "Push on this device" control and device list from user settings.
- Delete the orphaned frontend push code: usePushDevice, queries/push,
  lib/push, lib/pushSw, useServiceWorkerNavigation, public/sw.js, and the
  service-worker registration in main.tsx.
- Drop the now-unused pushKeys query keys and PushDeviceInfo/VapidKeyPair types.
- Clarify the Announcements admin copy (header, field help, audience, expiry).

The internal/push Go backend is left in place but dormant (no UI calls it,
no provider configured), so push can be re-enabled later if mobile ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 19a0e62f0d feat(web): home announcement bar and notification strip
Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code af575d7470 docs: home notification bars design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 3b3e9ed7b9 fix(push): scope delivery claim to (profile,device); never prune on empty token
- Migration: add profile_id column to push_deliveries
- EnqueueDelivery: write d.ProfileID into the new profile_id column
- ClaimDue: SELECT pd.profile_id and JOIN user_devices on profile_id so a
  multi-profile device resolves to the exact token-bearing row, eliminating
  the tokenless-row collision and duplicate-row bugs
- Delivery struct: add ProfileID field to hold the scanned value
- worker handle(): guard against empty token before calling transport —
  skip with reason "empty token" so a data gap never triggers ResultDead
  → PruneTokenTx, which was silently wiping the real device token
- Tests: TestWorker_EmptyTokenSkippedNotDead (worker_test.go),
  TestEnqueuer_ProfileIDPropagated (enqueuer_test.go)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code d9ddfdb879 docs: notifications & push test checklist
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 3c0fd5f897 fix(web): navigate the SPA on notification click from the service worker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code d2c8be9162 chore(web): lint and format cleanup for push client files
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 9a1b4ca3d7 fix(web): reflect configured status and clear-on-save for all push fields
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 4eb8c06023 feat(web): admin push provider config page
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code dd54e90894 fix(web): fall back to absolute date for old device registrations
When a device registration date is older than 30 days, timeAgo() returns null,
silently dropping the registration date from the UI. Add a fallback to display
an absolute date using toLocaleDateString(), matching the pattern used in
Notifications.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code abb080c6ed feat(web): push on-this-device control and device list in settings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code f8b7cb0605 fix(web): guard all usePushDevice status writes by generation
enable() and disable() now each capture their own generation at the top
(after incrementing genRef) and gate every setStatus call on
`genRef.current === myGen`, matching the pattern already used by
refresh(). This closes the enable-clobbers-enable and disable-vs-enable
races the review flagged — only the most-recent in-flight call may write
a terminal status; superseded calls silently no-op.

Adds a test proving the guard: enable() stalls at the webpush-key fetch
while disable() runs to completion (gen=2); when the stale enable's
key call is then rejected, its catch set("off") is suppressed (gen=1 ≠
gen=2) and the final status remains "off" from disable, not clobbered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code ed1ab977b3 feat(web): usePushDevice subscription lifecycle hook
Implements the browser push subscription state machine (unsupported|blocked|off|on|pending) with enable(), disable(), and refresh() backed by serviceWorker.ready + pushManager. Uses a generation counter to prevent background refresh() from racing with user-initiated enable()/disable(). TDD: 5/5 tests green, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 2fe7c8683c feat(web): push query hooks and types
Add PushDeviceInfo and VapidKeyPair types, pushKeys factory, and
useWebPushPublicKey / usePushDevices / useTogglePushDevice /
usePushStatus / useGenerateVapidKeys / useSendTestPush hooks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 7d0154852d feat(web): web push service worker, registration, and helpers
Adds static sw.js (push/notificationclick/pushsubscriptionchange handlers),
tested pure logic in pushSw.ts (buildNotification/clickUrl), app-side helpers
in push.ts (urlBase64ToUint8Array/cacheVapidKey/pushSupported), and SW
registration in main.tsx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e70bccceb7 feat(push): admin VAPID keygen and self-test endpoints
Add HandleGenerateVAPIDKeys (POST /admin/push/generate-vapid-keys) and
HandleSendTestPush (POST /admin/push/test) to PushHandler, with a new
pushNotifier interface and nil-tolerant third param on NewPushHandler.
Update both router.go call sites to pass deps.NotificationsService.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 615d35c131 docs: core push web client + admin config implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bbc865ce53 docs: core push web client + admin config design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 7649b30f77 fix(push): dedupe device list by device_id; document shared-device limitation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 088250f6ea chore(push): gofmt formatting 2026-06-11 09:45:49 +02:00
Claude Code ea049c3d1f chore(push): lint cleanup (errcheck, goconst, fcm credentials deprecation)
- transport_webpush.go: wrap defer resp.Body.Close() to silence errcheck
- transport_fcm.go: replace deprecated option.WithCredentialsJSON with
  google.CredentialsFromJSONWithType + option.WithCredentials; promotes
  golang.org/x/oauth2 from indirect to direct dependency
- worker_test.go: assert RunOnce errors instead of ignoring return values
  (errcheck); replace "apns"/"fcm" literals with TransportAPNs/TransportFCM
  and "sent"/"skipped"/"failed"/"dead" with Status* consts (goconst)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0b8b67f6c4 feat(push): wire presence, store, config, enqueuer, transports, worker, task
Wire the full push subsystem in cmd/silo/main.go: presence registry
(Redis when cfg.Redis.URL set, memory fallback), push store/config/
enqueuer/transports/worker, deps.PushStore/PushConfig/PresenceRegistry.
Add PresenceRegistry to api.Dependencies and thread it into
NewEventsHandler in router.go (replaces nil placeholder from Task 4).
Register push.NewPushDeliveryTask in taskMgr and pass pushStore to
NewNotificationsRetentionTask.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bb620a16ab feat(push): purge terminal deliveries in retention task
Extend NotificationsRetentionTask with an optional PushPurger dependency
that deletes terminal push_deliveries rows older than 7 days. Defines the
PushPurger interface in the tasks package; *push.Store satisfies it.
main.go call site updated to pass nil (real store wired in Task 15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code f9c5c13a1a fix(push): require profile middleware on token register/revoke routes
PUT /notifications/push/device and DELETE /notifications/push/device call
apimw.GetProfileID which is only populated by RequireProfile middleware, but
the routes were mounted without it, causing every request to 400 with
"profile required". Apply r.With(apimw.RequireProfile) per-route to those
two endpoints only; the three user-scoped routes (devices list, toggle,
webpush-key) are intentionally left without it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 06d11fa062 feat(push): registration, device management, and status endpoints
Adds PushHandler with testable pushRegistry/pushConfigReader interfaces
and wires user push routes under /notifications/push and admin status
at /admin/push/status. All 16 handler tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code addeb47807 feat(push): fcm transport
Add FCMTransport backed by firebase.google.com/go/v4 using service-account
JSON credentials. Maps FCM errors to SendResult via fcmResult (Dead for
unregistered/invalid-argument, SoftFail+30s for quota/unavailable). Includes
unit tests for nil-success path and unconfigured soft-fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code e7d7fd87d6 feat(push): apns transport
Token-based (.p8) APNs transport with pure apnsResult helper; unit-tested reason→result mapping and unconfigured soft-fail path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 84f027aa07 feat(push): web push transport
Implements WebPushTransport via github.com/SherClockHolmes/webpush-go v1.4.0.
Handles ResultDead on malformed subscriptions and 404/410 responses, soft-fail
with Retry-After on 429, and deduplication via Topic header (base-36 notification ID).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code afa6025a2a test(push): cover parked-transport, unconfigured-skip, intermediate backoff
Add doc comment to txOutcomes.run explaining atomic-batch semantics and
duplicate-send risk. Add three new tests: SkipsUnconfiguredTransport_NoCalls
(asserts Send never called), ParkedTransportFails (two deliveries in one
RunOnce, only one Send, both failed at now+2m), and IntermediateBackoff
(attempts 1→5m, attempts 2→30m via backoff schedule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 6a5f61dd39 feat(push): delivery worker, store-backed queue, scheduled task
Implements the push delivery outcome state machine (Worker/RunOnce/handle),
store-backed queue adapter (storeQueue/txOutcomes over ClaimDue tx), and
PushDeliveryTask (15 s interval, drains up to 20 batches). TDD: 5 unit tests
cover sent, skip-present-user, skip-unconfigured, soft-fail backoff→dead,
and hard-fail dead-token paths — all passing, no DB required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 0b96771964 feat(push): encrypted provider config and status
Register push provider credential keys in SensitiveSettingKeys so they
are encrypted at rest via EncryptedSettingsRepo. Add internal/push/Config
that reads per-transport credentials on demand and exposes a Status report
of configured booleans without leaking secrets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code 497fd6ee71 feat(push): enqueuer and notifications hook
Adds PushEnqueuer interface as a seam in the notifications package (no
import-cycle: push imports notifications, not the other way around), and
implements Enqueuer in internal/push that fans out per-device delivery
rows with a configurable presence-grace not_before. Service.Create() calls
the enqueuer after a successful insert+publish; nil-safe so existing tests
are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
Claude Code bde9d563ab feat(push): pgx store for devices and deliveries 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code f25cbb27aa feat(push): domain types and transport interface 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code bf9746ce32 feat(presence): track live connections in events websocket
Wire the presence Registry into EventsHandler so each WebSocket
connection registers/releases the user's live presence, and the
ping-loop refreshes the TTL for Redis-backed registries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 1fa00b784f feat(presence): redis-overlay registry
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 4d3f366dd2 feat(presence): in-process connection registry
Adds MemoryRegistry, a thread-safe refcount-based Registry that tracks
live realtime connections per user for suppressing push notifications.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code d5791ed541 feat(push): user_devices push columns and push_deliveries table 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 0a52b47e4e docs: core push notifications server implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 418b7347ca docs: core push notifications design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 75b4e1c72f chore(web): prettier formatting for notifications files 2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 6e817706f1 feat(web): admin announcements management page
Adds AdminAnnouncements page with table (title, audience summary, dates,
delete action), create dialog with audience mode selection (Everyone /
Specific users / Libraries), optional expiry, and validation gating;
wires Megaphone nav item in AdminSidebar and /admin/announcements route
in App.tsx. 6 TDD tests covering audience summaries, submit-disabled
states, payload shapes, delete confirm, and empty state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:49 +02:00
rxwatcherandClaude Code 404f782ee2 feat(web): optimistic notification preference toggles 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ab7c081ecc feat(web): notification preference toggles in settings
Adds NotificationsSettings page with per-category toggle rows wired to
useNotificationPreferences / useSetNotificationPreferences. Admin alerts
row is hidden for non-admin users. Nav entry (Bell icon) placed between
Appearance and Library & Data sections. Route registered in App.tsx.

Tests: NotificationsSettings.test.tsx (jsdom / createRoot style matching
sibling settings pages) — 6 cases covering admin/non-admin row counts,
content toggle mutation payload, digest default-off state, loading
disabled state, and footer note.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 01f828a11a fix(web): inbox relative-time copy and per-tab mark-on-view
- Parameterize timeAgo() with optional prefix (defaults to "Added")
  - Call with empty string to omit prefix: timeAgo(date, "")
  - Compose relative-time strings to avoid leading space when prefix=""
  - ItemCard callers use default; Notifications uses "" for cleaner UI

- Reset mark-on-view flag on category tab change
  - Each tab's first page now marks its unread rows independently
  - prevTabRef tracks activeTab; reset markedRef when it changes
  - Test: switch tab with unread item → markRead.mutate called again

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a79eb85ca0 feat(web): notifications inbox page with TDD
Adds /notifications route and Notifications page with category tabs (All,
Requests, Content, Announcements, System, Admin-for-admins), mark-all-read
header button, mark-on-view (first-page unread ids, ref-guarded), per-row
dismiss button, Load-more pagination, and 7 passing vitest tests (red→green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 5711771c9e test(web): real zero-count assertion for sidebar badge
Replace module-level mock with hoisted mutable-state pattern to properly
test the zero-count branch. The test now sets mockUnreadCount.data = 0
and verifies badge absence (checking for the absence of "rounded-full px-0.5"
class), ensuring the condition {unreadCount > 0 && <badge />} is tested
structurally rather than just semantically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code cdbfcbe9af feat(web): notifications sidebar item with unread badge
Add a Notifications nav item after History in the Your Stuff section of
AppSidebar, with a floating badge showing the unread count from
useUnreadCount. Badge is capped at 99+ and hidden when count is zero.
The bell icon is wrapped in a relative span so the badge stays visible
in collapsed sidebar mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ea92eb6c69 fix(web): notifications list staleTime; document bell flag constraint
Add staleTime configuration to useNotificationsList to prevent unnecessary refetches when the popover is opened multiple times. Update NotificationBell module comment to clarify that the bell must be a single instance — a second instance would conflict over the module flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code f7866a78d5 feat(web): notification bell with live badge in mobile header
Adds NotificationBell popover component with unread badge, per-item list,
mark-all-read, and dropdown-open toast suppression wired via isNotificationDropdownOpen();
mounts the bell and useNotificationsLive() once in Layout's mobile header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 59cfb01e71 feat(web): live notifications wiring (toasts, unread cache)
Adds useNotificationsLive hook (TDD): subscribes to the notifications WS
channel, toasts on notification.created for the active profile (with child
profile category filtering), bumps the unread-count cache optimistically,
and seeds the count from the channel snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 88f0228968 feat(web): notification query hooks
Add notificationKeys factory to keys.ts and create notifications.ts with
all nine TanStack Query hooks: infinite list, unread count (raw shape for
live cache writes), mark-read, dismiss, preferences CRUD, and admin
announcements CRUD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 4be3f498cf feat(web): notification API types and event channel 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code ac4a44c62c docs: core notifications web implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a724ac9c55 chore: gofmt notifications handler tests 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code abca92eec5 docs: record implementation-time findings in spec future work
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 30f59accd7 chore(notifications): lint cleanup in new files
- Fix gofmt issues in service_test.go and notifications_retention_test.go
- Remove useless type assertion in matcher_content.go (m.content already is ContentResolver)
- Replace "catalog.item.changed" string literal with TypeCatalogItemChanged constant
- Extract "metadata_updated" to const metadataUpdated in matcher_content.go
- Use TypeJobFailed constant in matcher_admin.go
- Extract "scan.failed" and "/admin/tasks" to consts eventScanFailed and adminTasksLink
- All tests pass; no golangci-lint errors in new matcher files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code c24df8b3bd feat(notifications): wire store, service, materializer, tasks
Constructs notificationsStore/notificationsSvc/notificationsMaterializer
immediately after the deps literal in main.go (where pool and eventsHub
are both in scope), starts the materializer, and sets
deps.NotificationsService so the router registers all notifications
routes.

Calls SetEventsHub on the API-facing requestSvc (router.go) and the
task-manager reconcile service (main.go) so request lifecycle events
reach the materializer.

Registers NewNotificationsRetentionTask and NewNotificationsDigestTask
with the task manager alongside the other system tasks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1c823aa507 fix(notifications): digest fires daily at fixed time
Changes digest trigger from interval-based (24h) to daily pattern firing at 08:00.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code c7079000a8 feat(notifications): digest and retention scheduled tasks
Add RunDailyDigest service method, DigestSubscribers/AddedItemCountForUser
store methods, and NotificationsRetentionTask/NotificationsDigestTask with
full TDD coverage including dedup, zero-count skip, and per-user error
continuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 9ce999a61d feat(notifications): system notification on password change
Add CreateSystem helper to notifications.Service that swallows errors
so security notices never break the calling flow. Wire it into
AdminHandler.HandleUpdateUser so a system.password_changed notification
is sent to the target user whenever a password is included in an admin
update. Add service-level and handler-level tests covering both the
happy path and the store-failure/no-password cases.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a638494540 feat(notifications): expose notifications and requests channels over WS with unread snapshot
Grants ChannelNotifications and ChannelRequests to all roles, adds
snapshotForChannel cases (unread_count via notifications service, {} for
requests), and wires NotificationsService into EventsHandler nil-tolerantly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code b5b3cccc18 fix(notifications): fail closed on child-profile resolution; cursor test
- childSafe() now returns true on ForUser or GetProfile errors, treating
  unresolvable profiles as children to prevent restricted categories from
  leaking on errors. Updated doc comment to clarify fail-closed behavior.
- Added TestNotificationsList_ResolverErrorFailsClosed to verify resolver
  errors trigger ChildSafe=true.
- Added TestNotificationsList_NextCursorSet to verify next_cursor is set
  correctly when limit=2 with exactly 2 items (next_cursor == ID of last item).
- Added cross-reference comments on limit clamps in both handlers and store
  files to ensure they stay in sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 2e9435d69e fix(notifications): resolve child profiles for category filtering
Add a profileResolver interface and childSafe helper to NotificationsHandler
so HandleList and HandleUnreadCount pass ChildSafe=true when the active profile
has IsChild=true. Provider is nil-tolerant (ChildSafe=false when absent).
Wire deps.UserStoreProvider in both router construction sites and add
TestNotificationsList_ChildProfileFiltered (plus adult/nil-resolver variants).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code f4fe1f7d94 feat(notifications): REST API for inbox, preferences, announcements
Adds service pass-throughs (List, UnreadCount, MarkRead, MarkAllRead,
Dismiss, GetPreferences, SetPreferences, ListAnnouncements), a
NotificationsHandler with cursor-paginated list, unread count, mark
read/all, dismiss, get/put preferences, and admin announcement CRUD,
wired into the chi router under /notifications (user, profile-scoped)
and /admin/announcements (RequireAdmin). TDD: 15 handler tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1094ecb523 test(notifications): scan.failed coverage and admin alert assertions
Add TestAdminMatcher_ScanFailedNotifiesAdmins to verify scan.failed events
trigger admin notifications with proper event type, body content, link, and
dedup ref. Enhance TestAdminMatcher_JobFailedNotifiesAdmins with assertions on
Body containing job_type and job id, Link format, and DedupRef prefix. Add
clarifying comment on ChannelTasks entry explaining why it exists despite no
current task.failed event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 4f80010de5 feat(notifications): admin failure alerts with hourly throttle
Replaces the matchAdmin stub with a real implementation that fans job.failed
and scan.failed events out to all admin users, throttled to one alert per
(event, source-kind) per clock-hour via a day+hour dedup_ref bucket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 229379e0a6 fix(notifications): day-bucket dedup, payload library authority, interest indexes
- Change hour-bucket dedup format to day-bucket (20060102) so refresh-driven
  repeats within a 48h window collapse while next-week episodes still notify.
- Use payload.LibraryID as authoritative library when non-zero; resolver's
  libraryID is now a fallback for events that omit the field.
- Pass raw seriesID as separate $4 parameter for the in-progress leg guard
  so a standalone item can never join episode rows on its own content id.
- New migration (20260610172401_notifications_interest_indexes) adds
  user_watchlist_media_item_idx and user_favorites_media_item_idx to support
  the InterestedProfiles access pattern efficiently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 954d26360f fix(notifications): gate content notifications on item recency
Extend ContentResolver.ItemContext to return the catalog row's created_at
and suppress content.added notifications when time.Since(createdAt) exceeds
the new 48-hour newItemWindow constant.  This prevents periodic library
refresh jobs (library_refresh.go, item_refresh.go) from re-notifying users
about years-old watchlist items on every metadata_updated event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 09f202f9f7 feat(notifications): content matcher with interest resolution and burst dedup
Implements ContentResolver interface (ItemContext + InterestedProfiles),
matchContent handler keyed on catalog.item.changed/metadata_updated with
hour-bucketed dedup_ref for series burst collapse, and ContentResolverRepo
backed by pgxpool joining media_items/episodes/user_watchlist/user_favorites/
user_watch_progress. No item_added change value exists in codebase; plan
assumption corrected to metadata_updated. In-progress leg retained using
user_watch_progress(updated_at). TDD: 4 new tests, red phase confirmed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 833ce25178 fix(notifications): scope notifications.send contract to plugins channel
Only envelopes on the ChannelPlugins channel are now processed by the
notifications.send matcher. Core code already calls Service.Create directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code a047519230 feat(notifications): notifications.send plugin contract
Implements matchSend in the Materializer so plugins can create
notifications by publishing a notifications.send event on ChannelPlugins.
Accepts bare and prefixed (plugin.<id>.*) event names; drops malformed
payloads and rejects announcements from plugin callers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 7c65c7b4dc fix(notifications): self-event guard, race-safe Stop, complete event mirror
- Add self-event guard in Materializer.handle() to prevent reacting to own notifications output
- Make Materializer.Stop() idempotent/race-safe using sync.Once
- Complete RequestEventData mirror by adding Quality field for payload completeness
- Add Title assertion in TestRequestMatcher_ApprovedCreatesNotification for coverage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 98ed20444a feat(notifications): materializer with isolated matchers; request matcher
Adds Materializer (hub consumer with panic-isolated per-matcher dispatch) and
matchRequest (request.approved/declined/completed/failed → notifications rows).
Stubs matchSend, matchContent, matchAdmin for later tasks. TDD: tests written
first and confirmed failing before implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code e59343968d fix(requests): publish lifecycle events from aggregate request state
- Reconcile per-target loop: publish request.completed/request.failed
  based on updatedReq.Status/Outcome (aggregate), not the individual
  target's newStatus. Partial failure (one target fails while a sibling
  is still downloading) no longer fires a spurious request.failed event.
- Approve soft-fail: extract publishApprovalOutcome helper that emits
  request.failed when Outcome==OutcomeFailed, else request.approved.
  Approve and CreateRequest auto-approve both use it, so a submission
  failure (no backend configured, no targets returned) publishes
  request.failed instead of request.approved.
- Update TestApprove_PublishesRequestApproved to use a working router
  so it actually exercises the request.approved branch.
- Add TestReconcile_PartialTargetFailureDoesNotPublishFailed,
  TestApprove_SoftFailPublishesRequestFailed, and
  TestCreateRequest_AutoApprovePublishesApproved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 0add71fbd8 feat(requests): publish lifecycle events on ChannelRequests
Adds RequestEventPayload, SetEventsHub, and publishRequestEvent to the
requests service. Publishes request.submitted, request.approved,
request.declined, request.cancelled, request.completed, and
request.failed at each real transition site. Nil hub is a no-op.
Tests cover the approved publish path and the nil-hub no-panic contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 8d5e0fec85 fix(notifications): exact-match announcement dismissal, fan-out ordering, error wrapping
Replace prefix LIKE dismissal with exact dedup_ref match, resolve audience
before InsertAnnouncement to avoid orphaned rows on error, add proper error
wrapping throughout, and make fakeStore assign incrementing IDs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 423e2c02ae feat(notifications): announcement publish with audience fan-out
Add AllEnabledUserIDs to Store interface and Repository, implement
PublishAnnouncement with audience resolution (all/user_ids/library_ids),
deduplication, and expiry propagation; add DeleteAnnouncement that
dismisses unread notification rows on removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 58fbc8f23c fix(notifications): WarnContext + digest opt-in tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 45995f659c feat(notifications): create service with prefs, dedup, hub publish
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 1756f54e21 fix(notifications): escape LIKE prefix, guard empty MarkRead, document store contracts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 5150fec3e3 feat(notifications): pgx store
Implements Store interface and Repository backed by pgxpool for the
notifications inbox: insert with dedup, list/unread-count with cursor
pagination, mark-read, dismiss, preferences upsert, announcements CRUD,
bulk-dismiss by type-ref, purge-old, and admin/library user-ID helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code bf880b292b feat(notifications): domain types 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code db22615874 feat(events): add notifications and requests channels 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code cfbd95e31c feat(notifications): add notifications, preferences, announcements tables 2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 70e8190348 docs: core notifications server implementation plan
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
rxwatcherandClaude Code 9626fd8f13 docs: core notifications system design spec
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:45:48 +02:00
0eb87fa0f5 fix(player): flip ASS subtitle delay sign to match VTT semantics (#130)
JASSUB renders the ASS event matching video.currentTime + timeOffset,
so an event at source time S appears at video time S - timeOffset.
Adding the user delay to the offset therefore showed ASS subtitles
EARLIER on positive delay, while the VTT path (and the subtitle menu's
"later" label) shifts cues later. At +2000ms an ASS track diverged from
an SRT track by a full 4 seconds.

Subtract the delay instead, correct the comment to state the actual
JASSUB render equation, and add tests covering the constructed
timeOffset for positive/negative delays plus the live-instance update
path.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:28:46 -04:00
fadd8ff456 feat(player): native PGS subtitle rendering via libpgs (#129)
* feat(playback): add IsPGS helper and sup streaming extract path

PGS (Blu-ray bitmap) subtitle tracks can be copied losslessly into a .sup
elementary stream for client-side rendering, so they no longer have to be
burned in. streamExtractOutput maps PGS to (copy, sup), and the seek/-t
windowing now skips PGS like ASS: both formats are fetched once and
consumed whole by their client-side renderers.

This also fixes a pre-existing truncation bug: the -t duration cap was
applied unconditionally, cutting embedded ASS extracts off at the default
600s window even though the ASS client fetches the full track.

Extract the ffmpeg argument construction into streamExtractArgs for
testability, following the buildFFmpegArgs pattern.

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

* feat(api): expose PGS subtitle tracks as .sup stream URLs

PGS tracks were filtered out of /playback/start subtitle_urls entirely,
so the web player showed no subtitles for PGS-only files (#34). Include
them with a .sup URL extension; DVD/DVB bitmap tracks stay hidden since
they still have no non-burn-in delivery path.

HandleSubtitle streams the full PGS track as application/octet-stream.
The seek/duration window is forced to zero for sup: subtitleSeekPosition
falls back to the session's last reported position even without a
?position= query, which would otherwise start the extract mid-file. The
proxy-node subtitle handler gets the same sup branch, streaming ffmpeg
output directly instead of buffering like its text paths.

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

* refactor(player): consolidate subtitle codec helpers into subtitleCodecs.ts

Rename assSubtitles.ts to subtitleCodecs.ts — the module already labeled
every codec, not just ASS — and add isPGSCodec/isBitmapCodec. Replace the
duplicated BITMAP_CODECS set in SubtitleTranslateModal with the shared
helper so codec lists live in one place.

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

* feat(player): native PGS subtitle rendering via libpgs

Render PGS subtitle tracks client-side instead of leaving them
unavailable (#34). usePGSSubtitles mirrors the JASSUB hook: when a PGS
track is active it lazy-loads libpgs, which fetches the .sup stream in a
worker, decodes display sets progressively as bytes arrive, and draws
them onto a canvas positioned over the video.

The renderer looks up the display set at currentTime + timeOffset, so
the HLS stream origin adds and the user-facing delay subtracts — a
positive delay shows subtitles later, matching VTT semantics. Offset
changes apply through the timeOffset setter without recreating the
renderer; track switches, PiP detach, and unmount dispose it.

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

* feat(player): prefer text over bitmap tracks in subtitle auto-select

With PGS tracks now listed, an earlier PGS track would win auto-select
over a later same-language SRT/ASS track. Deprioritize bitmap codecs
within the same source tier — text is lighter to render and styleable —
while a PGS track still wins when it is the only language match, and
forced-PGS auto-select now works.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:18:29 -04:00
a17529fd6f feat(config): live admin settings + truthful restart-required banner (#128)
* feat(nodeconfig): harden config watcher for integrated-mode use

- RequestReload(): non-blocking, coalescing reload nudge that runs on the
  poll goroutine, so concurrent requests can never swap a stale snapshot
  over a newer one (unlike ForceReload from request handlers)
- Skip OnChange callbacks when the reloaded config is deep-equal to the
  previous one, so the 60s poll doesn't fire rebuild/log callbacks on
  no-op reloads
- Add RedisURL to BootstrapOverrides; previously a reload clobbered an
  env-provided Redis URL in the live config
- Split reload into fetchSettings/applySettings and add unit tests

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

* feat(config): hot-reload config watcher in integrated mode

Start nodeconfig.Watcher in integrated/api mode (previously only proxy/
transcode worker modes hot-reloaded). Expose the live config to the API
and jellycompat routers via func-typed LiveConfig/OnConfigChange fields
with nil fallbacks to the startup snapshot, and wire the admin settings
update hook to RequestReload so same-process changes apply immediately
even without Redis.

No consumer reads the live config yet — conversions land separately.

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

* feat(admin): truthful restart-required banner for settings saves

The settings UI showed 'restart required' after every save regardless of
the key. The backend now classifies each key via a central registry
(internal/config/restart_keys.go) and PUT /admin/settings/{key} reports
restart_required per key; useSettingsForm only raises the banner when a
saved key actually needs a restart (and keeps it raised until restart).

The registry is conservative: every currently startup-frozen key is
marked restart-required; subsequent hot-reload conversions shrink it.
Settings read live from the settings repo (branding, overlays, markers,
download.*, ...) default to no-restart. DownloadSettings/OverlaySettings
drop their hardcoded restartRequired={false} special-casing.

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

* feat(logging): hot-reload server.log_level and server.log_quiet

Share one slog.LevelVar across the handler chain and make
logfilter.Handler's quiet-prefix list an atomic pointer shared with
WithAttrs/WithGroup clones (New previously returned the inner handler
unwrapped when the quiet list was empty, leaving nothing to update).
The integrated-mode config watcher now applies both settings live;
their keys leave the restart-required registry.

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

* feat(auth): hot-reload access/refresh token expiries

JWTService stores expiries as atomics with a SetExpiries hook; all three
instances (main API, ABS compat, jellycompat) re-apply them on config
reload. Applies to newly issued tokens; outstanding tokens keep their
original expiry. The JWT secret stays fixed for the process lifetime.

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

* feat(playback): read transcode config live at session start

The playback and stream handlers pull ffmpeg path / hwaccel / transcode
dir from the live config when starting a transcode or extracting
subtitles, instead of values frozen at router construction. Each session
snapshots the config once so its output dir and binary stay consistent.

Also fixes a real bug: playback.hw_device was parsed into the config but
never wired into the integrated-mode handler, so local transcodes always
ran with an empty HWDevice while transcode nodes honored it.

playback.transcode_dir leaves the restart-required registry (the handler
is its only consumer); ffmpeg_path/hw_accel stay restart-required until
scanner/chapterthumbs/audiobook consumers convert.

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

* feat(jellycompat): read compat identity settings live per request

System/Auth handlers take a config provider instead of the startup
snapshot, so jellyfin_compat.public_url, .server_name, and
.emulated_server_version apply without restart. server_id stays
restart-required (generate-once, baked into the resource mapper), as do
the session-store TTLs.

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

* feat(scanner,metadata,mdblist): hot-reload worker pools and API key

scanner.workers, matcher.workers/batch_size, metadata.cache_images, and
mdblist.api_key convert to atomic fields with setters wired to the
config watcher. Worker counts apply on the next scan/match cycle (the
loops read them per cycle); the MDBList key applies to the next request.

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

* feat(ai): hot-reload AI connection, models, toggles, and quotas

The shared llm.Client holds its config behind an atomic pointer
(UpdateConfig; each request snapshots once), and the subtitle/metadata
AI services gain UpdateConfig plus setters on the translator (batching)
and Whisper transcriber (ffmpeg path, chunk seconds). The router derives
their configs from shared helpers used both at construction and in
OnConfigChange callbacks, re-evaluating the chat-only-gateway transcribe
guard on each reload and warning only when it newly fires.

Everything on the AI Services page now applies without restart except
ai.max_concurrent_jobs (fixed-capacity dispatch semaphore).

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

* fix(playback): wire transcode_enabled; remove dead playback/scanner knobs

playback.transcode_enabled was parsed into the config but the resolver
always received a hardcoded true — the admin toggle did nothing. It now
reads the live config per playback start, so disabling transcodes
applies without restart.

Remove settings that were wired to nothing so 'save + restart' stops
pretending: playback.allow_hevc_encoding (resolver field never
assigned), playback.transcode_ahead_segments and
playback.segment_duration (parsed, never consumed — segment duration is
per-session from the client), scanner.file_removal_grace (DeleteMissing
is never called). UI fields removed and the config struct fields pruned
so they don't resurrect; YAML import still tolerates the legacy keys.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:25:07 -04: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
Quick 9a240edb17 Merge branch 'main' of https://github.com/Silo-Server/silo-server 2026-06-08 15:27:13 -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
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 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
736 changed files with 68854 additions and 3005 deletions
+2
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
+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
+136 -21
View File
@@ -47,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,7 +69,9 @@ import (
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/pluginhost"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/proxy"
"github.com/Silo-Server/silo-server/internal/push"
"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"
@@ -119,7 +122,7 @@ func resolvePluginCacheDir() string {
return filepath.Join(os.TempDir(), "silo-plugins")
}
func buildBaseHandler(format string, level slog.Level) slog.Handler {
func buildBaseHandler(format string, level slog.Leveler) slog.Handler {
opts := &slog.HandlerOptions{Level: level}
if strings.EqualFold(format, "json") {
return slog.NewJSONHandler(os.Stderr, opts)
@@ -127,6 +130,19 @@ func buildBaseHandler(format string, level slog.Level) slog.Handler {
return slog.NewTextHandler(os.Stderr, opts)
}
func parseLogLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func mustGetSetting(store interface {
Get(context.Context, string) (string, error)
}, ctx context.Context, key, fallback string) string {
@@ -143,8 +159,7 @@ func configureOperationalLogging(
settingsRepo catalog.SettingsStore,
redisCfg config.RedisConfig,
logStreamHub *logstream.Hub,
baseHandler slog.Handler,
logQuiet string,
filteredHandler slog.Handler,
nodeID string,
) (opslog.Writer, *opslog.Repo, *partman.Manager) {
if err := opslog.SeedDefaults(ctx, settingsRepo); err != nil {
@@ -181,7 +196,6 @@ func configureOperationalLogging(
opsCaptureLevel = slog.LevelError
}
filteredHandler := logfilter.New(baseHandler, logQuiet)
slog.SetDefault(slog.New(opslog.NewHandler(filteredHandler, operationalWriter, opsCaptureLevel, nodeID)))
return operationalWriter, opslog.NewRepo(pool), opsPM
@@ -456,20 +470,14 @@ func main() {
log.Fatalf("config validation: %v", err)
}
// Step 10: Configure log level
var slogLevel slog.Level
switch strings.ToLower(cfg.Server.LogLevel) {
case "debug":
slogLevel = slog.LevelDebug
case "warn", "warning":
slogLevel = slog.LevelWarn
case "error":
slogLevel = slog.LevelError
default:
slogLevel = slog.LevelInfo
}
baseHandler := buildBaseHandler(cfg.Server.LogFormat, slogLevel)
slog.SetDefault(slog.New(logfilter.New(baseHandler, cfg.Server.LogQuiet)))
// Step 10: Configure log level. The level var and quiet filter are
// shared with the operational-logging handler chain and hot-reloaded by
// the config watcher in integrated mode.
logLevelVar := new(slog.LevelVar)
logLevelVar.Set(parseLogLevel(cfg.Server.LogLevel))
baseHandler := buildBaseHandler(cfg.Server.LogFormat, logLevelVar)
quietFilter := logfilter.New(baseHandler, cfg.Server.LogQuiet)
slog.SetDefault(slog.New(quietFilter))
mode := cfg.Server.Mode
maybeApplyPostgresTuning(ctx, pool, cfg.Database.MaxConnections, mode)
@@ -490,8 +498,19 @@ func main() {
log.Fatalf("realtime hub start: %v", err)
}
eventsHub := realtimeHub.EventsHub()
var presenceRegistry presence.Registry
if cfg.Redis.URL != "" {
if rc, err := cache.NewRedisClient(cfg.Redis); err == nil {
presenceRegistry = presence.NewRedisRegistry(rc, 60*time.Second)
} else {
slog.Warn("presence: redis unavailable, falling back to in-memory registry", "error", err)
}
}
if presenceRegistry == nil {
presenceRegistry = presence.NewMemoryRegistry()
}
scanRegistry := evt.NewScanRegistry()
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, baseHandler, cfg.Server.LogQuiet, nodeID)
operationalWriter, opsRepo, opsPM := configureOperationalLogging(appCtx, pool, settingsRepo, cfg.Redis, logStreamHub, quietFilter, nodeID)
defer func() {
if err := eventBus.Close(); err != nil {
slog.Warn("event bus close error", "error", err)
@@ -511,6 +530,7 @@ func main() {
Mode: cfg.Server.Mode,
DatabaseURL: cfg.Database.URL,
JFListen: cfg.JellyfinCompat.Listen,
RedisURL: bc.RedisURL,
}
watcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, bootstrap)
if err := watcher.Start(appCtx); err != nil {
@@ -552,6 +572,29 @@ func main() {
return
}
// Hot-reload config watcher for integrated/api mode. Reloads on
// EventSettingsChanged (Redis) with a 60s poll fallback, so settings
// changes apply without restart even on Redis-less deployments. The
// watcher's config supersedes the startup snapshot from here on.
configWatcher := nodeconfig.NewWatcher(pool, dataCipher, eventBus, nodeconfig.BootstrapOverrides{
Listen: bc.Listen,
Mode: bc.Mode,
DatabaseURL: bc.DatabaseURL,
JFListen: bc.JFListen,
RedisURL: bc.RedisURL,
})
if err := configWatcher.Start(appCtx); err != nil {
log.Fatalf("config watcher start: %v", err)
}
cfg = configWatcher.Config()
// Apply server.log_level / server.log_quiet changes live. Both feed the
// shared level var and quiet filter inside the default logger chain.
configWatcher.OnChange(func(_, updated *config.Config) {
logLevelVar.Set(parseLogLevel(updated.Server.LogLevel))
quietFilter.SetQuiet(updated.Server.LogQuiet)
})
// Determine which components to initialize based on mode.
needsS3 := mode == "integrated" || mode == "api"
needsScanner := mode == "integrated" || mode == "api"
@@ -567,6 +610,8 @@ func main() {
deps := api.Dependencies{
Config: cfg,
LiveConfig: configWatcher.Config,
OnConfigChange: configWatcher.OnChange,
BootstrapSensitiveConfigured: bootstrapSensitiveConfigured,
BootstrapSensitiveValues: bootstrapSensitiveValues,
AppContext: appCtx,
@@ -587,7 +632,37 @@ func main() {
restartReqCh <- struct{}{}
return nil
},
OnServerSettingUpdated: func(_ context.Context, _, _ string) {
// Nudge the hot-reload watcher so same-process settings changes
// apply immediately even without Redis (the event bus is a no-op
// then, leaving only the 60s poll).
configWatcher.RequestReload()
},
}
// Notifications subsystem: store → service → materializer.
// pool is always non-nil here (proxy/transcode returned early above).
notificationsStore := notifications.NewRepository(pool)
notificationsSvc := notifications.NewService(notificationsStore, eventsHub)
notificationsMaterializer := notifications.NewMaterializer(eventsHub, notificationsSvc, notifications.NewContentResolverRepo(pool))
if err := notificationsMaterializer.Start(appCtx); err != nil {
log.Fatalf("notifications materializer start: %v", err)
}
deps.NotificationsService = notificationsSvc
deps.PresenceRegistry = presenceRegistry
// Push subsystem: store → config → enqueuer → transports → worker.
pushStore := push.NewStore(pool)
pushConfig := push.NewConfig(settingsRepo)
notificationsSvc.SetPushEnqueuer(push.NewEnqueuer(pushStore, 30*time.Second, time.Now))
pushTransports := []push.Transport{
push.NewWebPushTransport(pushConfig.WebPush),
push.NewAPNsTransport(pushConfig.APNs),
push.NewFCMTransport(pushConfig.FCM),
}
pushWorker := push.NewWorker(push.NewStoreQueue(pushStore), presenceRegistry, pushTransports, time.Now)
deps.PushStore = pushStore
deps.PushConfig = pushConfig
audiobooksService := audiobooks.New(&audiobooksSettingsAdapter{repo: settingsRepo})
absCompatEnabled, err := audiobooksService.ABSCompatEnabled(appCtx)
if err != nil {
@@ -669,6 +744,7 @@ func main() {
deps.ProxyPool = proxyPool
deps.TranscodePool = transcodePool
deps.NodePlanner = nodepool.NewPlanner(proxyPool, transcodePool)
healthChecker := nodepool.NewHealthChecker(proxyPool, transcodePool, nodeRepo)
healthChecker.Start(appCtx)
@@ -705,6 +781,9 @@ func main() {
ffprobePath := scanner.FFprobePathFromFFmpeg(cfg.Playback.FFmpegPath)
s := scanner.NewScanner(fileRepo, ffprobePath, deps.S3Public, cfg.Scanner.Workers, cfg.Scanner.EmptyTrashAfterScan)
configWatcher.OnChange(func(_, updated *config.Config) {
s.SetWorkers(updated.Scanner.Workers)
})
deps.Scanner = s
deps.ProbeEnsurer = scanner.NewPlaybackProbeEnsurer(fileRepo, ffprobePath, 10*time.Second)
slog.Info("scanner initialized")
@@ -941,6 +1020,7 @@ func main() {
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)
@@ -1022,6 +1102,14 @@ func main() {
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.
@@ -1033,6 +1121,9 @@ func main() {
imageCacher := imagecache.New(deps.S3Public)
metadataService.SetImageCacher(imageCacher)
metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages)
configWatcher.OnChange(func(_, updated *config.Config) {
metadataService.SetAutoCacheImages(updated.Metadata.CacheImages)
})
if deps.Scanner != nil {
deps.Scanner.SetImageCacher(imageCacher)
}
@@ -1044,9 +1135,16 @@ func main() {
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)
mwForReload := matchWorker
configWatcher.OnChange(func(_, updated *config.Config) {
mwForReload.SetConcurrency(updated.Matcher.Workers, updated.Matcher.BatchSize)
})
matchWorker.SetRealtimeHub(deps.RealtimeHub)
if movieQueueRepo != nil {
matchWorker.SetMovieFileClaimer(movieQueueRepo)
@@ -1481,6 +1579,10 @@ func main() {
deps.UserCollectionSync = userSync
deps.UserCollectionScheduler = userCollectionScheduler
deps.MDBListClient = mdblist.NewClient(cfg.MDBListAPIKey, nil)
mdblistForReload := deps.MDBListClient
configWatcher.OnChange(func(_, updated *config.Config) {
mdblistForReload.SetAPIKey(updated.MDBListAPIKey)
})
}
}
@@ -1540,6 +1642,7 @@ func main() {
),
)
requestReconcileSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
requestReconcileSvc.SetEventsHub(eventsHub)
api.AttachRequestRouter(requestReconcileSvc, pluginService)
if userStoreProvider != nil {
reconcileResolver := access.NewResolver(
@@ -1586,6 +1689,9 @@ func main() {
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 {
@@ -1595,6 +1701,9 @@ func main() {
taskMgr.Register(pluginTask)
}
}
taskMgr.Register(tasks.NewNotificationsRetentionTask(notificationsStore, pushStore))
taskMgr.Register(tasks.NewNotificationsDigestTask(notificationsSvc))
taskMgr.Register(push.NewPushDeliveryTask(pushWorker))
taskMgr.Start(appCtx)
defer taskMgr.Stop()
@@ -1614,6 +1723,9 @@ func main() {
cfg.Auth.AccessTokenExpiry,
cfg.Auth.RefreshTokenExpiry,
)
configWatcher.OnChange(func(_, updated *config.Config) {
absJWTService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
})
absAuthSvc := auth.NewService(
auth.NewLocalProvider(absUserRepo, absSessionRepo),
absJWTService,
@@ -1878,11 +1990,11 @@ func main() {
if (mode == "integrated" || mode == "api") && cfg.JellyfinCompat.Listen != "" {
compatDeps := jellycompat.Dependencies{
Config: cfg,
LiveConfig: configWatcher.Config,
DB: deps.DB,
SecretCipher: dataCipher,
ClientIPResolver: ipResolver,
ProxyPool: deps.ProxyPool,
TranscodePool: deps.TranscodePool,
NodePlanner: deps.NodePlanner,
JWTSecret: cfg.Auth.JWTSecret,
RecWorker: recWorker,
}
@@ -1946,6 +2058,9 @@ func main() {
cfg.Auth.AccessTokenExpiry,
cfg.Auth.RefreshTokenExpiry,
)
configWatcher.OnChange(func(_, updated *config.Config) {
jwtService.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
})
provider := auth.NewLocalProvider(userRepo, sessionRepo)
compatDeps.AuthService = auth.NewService(provider, jwtService, sessionRepo, userRepo, nil, nil, nil)
@@ -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,100 @@
# Notifications & Push — Test Checklist
A repeatable regression pass for the core notifications + push subsystems. Organized by layer: automated (fast, run anytime), server pipeline (API, no browser), real browser (human-in-the-loop), and not-yet-testable (future work). Run the automated layer on every change; run the browser layer before shipping a user-facing change.
Specs/plans this exercises live under `docs/superpowers/specs|plans/2026-06-10-core-notifications-*` and `*-push-*`.
---
## 1. Automated tests (fast, no live instance)
Run from the repo root. `export GOWORK=off` first (or run inside the worktree).
```bash
# Go — materializer, matchers, worker state machine, request events, presence, handlers, tasks
GOWORK=off go test ./internal/notifications/ ./internal/push/ ./internal/presence/ \
./internal/requests/ ./internal/api/handlers/ ./internal/taskmanager/tasks/
# Web — bell, inbox, settings, admin page, usePushDevice, SW logic
cd web && pnpm exec vitest run
```
Expected: all green. The Go suites cover the outcome state machine (sent/skipped/failed-with-backoff/dead + dead-token prune), child-suppression SQL, the `notifications.send` plugin contract, request lifecycle publishing, and presence refcounting. The web suites cover the UI and the `usePushDevice` lifecycle/race-guard.
What this does NOT cover: real WebSocket delivery, real push banners, real migrations against production-shaped data. Use layers 2–3 for those.
---
## 2. Server pipeline (API — proves source → materializer → enqueuer → worker, no browser)
Needs an admin API key and the instance reachable (on silo-new the API is `http://localhost:8090`; create a temp key, delete it after — see the snippet at the bottom). Observe results in the `notifications` and `push_deliveries` tables.
For each notification **source**, trigger it and confirm a row lands (and, if push is configured + a device registered, a `push_deliveries` row):
- [ ] **Announcement** — `POST /api/v1/admin/announcements {title, body, audience:{all:true}}` → confirm a `notifications` row (category `announcement`) for each targeted user. ⚠️ `audience:{all:true}` fans out to **every** user's inbox — use `{user_ids:[<your id>]}` when testing on a live instance.
- [ ] **Request lifecycle** — submit a media request, then `POST /api/v1/requests/{id}/approve` (admin) → confirm a `request.approved` notification for the requester. Decline/complete/fail similarly produce `request.declined` / `request.completed` / `request.failed`.
- [ ] **System** — admin user-update that changes a password → confirm a `system.password_changed` notification for that user.
- [ ] **Content** — add an item to a profile's watchlist/favorites, then add/scan a matching library item → confirm a `content.added` notification for that profile (respects the 48h recency gate + per-day burst dedup).
- [ ] **Admin alert** — force a failed job/scan → confirm an `admin`-category notification fans out to admin users only (throttled 1/hour per source).
- [ ] **Digest** — opt a user into `content_digest`, then run the `notifications_digest` task (or wait for 08:00) → one digest notification if there were additions.
- [ ] **Push test** — `POST /api/v1/admin/push/test` → 202; a `system.test_push` notification + a `push_deliveries` row per the caller's registered push devices; the worker transitions it within ~30–45s (grace + tick). With a real subscription it sends; with a bogus one it fails-with-backoff (still proves the worker path).
Push config endpoints to verify:
- [ ] `GET /api/v1/admin/push/status` → `{apns,fcm,webpush}` booleans (flip after saving creds).
- [ ] `POST /api/v1/admin/push/generate-vapid-keys` → `{vapid_public, vapid_private}` (admin only).
- [ ] `GET /api/v1/notifications/push/webpush-key` → the configured public key (empty when unconfigured).
- [ ] `PUT /api/v1/notifications/push/device {transport:"webpush", token:<subscription JSON>}` with `X-Profile-Id` → 204 (400 without the profile header — the route is profile-gated).
- [ ] `GET /api/v1/notifications/push/devices` → lists the user's push devices; `PUT .../devices/{id} {enabled}` toggles.
---
## 3. Real browser (the only way to validate the UI + actual push banner)
Open the web app over a secure context (`localhost` or HTTPS — service workers require it). On silo-new that's `http://localhost:8090` (tunnel if remote).
In-app notifications:
- [ ] **Bell + badge** — top of the app; unread count badge.
- [ ] **Dropdown** — latest notifications, "Mark all read", deep links.
- [ ] **Inbox page** (`/notifications`) — category tabs, dismiss, mark-on-view, "Load more".
- [ ] **Live toast** — with the app open, trigger a notification (announcement to yourself, or the push-test) → a toast appears in real time (via the WebSocket), unless that category is muted.
- [ ] **Preferences** — Settings → Notifications → per-category mute toggles persist; "Daily digest" defaults off; the admin category shows only for admins.
Push (the headline manual test):
- [ ] **Admin config** — Admin → Settings → Push Notifications → "Generate keys", save the VAPID public/private + a `mailto:` subject → the Web Push badge flips to "Ready".
- [ ] **Enable on this device** — Settings → Notifications → "Push on this device" → grant the browser permission prompt → a device row appears in the list.
- [ ] **Background banner** — **close the app tab**, then `POST /admin/push/test` (or trigger any notification) → within ~30–45s an OS notification banner appears.
- [ ] **Click-through** — click the banner → the app opens/focuses and navigates to the notification's deep link.
- [ ] **Presence gating** — with the app tab open and focused, the same notification should NOT push a redundant banner (you saw it in-app); after closing the tab for >30s it does.
- [ ] **Per-device toggle / revoke** — disable the device in the list → no more banners; re-enable restores.
---
## 4. Not yet testable (tracked future work)
- **APNs (Apple) / FCM (Android)** — no mobile clients exist to register tokens, so these transports are only unit-tested (pure result-mappers). End-to-end needs silo-apple / silo-android (Phase 3 mobile).
- **Plugin `notifications.send`** — the ebook/audiobook request plugins don't publish it yet. Until those one-line `PublishEvent("notifications.send", …)` calls are added, plugin-sourced notifications can't be exercised end to end (the contract itself is unit-tested).
- **iOS Safari web push** — only works for an installed PWA, not a normal tab; the client degrades to an "unsupported" state otherwise.
---
## Appendix — temp admin key on silo-new (create, use, delete)
```bash
# create
KEY="sa_$(openssl rand -hex 32)"
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"INSERT INTO api_keys (user_id,label,api_key) VALUES (1,'manual-test','$KEY');"
echo "$KEY" # use as: Authorization: Bearer $KEY against http://localhost:8090/api/v1/...
# observe
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"SELECT id,category,type,title FROM notifications ORDER BY id DESC LIMIT 10;"
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"SELECT status,transport,attempts,left(last_error,40) FROM push_deliveries ORDER BY id DESC LIMIT 10;"
# clean up (delete the key + any test artifacts you created)
docker exec silo-server-postgres-1 psql -U silo -d silo -tAc \
"DELETE FROM api_keys WHERE label='manual-test';"
```
Always remove test notifications/devices you create on a real user's account; `push_deliveries` cascades when its `notifications` row is deleted.
@@ -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,809 @@
# Core Notifications — Web UI Implementation Plan (Phase 2 of 3)
> **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:** Notifications UI in the silo-server web app: unread badge (sidebar + mobile header bell with dropdown), live toasts from the events WebSocket, an inbox page, per-category preference toggles in settings, and an admin announcements page.
**Architecture:** A `notifications` query-hook module talks to the Phase-1 REST API; one live-wiring hook subscribes to the `notifications` WS channel via the existing `RealtimeEventsProvider` ref-counted `useEventChannel`, firing sonner toasts and patching React Query caches. UI components consume only the hooks. Desktop surface is an `AppSidebar` item with badge (no desktop top bar exists); mobile surface is a bell + popover in the `Layout` header.
**Tech Stack:** React 19, React Router v7, TanStack Query, sonner, lucide-react, shadcn/ui primitives (Popover, Switch, Dialog), Vitest + RTL.
**Spec:** `docs/superpowers/specs/2026-06-10-core-notifications-design.md` (Web UI section). Server API shipped in Phase 1.
Commands assume the repository root is the cwd; web commands run in `web/`. `pnpm` is the package manager.
---
## Server API contract (frozen, Phase 1)
The `api()` client prefixes `/api/v1` and adds auth + `X-Profile-Id` headers automatically.
```
GET /notifications?unread=1&category=&cursor=&limit= → {"items": Notification[], "next_cursor": number|null}
GET /notifications/unread-count → {"count": number}
POST /notifications/read {"ids":[...]} | {"all":true} → 204
POST /notifications/{id}/dismiss → 204 | 404
GET /notifications/preferences → {"preferences":[{"category","enabled"}]}
PUT /notifications/preferences {"preferences":[...]} → 204 | 400
GET /admin/announcements → {"items": Announcement[]}
POST /admin/announcements {title, body, audience:{all|user_ids|library_ids}, expires_at?} → 201
DELETE /admin/announcements/{id} → 204 | 404
```
Notification JSON: `{id, user_id, profile_id?, category, type, title, body, link?, item_id?, created_at, read_at?, expires_at?}`. Categories: `request | content | announcement | system | admin`; preference categories additionally `content_digest` (never a notification category). WS: channel `"notifications"`, event `"notification.created"`, `data` = the Notification; subscribe snapshot `data` = `{"unread_count": number}`.
## File map
| File | Action | Responsibility |
|---|---|---|
| `web/src/api/types.ts` | modify | `AppNotification`, `NotificationPreference`, `Announcement`, `AnnouncementAudience` types; add `"notifications"` to `EventChannel` |
| `web/src/hooks/queries/keys.ts` | modify | `notificationKeys` factory |
| `web/src/hooks/queries/notifications.ts` | create | all query/mutation hooks (user + admin) |
| `web/src/hooks/useNotificationsLive.ts` | create | WS subscription → toast + cache patching |
| `web/src/hooks/useNotificationsLive.test.ts` | create | live-hook tests |
| `web/src/components/NotificationBell.tsx` | create | badge + popover dropdown (mobile header) |
| `web/src/components/NotificationBell.test.tsx` | create | bell tests |
| `web/src/components/Layout.tsx` | modify | mount bell in mobile header; mount live hook |
| `web/src/components/AppSidebar.tsx` | modify | Notifications nav item with badge |
| `web/src/pages/Notifications.tsx` | create | inbox page |
| `web/src/pages/Notifications.test.tsx` | create | inbox tests |
| `web/src/pages/settings/NotificationsSettings.tsx` | create | preference toggles |
| `web/src/pages/SettingsLayout.tsx` | modify | nav section entry |
| `web/src/pages/AdminAnnouncements.tsx` | create | admin CRUD page |
| `web/src/components/AdminSidebar.tsx` | modify | announcements nav item |
| `web/src/App.tsx` | modify | routes: `/notifications`, admin `announcements`, settings `notifications` |
---
### Task 1: API types and event channel
**Files:**
- Modify: `web/src/api/types.ts` (EventChannel union ~line 2198; types appended near other domain types)
- [ ] **Step 1: Add the types**
Add `"notifications"` to the `EventChannel` union. Then add (named `AppNotification` to avoid colliding with the DOM `Notification` type — verify no existing `AppNotification` symbol first with grep):
```typescript
export type NotificationCategory = "request" | "content" | "announcement" | "system" | "admin";
export type NotificationPreferenceCategory = NotificationCategory | "content_digest";
export interface AppNotification {
id: number;
user_id: number;
profile_id?: string;
category: NotificationCategory;
type: string;
title: string;
body: string;
link?: string;
item_id?: string;
created_at: string;
read_at?: string;
expires_at?: string;
}
export interface NotificationListResponse {
items: AppNotification[] | null;
next_cursor: number | null;
}
export interface NotificationPreference {
category: NotificationPreferenceCategory;
enabled: boolean;
}
export interface AnnouncementAudience {
all?: boolean;
user_ids?: number[];
library_ids?: number[];
}
export interface Announcement {
id: number;
title: string;
body: string;
audience: AnnouncementAudience;
created_by?: number;
created_at: string;
expires_at?: string;
}
```
- [ ] **Step 2: Typecheck and commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
git add src/api/types.ts && git commit -m "feat(web): notification API types and event channel"
```
(If the repo's typecheck command differs — check package.json scripts for `typecheck`/`build` — use that; `pnpm run build` also typechecks via `tsc -b`.)
---
### Task 2: Query keys and hooks
**Files:**
- Modify: `web/src/hooks/queries/keys.ts`
- Create: `web/src/hooks/queries/notifications.ts`
- [ ] **Step 1: Keys** — follow the existing factory style in keys.ts:
```typescript
export const notificationKeys = {
all: ["notifications"] as const,
list: (filters: { unread?: boolean; category?: string }) =>
[...notificationKeys.all, "list", filters] as const,
unreadCount: () => [...notificationKeys.all, "unread-count"] as const,
preferences: () => [...notificationKeys.all, "preferences"] as const,
announcements: () => ["admin", "announcements"] as const,
};
```
- [ ] **Step 2: Hooks** — `web/src/hooks/queries/notifications.ts`. Mirror the conventions in `web/src/hooks/queries/admin/users.ts` (api client import path, toast usage, invalidation):
```typescript
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import type {
Announcement,
AnnouncementAudience,
AppNotification,
NotificationListResponse,
NotificationPreference,
} from "@/api/types";
import { notificationKeys } from "@/hooks/queries/keys";
const PAGE_SIZE = 25;
export function useNotificationsList(filters: { unread?: boolean; category?: string } = {}) {
return useInfiniteQuery({
queryKey: notificationKeys.list(filters),
queryFn: async ({ pageParam }) => {
const params = new URLSearchParams();
params.set("limit", String(PAGE_SIZE));
if (filters.unread) params.set("unread", "1");
if (filters.category) params.set("category", filters.category);
if (pageParam) params.set("cursor", String(pageParam));
return api<NotificationListResponse>(`/notifications?${params.toString()}`);
},
initialPageParam: 0,
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
}
export function useUnreadCount() {
return useQuery({
queryKey: notificationKeys.unreadCount(),
queryFn: () => api<{ count: number }>("/notifications/unread-count"),
select: (d) => d.count,
});
}
export function useMarkRead() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: { ids?: number[]; all?: boolean }) =>
api("/notifications/read", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to mark read");
},
});
}
export function useDismissNotification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api(`/notifications/${id}/dismiss`, { method: "POST" }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.all });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to dismiss");
},
});
}
export function useNotificationPreferences() {
return useQuery({
queryKey: notificationKeys.preferences(),
queryFn: () => api<{ preferences: NotificationPreference[] }>("/notifications/preferences"),
select: (d) => d.preferences,
});
}
export function useSetNotificationPreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (preferences: NotificationPreference[]) =>
api("/notifications/preferences", { method: "PUT", body: JSON.stringify({ preferences }) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: notificationKeys.preferences() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to save preferences");
},
});
}
export function useAnnouncements() {
return useQuery({
queryKey: notificationKeys.announcements(),
queryFn: () => api<{ items: Announcement[] | null }>("/admin/announcements"),
select: (d) => d.items ?? [],
});
}
export function useCreateAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: {
title: string;
body: string;
audience: AnnouncementAudience;
expires_at?: string;
}) => api<Announcement>("/admin/announcements", { method: "POST", body: JSON.stringify(body) }),
onSuccess: () => {
toast.success("Announcement published");
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to publish");
},
});
}
export function useDeleteAnnouncement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => api(`/admin/announcements/${id}`, { method: "DELETE" }),
onSuccess: () => {
toast.success("Announcement deleted");
void queryClient.invalidateQueries({ queryKey: notificationKeys.announcements() });
},
onError: (err) => {
toast.error(err instanceof Error ? err.message : "Failed to delete");
},
});
}
```
Check `api()`'s actual error behavior (does it throw on non-2xx with a message? read `web/src/api/client.ts:343-380`) and the import alias style (`@/` vs relative — match the queries directory's existing imports).
- [ ] **Step 3: Typecheck + commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -5
git add src/hooks/queries/ && git commit -m "feat(web): notification query hooks"
```
---
### Task 3: Live wiring hook (WS → toasts + cache)
**Files:**
- Create: `web/src/hooks/useNotificationsLive.ts`
- Test: `web/src/hooks/useNotificationsLive.test.ts`
- [ ] **Step 1: Write the failing test**
Mock `useEventChannel` (from `@/components/realtimeEventsContext`) to capture handlers, mock `sonner`'s toast, wrap in a QueryClientProvider. Follow the mocking style of `web/src/components/RealtimeEventsProvider.test.tsx` (vi.mock + vi.hoisted).
```typescript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const { capturedHandlers, toastMock } = vi.hoisted(() => ({
capturedHandlers: {} as { onEvent?: (m: unknown) => void; onSnapshot?: (m: unknown) => void },
toastMock: vi.fn(),
}));
vi.mock("@/components/realtimeEventsContext", () => ({
useEventChannel: (_channel: string, handlers: typeof capturedHandlers) => {
capturedHandlers.onEvent = handlers?.onEvent;
capturedHandlers.onSnapshot = handlers?.onSnapshot;
},
}));
vi.mock("sonner", () => ({ toast: toastMock }));
const profileState = vi.hoisted(() => ({ profile: { id: "p-1", is_child: false } }));
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => profileState }));
import { useNotificationsLive } from "./useNotificationsLive";
import { notificationKeys } from "@/hooks/queries/keys";
function setup() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(notificationKeys.unreadCount(), { count: 1 });
renderHook(() => useNotificationsLive(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
return queryClient;
}
function frame(data: object) {
return { type: "event", channel: "notifications", event: "notification.created", data };
}
describe("useNotificationsLive", () => {
it("toasts and bumps unread count on notification.created", () => {
const qc = setup();
capturedHandlers.onEvent?.(
frame({ id: 5, category: "request", title: "Request approved", body: "Dune", profile_id: "p-1" }),
);
expect(toastMock).toHaveBeenCalledWith("Request approved", expect.anything());
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 2 });
});
it("ignores frames for another profile", () => {
setup();
capturedHandlers.onEvent?.(
frame({ id: 6, category: "content", title: "New content", body: "", profile_id: "p-other" }),
);
expect(toastMock).not.toHaveBeenCalled();
});
it("suppresses restricted categories on child profiles", () => {
profileState.profile = { id: "p-1", is_child: true };
setup();
capturedHandlers.onEvent?.(
frame({ id: 7, category: "request", title: "Request approved", body: "", profile_id: undefined }),
);
expect(toastMock).not.toHaveBeenCalled();
profileState.profile = { id: "p-1", is_child: false };
});
it("seeds unread count from snapshot", () => {
const qc = setup();
capturedHandlers.onSnapshot?.({ type: "snapshot", channel: "notifications", data: { unread_count: 9 } });
expect(qc.getQueryData(notificationKeys.unreadCount())).toEqual({ count: 9 });
});
});
```
(The test file needs `.tsx` extension if JSX is used in the wrapper — name it `useNotificationsLive.test.tsx`.)
- [ ] **Step 2: Run to verify failure**
```bash
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
```
Expected: FAIL — module `./useNotificationsLive` not found.
- [ ] **Step 3: Implement**
```typescript
import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { AppNotification } from "@/api/types";
import { useEventChannel } from "@/components/realtimeEventsContext";
import { notificationKeys } from "@/hooks/queries/keys";
import { useAuth } from "@/hooks/useAuth";
const CHILD_HIDDEN_CATEGORIES = new Set(["request", "system", "admin"]);
/**
* Subscribes to the notifications WS channel: fires a toast for live
* notifications addressed to the active profile, keeps the unread-count
* cache current, and seeds it from the subscribe snapshot.
* Mount exactly once inside the authenticated layout.
*/
export function useNotificationsLive() {
const queryClient = useQueryClient();
const { profile } = useAuth();
const profileId = profile?.id;
const isChild = profile?.is_child ?? false;
const onEvent = useCallback(
(message: unknown) => {
const frame = message as { event?: string; data?: AppNotification };
if (frame.event !== "notification.created" || !frame.data) return;
const n = frame.data;
if (n.profile_id && n.profile_id !== profileId) return;
if (isChild && CHILD_HIDDEN_CATEGORIES.has(n.category)) return;
queryClient.setQueryData<{ count: number }>(notificationKeys.unreadCount(), (prev) => ({
count: (prev?.count ?? 0) + 1,
}));
void queryClient.invalidateQueries({
queryKey: [...notificationKeys.all, "list"],
refetchType: "none",
});
toast(n.title, { description: n.body });
},
[queryClient, profileId, isChild],
);
const onSnapshot = useCallback(
(message: unknown) => {
const frame = message as { data?: { unread_count?: number } };
if (typeof frame.data?.unread_count !== "number") return;
queryClient.setQueryData(notificationKeys.unreadCount(), { count: frame.data.unread_count });
},
[queryClient],
);
useEventChannel("notifications", { onEvent, onSnapshot });
}
```
NOTE on handler identity: `useEventChannel`'s effect re-subscribes when the handlers object changes — pass a stable object. Check how existing consumers handle this (grep `useEventChannel(` usages); if they memoize the handlers object, do the same: `useMemo(() => ({ onEvent, onSnapshot }), [onEvent, onSnapshot])`.
The toast call shape (`toast(title, { description })`) must match the test's `expect.anything()` second arg. If `useAuth` returns a different profile field shape, adapt (read `useAuth.tsx`).
- [ ] **Step 4: Run tests until green**
```bash
cd web && pnpm exec vitest run src/hooks/useNotificationsLive.test.tsx 2>&1 | tail -5
```
- [ ] **Step 5: Commit**
```bash
git add web/src/hooks/useNotificationsLive.* && git commit -m "feat(web): live notifications wiring (toasts, unread cache)"
```
---
### Task 4: NotificationBell component + Layout mount
**Files:**
- Create: `web/src/components/NotificationBell.tsx`
- Test: `web/src/components/NotificationBell.test.tsx`
- Modify: `web/src/components/Layout.tsx` (mobile header actions ~lines 151-168; also mount `useNotificationsLive()` once in the Layout component body)
- [ ] **Step 1: Failing test**
```typescript
// Mocks: @/hooks/queries/notifications (useUnreadCount → 3, useNotificationsList → two items,
// useMarkRead → mutate spy), react-router (MemoryRouter wrapper).
// Asserts: badge renders "3"; opening the popover (click bell button, role="button" name /notifications/i)
// shows both item titles; "Mark all read" calls markRead with {all:true};
// item with link renders an anchor/Link to that link.
```
Write it concretely following an existing component test's structure (see `web/src/components/AppSidebar.test.tsx` for provider wrapping conventions). Badge cap: 4+ digits render "99+" when count > 99 — include one assertion with count 120.
- [ ] **Step 2: Implement NotificationBell**
```tsx
import { Bell } from "lucide-react";
import { Link } from "react-router";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
useMarkRead,
useNotificationsList,
useUnreadCount,
} from "@/hooks/queries/notifications";
export function NotificationBell() {
const { data: count = 0 } = useUnreadCount();
const list = useNotificationsList({});
const markRead = useMarkRead();
const items = (list.data?.pages ?? []).flatMap((p) => p.items ?? []).slice(0, 10);
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Notifications" className="relative">
<Bell className="h-5 w-5" />
{count > 0 && (
<Badge className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px]">
{count > 99 ? "99+" : count}
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-sm font-medium">Notifications</span>
<Button
variant="ghost"
size="sm"
disabled={count === 0}
onClick={() => markRead.mutate({ all: true })}
>
Mark all read
</Button>
</div>
<ul className="max-h-96 list-none overflow-y-auto">
{items.length === 0 && (
<li className="text-muted-foreground px-3 py-6 text-center text-sm">
You're all caught up
</li>
)}
{items.map((n) => (
<li key={n.id} className={n.read_at ? "opacity-60" : ""}>
<Link to={n.link ?? "/notifications"} className="hover:bg-accent block px-3 py-2">
<div className="text-sm font-medium">{n.title}</div>
{n.body && <div className="text-muted-foreground truncate text-xs">{n.body}</div>}
</Link>
</li>
))}
</ul>
<div className="border-t px-3 py-2 text-center">
<Link to="/notifications" className="text-primary text-sm">
View all
</Link>
</div>
</PopoverContent>
</Popover>
);
}
```
Verify the ui primitives exist with these export names (`badge.tsx`, `button.tsx`, `popover.tsx` under `components/ui/`); adjust imports/props to the actual variants. Match `react-router` import name used elsewhere (the repo uses `react-router` v7 — check whether components import `Link` from `react-router` or `react-router-dom`).
Dropdown-open toast suppression (spec requirement): export a tiny module-level flag from NotificationBell —
```tsx
let bellOpenFlag = false;
export function isNotificationDropdownOpen() {
return bellOpenFlag;
}
// in the component: <Popover onOpenChange={(open) => { bellOpenFlag = open; }}>
```
and in `useNotificationsLive`'s onEvent, before toasting: `if (isNotificationDropdownOpen()) return;` (cache updates still run — only the toast is suppressed; the open dropdown shows the new item via the list invalidation). Add one live-hook test: set the flag via a popover-open simulation or export a test-only setter, assert no toast while open.
- [ ] **Step 3: Mount in Layout**
In `Layout.tsx`: call `useNotificationsLive()` at the top of the component (single mount point for the live hook), and render `<NotificationBell />` in the mobile header's right-side action cluster (next to the search icon button).
- [ ] **Step 4: Tests green, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/components/NotificationBell.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/components/NotificationBell.* src/components/Layout.tsx src/hooks/
git commit -m "feat(web): notification bell with live badge in mobile header"
```
---
### Task 5: Sidebar nav item with badge (desktop)
**Files:**
- Modify: `web/src/components/AppSidebar.tsx` (nav list — insert after the History item, ~line 645)
- Test: extend `web/src/components/AppSidebar.test.tsx`
- [ ] **Step 1: Add the nav item**
Follow the exact ViewTransitionLink pattern of the History item (active indicator span, icon sizing, `SidebarLabel`):
```tsx
<li>
<ViewTransitionLink
to="/notifications"
onClick={onNavigate}
className={navLinkClass("/notifications")}
aria-current={isActive("/notifications") ? "page" : undefined}
>
{isActive("/notifications") && (
<span
className="absolute top-1/2 left-0 h-[18px] w-[3px] -translate-y-1/2 rounded-r-sm"
style={{ background: "var(--primary)" }}
/>
)}
<Bell className="h-[18px] w-[18px] shrink-0" />
{unreadCount > 0 && (
<span className="bg-primary text-primary-foreground absolute top-1 left-6 flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px]">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<SidebarLabel show={showLabels}>Notifications</SidebarLabel>
</ViewTransitionLink>
</li>
```
`const { data: unreadCount = 0 } = useUnreadCount();` at the top of AppSidebar (import from `@/hooks/queries/notifications`; `Bell` from lucide-react — add to the existing lucide import list at line ~56). Badge positioning: verify visually against the collapsed-sidebar state (`showLabels` false) — the absolute chip must remain visible on the icon in both states; tweak offsets if the icon container differs.
- [ ] **Step 2: Test**
Extend AppSidebar.test.tsx: mock `@/hooks/queries/notifications` `useUnreadCount` to return `{ data: 5 }`; assert the rendered sidebar contains a link to `/notifications` showing "5". Follow the file's existing mock arrangement for other hooks.
- [ ] **Step 3: Run, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/components/AppSidebar.test.tsx 2>&1 | tail -4
git add src/components/AppSidebar.* && git commit -m "feat(web): notifications sidebar item with unread badge"
```
---
### Task 6: Inbox page + route
**Files:**
- Create: `web/src/pages/Notifications.tsx`
- Test: `web/src/pages/Notifications.test.tsx`
- Modify: `web/src/App.tsx` (user routes block ~line 331+)
- [ ] **Step 1: Failing test**
Mock the notifications query hooks. Assertions:
- renders rows for items across categories with title/body/relative time;
- category filter tabs (All, Requests, Content, Announcements, System — admin tab only when `useAuth().user?.role === "admin"`) change the `category` filter passed to `useNotificationsList` (capture via mock);
- unread rows visually distinct (e.g. a `data-unread="true"` attribute — assert on it);
- per-row dismiss button calls `useDismissNotification().mutate(id)`;
- "Load more" appears when `hasNextPage` and calls `fetchNextPage`;
- on mount, visible unread ids are marked read: `useMarkRead().mutate({ ids: [...] })` (assert called once with the unread ids from page one).
- [ ] **Step 2: Implement**
Page structure (use the repo's page conventions — check an existing simple page like the History/Collections page for header/container classes):
```tsx
const CATEGORY_TABS: { key: string; label: string; adminOnly?: boolean }[] = [
{ key: "", label: "All" },
{ key: "request", label: "Requests" },
{ key: "content", label: "Content" },
{ key: "announcement", label: "Announcements" },
{ key: "system", label: "System" },
{ key: "admin", label: "Admin", adminOnly: true },
];
```
- `useState` for active category; `useNotificationsList({ category })` (omit when "");
- mark-on-view effect:
```tsx
const firstPage = list.data?.pages?.[0];
const markRead = useMarkRead();
const markedRef = useRef(false);
useEffect(() => {
if (markedRef.current || !firstPage) return;
const unreadIds = (firstPage.items ?? []).filter((n) => !n.read_at).map((n) => n.id);
if (unreadIds.length > 0) markRead.mutate({ ids: unreadIds });
markedRef.current = true;
}, [firstPage, markRead]);
```
- rows: title, body, `created_at` rendered with the repo's existing relative-time helper (grep `formatDistance\|relativeTime\|timeAgo` in web/src/lib — use what exists; if nothing, use `Intl.RelativeTimeFormat` inline helper in the page);
- dismiss button (X icon) per row; "Mark all read" header button; "Load more" via `fetchNextPage` when `hasNextPage`;
- empty state per tab.
- [ ] **Step 3: Route**
In `App.tsx`, register alongside the other authenticated user pages (match the guard used by e.g. the favorites/history page — likely inside the layout route with profile required):
```tsx
<Route path="/notifications" element={<Notifications />} />
```
(Lazy-load if neighboring pages are lazy — match the file's import pattern.)
- [ ] **Step 4: Tests, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/pages/Notifications.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/pages/Notifications.* src/App.tsx && git commit -m "feat(web): notifications inbox page"
```
---
### Task 7: Settings preferences section
**Files:**
- Create: `web/src/pages/settings/NotificationsSettings.tsx`
- Modify: `web/src/pages/SettingsLayout.tsx` (NAV_SECTIONS), `web/src/App.tsx` (settings child route)
- [ ] **Step 1: Implement the settings page**
Template: `web/src/pages/settings/PlaybackSettings.tsx` (SettingsGroup/SettingRow/Switch composition). Content:
```tsx
const PREF_LABELS: Record<string, { label: string; description: string; adminOnly?: boolean }> = {
request: { label: "Requests", description: "Updates when your requests are approved, declined, or ready" },
content: { label: "New content", description: "New arrivals matching your watchlist, favorites, and shows in progress" },
system: { label: "Account & security", description: "Password changes and account notices" },
admin: { label: "Admin alerts", description: "Failed jobs and scans (admins only)", adminOnly: true },
content_digest: { label: "Daily digest", description: "One daily summary of everything added to your libraries" },
};
```
- `useNotificationPreferences()` for current values; render a `Switch` per category in PREF_LABELS order, skipping `adminOnly` entries when `useAuth().user?.role !== "admin"`;
- on toggle: `useSetNotificationPreferences().mutate([{ category, enabled }])` (single-category PUT — server upserts per row);
- note under the group: "Announcements from your server admin can't be turned off."
- [ ] **Step 2: Register in SettingsLayout NAV_SECTIONS**
Add a "Notifications" section (icon `Bell`) pointing at path `notifications`, following the structure at SettingsLayout.tsx lines 39-100. Register the child route in App.tsx where other settings pages are routed (find `PlaybackSettings` route registration and mirror it).
- [ ] **Step 3: Test**
Extend `web/src/pages/SettingsLayout.test.tsx` if it asserts nav items (check); add `NotificationsSettings` rendering test only if sibling settings pages have tests (check `ls web/src/pages/settings/*.test.tsx`) — match local convention; if none exist, rely on the layout test + typecheck (note this in the commit message).
- [ ] **Step 4: Typecheck, test, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/SettingsLayout.test.tsx 2>&1 | tail -4
git add src/pages/settings/NotificationsSettings.tsx src/pages/SettingsLayout.tsx src/App.tsx
git commit -m "feat(web): notification preference toggles in settings"
```
---
### Task 8: Admin announcements page
**Files:**
- Create: `web/src/pages/AdminAnnouncements.tsx`
- Modify: `web/src/components/AdminSidebar.tsx` (nav items ~lines 81-134), `web/src/App.tsx` (admin routes ~lines 363-398)
- [ ] **Step 1: Implement the page**
Template: `web/src/pages/AdminUsers.tsx` for the table + dialog patterns (read it first; reuse its Table/Dialog/Button composition). Features:
- Table of announcements: title, audience summary ("Everyone" / "N users" / "Libraries: a, b"), created date, expires date, delete button with confirm dialog ("Deleting dismisses unread copies from user inboxes.").
- "New announcement" button → Dialog with: title (Input, required), body (Textarea), audience radio group (Everyone / Specific users / Libraries) — for "Specific users" a multi-select of users (check how AdminUsers fetches the user list — reuse that hook; if a multi-select primitive is missing, comma-separated ids input is NOT acceptable — use checkboxes in a scrollable list), for Libraries the same against the libraries list hook (grep `useLibraries` in hooks/queries) — optional `expires_at` datetime-local input.
- Submit via `useCreateAnnouncement()`; disable submit until title non-empty and a valid audience selection exists.
- [ ] **Step 2: Nav + route**
AdminSidebar: add item (icon `Megaphone` from lucide-react) labeled "Announcements" → `/admin/announcements`, following the existing items' structure. App.tsx admin routes: `<Route path="announcements" element={<AdminAnnouncements />} />`.
- [ ] **Step 3: Test**
`web/src/pages/AdminAnnouncements.test.tsx` — mock the three admin hooks; assert: table renders rows incl. audience summaries; create dialog validates (submit disabled with empty title); submitting with Everyone calls `useCreateAnnouncement().mutate` with `{audience: {all: true}, ...}`; delete confirm calls `useDeleteAnnouncement().mutate(id)`.
- [ ] **Step 4: Tests, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/pages/AdminAnnouncements.test.tsx 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add src/pages/AdminAnnouncements.* src/components/AdminSidebar.tsx src/App.tsx
git commit -m "feat(web): admin announcements management page"
```
---
### Task 9: Full verification
- [ ] **Step 1: Web checks**
```bash
cd web && pnpm run lint && pnpm run format:check && pnpm exec vitest run 2>&1 | tail -6 && pnpm run build 2>&1 | tail -4
```
All green; build also refreshes `web/dist` so the Go embed keeps working.
- [ ] **Step 2: Backend still green**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ 2>&1 | tail -2
make verify-local-paths
```
- [ ] **Step 3: E2E smoke against the dev stack**
```bash
docker compose up -d postgres redis
GOWORK=off go build -o /tmp/silo-web-e2e ./cmd/silo && /tmp/silo-web-e2e &
```
In a browser (or report for manual check): log in, confirm the sidebar Notifications item + mobile bell render; create an announcement in /admin/announcements; verify toast fires live (WS) and badge increments without reload; open inbox, rows mark read, badge clears; toggle a preference off and verify it persists after reload.
- [ ] **Step 4: Commit anything outstanding; report**
---
## Self-review notes
- Desktop bell+dropdown from the spec adapted to a sidebar nav item + badge (no desktop top bar exists in this app); the dropdown ships on the mobile header bell. Spec intent (always-visible unread indicator + 1-click access) preserved.
- Toast child/profile filtering is client-side by necessity (WS frames are user-scoped; see spec Future work on claims enrichment) — `useNotificationsLive` suppresses other-profile and child-restricted frames.
- `content_digest` rows arrive as category `content` (server stores digest as content/content.digest) — the inbox Content tab includes them; the preferences page exposes the separate `content_digest` toggle. No UI filters by `content_digest` category (final server review noted this trap).
- Mark-on-view marks only page one's unread ids once per mount — deliberate; deeper pages mark via "Mark all read".
- Several steps include verify-greps (ui primitive names, Link import source, relative-time helper, settings test conventions, multi-select availability) — implementers must align with what exists rather than invent.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,838 @@
# Core Push — Web Client & Admin Config — 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:** Make push operable end-to-end from the browser: a Web Push client (service worker, subscribe, receive, per-device control, device list) plus an admin provider-config page (APNs/FCM/WebPush credentials, VAPID keygen, readiness), on top of the frozen push API.
**Architecture:** Five web units + two small Go endpoints. A static service worker receives pushes; a `usePushDevice` hook owns the browser subscription lifecycle; the existing notifications-settings page gains a push control + device list; a new admin page reuses the existing encrypted-settings write path for credentials. Two net-new server endpoints: VAPID keygen and a self-targeted test push.
**Tech Stack:** React 19, TanStack Query, sonner, the existing api client (auto device/profile headers), browser Push API / Service Worker / Notifications API, Go (chi), webpush-go (already a dep). Vitest + RTL.
**Spec:** `docs/superpowers/specs/2026-06-10-core-push-web-client-design.md`. The push registration API + delivery worker are frozen (Phase-3-server).
Commands assume the repository root is the cwd; web commands run in `web/` with `pnpm`. Go: `export GOWORK=off`.
## Frozen API the client calls
```
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
PUT /api/v1/notifications/push/device {transport, token} → 204 (needs X-Profile-Id; api client sends it)
DELETE /api/v1/notifications/push/device → 204
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,transport,push_enabled,registered_at}]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
PUT /api/v1/admin/settings/{key} {value} → writes an encrypted push.* key (existing)
GET /api/v1/admin/settings/sensitive-status → {configured:[keys]} (existing)
```
Net-new this plan: `POST /api/v1/admin/push/generate-vapid-keys` and `POST /api/v1/admin/push/test`.
## File map
| File | Action | Responsibility |
|---|---|---|
| `internal/api/handlers/push.go` | modify | add `HandleGenerateVAPIDKeys`, `HandleSendTestPush`; add a `notifier` dep |
| `internal/api/router.go` | modify | register the two admin routes |
| `cmd/silo/main.go` | modify | pass the notifications service into the push handler |
| `web/public/sw.js` | create | service worker: push, notificationclick, pushsubscriptionchange |
| `web/src/lib/pushSw.ts` | create | pure SW logic (notification options, click URL) — tested; mirrored in sw.js |
| `web/src/lib/push.ts` | create | `urlBase64ToUint8Array`, VAPID-key cache helpers |
| `web/src/main.tsx` | modify | register `/sw.js` when supported |
| `web/src/api/types.ts` | modify | `PushDeviceInfo`, `VapidKeyPair` |
| `web/src/hooks/queries/push.ts` | create | query/mutation hooks (webpush-key, devices, toggle, register/revoke, admin keygen/test) |
| `web/src/hooks/usePushDevice.ts` | create | browser subscription lifecycle state machine |
| `web/src/hooks/usePushDevice.test.tsx` | create | hook tests |
| `web/src/pages/settings/NotificationsSettings.tsx` | modify | push control + device list |
| `web/src/pages/admin-settings/PushSettings.tsx` | create | three credential cards + keygen + test |
| `web/src/pages/admin-settings/AdminSettingsLayout.tsx` | modify | nav entry |
---
### Task 1: Server endpoints — VAPID keygen + test push
**Files:**
- Modify: `internal/api/handlers/push.go`, `internal/api/router.go`, `cmd/silo/main.go`
- Test: `internal/api/handlers/push_test.go`
- [ ] **Step 1: Add a notifier dep + the two handlers**
In `push.go`, add a narrow interface and a field on `PushHandler`:
```go
// pushNotifier creates a notification for the calling user (test push).
type pushNotifier interface {
CreateSystem(ctx context.Context, userID int, typ, title, body string)
}
```
Add `notifier pushNotifier` to `PushHandler` and a parameter to `NewPushHandler` (nil-tolerant — test-push 503s if absent). `*notifications.Service` satisfies `pushNotifier` (it has `CreateSystem(ctx, userID int, typ, title, body string)` from Phase-1).
Handlers:
```go
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
return
}
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
}
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
if h.notifier == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
return
}
h.notifier.CreateSystem(r.Context(), userID,
"system.test_push", "Test push",
"If you can see this on a device, push delivery is working.")
w.WriteHeader(http.StatusAccepted)
}
```
Import `webpush "github.com/SherClockHolmes/webpush-go"`. **Verify the `GenerateVAPIDKeys()` return order** against the installed version — webpush-go returns `(privateKey, publicKey string, err error)`. Confirm by reading the vendored signature or `go doc`; if it's `(public, private, err)` swap accordingly. Get this right — swapping the keys silently breaks push.
- [ ] **Step 2: Routes**
In `router.go`, in the admin push block (next to `r.Get("/push/status", ...)`, ~line 2295, behind `deps.PushStore != nil`):
```go
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
r.Post("/push/test", pushHandler.HandleSendTestPush)
```
- [ ] **Step 3: Wire the notifier**
Update every `NewPushHandler(...)` call site (find with grep) to pass the notifications service. In `cmd/silo/main.go` and/or `router.go` where the push handler is constructed, add `deps.NotificationsService` (or the in-scope `notificationsSvc`) as the third arg. Build will tell you the sites.
- [ ] **Step 4: Tests**
Add to `push_test.go` (reuse the package's fakes / chi-request helper from Task-13 push tests):
```go
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rr := httptest.NewRecorder()
h.HandleGenerateVAPIDKeys(rr, httptest.NewRequest(http.MethodPost, "/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("code = %d", rr.Code)
}
var body map[string]string
_ = json.Unmarshal(rr.Body.Bytes(), &body)
if body["vapid_public"] == "" || body["vapid_private"] == "" || body["vapid_public"] == body["vapid_private"] {
t.Fatalf("bad key pair: %+v", body)
}
}
type fakeNotifier struct{ called bool; userID int; typ string }
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, _, _ string) {
f.called = true
f.userID = userID
f.typ = typ
}
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
n := &fakeNotifier{}
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
r := httptest.NewRequest(http.MethodPost, "/", nil)
r = r.WithContext(apimw.SetUserID(r.Context(), 7)) // use the package's claims-injection helper if SetUserID differs
rr := httptest.NewRecorder()
h.HandleSendTestPush(rr, r)
if rr.Code != http.StatusAccepted || !n.called || n.userID != 7 || n.typ != "system.test_push" {
t.Fatalf("unexpected: code=%d notifier=%+v", rr.Code, n)
}
}
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rr := httptest.NewRecorder()
h.HandleSendTestPush(rr, httptest.NewRequest(http.MethodPost, "/", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d", rr.Code)
}
}
```
Check how the existing push tests inject the user id into context (Task-13 used a claims/`apimw` helper — mirror it; `apimw.SetUserID` may not exist, use whatever the package's tests already use to set `GetUserID`).
- [ ] **Step 5: Build, test, commit**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush -v 2>&1 | tail -8 && GOWORK=off go vet ./internal/api/handlers/
git add internal/api/handlers/push.go internal/api/handlers/push_test.go internal/api/router.go cmd/silo/main.go
git commit -m "feat(push): admin VAPID keygen and self-test endpoints"
```
---
### Task 2: Service worker + registration + helpers
**Files:**
- Create: `web/public/sw.js`, `web/src/lib/pushSw.ts`, `web/src/lib/push.ts`
- Test: `web/src/lib/pushSw.test.ts`, `web/src/lib/push.test.ts`
- Modify: `web/src/main.tsx`
- [ ] **Step 1: Pure SW logic (tested) — `web/src/lib/pushSw.ts`**
```ts
export interface PushPayload {
id?: number;
title?: string;
body?: string;
link?: string;
category?: string;
}
export interface SwNotification {
title: string;
options: { body: string; data: { link: string }; tag: string };
}
/** Maps a raw push payload to showNotification args; tolerant of partial/missing fields. */
export function buildNotification(payload: PushPayload | null): SwNotification {
const p = payload ?? {};
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
const tag = p.id != null ? `n${p.id}` : "n";
return {
title: p.title && p.title.length > 0 ? p.title : "Silo",
options: { body: p.body ?? "", data: { link }, tag },
};
}
/** The URL a notification click should open/focus. */
export function clickUrl(data: unknown): string {
const d = (data ?? {}) as { link?: string };
return d.link && d.link.length > 0 ? d.link : "/notifications";
}
```
- [ ] **Step 2: Test it — `web/src/lib/pushSw.test.ts`**
```ts
import { describe, expect, it } from "vitest";
import { buildNotification, clickUrl } from "./pushSw";
describe("buildNotification", () => {
it("maps a full payload", () => {
const n = buildNotification({ id: 5, title: "Approved", body: "Dune", link: "/requests" });
expect(n.title).toBe("Approved");
expect(n.options).toEqual({ body: "Dune", data: { link: "/requests" }, tag: "n5" });
});
it("falls back on missing fields", () => {
const n = buildNotification(null);
expect(n.title).toBe("Silo");
expect(n.options.body).toBe("");
expect(n.options.data.link).toBe("/notifications");
expect(n.options.tag).toBe("n");
});
});
describe("clickUrl", () => {
it("uses link when present", () => expect(clickUrl({ link: "/items/9" })).toBe("/items/9"));
it("falls back to inbox", () => expect(clickUrl({})).toBe("/notifications"));
});
```
Run: `pnpm exec vitest run src/lib/pushSw.test.ts` → green.
- [ ] **Step 3: The static service worker — `web/public/sw.js`**
Self-contained (served statically, not bundled). Mirror the pure logic exactly (the canonical, tested source is `pushSw.ts`):
```js
// web/public/sw.js — static service worker for Web Push.
// NOTE: the buildNotification/clickUrl logic mirrors src/lib/pushSw.ts (the
// tested source of truth). Keep them in sync.
function buildNotification(payload) {
const p = payload || {};
const link = p.link && p.link.length > 0 ? p.link : "/notifications";
const tag = p.id != null ? "n" + p.id : "n";
return {
title: p.title && p.title.length > 0 ? p.title : "Silo",
options: { body: p.body || "", data: { link: link }, tag: tag },
};
}
function clickUrl(data) {
const d = data || {};
return d.link && d.link.length > 0 ? d.link : "/notifications";
}
self.addEventListener("push", (event) => {
let payload = null;
try {
payload = event.data ? event.data.json() : null;
} catch (e) {
payload = null;
}
const n = buildNotification(payload);
event.waitUntil(self.registration.showNotification(n.title, n.options));
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = clickUrl(event.notification.data);
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((wins) => {
for (const c of wins) {
if ("focus" in c) {
c.postMessage({ type: "notification-click", link: url });
return c.focus();
}
}
return self.clients.openWindow(url);
}),
);
});
self.addEventListener("pushsubscriptionchange", (event) => {
// Best-effort re-subscribe using the cached VAPID key; the app re-registers
// the new subscription on next load if this fails.
event.waitUntil(
(async () => {
try {
const cache = await caches.open("silo-push");
const res = await cache.match("vapid-public-key");
if (!res) return;
const vapid = await res.text();
const sub = await self.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: vapid,
});
await fetch("/api/v1/notifications/push/device", {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
});
} catch (e) {
// swallow; app re-registers on next load
}
})(),
);
});
```
Note: the `pushsubscriptionchange` re-register relies on cookie auth (`credentials: include`) and the device/profile headers it can't set from the SW — it is genuinely best-effort and the app heals it on next load (documented in the spec). `applicationServerKey` accepts a base64url string in modern browsers; if the target browsers need a Uint8Array here, the app-side path (Task 4) converts it — the SW path is the fallback and may no-op on stricter browsers, which is acceptable.
- [ ] **Step 4: App-side helpers — `web/src/lib/push.ts`**
```ts
/** Convert a base64url VAPID public key to the Uint8Array applicationServerKey wants. */
export function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(b64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
/** Cache the raw VAPID public key so the SW can re-subscribe without the app. */
export async function cacheVapidKey(base64: string): Promise<void> {
if (!("caches" in self)) return;
const cache = await caches.open("silo-push");
await cache.put("vapid-public-key", new Response(base64));
}
export function pushSupported(): boolean {
return (
typeof navigator !== "undefined" &&
"serviceWorker" in navigator &&
typeof window !== "undefined" &&
"PushManager" in window &&
"Notification" in window
);
}
```
- [ ] **Step 5: Test the converter — `web/src/lib/push.test.ts`**
```ts
import { describe, expect, it } from "vitest";
import { urlBase64ToUint8Array } from "./push";
describe("urlBase64ToUint8Array", () => {
it("decodes a url-safe base64 key to bytes", () => {
// "AQID" → [1,2,3]
expect(Array.from(urlBase64ToUint8Array("AQID"))).toEqual([1, 2, 3]);
});
it("handles url-safe chars and missing padding", () => {
const out = urlBase64ToUint8Array("a-_9"); // '-'→'+', '_'→'/'
expect(out.length).toBe(3);
});
});
```
- [ ] **Step 6: Register the SW — `web/src/main.tsx`**
After `createRoot(...).render(...)`, add:
```tsx
if ("serviceWorker" in navigator && "PushManager" in window) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
// registration failure is non-fatal; push just won't be available
});
});
}
```
- [ ] **Step 7: Run, build, commit**
```bash
cd web && pnpm exec vitest run src/lib/pushSw.test.ts src/lib/push.test.ts 2>&1 | tail -5 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/public/sw.js web/src/lib/pushSw.ts web/src/lib/pushSw.test.ts web/src/lib/push.ts web/src/lib/push.test.ts web/src/main.tsx
git commit -m "feat(web): web push service worker, registration, and helpers"
```
---
### Task 3: Push query hooks + types
**Files:**
- Modify: `web/src/api/types.ts`
- Create: `web/src/hooks/queries/push.ts`
- Modify: `web/src/hooks/queries/keys.ts` (add `pushKeys`)
- [ ] **Step 1: Types** — append to `types.ts`:
```ts
export interface PushDeviceInfo {
device_id: string;
name: string;
platform: string;
transport: string;
push_enabled: boolean;
registered_at?: string;
}
export interface VapidKeyPair {
vapid_public: string;
vapid_private: string;
}
```
- [ ] **Step 2: Keys** — in `keys.ts`:
```ts
export const pushKeys = {
all: ["push"] as const,
webpushKey: () => [...pushKeys.all, "webpush-key"] as const,
devices: () => [...pushKeys.all, "devices"] as const,
status: () => [...pushKeys.all, "status"] as const,
};
```
- [ ] **Step 3: Hooks — `web/src/hooks/queries/push.ts`**
Mirror `admin/users.ts` conventions (api import, toast, invalidation).
```ts
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { api } from "@/api/client";
import type { PushDeviceInfo, VapidKeyPair } from "@/api/types";
import { pushKeys } from "@/hooks/queries/keys";
export function useWebPushPublicKey() {
return useQuery({
queryKey: pushKeys.webpushKey(),
queryFn: () => api<{ vapid_public_key: string }>("/notifications/push/webpush-key"),
select: (d) => d.vapid_public_key,
staleTime: 5 * 60 * 1000,
});
}
export function usePushDevices() {
return useQuery({
queryKey: pushKeys.devices(),
queryFn: () => api<{ devices: PushDeviceInfo[] | null }>("/notifications/push/devices"),
select: (d) => d.devices ?? [],
});
}
export function useTogglePushDevice() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ deviceId, enabled }: { deviceId: string; enabled: boolean }) =>
api(`/notifications/push/devices/${deviceId}`, {
method: "PUT",
body: JSON.stringify({ enabled }),
}),
onSuccess: () => void qc.invalidateQueries({ queryKey: pushKeys.devices() }),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to update device"),
});
}
export function usePushStatus() {
return useQuery({
queryKey: pushKeys.status(),
queryFn: () => api<{ apns: boolean; fcm: boolean; webpush: boolean }>("/admin/push/status"),
});
}
export function useGenerateVapidKeys() {
return useMutation({
mutationFn: () => api<VapidKeyPair>("/admin/push/generate-vapid-keys", { method: "POST" }),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to generate keys"),
});
}
export function useSendTestPush() {
return useMutation({
mutationFn: () => api("/admin/push/test", { method: "POST" }),
onSuccess: () => toast.success("Test push queued — watch for a banner"),
onError: (err) => toast.error(err instanceof Error ? err.message : "Failed to send test"),
});
}
```
(Register/revoke device calls live inside `usePushDevice` (Task 4) since they're tied to the subscription, not standalone query hooks.)
- [ ] **Step 4: Typecheck + commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/src/api/types.ts web/src/hooks/queries/keys.ts web/src/hooks/queries/push.ts
git commit -m "feat(web): push query hooks and types"
```
---
### Task 4: `usePushDevice` lifecycle hook
**Files:**
- Create: `web/src/hooks/usePushDevice.ts`, `web/src/hooks/usePushDevice.test.tsx`
- [ ] **Step 1: Failing tests**
Mock `navigator.serviceWorker`, `window.PushManager`, `Notification`, the api client, and the push helpers. Assert the status machine and the enable/disable flows.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { apiMock, cacheVapidMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
cacheVapidMock: vi.fn(),
}));
vi.mock("@/api/client", () => ({ api: apiMock }));
vi.mock("@/lib/push", async (orig) => ({
...(await orig<typeof import("@/lib/push")>()),
cacheVapidKey: cacheVapidMock,
}));
import { usePushDevice } from "./usePushDevice";
function wrap() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
let subscribeMock: ReturnType<typeof vi.fn>;
let getSubMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
apiMock.mockReset();
cacheVapidMock.mockReset();
subscribeMock = vi.fn(async () => ({ toJSON: () => ({ endpoint: "https://x", keys: {} }) }));
getSubMock = vi.fn(async () => null);
(globalThis as any).PushManager = function () {};
(navigator as any).serviceWorker = {
register: vi.fn(),
ready: Promise.resolve({ pushManager: { subscribe: subscribeMock, getSubscription: getSubMock } }),
};
(globalThis as any).Notification = { permission: "default", requestPermission: vi.fn(async () => "granted") };
});
afterEach(() => vi.restoreAllMocks());
describe("usePushDevice", () => {
it("reports unsupported when PushManager missing", () => {
delete (globalThis as any).PushManager;
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
expect(result.current.status).toBe("unsupported");
});
it("reports blocked when permission denied", () => {
(globalThis as any).Notification.permission = "denied";
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
expect(result.current.status).toBe("blocked");
});
it("enable(): permission → key → subscribe → PUT device, caches key, status on", async () => {
apiMock.mockImplementation((path: string) => {
if (path.includes("webpush-key")) return Promise.resolve({ vapid_public_key: "AQID" });
return Promise.resolve(undefined); // PUT device
});
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.enable(); });
expect(subscribeMock).toHaveBeenCalledOnce();
expect(cacheVapidMock).toHaveBeenCalledWith("AQID");
const putCall = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device");
expect(putCall?.[1]).toMatchObject({ method: "PUT" });
expect(JSON.parse(putCall![1].body)).toMatchObject({ transport: "webpush" });
await waitFor(() => expect(result.current.status).toBe("on"));
});
it("enable(): permission denied short-circuits, no subscribe", async () => {
(globalThis as any).Notification.requestPermission = vi.fn(async () => "denied");
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.enable(); });
expect(subscribeMock).not.toHaveBeenCalled();
expect(result.current.status).toBe("blocked");
});
it("disable(): unsubscribe + DELETE, status off", async () => {
const unsub = vi.fn(async () => true);
getSubMock.mockResolvedValue({ unsubscribe: unsub });
apiMock.mockResolvedValue(undefined);
const { result } = renderHook(() => usePushDevice(), { wrapper: wrap() });
await act(async () => { await result.current.disable(); });
expect(unsub).toHaveBeenCalled();
const del = apiMock.mock.calls.find((c) => c[0] === "/notifications/push/device" && c[1]?.method === "DELETE");
expect(del).toBeTruthy();
await waitFor(() => expect(result.current.status).toBe("off"));
});
});
```
- [ ] **Step 2: Run, verify fail**
```bash
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6
```
- [ ] **Step 3: Implement — `web/src/hooks/usePushDevice.ts`**
```ts
import { useCallback, useEffect, useState } from "react";
import { api } from "@/api/client";
import { cacheVapidKey, pushSupported, urlBase64ToUint8Array } from "@/lib/push";
export type PushDeviceStatus = "unsupported" | "blocked" | "off" | "on" | "pending";
export function usePushDevice() {
const [status, setStatus] = useState<PushDeviceStatus>("pending");
const refresh = useCallback(async () => {
if (!pushSupported()) {
setStatus("unsupported");
return;
}
if (Notification.permission === "denied") {
setStatus("blocked");
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
setStatus(sub ? "on" : "off");
} catch {
setStatus("off");
}
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
const enable = useCallback(async () => {
if (!pushSupported()) {
setStatus("unsupported");
return;
}
setStatus("pending");
const perm = await Notification.requestPermission();
if (perm !== "granted") {
setStatus(perm === "denied" ? "blocked" : "off");
return;
}
try {
const reg = await navigator.serviceWorker.ready;
const { vapid_public_key } = await api<{ vapid_public_key: string }>(
"/notifications/push/webpush-key",
);
if (!vapid_public_key) {
setStatus("off");
return;
}
await cacheVapidKey(vapid_public_key);
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
});
await api("/notifications/push/device", {
method: "PUT",
body: JSON.stringify({ transport: "webpush", token: JSON.stringify(sub.toJSON()) }),
});
setStatus("on");
} catch {
setStatus("off");
}
}, []);
const disable = useCallback(async () => {
setStatus("pending");
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) await sub.unsubscribe();
} catch {
/* best-effort */
}
try {
await api("/notifications/push/device", { method: "DELETE" });
} catch {
/* server may already lack the row */
}
setStatus("off");
}, []);
return { status, enable, disable, refresh };
}
```
- [ ] **Step 4: Run green, typecheck, commit**
```bash
cd web && pnpm exec vitest run src/hooks/usePushDevice.test.tsx 2>&1 | tail -6 && pnpm exec tsc -b --noEmit 2>&1 | head -3
git add web/src/hooks/usePushDevice.ts web/src/hooks/usePushDevice.test.tsx
git commit -m "feat(web): usePushDevice subscription lifecycle hook"
```
---
### Task 5: NotificationsSettings — push control + device list
**Files:**
- Modify: `web/src/pages/settings/NotificationsSettings.tsx`
- Test: extend the page's test if one exists (check `ls web/src/pages/settings/*.test.tsx`)
- [ ] **Step 1: Add a push section**
Read the current `NotificationsSettings.tsx` (it uses `SettingsGroup`/`SettingRow`/`Switch`). Add, below the category-preference group, a new "Push notifications" group:
- A row driven by `usePushDevice()`:
- `status === "unsupported"` → static text "This browser doesn't support push notifications."
- `status === "blocked"` → text "Notifications are blocked for this site. Re-enable them in your browser's site settings."
- else → a `Switch` checked when `status === "on"`, disabled when `status === "pending"`, `onCheckedChange` calls `enable()` / `disable()`.
- Below it, a device list from `usePushDevices()`: one row per device (`name || device_id`, `platform`/`transport`, `registered_at`), each with a `Switch` bound to `useTogglePushDevice().mutate({ deviceId, enabled })` reflecting `push_enabled`. Empty state: "No devices registered for push yet."
Match the page's existing component composition and classes. Keep `content_digest` and the existing toggles untouched.
- [ ] **Step 2: Test (if the page is tested)**
If `NotificationsSettings.test.tsx` exists, mock `usePushDevice` (return `{status:"off", enable, disable}`) and `usePushDevices` (two devices); assert: toggling the device-level switch calls `useTogglePushDevice().mutate` with the device id; the main push switch calls `enable` when off. If no sibling test exists, rely on typecheck + the hook tests and note it in the commit.
- [ ] **Step 3: Typecheck, test, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/settings 2>&1 | tail -4
git add web/src/pages/settings/NotificationsSettings.tsx web/src/pages/settings/*.test.tsx
git commit -m "feat(web): push on-this-device control and device list in settings"
```
---
### Task 6: Admin PushSettings page + nav
**Files:**
- Create: `web/src/pages/admin-settings/PushSettings.tsx`
- Modify: `web/src/pages/admin-settings/AdminSettingsLayout.tsx`
- Test: `web/src/pages/admin-settings/PushSettings.test.tsx`
- [ ] **Step 1: Build the page**
Template: `IntegrationsSettings.tsx` (credential card = `CredentialStatus` badge + `SettingField` + `useUpdateServerSetting` + `useAdminSensitiveStatus`). Three cards. Header badge per card from `usePushStatus()`.
- **Web Push card** (leads): fields `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A "Generate keys" button → `useGenerateVapidKeys().mutateAsync()` fills the public + private field state (local `useState`, not auto-saved). Subject field hint: `mailto:you@example.com`. Each field saves via `updateSetting.mutateAsync({ key, value })`. A "Send test push" button → `useSendTestPush().mutate()`.
- **APNs card**: `push.apns.p8_key` (textarea via `SettingField` multiline if supported, else a `<textarea>`), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
- **FCM card**: `push.fcm.service_account_json` (textarea).
`configured` per field from `new Set(sensitive?.configured ?? []).has(key)`. Page header + intro like IntegrationsSettings.
- [ ] **Step 2: Register nav**
In `AdminSettingsLayout.tsx`, add to the "Connections" group's `items` (import the component + a lucide icon, e.g. `BellRing`):
```tsx
{ id: "push", label: "Push Notifications", icon: BellRing, component: PushSettings },
```
(`id: "push"` is stable URL state.)
- [ ] **Step 3: Test — `PushSettings.test.tsx`**
Mock `useUpdateServerSetting` (capture mutate args), `useAdminSensitiveStatus` (some keys configured), `usePushStatus` (webpush true), `useGenerateVapidKeys` (returns a pair), `useSendTestPush`. Assert: three cards render; webpush badge shows ready; saving the VAPID public field calls `updateSetting.mutateAsync({ key: "push.webpush.vapid_public", value })`; "Generate keys" populates the public/private fields; "Send test push" calls the mutation. Follow the admin-settings test conventions (check a sibling `*.test.tsx` in that dir; if none, write a minimal RTL test with the mocks).
- [ ] **Step 4: Typecheck, test, build, commit**
```bash
cd web && pnpm exec tsc -b --noEmit 2>&1 | head -3 && pnpm exec vitest run src/pages/admin-settings 2>&1 | tail -5
git add web/src/pages/admin-settings/PushSettings.tsx web/src/pages/admin-settings/PushSettings.test.tsx web/src/pages/admin-settings/AdminSettingsLayout.tsx
git commit -m "feat(web): admin push provider config page"
```
---
### Task 7: Full verification + manual smoke
- [ ] **Step 1: Web gates**
```bash
cd web && pnpm run lint 2>&1 | tail -3 && pnpm run format:check 2>&1 | tail -2 && pnpm exec vitest run 2>&1 | tail -5 && pnpm run build 2>&1 | tail -3
```
All green (lint warnings allowed, errors not). Fix any new-code lint/format issues (prettier --write the new files if format:check flags them). `build` also confirms `public/sw.js` is copied to `dist/` (Vite copies `public/` verbatim).
- [ ] **Step 2: Backend gates**
```bash
GOWORK=off go build ./... && GOWORK=off go test ./internal/api/handlers/ -run TestPush 2>&1 | tail -3 && make verify-local-paths
```
- [ ] **Step 3: Manual browser smoke (documented; requires a real browser over HTTPS or localhost)**
Service workers + Push require a secure context — `localhost` qualifies. Build the server with the web assets and run on a localhost port.
Commands assume the repository root is the cwd.
```bash
cd web && pnpm run build && cd ..
GOWORK=off go build -o ./bin/silo-pushweb ./cmd/silo && (PORT=18080 JF_PORT=18096 ./bin/silo-pushweb > ./silo-pushweb.log 2>&1 &)
```
Then in a Chromium browser at `http://localhost:18080`:
1. Log in as admin. Go to Admin → Settings → Push Notifications. Click "Generate keys", Save the VAPID public/private + a `mailto:` subject. Confirm the Web Push badge flips to "Ready" (and `GET /admin/push/status` → webpush:true).
2. Go to Settings → Notifications. Toggle "Push on this device" on → grant the browser permission prompt. Confirm a row appears in the device list and the toggle stays on.
3. Click "Send test push" on the admin page (or create an announcement). Within ~30–45s (grace + worker tick) a desktop notification banner appears. Close the app tab first to prove background delivery.
4. Click the banner → the app opens/focuses at the deep link.
5. Toggle push off → confirm `DELETE` and the device drops from the list.
Record the outcome. (If a real browser isn't available in the harness, run steps 1–2 via curl to confirm the API wiring — register a webpush device, confirm `push_deliveries` row + status — and note the visual steps as operator-verified.)
- [ ] **Step 4: Commit any verification fixes; report.** Work stays on `feat/core-notifications-server` (or a dedicated branch off it).
---
## Self-review notes (resolved during writing)
- `webpush.GenerateVAPIDKeys()` returns `(privateKey, publicKey, err)` in webpush-go — Task 1 Step 1 flags verifying the order so the keys aren't swapped.
- The SW's pure logic is duplicated between `public/sw.js` (untestable static file) and the tested `src/lib/pushSw.ts`; the duplication is small and intentional, with sw.js annotated as mirroring the tested source. The reviewer should diff the two functions for drift.
- `usePushDevice` owns register/revoke (tied to the live subscription); standalone query hooks (`push.ts`) cover read/list/toggle/admin. No overlap.
- Test push reuses `notifications.Service.CreateSystem` (Phase-1) → a real system notification → exercises the full enqueue→worker→transport pipeline to the caller's own devices. No bespoke push path.
- Admin credential writes use the existing `PUT /admin/settings/{key}` (encrypted, redacted) — no new write endpoint; only keygen + test are net-new server surface.
- iOS Safari web push (installed-PWA only) is handled by `pushSupported()` returning false in a normal tab → `unsupported` state; documented, not specially coded.
- Several steps embed verify-greps (NewPushHandler call sites, GenerateVAPIDKeys order, SettingField multiline support, sibling test conventions, the user-id context injection helper in push_test.go) — align with reality.
@@ -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,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,188 @@
# Core Notifications System — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
A first-party, whole-system notification layer for Silo: persisted per-user notifications with an inbox, read state, per-category preferences, real-time toasts over the existing event WebSocket, and admin announcements. Ships across the server, web UI, and both mobile apps (in-app only — no APNs/FCM push in this project). External delivery (Discord, email, webhooks) remains the job of the existing `silo.notifications` plugin, which consumes the same events.
## Goals
- Persisted per-user notifications: inbox, unread badge, read/dismiss state.
- Real-time in-app delivery to connected clients via the existing `events.Hub` / `/api/v1/events/ws` pipeline.
- Five categories: `request`, `content`, `announcement`, `system`, `admin` (operational alerts for admin users), plus opt-in `content_digest`.
- Per-category user preferences (announcements not mutable).
- Admin announcements with audience targeting and expiry.
- A documented `notifications.send` event contract so any plugin can create in-app notifications (and, via the existing external plugin's subscription to the same event, external ones).
- Core request lifecycle events (`request.*`) — net-new publishing from `internal/requests`, consumed by notifications and available to the requests UI.
## Non-goals
- Mobile push (APNs/FCM) — future project; nothing here blocks it.
- Request-system unification (`request_router.v1`, migrating ebook/audiobook request plugins onto `media_requests`) — separate future project. When it lands, the book plugins' `notifications.send` publishes are replaced by core `request.*` events with no notifications schema/API change.
- Absorbing external delivery channels into core.
- Fine-grained per-show/per-author subscription rules (watchlist/favorites are the v1 signal).
## Architecture
New package `internal/notifications` in silo-server:
1. **Materializer** — in-process consumer registered on `events.Hub` at startup (same pattern as `internal/plugins/event_dispatcher.go`). Runs events through per-category matchers; writes `notifications` rows for each recipient.
2. **Store** — Postgres repository: insert (idempotent), list (cursor), unread count, mark read, dismiss, preferences CRUD.
3. **Notifier** — after insert, publishes `notification.created` on a new `ChannelNotifications` event channel, user-scoped like `ChannelUserState`. The WS handler (`internal/api/handlers/events_ws.go`) adds this channel to the per-user subscribe set; frames carry the full notification object. Subscribe-time snapshot carries unread count only.
4. **API handlers** — REST endpoints below.
5. **Service API** — `notifications.Create(...)` for direct in-process callers (system/security events).
Creation is DB-first and synchronous: a notification exists when the row commits. WS fan-out is best-effort; clients that miss frames reconcile on next fetch. No delivery queue or retry in core — "delivery" is reading your own table.
## Data model
One migration, three tables:
```sql
notifications (
id bigserial PRIMARY KEY,
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
profile_id text NULL REFERENCES user_profiles(id) ON DELETE CASCADE, -- NULL = user-wide
category text NOT NULL, -- 'request' | 'content' | 'announcement' | 'system' | 'admin'
type text NOT NULL, -- e.g. 'request.approved', 'content.watchlist_episode'
title text NOT NULL,
body text NOT NULL,
link text NULL, -- in-app deep link
item_id bigint NULL, -- optional content reference (artwork, dedup)
source_event text NULL, -- originating event name (audit)
dedup_ref text NULL, -- idempotency: UNIQUE (user_id, type, dedup_ref) WHERE dedup_ref IS NOT NULL
created_at timestamptz NOT NULL DEFAULT now(),
read_at timestamptz NULL,
dismissed_at timestamptz NULL,
expires_at timestamptz NULL
);
-- index: (user_id, created_at DESC) partial WHERE dismissed_at IS NULL
-- partial unique index for dedup as above; inserts use ON CONFLICT DO NOTHING
notification_preferences (
user_id int NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category text NOT NULL, -- 'request' | 'content' | 'system' | 'admin' | 'content_digest'
enabled bool NOT NULL,
PRIMARY KEY (user_id, category)
);
-- absent row = enabled, except content_digest which defaults OFF
-- 'announcement' is not a valid category here (not mutable)
announcements (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
audience jsonb NOT NULL, -- {"all":true} | {"user_ids":[...]} | {"library_ids":[...]}
created_by int REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NULL
);
```
Announcements fan out to `notifications` rows at publish time (audience resolved then), so the inbox is always a single-table query and read state is per-user.
Child profiles: rows with `category IN ('request','system','admin')` are filtered out of child-profile sessions server-side; content matching never targets a child profile unless the matched watchlist/favorite belongs to that profile.
Retention (nightly job, existing scheduler pattern): dismissed rows purged after 30 days; all rows after 90 days; unread rows from expired announcements at expiry.
## API
User endpoints (authenticated, profile from claims):
```
GET /api/v1/notifications ?unread=1&category=&cursor=&limit=
GET /api/v1/notifications/unread-count → {count}
POST /api/v1/notifications/read {ids:[...]} | {all:true}
POST /api/v1/notifications/{id}/dismiss
GET /api/v1/notifications/preferences
PUT /api/v1/notifications/preferences {category: enabled, ...}
```
List returns user-wide rows plus active-profile rows, newest first, cursor-paginated in the standard envelope. Child-profile filtering server-side.
Admin endpoints (RequireAdmin group):
```
GET /api/v1/admin/announcements
POST /api/v1/admin/announcements {title, body, audience, expires_at?}
DELETE /api/v1/admin/announcements/{id} -- also dismisses unread fanned-out rows
```
## Events
### New: core request lifecycle events
`internal/requests/service.go` and the reconciliation task publish on a new `ChannelRequests`:
- `request.submitted`, `request.approved`, `request.declined`, `request.completed`, `request.failed`
- Payload: `request_id, user_id, profile_id, title, media_type, quality, status`
`ChannelRequests` is added to WS subscribe sets (requesting user + admins), giving the requests UI live updates as a side benefit. This is the only change to the requests subsystem in this project.
### New: `notifications.send` plugin contract
The materializer consumes `notifications.send` events:
```json
{ "user_id": 1, "profile_id": null, "category": "request",
"type": "request.fulfilled", "title": "...", "body": "...",
"link": null, "dedup_ref": "ebook-req-123:fulfilled" }
```
Payloads are validated; malformed events are dropped with a logged warning. The external `silo.notifications` plugin already subscribes to this event name, so one publish reaches both in-app and external channels. The ebook/audiobook request plugins each add one `PublishEvent("notifications.send", ...)` at their acknowledged/fulfilled/failed transitions (their existing status events do not carry `user_id`; the plugins own that context).
### Matchers
- **request** — consumes core `request.*` events only. Dedup ref `(request_id, status)`.
- **content** — consumes `library.item_added` / `catalog.library.changed`. For each added item: resolve series/author; match per profile against watchlist, favorites (author/series link), and in-progress series (last play within 21 days); union, dedup, filter by library access. Burst guard: one notification per series per profile per hour (season imports collapse).
- **content_digest** — nightly job (not event-driven), opt-in: one row per user summarizing the previous day's additions to accessible libraries.
- **system** — not matched from the hub; emitted via `notifications.Create()` directly at the source (password change, new-device login, guest-pass redemption). Sites also publish their events as today; the explicit call means security notices never depend on matcher wiring.
- **announcement** — fan-out at publish time.
- **admin** — consumes existing `ChannelJobs` / `ChannelScans` / `ChannelTasks` failure events plus plugin-host crash/install failures; targets admin users; repeat-failure throttle (same source, max one per hour).
Preference checks run in the materializer before insert (cached per-user prefs lookup).
## Web UI
1. **Bell + dropdown** in the top bar: unread badge from `unread-count`, kept live by `ChannelNotifications` frames via the existing `RealtimeEventsProvider` socket. Dropdown: latest ~10, mark-all-read, deep links.
2. **Toasts** — WS notification frames fire sonner toasts; suppressed while the dropdown is open or when the frame targets another profile.
3. **Inbox page** (`/notifications`) — paginated list, category filter tabs, dismiss, mark-read on view.
4. **Settings** — per-category toggles (`content_digest` default off; `admin` visible to admins only). Admin: Announcements page (list/compose/expire) under admin settings.
## Mobile (in-app, both platforms)
Same three features in silo-apple and silo-android against the frozen REST API: inbox screen (list, mark-read, dismiss), unread badge on existing navigation, preferences screen. Transport: REST refresh of unread-count + first page on app foreground and pull-to-refresh. No WS subscription in v1 (current app sockets are playback-scoped; no background delivery exists without push anyway). TV variants: badge + read-only inbox, no preferences editing.
## Error handling
- Matcher isolation: a panic/error in one matcher logs and skips that event for that matcher only; never blocks the hub or other matchers (same containment as the plugin event dispatcher).
- Idempotent inserts via the dedup partial unique index + `ON CONFLICT DO NOTHING`; replayed events cannot double-notify.
- WS fan-out is fire-and-forget; DB rows are the source of truth.
- `notifications.send` validation failures: drop + warn (plugin bugs must not break core paths).
## Testing
- Unit tests per matcher with synthetic events; content-matcher edge cases: child profiles, library access, burst guard, dedup.
- Store tests against Postgres (repo convention).
- One integration test: event → materializer → row → WS frame through a real hub.
- API handler tests: auth, profile scoping, child filtering, admin gates.
- Web: component tests for bell/inbox; extend the RealtimeEventsProvider test pattern for the new channel.
## Implementation sequencing
1. **Server** — migration, store, materializer + matchers, request event publishing, API, WS channel, retention job.
2. **Web** — bell, toasts, inbox, settings, announcements admin.
3. **Mobile** — apple + android in-app surfaces against the frozen API.
4. (Separate projects, later: request unification via `request_router.v1`; mobile push.)
## Future work
- Request unification: core `request.*` events replace the book plugins' `notifications.send` publishes; notifications unchanged.
- Push: APNs/FCM device registry; the notifier gains a push fan-out alongside WS.
- Digest scheduling preferences (weekly, time-of-day).
- Profile claims enrichment: embed `is_child` in profile-scoped auth claims. Fixes two implementation-time findings: the WS pipeline cannot child-filter (snapshot and live frames use client-asserted profile identity; REST remains authoritative and fails closed — see the snapshot comment in `internal/api/handlers/events_ws.go`), and the REST `childSafe` check costs one profile lookup per request.
- Admin-alert coverage: only `job.failed` and `scan.failed` events exist today. `task.failed` and plugin-host crash events are not published by their subsystems yet; when they are, the admin matcher needs only a map entry.
- Dedicated `item_added` catalog event: the content matcher keys on `catalog.item.changed` (`metadata_updated`) plus a 48-hour `media_items.created_at` recency gate, because the scanner publishes no per-item add event. An explicit ingest-time event would remove the proxy heuristic.
- New-device login notifications: deferred from v1 (no device-fingerprint store exists); password-change notifications shipped.
@@ -0,0 +1,173 @@
# Core Push Notifications — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
A server-side push subsystem that mirrors the in-app notifications created in Phase 1 out to registered devices via APNs (Apple), FCM (Android), and Web Push (browsers). Delivery is durable (a DB-backed queue with a retry worker) and presence-gated (a notification only pushes to devices whose user has no live WebSocket connection after a short grace delay). This is a core-server project: the mobile clients have no push code yet, so the deliverable is the registry, queue, worker, transports, and APIs — built transport-agnostic so APNs/FCM are drop-in alongside the immediately-testable Web Push.
## Goals
- Deliver Phase-1 notifications to devices over APNs, FCM, and Web Push.
- Durable delivery: survive restarts, retry with backoff, prune dead tokens, observable in the admin task view.
- Presence-gated: don't buzz a device for a notification the user is actively seeing in-app.
- Reuse the existing per-category mute preferences; add per-device push on/off. No new per-channel matrix.
- Encrypted provider credentials via the existing settings mechanism.
- Transport-agnostic core: one `Transport` interface, three implementations, selected by device platform.
## Non-goals
- Mobile/web client integration (separate per-client projects; this ships the server contract).
- A separate in-app-vs-push preference matrix (category mutes + per-device toggle only).
- Rich push (images, action buttons) — title/body/link/category payload only in v1.
- New-device-login / security pushes beyond what the notification categories already produce.
## Architecture
New `internal/push` package and a new `internal/presence` package, plus touches to three existing seams (`notifications.Service.Create`, the events WS handler, `cmd/silo/main.go` wiring). Five independently-testable units:
1. **Device/token registry** — extends `user_devices` with push columns; a registration endpoint clients call to enroll/refresh/revoke a token and toggle per-device push. Owns "which devices want push and how to reach them."
2. **Presence registry** (`internal/presence`) — process-local `user_id → live-connection refcount`, incremented/decremented by the events WS handler on connect/disconnect. Answers "does this user have a live connection right now?" Cluster-aware via a short-TTL Redis key; degrades to per-node if Redis is absent.
3. **Enqueuer** — called from `notifications.Service.Create()` after the row commits. Resolves push-eligible devices, writes one `push_deliveries` row per device with `not_before = now + grace`.
4. **Delivery worker** — a `PushDeliveryTask` on the existing task framework; claims due rows, re-checks presence, dispatches via the platform's transport, records outcome, prunes dead tokens.
5. **Transports** — a `Transport` interface (`Send(ctx, target, payload) → Result`) with APNs, FCM, and Web Push implementations selected by device platform; credentials from the encrypted settings repo.
Data flow: `Create()` → enqueuer writes deliveries → worker (interval) claims due & un-present → transport sends → outcome / dead-token prune. The in-app path (immediate WS publish) is untouched; push is the deferred, gated mirror.
### Seam reference (current code)
- Fan-out hook: `internal/notifications/service.go` `Create()` — after `created, err := s.store.Insert(...)` succeeds and before/alongside the `hub.PublishJSON(... "notification.created" ...)` call. Enqueue is best-effort and must not block or fail `Create()`.
- Presence increment: `internal/api/handlers/events_ws.go` — after a successful subscribe, `release := presence.Add(claims.UserID)`, `defer release()`.
- No presence tracking exists today (confirmed); the registry is net-new.
- `user_devices` exists (`migrations/sql/180_user_devices.sql`, PK `user_id, profile_id, device_id`) with no token column.
- Credential encryption: `internal/catalog/encrypted_settings_repo.go` `SensitiveSettingKeys`.
- Background-worker precedent: `internal/taskmanager/tasks/*` (e.g. the request reconciler, notifications digest/retention).
## Data model
Extend `user_devices` (a push token belongs to a device):
```sql
ALTER TABLE user_devices
ADD COLUMN push_token text NULL,
ADD COLUMN push_transport text NULL, -- 'apns' | 'fcm' | 'webpush'
ADD COLUMN push_enabled boolean NOT NULL DEFAULT true,
ADD COLUMN push_token_at timestamptz NULL,
ADD COLUMN push_failures integer NOT NULL DEFAULT 0;
```
A device is push-eligible when `push_token IS NOT NULL AND push_enabled`. For `webpush`, `push_token` holds the JSON subscription object (endpoint + p256dh + auth); APNs/FCM store the bare token string. Each transport interprets its own format.
Delivery queue:
```sql
CREATE TABLE push_deliveries (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
notification_id bigint NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
user_id integer NOT NULL,
device_id text NOT NULL,
transport text NOT NULL,
status text NOT NULL DEFAULT 'pending', -- pending|sent|failed|skipped|dead
attempts integer NOT NULL DEFAULT 0,
not_before timestamptz NOT NULL,
last_error text NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX push_deliveries_claim_idx
ON push_deliveries (not_before)
WHERE status IN ('pending','failed');
```
Worker claim: `status IN ('pending','failed') AND not_before <= now()`, ordered by `not_before`, `FOR UPDATE SKIP LOCKED` (safe across nodes/concurrent workers).
Outcomes:
- **sent** — terminal success.
- **skipped** — user was present (live WS) at claim time; the in-app delivery sufficed. Terminal.
- **failed** — soft error (timeout, 5xx, rate-limit); bump `attempts`, push `not_before` out by the backoff schedule (1m, 5m, 30m). Once the schedule is exhausted (4 total attempts) → `dead`.
- **dead** — hard error / unregistered token; terminal. Also clears `push_token` and sets `push_enabled=false` on the `user_devices` row so the dead device stops being enqueued.
Deliveries do not duplicate notification content — the worker joins `notifications` at send time so an edited/expired notification sends current content (and an expired/dismissed one can be skipped). Retention: terminal deliveries older than 7 days are deleted by a pass on the existing notifications retention task.
Provider config (added to `SensitiveSettingKeys`, encrypted at rest):
`push.apns.p8_key`, `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`, `push.fcm.service_account_json`, `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. A transport with missing/blank config reports itself unconfigured; its deliveries short-circuit to `skipped` (reason: unconfigured) rather than erroring in a loop.
## API
Device push registration (authenticated user routes; device identified by the existing `X-Silo-Device-Id` header + `user_id` from claims — never from the body):
```
PUT /api/v1/notifications/push/device {transport, token} → 204
enroll/refresh this device's token (upsert onto the user_devices row);
resets push_failures, sets push_enabled=true, push_token_at=now
DELETE /api/v1/notifications/push/device → 204
revoke: clear token, disable push for this device
GET /api/v1/notifications/push/devices → {devices:[{device_id,name,platform,push_enabled,registered_at}]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
per-device on/off toggle
GET /api/v1/notifications/push/webpush-key → {vapid_public_key} (empty when web push unconfigured)
```
Admin provider config uses the existing admin settings mechanism (encrypted `push.*` keys) — no bespoke write endpoints. One read for the admin UI:
```
GET /api/v1/admin/push/status → {apns:{configured:bool}, fcm:{configured:bool}, webpush:{configured:bool}}
```
Status reports booleans only; secrets are never returned.
## Presence registry
`presence.Registry` interface: `Connected(userID int) bool` and `Add(userID int) (release func())`. The events WS handler calls `release := registry.Add(claims.UserID)` after a successful subscribe and `defer release()` on disconnect.
- In-process implementation: `map[int]int` refcount under a mutex.
- Cluster-aware implementation: `INCR push:presence:{userID}` in Redis with a 60s TTL refreshed on the WS heartbeat; `DECR` on release; `Connected` is key `> 0`. Redis is already used by the events hub.
- Presence failure (Redis down) fails **open** — we push — because a missed push is worse than a redundant one.
The worker re-checks `Connected` at claim time; a present user's due delivery becomes `skipped`. The `not_before` grace (default 30s) means an actively-in-app user almost never gets a redundant push, while a user who closed the app moments ago does.
## Error handling & delivery semantics
- Enqueuer is best-effort; failure to write deliveries logs and returns. `Create()` is never blocked or failed by push.
- Worker isolates per-delivery: one device's failure never blocks the batch.
- Soft errors → `failed` + exponential backoff on `not_before` (1m, 5m, 30m), then `dead` after 4 total attempts.
- Hard errors (`Unregistered`/`InvalidToken`/FCM `NOT_FOUND`) → immediate `dead` + clear token + disable push on `user_devices`.
- Unconfigured transport → its deliveries short-circuit to `skipped` (reason logged once, not per-row spam).
- Rate-limit responses park that transport briefly (honor `Retry-After`).
- Child-profile suppression applied at enqueue: categories in {request, system, admin} are never enqueued for a child profile (same rule as the web toast suppression).
- APNs/FCM payloads set a collapse/thread key = notification id to avoid duplicate banners across retries.
## Security
- Provider secrets live only in the encrypted settings repo; never logged, never returned by any read endpoint. `/admin/push/status` reports booleans.
- Token registration is authenticated and device-scoped to the caller's `user_id` (from claims).
- Push payload carries title/body/link/category + notification id — no content beyond what the notification already holds.
## Testing
- Worker tested with a fake `Transport` asserting outcome transitions: sent, skipped (present), failed-with-backoff, dead-prunes-token. No network.
- Each real transport unit-tested against a mocked HTTP endpoint (request shape, auth header, error-code mapping) — standard repo pattern.
- Enqueuer tested with a fake device store + presence stub: eligible-device resolution, child suppression, per-device disable, `not_before` grace.
- Presence registry: refcount add/release, Connected transitions, Redis-absent fallback.
- Store tests against Postgres: claim query with `SKIP LOCKED`, dead-token prune, retention.
- One integration test: `Create() → delivery row → worker → fake transport → sent`.
- Handler tests: registration auth-scoping, per-device toggle, `/admin/push/status` unconfigured states, `webpush-key` empty when unconfigured.
## Implementation sequencing
1. Migration (user_devices columns + push_deliveries) and domain types.
2. `internal/presence` registry (in-process + Redis) and WS-handler wiring.
3. Device registry store + registration/toggle endpoints.
4. Enqueuer + hook into `Create()`.
5. `Transport` interface + fake; delivery worker + task registration; outcome/backoff/dead-prune.
6. Real transports (Web Push first — end-to-end testable today; then APNs, then FCM) + encrypted config keys + `/admin/push/status`.
7. Retention pass + full verification.
## Future work
- Client integration: Apple (APNs token registration + handling), Android (FCM), web (service worker + VAPID subscription). Each binds to the frozen registration API.
- Presence-aware in-app→push escalation tuning (per-category grace, quiet hours).
- Rich push (images from `item_id` artwork, action buttons).
- Per-channel preference matrix if category+device proves too coarse.
- Shared-device push semantics: a physical device registers one OS token but one user_devices row per profile. For user-wide notifications, EligibleDevices currently returns one delivery per profile-row → the same token is sent to more than once (the per-notification collapse key dedupes the on-device banner, so the user sees one, but the sends are redundant). And child-category suppression on a device shared by a child and an adult profile is ambiguous because push cannot know the active profile at send time. Both resolve with the active-profile/claims-enrichment work also needed for WS child-filtering: carry the active profile identity so EligibleDevices can dedupe by token and apply the correct profile's suppression.
@@ -0,0 +1,120 @@
# Core Push — Web Client & Admin Config — Design
Date: 2026-06-10
Status: approved (design); implementation plan to follow
## Summary
The core-buildable slice of Phase 3 (client work): a Web Push browser client and an admin push-provider config UI, both living in the silo-server web app plus one ~10-line Go endpoint. This makes the push subsystem operable end to end from the browser — an operator enters provider credentials in an admin page, a user enables push on their browser, and notifications arrive as OS banners with the app closed. The mobile clients (silo-apple APNs, silo-android FCM) remain out of scope; they are separate per-repo projects that consume the same frozen registration API.
This delivers user-facing notification delivery to browsers. It is not a plugin-communication channel; plugins relate to notifications only via the existing `notifications.send` event contract (a plugin produces a user notification, which then flows to in-app + push).
## Goals
- Web Push delivery to browsers: service worker, subscription, receive, click-through.
- A per-device "push on this device" control and a device-management list in user settings.
- An admin page to configure APNs / FCM / Web Push provider credentials with readiness status.
- VAPID key generation from the admin UI.
- Reuse everything already built: the frozen push registration API, the encrypted settings write path, the Phase-2 notifications settings page and api client.
## Non-goals
- Mobile client integration (silo-apple / silo-android) — separate projects against the frozen API.
- A PWA framework / offline caching (vite-plugin-pwa) — a static service worker covers push; offline is out of scope.
- Any change to the push delivery worker, transports, or registration API (all frozen).
- Rich push (images, action buttons).
## Frozen dependencies (already on this branch)
Push registration API (`internal/api/handlers/push.go`, routes in `router.go`):
```
PUT /api/v1/notifications/push/device {transport, token} → 204 (RequireProfile)
DELETE /api/v1/notifications/push/device → 204 (RequireProfile)
GET /api/v1/notifications/push/devices → {devices:[DeviceInfo]}
PUT /api/v1/notifications/push/devices/{device_id} {enabled} → 204
GET /api/v1/notifications/push/webpush-key → {vapid_public_key}
GET /api/v1/admin/push/status → {apns,fcm,webpush: bool}
```
`DeviceInfo`: `{device_id, name, platform, transport, push_enabled, registered_at}`. The api client (`web/src/api/client.ts`) auto-attaches `X-Silo-Device-Id` and `X-Profile-Id`. WebPush tokens are stored as the JSON subscription object.
Admin settings write path: `PUT /api/v1/admin/settings/{key}` `{value}` (`admin.go` HandleUpdateSetting) — all `push.*` keys are pre-registered sensitive in `encrypted_settings_repo.go` (encrypted at rest, value redacted from reads). No new write endpoint needed. Sensitive-status hook (`useAdminSensitiveStatus`) reports which keys are stored.
Web push server transport already sends payload `{id, title, body, link, category}` with `Topic: "n"+base36(notificationID)` for collapse.
## Architecture
Six units (five web, one tiny Go endpoint):
1. **Service worker** — `web/public/sw.js` (static). `push` → `showNotification` from payload; `notificationclick` → focus an open app tab (postMessage the link) or `openWindow(link)`; `pushsubscriptionchange` → re-subscribe with the cached VAPID key and re-register (best-effort token refresh).
2. **SW registration** — in `web/src/main.tsx`, registers `/sw.js` when `serviceWorker` + `PushManager` are supported; no-op otherwise.
3. **`usePushDevice` hook** — `web/src/hooks/usePushDevice.ts`. Owns the browser-push lifecycle and exposes a status state machine + `enable()`/`disable()`.
4. **Settings UI** — extend `web/src/pages/settings/NotificationsSettings.tsx`: "Push on this device" control + a device-management list (per-device enable/disable).
5. **Admin push-config page** — `web/src/pages/admin-settings/PushSettings.tsx`: three credential cards + VAPID keygen + readiness badges.
6. **VAPID keygen endpoint** — `POST /api/v1/admin/push/generate-vapid-keys` (RequireAdmin), ~10 lines in `push.go`. The only net-new server code.
## Push client data flow
`usePushDevice` reports `status`:
- **unsupported** — no serviceWorker/PushManager, or iOS Safari not installed as a PWA. UI explains; no action.
- **blocked** — `Notification.permission === "denied"`. UI explains how to re-enable in site settings (cannot un-deny programmatically).
- **off** — supported, not subscribed. Toggle → `enable()`.
- **on** — subscribed and registered. Toggle → `disable()`.
- **pending** — transition in flight; control disabled.
`enable()`: `Notification.requestPermission()` → if granted, `navigator.serviceWorker.ready` → `GET /notifications/push/webpush-key` → `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublic) })` → `PUT /notifications/push/device { transport: "webpush", token: JSON.stringify(subscription.toJSON()) }`. The api client supplies device + profile headers (satisfies the route's RequireProfile). Cache the VAPID public key (Cache Storage / IndexedDB) so the SW can re-subscribe on `pushsubscriptionchange` without the app open. Success → `on`, toast.
`disable()`: `pushManager.getSubscription()` → `unsubscribe()` (best-effort) → `DELETE /notifications/push/device` → `off`, toast.
Receiving (SW): payload `{id,title,body,link,category}` → `showNotification(title, { body, data:{link}, tag:"n"+id })`. The `tag` is an on-device display-dedup keyed on the notification id (a redelivery of the same notification replaces the visible banner rather than stacking); it is independent of the server's Web Push `Topic` header (which dedups in the push-service queue). `notificationclick` → focus matching-origin client + postMessage `{type:"notification-click", link}`, else `openWindow(link ?? "/notifications")`.
Idempotency / healing: `enable()` is safe to re-run (server upserts); a stale local subscription with no server row heals on re-enable. Permission-granted-but-subscribe-fails → toast, stay `off`.
## Admin config page
`PushSettings.tsx`, registered in `AdminSettingsLayout` `SETTINGS_GROUPS` (Connections group). Pattern copied from `IntegrationsSettings.tsx` (`SettingField` + `CredentialStatus` + `useUpdateServerSetting`). Cards:
- **Web Push** (leads — usable today): `push.webpush.vapid_public`, `push.webpush.vapid_private`, `push.webpush.subject`. "Generate keys" button → `POST /admin/push/generate-vapid-keys` → fills public/private fields (not auto-saved; admin reviews then saves each). Subject defaults to a `mailto:` hint.
- **APNs**: `push.apns.p8_key` (textarea), `push.apns.key_id`, `push.apns.team_id`, `push.apns.bundle_id`.
- **FCM**: `push.fcm.service_account_json` (textarea).
Readiness: card header badge from `GET /admin/push/status`; per-field "configured, leave blank to keep" from the sensitive-status hook (secrets never returned). Each save = `PUT /admin/settings/{key}` `{value}` via `useUpdateServerSetting`; encryption automatic; badges refetch after save.
VAPID keygen endpoint: generates and returns `{vapid_public, vapid_private}`; does not persist. Handler test: non-empty pair, admin-gated.
### Open decision — "Send test push" button
OPTION (admin's call at spec review): a "Send test push" button that creates a one-off system notification addressed to the admin themselves to verify config→subscribe→receive end to end from the UI. Requires one more net-new server endpoint (`POST /api/v1/admin/push/test` → `notifications.Create` a system notification for the caller). Recommended for operability (turns "did I configure this right?" into one click) but it is the only other net-new server surface. **Decision: include it** unless cut at review. (Implementation plan will gate this behind the decision.)
## Error handling
- Capability gating (unsupported, denied) are UI states, not errors; iOS Safari shows "Add to Home Screen to enable push."
- Transient subscribe/register failures → toast, leave `off`; nothing half-committed (server PUT is source of truth, idempotent).
- SW resilience: tolerate partial payload (generic title fallback); `notificationclick` falls back to `/notifications`; failed `pushsubscriptionchange` re-register logs and retries on next app load (browser stops receiving until reopened — never a crash).
- Admin page: save failures toast; readiness badges always reflect server truth (refetch after save); partial config shows "Not configured" until complete; generate-keys failure leaves fields untouched.
## Testing
- `usePushDevice` — unit tests with mocked serviceWorker/PushManager/Notification/api client: status derivation, enable() happy path (permission→key→subscribe→PUT body), permission-denied short-circuit, disable() (unsubscribe + DELETE), idempotent re-enable.
- Settings UI — component tests: each status renders the right control; device-list rows + per-device toggle mutation.
- Service worker — vitest over the pure handlers: payload→showNotification-args mapping, click→target-URL logic. DOM wiring exercised in the manual smoke.
- Admin page — component tests: three cards render, badges reflect `/admin/push/status`, save calls `PUT /admin/settings/{key}` with the right key, generate-keys fills fields.
- Server — VAPID keygen handler test (non-empty pair, admin-gated); test-push endpoint handler test (if included).
- Manual smoke (documented in plan): real browser — configure VAPID, enable push, trigger a notification, confirm OS banner with app closed, click → deep link.
## Implementation sequencing
1. Server: VAPID keygen endpoint (+ test-push endpoint if kept).
2. Web: service worker + registration + VAPID-key caching.
3. Web: `usePushDevice` hook + tests.
4. Web: NotificationsSettings push control + device list.
5. Web: admin PushSettings page + nav.
6. Verification + manual browser smoke.
## Future work
- Mobile clients (APNs/FCM) against the frozen API.
- Shared-device token dedup + active-profile child-suppression (recorded in the push server spec) — informs whether multiple browsers/profiles dedupe.
- Per-category push targeting if category mutes prove too coarse.
- PWA/offline (vite-plugin-pwa) if the app later wants installable offline behavior.
@@ -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,65 @@
# Home Notification Bars — Design
Date: 2026-06-11
Status: approved (design); implementation to follow
## Summary
Surface notifications at the top of the Home page so they're seen without opening the inbox tab, with system-level **announcements** visually distinguished from personal notifications. Two slim, dismissible bars stack at the top of Home: an **announcement bar** (distinct accent + 📢 + "Announcement" label) above a **notification strip** (the most recent unread personal notification). Both reuse existing hooks; no server changes. Web-only, additive.
## Goals
- Show the most recent unread notification high on the Home page, not only behind the bell/inbox.
- Make admin **announcements** clearly distinct from request/content/system/admin notifications, via a separate styled bar.
- Dismiss (✕) marks read and advances to the next unread; bars hide when nothing's unread.
- Reuse the Phase-2 notifications hooks; inherit per-profile + child filtering and live WebSocket updates for free.
## Non-goals
- No server/API changes (the data already distinguishes `category: "announcement"`).
- No change to the bell, inbox page, or toast (carrying the 📢 styling there is a possible follow-up, out of scope here).
- Not app-wide — Home page only.
## Components
Two components in `web/src/components/home/` (or alongside Home), each small and independently testable, plus one mount edit.
### AnnouncementBar (`web/src/components/AnnouncementBar.tsx`)
- Data: `useNotificationsList({ unread: true, category: "announcement" })` → the first (newest) item. Reliably finds the latest unread announcement regardless of how many other notifications are unread.
- Render: a distinct bar using the app's accent tokens (reads as a system notice, not a personal row) — a 📢 icon, an "ANNOUNCEMENT" label, the title, truncated body, relative time, and a ✕.
- Empty: renders nothing when there's no unread announcement.
- ✕: `useMarkRead().mutate({ ids: [id] })` → the unread query updates → the next unread announcement shows, or the bar hides. Marking read also drops the bell badge (shared cache).
- Click (anywhere but ✕): navigate to the notification's `link` (or `/notifications` when it has none) and mark it read.
### NotificationStrip (`web/src/components/NotificationStrip.tsx`)
- Data: `useNotificationsList({ unread: true })` → the first item whose `category !== "announcement"` (so announcements live only in the bar above, never duplicated here).
- Render: the slim strip — 🔔 icon, title, truncated body, relative time, ✕. Lighter styling than the announcement bar.
- Empty: renders nothing when there's no unread non-announcement.
- ✕ / click: same behavior as the bar (mark read + advance / navigate + mark read).
### Mount (`web/src/pages/Home.tsx`)
At the top of the Home content region (near the existing `heroSlot` / `TasteSeedBanner`), render `<AnnouncementBar />` then `<NotificationStrip />` as the first elements, so they sit above the content rows. When both are empty, Home renders exactly as today.
## Data flow & shared behavior
- Both bars read through TanStack Query; the announcement bar's query (`{unread:true, category:"announcement"}`) and the strip's (`{unread:true}`) are distinct cache keys — two small fetches, both already used elsewhere.
- Live updates: `useNotificationsLive` already invalidates the notifications list on `notification.created`, so both bars reactively show the newest unread without a reload.
- Filtering: the list endpoint already scopes to the active profile and suppresses child-restricted categories server-side, so the bars inherit correct per-profile behavior.
- The relative-time helper is the existing `timeAgo` (with the absolute-date fallback for old timestamps).
## Error handling
- While the query is loading, render nothing (no skeleton flash for a slim bar).
- A mark-read failure surfaces the existing mutation `onError` toast; the bar stays until the next successful read.
- A notification with no `link` navigates to `/notifications` rather than a dead link.
## Testing
- `AnnouncementBar.test.tsx`: renders the latest unread announcement with the 📢/label styling; ✕ calls `markRead({ids:[id]})`; clicking navigates (mocked `useNavigate`) to the link and marks read; renders nothing when no unread announcement.
- `NotificationStrip.test.tsx`: shows the newest unread non-announcement (skips an announcement at the head of the list); ✕ marks read; click navigates + marks read; renders nothing when no unread non-announcement.
- Existing suites stay green; tsc + lint clean.
## Future work
- Carry the 📢 announcement styling into the bell dropdown and inbox rows for a consistent identity.
- Optional: a per-user "hide the home bars" preference if they prove noisy.
+23 -17
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,12 +14,13 @@ 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
)
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/abadojack/whatlanggo v1.0.1
github.com/go-chi/cors v1.2.2
github.com/gorilla/websocket v1.5.3
@@ -30,24 +31,30 @@ require (
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/sideshow/apns2 v0.25.0
github.com/zishang520/socket.io/v2 v2.5.0
go.n16f.net/thumbhash v1.1.0
golang.org/x/image v0.39.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529
golang.org/x/image v0.41.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.36.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
cloud.google.com/go/compute/metadata v0.9.0 // indirect
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/fatih/color v1.13.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // 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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/kr/text v0.2.0 // 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
@@ -55,8 +62,10 @@ require (
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.5.1 // indirect
github.com/quic-go/quic-go v0.53.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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
@@ -67,12 +76,7 @@ require (
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/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/tools v0.43.0 // indirect
)
require (
@@ -100,9 +104,11 @@ require (
github.com/sony/sonyflake v1.3.0
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/time v0.14.0
google.golang.org/grpc v1.80.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
google.golang.org/grpc v1.81.1
google.golang.org/protobuf v1.36.11
)
replace github.com/zishang520/webtransport-go => ./internal/compat/zishang520-webtransport-go
+119 -39
View File
@@ -1,11 +1,17 @@
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ=
entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM=
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/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
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/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20201120081800-1786d5ef83d4/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
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=
@@ -44,20 +50,20 @@ 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=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
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=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
@@ -70,10 +76,15 @@ github.com/go-pg/pg/v10 v10.11.0 h1:CMKJqLgTrfpE/aOVeLdybezR2om071Vh38OLZjsyMI0=
github.com/go-pg/pg/v10 v10.11.0/go.mod h1:4BpHRoxE61y4Onpof3x1a2SQvi9c+q1dJnrNdMjsroA=
github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4mU=
github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo=
github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@@ -154,8 +165,9 @@ github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNs
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pgvector/pgvector-go v0.3.0 h1:Ij+Yt78R//uYqs3Zk35evZFvr+G0blW0OUN+Q2D1RWc=
github.com/pgvector/pgvector-go v0.3.0/go.mod h1:duFy+PXWfW7QQd5ibqutBO4GxLsUZ9RVXhFZGIBsWSA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -166,10 +178,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/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.53.0 h1:QHX46sISpG2S03dPeZBgVIZp8dGagIaiu2FiVYvpCZI=
github.com/quic-go/quic-go v0.53.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
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=
@@ -182,10 +198,13 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEV
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sideshow/apns2 v0.25.0 h1:XOzanncO9MQxkb03T/2uU2KcdVjYiIf0TMLzec0FTW4=
github.com/sideshow/apns2 v0.25.0/go.mod h1:7Fceu+sL0XscxrfLSkAoH6UtvKefq3Kq1n4W3ayQZqE=
github.com/sony/sonyflake v1.3.0 h1:tiB4Dlp0lnmKp/h6BLXA14P8Qi+LYS9+0QRpcrKHvg4=
github.com/sony/sonyflake v1.3.0/go.mod h1:LORtCywH/cq10ZbyfhKrHYgAUGH7mOBa76enV9txy/Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
@@ -212,6 +231,7 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
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=
@@ -222,8 +242,6 @@ github.com/zishang520/socket.io-go-parser/v2 v2.5.0 h1:uGKTwcH2qrrs9uwzfCo3ems+q
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=
github.com/zishang520/webtransport-go v0.9.1 h1:Y3gqPM8cIDvQILsTyXJ5G9fp2PYqGqLI2z+QXpgboQc=
github.com/zishang520/webtransport-go v0.9.1/go.mod h1:IgNAD6qLe3oWu7MSSkjusRNftpvjYxWjI4LmoH4VEyY=
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=
@@ -232,64 +250,126 @@ go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
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.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
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/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/crypto v0.0.0-20170512130425-ab89591268e0/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
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/net v0.0.0-20220403103023-749bd193bc2b/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
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/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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-20211216021012-1d35b9e2eb4e/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/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
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/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+1
View File
@@ -73,6 +73,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
+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
}
}
+314
View File
@@ -0,0 +1,314 @@
// 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"
"sync/atomic"
"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).
// The config is held behind an atomic pointer so admin settings changes apply
// to subsequent requests without rebuilding the client; each request snapshots
// the config once so its URL, key, and model stay consistent.
type Client struct {
cfg atomic.Pointer[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 {
c := &Client{
chatHTTP: &http.Client{Timeout: 10 * time.Minute},
asrHTTP: &http.Client{},
}
c.UpdateConfig(cfg)
return c
}
// UpdateConfig swaps the endpoint config. Safe for concurrent use; in-flight
// requests finish with the config they started with.
func (c *Client) UpdateConfig(cfg Config) {
c.cfg.Store(&cfg)
}
// Config returns a snapshot of the current endpoint config.
func (c *Client) Config() Config {
return *c.cfg.Load()
}
// 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) {
cfg := c.Config()
reqBody := chatCompletionRequest{
Model: 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(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 cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+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", 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)
}
}
}
+287
View File
@@ -0,0 +1,287 @@
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) {
cfg := c.Config()
if !cfg.ASRConfigured() {
return nil, fmt.Errorf("transcription endpoint is not configured")
}
if IsChatOnlyGateway(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", 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(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", 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(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 := 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", 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)
}
}
+46 -2
View File
@@ -21,14 +21,18 @@ 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"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/clientip"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"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"
)
@@ -527,6 +531,15 @@ func (h *AdminHandler) HandleUpdateUser(w http.ResponseWriter, r *http.Request)
}
}
// Publish a domain event rather than creating the notification inline; the
// notifications materializer's system matcher turns it into the user's
// "Password changed" notice, keeping all notification creation event-driven.
if req.Password != nil && h.RealtimeHub != nil {
_ = h.RealtimeHub.EventsHub().PublishJSON(r.Context(),
evt.ChannelSessions, notifications.EventUserPasswordChanged, nil,
evt.PublishOptions{UserID: id})
}
user, err := h.userRepo.GetByID(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to fetch updated user")
@@ -1215,6 +1228,9 @@ func (h *AdminHandler) HandleGetSensitiveStatus(w http.ResponseWriter, r *http.R
type adminSettingResponse struct {
Key string `json:"key"`
Value string `json:"value"`
// RestartRequired reports whether the saved value only takes effect
// after a server restart (set on update responses only).
RestartRequired bool `json:"restart_required,omitempty"`
}
type adminSettingsListResponse struct {
@@ -2101,6 +2117,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 {
@@ -2116,9 +2159,10 @@ func (h *AdminHandler) HandleUpdateSetting(w http.ResponseWriter, r *http.Reques
h.OnServerSettingUpdated(r.Context(), key, req.Value)
}
restartRequired := config.RestartRequired(key)
if sensitiveSettingKeys[key] {
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, RestartRequired: restartRequired})
return
}
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value})
writeJSON(w, http.StatusOK, adminSettingResponse{Key: key, Value: req.Value, RestartRequired: restartRequired})
}
@@ -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},
@@ -0,0 +1,143 @@
package handlers
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/notifications"
)
// ---------------------------------------------------------------------------
// Minimal fake UserRepository for AdminHandler tests.
// ---------------------------------------------------------------------------
type fakeUserRepo struct {
user *models.User
getErr error
}
func (f *fakeUserRepo) List(_ context.Context) ([]*models.User, error) { return nil, nil }
func (f *fakeUserRepo) Create(_ context.Context, _ models.CreateUserInput) (*models.User, error) {
return f.user, nil
}
func (f *fakeUserRepo) Update(_ context.Context, _ int, _ models.UpdateUserInput) error {
return nil
}
func (f *fakeUserRepo) Delete(_ context.Context, _ int) error { return nil }
func (f *fakeUserRepo) GetByID(_ context.Context, _ int) (*models.User, error) {
return f.user, f.getErr
}
// Compile-time check.
var _ UserRepository = (*fakeUserRepo)(nil)
// ---------------------------------------------------------------------------
// Helper: build AdminHandler with a realtime hub whose events the test can
// observe. Password changes publish a domain event; the notifications
// materializer (tested separately) turns it into the user's notification.
// ---------------------------------------------------------------------------
func newAdminHandlerWithHub(userRepo UserRepository) (*AdminHandler, *evt.Hub) {
rh := notifications.NewHub("test", nil)
h := NewAdminHandler(userRepo, nil, nil)
h.RealtimeHub = rh
return h, rh.EventsHub()
}
// adminUpdateRequest builds a PUT /admin/users/{id} HTTP request.
func adminUpdateRequest(body []byte, userID string) *http.Request {
req := httptest.NewRequest(http.MethodPut, "/admin/users/"+userID, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", userID)
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
}
// ---------------------------------------------------------------------------
// Tests: HandleUpdateUser password change notification
// ---------------------------------------------------------------------------
// TestHandleUpdateUser_WithPassword_PublishesEvent asserts that including a
// Password in the update payload publishes one user.password_changed event on
// ChannelSessions targeted at the updated user.
func TestHandleUpdateUser_WithPassword_PublishesEvent(t *testing.T) {
targetUser := &models.User{
ID: 99,
Username: "alice",
Email: "alice@example.com",
Role: "user",
}
repo := &fakeUserRepo{user: targetUser}
h, eventsHub := newAdminHandlerWithHub(repo)
ch, unsub := eventsHub.Subscribe()
defer unsub()
body := []byte(`{"password":"newSecret123"}`)
req := adminUpdateRequest(body, "99")
rec := httptest.NewRecorder()
h.HandleUpdateUser(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
select {
case env := <-ch:
if env.Channel != evt.ChannelSessions {
t.Errorf("channel = %q, want %q", env.Channel, evt.ChannelSessions)
}
if env.Event != notifications.EventUserPasswordChanged {
t.Errorf("event = %q, want %q", env.Event, notifications.EventUserPasswordChanged)
}
if env.UserID != 99 {
t.Errorf("event user_id = %d, want 99", env.UserID)
}
case <-time.After(time.Second):
t.Fatal("expected a user.password_changed event, got none")
}
}
// TestHandleUpdateUser_WithoutPassword_NoEvent asserts that a non-password
// update publishes no user.password_changed event.
func TestHandleUpdateUser_WithoutPassword_NoEvent(t *testing.T) {
targetUser := &models.User{
ID: 99,
Username: "alice",
Email: "alice@example.com",
Role: "user",
}
repo := &fakeUserRepo{user: targetUser}
h, eventsHub := newAdminHandlerWithHub(repo)
ch, unsub := eventsHub.Subscribe()
defer unsub()
body := []byte(`{"username":"alice-renamed"}`)
req := adminUpdateRequest(body, "99")
rec := httptest.NewRecorder()
h.HandleUpdateUser(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
select {
case env := <-ch:
t.Errorf("expected no event, got %q on %q", env.Event, env.Channel)
case <-time.After(100 * time.Millisecond):
// no event, as expected
}
}
@@ -326,6 +326,47 @@ func TestAdminUpdateSettingRedactsSensitiveSetting(t *testing.T) {
}
}
func TestAdminUpdateSettingReportsRestartRequired(t *testing.T) {
cases := []struct {
key string
value string
restartRequired bool
}{
// Infrastructure settings are captured at startup.
{key: "database.max_connections", value: "40", restartRequired: true},
{key: "s3.public_bucket", value: "assets", restartRequired: true},
// Branding is read live from the settings repo per request.
{key: "branding.server_name", value: "Casa", restartRequired: false},
}
for _, tc := range cases {
t.Run(tc.key, func(t *testing.T) {
settings := &fakeServerSettingsStore{}
handler := &AdminHandler{SettingsRepo: settings}
req := httptest.NewRequest(
http.MethodPut,
"/admin/settings/"+tc.key,
strings.NewReader(`{"value":"`+tc.value+`"}`),
)
req = withChiParam(req, "key", tc.key)
rec := httptest.NewRecorder()
handler.HandleUpdateSetting(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
var resp adminSettingResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.RestartRequired != tc.restartRequired {
t.Fatalf("restart_required = %v, want %v", resp.RestartRequired, tc.restartRequired)
}
})
}
}
func performSettingsCheckRequest(
t *testing.T,
handler *AdminHandler,
+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 {
+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
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"
@@ -89,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))
+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", "audiobook":
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
+901
View File
@@ -0,0 +1,901 @@
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,
Role: "user",
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,
Role: "user",
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,
Role: "user",
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,
Role: "user",
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 }
+60 -2
View File
@@ -12,6 +12,8 @@ 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/notifications"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/scanqueue"
"github.com/Silo-Server/silo-server/internal/taskmanager"
"github.com/gorilla/websocket"
@@ -31,6 +33,11 @@ type activeScanLister interface {
ListActive(ctx context.Context) ([]evt.ScanRun, error)
}
// notificationUnreadCounter is the subset of notifications.Service used by EventsHandler.
type notificationUnreadCounter interface {
UnreadCount(ctx context.Context, userID int, profileID string, childSafe bool) (int, error)
}
type EventsHandler struct {
hub *evt.Hub
jobs *AdminJobsHandler
@@ -39,6 +46,8 @@ type EventsHandler struct {
scans *evt.ScanRegistry
persistedScans activeScanLister
historyImports historyImportActiveLister
notifications notificationUnreadCounter
presence presence.Registry
}
func NewEventsHandler(
@@ -49,6 +58,8 @@ func NewEventsHandler(
scans *evt.ScanRegistry,
persistedScans *scanqueue.Service,
historyImports historyImportActiveLister,
notificationsSvc *notifications.Service,
presenceReg presence.Registry,
) *EventsHandler {
return &EventsHandler{
hub: hub,
@@ -58,6 +69,8 @@ func NewEventsHandler(
scans: scans,
persistedScans: persistedScans,
historyImports: historyImports,
notifications: notificationsSvc,
presence: presenceReg,
}
}
@@ -85,7 +98,16 @@ func (h *EventsHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request)
eventsCh, unsubscribe := h.hub.Subscribe()
defer unsubscribe()
if h.presence != nil {
release := h.presence.Add(ctx, claims.UserID)
defer release()
}
startWebSocketPingLoop(ctx, func() error {
if h.presence != nil {
h.presence.Refresh(ctx, claims.UserID)
}
return writeWebSocketControl(conn, websocket.PingMessage, nil)
})
@@ -274,6 +296,8 @@ func allowedChannelsForRole(role string) []evt.EventChannel {
channels := []evt.EventChannel{
evt.ChannelCatalog,
evt.ChannelHistoryImport,
evt.ChannelNotifications,
evt.ChannelRequests,
evt.ChannelUserState,
}
if role == "admin" {
@@ -294,12 +318,24 @@ func allowsEventForClaims(claims *auth.Claims, env evt.Envelope) bool {
if env.AdminOnly && claims.Role != "admin" {
return false
}
if env.UserID > 0 && claims.Role != "admin" && env.UserID != claims.UserID {
return false
if env.UserID > 0 && env.UserID != claims.UserID {
// The notifications and requests channels carry per-user private content
// (inbox titles/bodies, requested media names). Nobody — including
// admins — may receive another user's events on them. Other channels
// keep the admin cross-user monitoring bypass.
if claims.Role != "admin" || isPersonalChannel(env.Channel) {
return false
}
}
return true
}
// isPersonalChannel reports whether a channel carries per-user private content
// that must never be delivered across user boundaries, even to admins.
func isPersonalChannel(c evt.EventChannel) bool {
return c == evt.ChannelNotifications || c == evt.ChannelRequests
}
func marshalJSON(value any) json.RawMessage {
if value == nil {
return json.RawMessage("null")
@@ -319,6 +355,28 @@ func (h *EventsHandler) snapshotForChannel(
switch channel {
case evt.ChannelCatalog, evt.ChannelUserState:
return json.RawMessage("null"), nil
case evt.ChannelNotifications:
if h == nil || h.notifications == nil {
return json.RawMessage(`{"unread_count":0}`), nil
}
// Profile ID is read directly from the WS upgrade request header because
// the events/ws route does not go through the RequireProfile middleware.
// childSafe is intentionally false here: the EventsHandler has no access
// to a profile store, so the WS snapshot is a conservative approximation.
// The REST GET /notifications/unread-count endpoint is authoritative for
// child-safe profile filtering.
profileID := r.Header.Get("X-Profile-Id")
count, err := h.notifications.UnreadCount(r.Context(), claims.UserID, profileID, false)
if err != nil {
return nil, err
}
return marshalJSON(struct {
UnreadCount int `json:"unread_count"`
}{UnreadCount: count}), nil
case evt.ChannelRequests:
// Request state is fetched via REST; the WS channel only carries
// lifecycle events. Return an empty object as the initial snapshot.
return json.RawMessage(`{}`), nil
case evt.ChannelJobs:
if h == nil || h.jobs == nil || h.jobs.repo == nil {
return json.RawMessage("[]"), nil
+125
View File
@@ -0,0 +1,125 @@
package handlers
import (
"context"
"encoding/json"
"net/http/httptest"
"slices"
"testing"
"github.com/Silo-Server/silo-server/internal/auth"
evt "github.com/Silo-Server/silo-server/internal/events"
)
// ---------------------------------------------------------------------------
// Fake notificationUnreadCounter for snapshot tests.
// ---------------------------------------------------------------------------
type fakeUnreadCounter struct {
count int
err error
}
func (f *fakeUnreadCounter) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
return f.count, f.err
}
// ---------------------------------------------------------------------------
// allowedChannelsForRole tests
// ---------------------------------------------------------------------------
func TestAllowedChannelsForRole_UserContainsNotificationsAndRequests(t *testing.T) {
channels := allowedChannelsForRole("user")
if !slices.Contains(channels, evt.ChannelNotifications) {
t.Errorf("allowedChannelsForRole(user) missing ChannelNotifications; got %v", channels)
}
if !slices.Contains(channels, evt.ChannelRequests) {
t.Errorf("allowedChannelsForRole(user) missing ChannelRequests; got %v", channels)
}
}
func TestAllowedChannelsForRole_AdminContainsAllUserChannelsPlusExtras(t *testing.T) {
userChannels := allowedChannelsForRole("user")
adminChannels := allowedChannelsForRole("admin")
for _, ch := range userChannels {
if !slices.Contains(adminChannels, ch) {
t.Errorf("allowedChannelsForRole(admin) missing user channel %q", ch)
}
}
adminExtras := []evt.EventChannel{
evt.ChannelJobs,
evt.ChannelSessions,
evt.ChannelTasks,
evt.ChannelScans,
}
for _, ch := range adminExtras {
if !slices.Contains(adminChannels, ch) {
t.Errorf("allowedChannelsForRole(admin) missing admin-only channel %q", ch)
}
}
}
// ---------------------------------------------------------------------------
// snapshotForChannel tests
// ---------------------------------------------------------------------------
func TestSnapshotForChannel_NotificationsNilService(t *testing.T) {
h := &EventsHandler{notifications: nil}
r := httptest.NewRequest("GET", "/events/ws", nil)
claims := &auth.Claims{UserID: 42, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if got.UnreadCount != 0 {
t.Errorf("nil service: want unread_count=0, got %d", got.UnreadCount)
}
}
func TestSnapshotForChannel_NotificationsWithService(t *testing.T) {
h := &EventsHandler{notifications: &fakeUnreadCounter{count: 7}}
r := httptest.NewRequest("GET", "/events/ws", nil)
r.Header.Set("X-Profile-Id", "profile-abc")
claims := &auth.Claims{UserID: 5, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelNotifications)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got struct {
UnreadCount int `json:"unread_count"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if got.UnreadCount != 7 {
t.Errorf("want unread_count=7, got %d", got.UnreadCount)
}
}
func TestSnapshotForChannel_RequestsReturnsEmptyObject(t *testing.T) {
h := &EventsHandler{}
r := httptest.NewRequest("GET", "/events/ws", nil)
claims := &auth.Claims{UserID: 1, Role: "user"}
data, err := h.snapshotForChannel(r, claims, evt.ChannelRequests)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v, raw=%s", err, data)
}
if len(got) != 0 {
t.Errorf("ChannelRequests snapshot should be empty object, got %v", got)
}
}
+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())
}
}
+249 -27
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
@@ -376,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)
}
@@ -432,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):
@@ -443,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{
@@ -462,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)
@@ -474,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
}
@@ -742,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{}
}
@@ -760,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) != "" {
@@ -783,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
}
@@ -853,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))
@@ -893,7 +1011,7 @@ 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)
}
@@ -983,6 +1101,7 @@ func (h *ItemsHandler) listUserSortMetrics(
items []*models.MediaItem,
sortField string,
store userstore.UserStore,
userID int,
profileID string,
metrics map[string]*sortMetricsResponse,
) {
@@ -998,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":
@@ -1006,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":
@@ -1038,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
}
@@ -1047,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
}
@@ -1057,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))
@@ -1193,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
@@ -1204,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,
@@ -1217,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
@@ -1261,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
@@ -1271,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
}
@@ -1398,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()),
}
}
@@ -1468,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
@@ -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)
}
}
+23 -12
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,
@@ -652,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,
})
@@ -2034,18 +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 "audiobooks", "audiobook":
levels = []string{"audiobook"}
case "mixed":
levels = []string{"movie", "series", "season", "episode", "audiobook"}
default:
levels := metadataContentLevelsForLibraryType(libraryType)
if len(levels) == 0 {
return nil
}
@@ -2098,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)
}
})
}
}
+212
View File
@@ -0,0 +1,212 @@
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/Silo-Server/silo-server/internal/access"
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/metadata/translation"
)
// MetadataAIHandler exposes AI translation of catalog descriptions into the
// localization tables. The admin routes are mounted under the per-item
// metadata curation guard; the on-view route is viewer-facing and enforces
// item access itself.
type MetadataAIHandler struct {
service *translation.Service
// ItemAccess authorizes the viewer-facing on-view route; nil disables it.
ItemAccess *catalog.ItemRepository
}
// NewMetadataAIHandler creates a handler backed by the given service.
func NewMetadataAIHandler(service *translation.Service) *MetadataAIHandler {
return &MetadataAIHandler{service: service}
}
// HandleStatus reports whether metadata AI translation is available and the
// viewer-facing on-view mode, so the metadata editor and detail pages can
// show or hide their entry points.
// GET /api/v1/metadata/ai/status
func (h *MetadataAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": h.service.Enabled(),
"on_view": h.service.OnViewMode(),
})
}
// WriteMetadataAIDisabledStatus answers the status probe with a clean negative
// when no metadata AI handler is wired.
func WriteMetadataAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "on_view": "off"})
}
type translateDescriptionRequest struct {
// TargetLanguage echoes the detail response's pending_translation_language.
TargetLanguage string `json:"target_language"`
}
// HandleTranslateOnView is the viewer-facing on-demand description
// translation: any profile that can access the item may request its
// descriptions in the language the detail response reported missing. Gated by
// metadata_ai.on_view; duplicate viewers collapse onto one job and recently
// failed targets are not retried (cooldown in the service).
// POST /api/v1/items/{id}/translate-description
func (h *MetadataAIHandler) HandleTranslateOnView(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
var req translateDescriptionRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
return
}
if req.TargetLanguage == "" {
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
return
}
scope, ok := access.GetScope(r.Context())
if !ok || h.ItemAccess == nil {
writeError(w, http.StatusForbidden, "forbidden", "Viewer access is required")
return
}
filter := catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
UserID: scope.UserID,
ProfileID: scope.ProfileID,
}
if err := h.ItemAccess.EnsureAccessible(r.Context(), contentID, filter); err != nil {
if errors.Is(err, catalog.ErrItemNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Item not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to authorize item")
return
}
var requestedBy *int
if userID := apimw.GetUserID(r.Context()); userID != 0 {
requestedBy = &userID
}
job, err := h.service.RequestOnView(r.Context(), contentID, req.TargetLanguage, requestedBy)
if err != nil {
switch {
case errors.Is(err, translation.ErrNotConfigured):
writeError(w, http.StatusServiceUnavailable, "not_configured",
"On-view translation is not enabled on this server")
case errors.Is(err, translation.ErrInvalidRequest):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
slog.Error("failed to request on-view translation",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
}
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
}
type translateMetadataRequest struct {
TargetLanguage string `json:"target_language"`
IncludeChildren *bool `json:"include_children"` // default true
Force bool `json:"force"`
}
// HandleTranslate enqueues a translation job for an item.
// POST /api/v1/admin/items/{id}/metadata-translation
func (h *MetadataAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Request) {
contentID := chi.URLParam(r, "id")
var req translateMetadataRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid request body")
return
}
if req.TargetLanguage == "" {
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
return
}
includeChildren := true
if req.IncludeChildren != nil {
includeChildren = *req.IncludeChildren
}
var requestedBy *int
if userID := apimw.GetUserID(r.Context()); userID != 0 {
requestedBy = &userID
}
job, err := h.service.Enqueue(r.Context(), translation.JobRequest{
TargetKind: translation.TargetItem,
ContentID: contentID,
TargetLanguage: req.TargetLanguage,
IncludeChildren: includeChildren,
Force: req.Force,
RequestedBy: requestedBy,
})
if err != nil {
switch {
case errors.Is(err, translation.ErrNotConfigured):
writeError(w, http.StatusServiceUnavailable, "not_configured",
"Metadata AI translation is not configured on this server")
case errors.Is(err, translation.ErrInvalidRequest):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
slog.Error("failed to enqueue metadata translation",
"content_id", contentID, "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start translation")
}
return
}
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
}
// HandleListJobs lists recent translation jobs for an item; the metadata
// editor polls this for progress.
// GET /api/v1/admin/items/{id}/metadata-translation/jobs
func (h *MetadataAIHandler) HandleListJobs(w http.ResponseWriter, r *http.Request) {
jobs, err := h.service.ListJobs(r.Context(), chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusInternalServerError, "list_error", "Failed to list jobs")
return
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs})
}
// HandleCancelJob cancels a job belonging to the item in the URL.
// POST /api/v1/admin/items/{id}/metadata-translation/jobs/{job_id}/cancel
func (h *MetadataAIHandler) HandleCancelJob(w http.ResponseWriter, r *http.Request) {
jobID, err := strconv.ParseInt(chi.URLParam(r, "job_id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_id", "Invalid job ID")
return
}
job, err := h.service.GetJob(r.Context(), jobID)
if err != nil {
if errors.Is(err, translation.ErrJobNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Job not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load job")
return
}
// The curation guard authorized {id}; the job must belong to it.
if job.ContentID != chi.URLParam(r, "id") {
writeError(w, http.StatusNotFound, "not_found", "Job not found")
return
}
if err := h.service.Cancel(r.Context(), jobID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to cancel job")
return
}
w.WriteHeader(http.StatusNoContent)
}
+5 -3
View File
@@ -26,7 +26,7 @@ type NodeRepository interface {
Create(ctx context.Context, input nodepool.CreateNodeInput) (*nodepool.Node, error)
Update(ctx context.Context, id int, input nodepool.UpdateNodeInput) (*nodepool.Node, error)
Delete(ctx context.Context, id int) error
UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs int) error
UpdateHealth(ctx context.Context, id int, healthy bool, activeJobs, egressKbps int) error
}
// NodeListEnabled queries enabled nodes by type for pool reload.
@@ -70,6 +70,7 @@ type ForceReloadResult struct {
type checkNodeResult struct {
Healthy bool `json:"healthy"`
ActiveJobs int `json:"active_jobs"`
EgressKbps int `json:"egress_kbps"`
}
// HandleListNodes handles GET /admin/nodes.
@@ -181,15 +182,16 @@ func (h *NodeHandler) HandleCheckNode(w http.ResponseWriter, r *http.Request) {
return
}
healthy, activeJobs := nodepool.CheckNode(r.Context(), node)
healthy, activeJobs, egressKbps := nodepool.CheckNode(r.Context(), node)
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs); err != nil {
if err := h.repo.UpdateHealth(r.Context(), id, healthy, activeJobs, egressKbps); err != nil {
slog.Error("persisting health check result", "node_id", id, "error", err)
}
writeJSON(w, http.StatusOK, checkNodeResult{
Healthy: healthy,
ActiveJobs: activeJobs,
EgressKbps: egressKbps,
})
}
+316
View File
@@ -0,0 +1,316 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// profileResolver is the minimal interface NotificationsHandler needs from the
// userstore provider. Defined on the handler side so tests can stub it without
// importing the full provider.
type profileResolver interface {
ForUser(ctx context.Context, userID int) (userstore.UserStore, error)
}
// NotificationsHandler handles inbox, preferences, and announcement endpoints.
type NotificationsHandler struct {
svc *notifications.Service
profiles profileResolver // nil-tolerant: ChildSafe=false when absent
}
// NewNotificationsHandler creates a new NotificationsHandler.
// provider may be nil; when nil, ChildSafe defaults to false on all requests.
func NewNotificationsHandler(svc *notifications.Service, provider ...profileResolver) *NotificationsHandler {
h := &NotificationsHandler{svc: svc}
if len(provider) > 0 {
h.profiles = provider[0]
}
return h
}
// childSafe resolves the active profile and returns Profile.IsChild.
// Fails closed: an unresolvable profile is treated as a child so restricted
// categories are never leaked on errors. Logs at debug level only — never fails
// the request. Nil resolver / missing ids keep returning false (unauthenticated
// paths never reach here; that's the wiring-absent case).
func (h *NotificationsHandler) childSafe(r *http.Request, userID int, profileID string) bool {
if h.profiles == nil || userID == 0 || profileID == "" {
return false
}
store, err := h.profiles.ForUser(r.Context(), userID)
if err != nil {
slog.Debug("notifications: childSafe: ForUser failed", "user_id", userID, "error", err)
return true // fail closed: treat unknown as child
}
p, err := store.GetProfile(r.Context(), profileID)
if err != nil || p == nil {
slog.Debug("notifications: childSafe: GetProfile failed", "profile_id", profileID, "error", err)
return true // fail closed: treat unknown as child
}
return p.IsChild
}
// ---------------------------------------------------------------------------
// User-scoped inbox endpoints
// ---------------------------------------------------------------------------
// HandleList handles GET /notifications.
// Query params: unread=1, category, cursor (int64), limit (int, default 50, max 100).
// Response: {"items":[...],"next_cursor":<int64|null>}
func (h *NotificationsHandler) HandleList(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
q := r.URL.Query()
limit := 50
if v := q.Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
// keep in sync with the clamp in internal/notifications/store.go List
if limit > 100 {
limit = 100
}
f := notifications.ListFilter{
UserID: userID,
ProfileID: profileID,
Limit: limit,
ChildSafe: h.childSafe(r, userID, profileID),
}
if q.Get("unread") == "1" {
f.UnreadOnly = true
}
if cat := q.Get("category"); cat != "" {
f.Category = notifications.Category(cat)
}
if v := q.Get("cursor"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
f.Cursor = n
}
}
items, err := h.svc.List(r.Context(), f)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list notifications")
return
}
var nextCursor *int64
if len(items) == limit && limit > 0 {
last := items[len(items)-1].ID
nextCursor = &last
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}{
Items: items,
NextCursor: nextCursor,
})
}
// HandleUnreadCount handles GET /notifications/unread-count.
// Response: {"count": n}
func (h *NotificationsHandler) HandleUnreadCount(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
count, err := h.svc.UnreadCount(r.Context(), userID, profileID, h.childSafe(r, userID, profileID))
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get unread count")
return
}
writeJSON(w, http.StatusOK, struct {
Count int `json:"count"`
}{Count: count})
}
// markReadRequest is the request body for POST /notifications/read.
type markReadRequest struct {
IDs []int64 `json:"ids"`
All bool `json:"all"`
}
// HandleMarkRead handles POST /notifications/read.
// Body: {"ids":[...]} OR {"all":true}. 400 when neither present.
func (h *NotificationsHandler) HandleMarkRead(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
var req markReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if !req.All && len(req.IDs) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Provide ids or all:true")
return
}
if req.All {
if err := h.svc.MarkAllRead(r.Context(), userID, profileID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark all read")
return
}
} else {
if err := h.svc.MarkRead(r.Context(), userID, profileID, req.IDs); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to mark notifications read")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
// HandleDismiss handles POST /notifications/{id}/dismiss.
// 400 for invalid id; 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDismiss(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid notification id")
return
}
if err := h.svc.Dismiss(r.Context(), userID, profileID, id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Notification not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to dismiss notification")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleGetPreferences handles GET /notifications/preferences.
// Response: {"preferences":[{category,enabled},...]}
func (h *NotificationsHandler) HandleGetPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
prefs, err := h.svc.GetPreferences(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get preferences")
return
}
writeJSON(w, http.StatusOK, struct {
Preferences []notifications.Preference `json:"preferences"`
}{Preferences: prefs})
}
// putPreferencesRequest is the request body for PUT /notifications/preferences.
type putPreferencesRequest struct {
Preferences []notifications.Preference `json:"preferences"`
}
// HandlePutPreferences handles PUT /notifications/preferences.
// 400 on empty or invalid category; 204 on success.
func (h *NotificationsHandler) HandlePutPreferences(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
var req putPreferencesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if len(req.Preferences) == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "preferences must not be empty")
return
}
if err := h.svc.SetPreferences(r.Context(), userID, req.Preferences); err != nil {
if errors.Is(err, notifications.ErrInvalidCategory) {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save preferences")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Admin announcement endpoints
// ---------------------------------------------------------------------------
// HandleListAnnouncements handles GET /admin/announcements.
// Response: {"items":[...]}
func (h *NotificationsHandler) HandleListAnnouncements(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.ListAnnouncements(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list announcements")
return
}
if items == nil {
items = []*notifications.Announcement{}
}
writeJSON(w, http.StatusOK, struct {
Items []*notifications.Announcement `json:"items"`
}{Items: items})
}
// HandleCreateAnnouncement handles POST /admin/announcements.
// Decodes an Announcement, sets CreatedBy from claims, publishes; 400 on
// validation error; 201 with the announcement on success.
func (h *NotificationsHandler) HandleCreateAnnouncement(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
// Announcements fan out one row per (user, profile); cap the request body so
// a large title/body cannot be amplified into the whole server's inboxes.
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
var a notifications.Announcement
if err := json.NewDecoder(r.Body).Decode(&a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if claims != nil {
userID := claims.UserID
a.CreatedBy = &userID
}
if err := h.svc.PublishAnnouncement(r.Context(), &a); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
return
}
writeJSON(w, http.StatusCreated, &a)
}
// HandleDeleteAnnouncement handles DELETE /admin/announcements/{id}.
// 404 on ErrNotFound; 204 on success.
func (h *NotificationsHandler) HandleDeleteAnnouncement(w http.ResponseWriter, r *http.Request) {
rawID := chi.URLParam(r, "id")
id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid announcement id")
return
}
if err := h.svc.DeleteAnnouncement(r.Context(), id); err != nil {
if errors.Is(err, notifications.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Announcement not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete announcement")
return
}
w.WriteHeader(http.StatusNoContent)
}
+673
View File
@@ -0,0 +1,673 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"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"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ---------------------------------------------------------------------------
// Fake profile resolver for child-safe tests.
// ---------------------------------------------------------------------------
// fakeProfileStore satisfies userstore.UserStore minimally.
// Only GetProfile is exercised by childSafe.
type fakeProfileStore struct {
userstore.UserStore // embed for unimplemented methods
isChild bool
}
func (s *fakeProfileStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
return &userstore.Profile{ID: id, IsChild: s.isChild}, nil
}
// fakeResolverAsProfileResolver implements the profileResolver interface used
// by NotificationsHandler.
type fakeResolverAsProfileResolver struct {
isChild bool
}
func (f *fakeResolverAsProfileResolver) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
return &fakeProfileStore{isChild: f.isChild}, nil
}
// fakeResolverWithError returns an error from ForUser.
type fakeResolverWithError struct{}
func (f *fakeResolverWithError) ForUser(_ context.Context, _ int) (userstore.UserStore, error) {
return nil, errors.New("resolver failed")
}
// ---------------------------------------------------------------------------
// Fake store implementing notifications.Store for handler tests.
// ---------------------------------------------------------------------------
type fakeNotificationsStore struct {
notifications.Store // embed for unimplemented panics
list []*notifications.Notification
announcements []*notifications.Announcement
unreadCount int
prefs map[notifications.Category]bool
markReadCalled bool
markAllReadCalled bool
dismissErr error
setPrefErr error
deleteAnnouncementErr error
insertCalled bool
// capturedFilter records the most recent ListFilter passed to List.
capturedFilter *notifications.ListFilter
}
func (f *fakeNotificationsStore) Insert(_ context.Context, n *notifications.Notification) (bool, error) {
f.insertCalled = true
f.list = append(f.list, n)
return true, nil
}
func (f *fakeNotificationsStore) List(_ context.Context, filter notifications.ListFilter) ([]*notifications.Notification, error) {
f.capturedFilter = &filter
return f.list, nil
}
func (f *fakeNotificationsStore) UnreadCount(_ context.Context, _ int, _ string, _ bool) (int, error) {
return f.unreadCount, nil
}
func (f *fakeNotificationsStore) MarkRead(_ context.Context, _ int, _ string, _ []int64) error {
f.markReadCalled = true
return nil
}
func (f *fakeNotificationsStore) MarkAllRead(_ context.Context, _ int, _ string) error {
f.markAllReadCalled = true
return nil
}
func (f *fakeNotificationsStore) Dismiss(_ context.Context, _ int, _ string, _ int64) error {
return f.dismissErr
}
func (f *fakeNotificationsStore) Preferences(_ context.Context, _ int) (map[notifications.Category]bool, error) {
if f.prefs != nil {
return f.prefs, nil
}
return map[notifications.Category]bool{}, nil
}
func (f *fakeNotificationsStore) SetPreferences(_ context.Context, _ int, _ []notifications.Preference) error {
return f.setPrefErr
}
func (f *fakeNotificationsStore) InsertAnnouncement(_ context.Context, a *notifications.Announcement) error {
a.ID = int64(len(f.announcements) + 1)
a.CreatedAt = time.Now().UTC()
f.announcements = append(f.announcements, a)
return nil
}
func (f *fakeNotificationsStore) ListAnnouncements(_ context.Context) ([]*notifications.Announcement, error) {
return f.announcements, nil
}
func (f *fakeNotificationsStore) DeleteAnnouncement(_ context.Context, id int64) error {
if f.deleteAnnouncementErr != nil {
return f.deleteAnnouncementErr
}
for i, a := range f.announcements {
if a.ID == id {
f.announcements = append(f.announcements[:i], f.announcements[i+1:]...)
return nil
}
}
return notifications.ErrNotFound
}
func (f *fakeNotificationsStore) DismissAnnouncementNotifications(_ context.Context, _ int64) error {
return nil
}
func (f *fakeNotificationsStore) ProfileIDsForUsers(_ context.Context, _ []int) (map[int][]string, error) {
return nil, nil
}
func (f *fakeNotificationsStore) PurgeOld(_ context.Context, _, _ time.Time) (int64, error) {
return 0, nil
}
func (f *fakeNotificationsStore) AdminUserIDs(_ context.Context) ([]int, error) { return nil, nil }
func (f *fakeNotificationsStore) UserIDsWithLibraryAccess(_ context.Context, _ int) ([]int, error) {
return nil, nil
}
func (f *fakeNotificationsStore) AllEnabledUserIDs(_ context.Context) ([]int, error) { return nil, nil }
// Compile-time check.
var _ notifications.Store = (*fakeNotificationsStore)(nil)
// ---------------------------------------------------------------------------
// Helper: build a handler over the fake store.
// ---------------------------------------------------------------------------
func newTestNotificationsHandler(store *fakeNotificationsStore) *NotificationsHandler {
hub := evt.NewHub("test", nil)
svc := notifications.NewService(store, hub)
return NewNotificationsHandler(svc)
}
func newTestNotificationsHandlerWithResolver(store *fakeNotificationsStore, resolver profileResolver) *NotificationsHandler {
hub := evt.NewHub("test", nil)
svc := notifications.NewService(store, hub)
return NewNotificationsHandler(svc, resolver)
}
// notifUserRequest creates an authenticated request with a profile ID.
func notifUserRequest(method, target string, body []byte, userID int, profileID string) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
ctx = apimw.SetProfileID(ctx, profileID)
return req.WithContext(ctx)
}
// notifAdminRequest creates an admin-authenticated request.
func notifAdminRequest(method, target string, body []byte, userID int) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "admin"})
return req.WithContext(ctx)
}
// notifChiRequest adds a chi URL param to a request context.
func notifChiRequest(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// ---------------------------------------------------------------------------
// Tests: HandleList
// ---------------------------------------------------------------------------
func TestNotificationsHandleList_HappyPath_TwoRowsNoCursor(t *testing.T) {
now := time.Now().UTC()
store := &fakeNotificationsStore{
list: []*notifications.Notification{
{ID: 2, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
{ID: 1, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Items) != 2 {
t.Fatalf("items count = %d, want 2", len(resp.Items))
}
// len < limit (default 50), so next_cursor must be null.
if resp.NextCursor != nil {
t.Fatalf("next_cursor = %v, want nil (items < limit)", resp.NextCursor)
}
}
// ---------------------------------------------------------------------------
// Tests: HandleUnreadCount
// ---------------------------------------------------------------------------
func TestNotificationsHandleUnreadCount_ReturnsCount(t *testing.T) {
store := &fakeNotificationsStore{unreadCount: 7}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications/unread-count", nil, 1, "prof-1")
h.HandleUnreadCount(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
Count int `json:"count"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Count != 7 {
t.Fatalf("count = %d, want 7", resp.Count)
}
}
// ---------------------------------------------------------------------------
// Tests: HandleMarkRead
// ---------------------------------------------------------------------------
func TestNotificationsHandleMarkRead_EmptyBody_Returns400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleMarkRead_AllTrue_Returns204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"all":true}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if !store.markAllReadCalled {
t.Fatal("MarkAllRead was not called")
}
}
func TestNotificationsHandleMarkRead_IDs_Returns204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/read", []byte(`{"ids":[1,2,3]}`), 1, "prof-1")
h.HandleMarkRead(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if !store.markReadCalled {
t.Fatal("MarkRead was not called")
}
}
// ---------------------------------------------------------------------------
// Tests: HandleDismiss
// ---------------------------------------------------------------------------
func TestNotificationsHandleDismiss_NotFound_Returns404(t *testing.T) {
store := &fakeNotificationsStore{dismissErr: notifications.ErrNotFound}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/999/dismiss", nil, 1, "prof-1")
req = notifChiRequest(req, "id", "999")
h.HandleDismiss(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDismiss_BadID_Returns400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPost, "/notifications/abc/dismiss", nil, 1, "prof-1")
req = notifChiRequest(req, "id", "abc")
h.HandleDismiss(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Tests: HandleGetPreferences
// ---------------------------------------------------------------------------
func TestNotificationsHandleGetPreferences_DefaultsReturned(t *testing.T) {
// No stored prefs → service returns all MutableCategories with defaults.
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications/preferences", nil, 1, "prof-1")
h.HandleGetPreferences(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Preferences []notifications.Preference `json:"preferences"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Preferences) != len(notifications.MutableCategories) {
t.Fatalf("prefs count = %d, want %d", len(resp.Preferences), len(notifications.MutableCategories))
}
prefMap := make(map[notifications.Category]bool)
for _, p := range resp.Preferences {
prefMap[p.Category] = p.Enabled
}
// content_digest defaults to false (opt-in); all others default to true.
for _, cat := range notifications.MutableCategories {
enabled, ok := prefMap[cat]
if !ok {
t.Errorf("category %q missing from preferences", cat)
continue
}
if cat == notifications.CategoryContentDigest {
if enabled {
t.Errorf("content_digest default should be false (opt-in), got true")
}
} else {
if !enabled {
t.Errorf("category %q default should be true, got false", cat)
}
}
}
}
// ---------------------------------------------------------------------------
// Tests: HandlePutPreferences
// ---------------------------------------------------------------------------
func TestNotificationsHandlePutPreferences_RejectsAnnouncementCategory_400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
// "announcement" is not in MutableCategories.
body := `{"preferences":[{"category":"announcement","enabled":false}]}`
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
h.HandlePutPreferences(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandlePutPreferences_ValidPrefs_204(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
body := `{"preferences":[{"category":"request","enabled":false},{"category":"content","enabled":true}]}`
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodPut, "/notifications/preferences", []byte(body), 1, "prof-1")
h.HandlePutPreferences(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Tests: Admin announcements
// ---------------------------------------------------------------------------
func TestNotificationsHandleCreateAnnouncement_201(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
body := `{"title":"Test Announcement","body":"Hello","audience":{"all":true}}`
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
h.HandleCreateAnnouncement(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status = %d, want 201; body: %s", rec.Code, rec.Body.String())
}
var resp notifications.Announcement
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Title != "Test Announcement" {
t.Fatalf("title = %q, want %q", resp.Title, "Test Announcement")
}
if resp.CreatedBy == nil || *resp.CreatedBy != 1 {
t.Fatalf("created_by = %v, want 1", resp.CreatedBy)
}
}
func TestNotificationsHandleCreateAnnouncement_ValidationError_400(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
// Empty title triggers service validation error.
body := `{"title":"","body":"Hello","audience":{"all":true}}`
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodPost, "/admin/announcements", []byte(body), 1)
h.HandleCreateAnnouncement(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDeleteAnnouncement_204(t *testing.T) {
store := &fakeNotificationsStore{
announcements: []*notifications.Announcement{
{ID: 5, Title: "To Delete", Audience: notifications.Audience{All: true}},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/5", nil, 1)
req = notifChiRequest(req, "id", "5")
h.HandleDeleteAnnouncement(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleDeleteAnnouncement_NotFound_404(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodDelete, "/admin/announcements/999", nil, 1)
req = notifChiRequest(req, "id", "999")
h.HandleDeleteAnnouncement(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestNotificationsHandleListAnnouncements_ReturnsItems(t *testing.T) {
store := &fakeNotificationsStore{
announcements: []*notifications.Announcement{
{ID: 1, Title: "Hello", Audience: notifications.Audience{All: true}},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifAdminRequest(http.MethodGet, "/admin/announcements", nil, 1)
h.HandleListAnnouncements(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Announcement `json:"items"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Items) != 1 {
t.Fatalf("items count = %d, want 1", len(resp.Items))
}
}
// ---------------------------------------------------------------------------
// Tests: ChildSafe profile filtering
// ---------------------------------------------------------------------------
// TestNotificationsList_ChildProfileFiltered asserts that when the profile
// resolver reports IsChild=true, the ListFilter passed to the store has
// ChildSafe=true.
func TestNotificationsList_ChildProfileFiltered(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverAsProfileResolver{isChild: true}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-child")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if !store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = false, want true for child profile")
}
}
// TestNotificationsList_AdultProfileNotFiltered asserts that a non-child
// profile leaves ChildSafe=false in the ListFilter.
func TestNotificationsList_AdultProfileNotFiltered(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverAsProfileResolver{isChild: false}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-adult")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = true, want false for non-child profile")
}
}
// TestNotificationsList_NilResolverDefaultsToFalse asserts that when no
// resolver is wired (nil), ChildSafe remains false.
func TestNotificationsList_NilResolverDefaultsToFalse(t *testing.T) {
store := &fakeNotificationsStore{}
h := newTestNotificationsHandler(store) // no resolver
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = true, want false when no resolver is wired")
}
}
// TestNotificationsList_ResolverErrorFailsClosed asserts that when the resolver
// returns an error, ChildSafe=true (fail closed).
func TestNotificationsList_ResolverErrorFailsClosed(t *testing.T) {
store := &fakeNotificationsStore{}
resolver := &fakeResolverWithError{}
h := newTestNotificationsHandlerWithResolver(store, resolver)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
if store.capturedFilter == nil {
t.Fatal("List was not called; capturedFilter is nil")
}
if !store.capturedFilter.ChildSafe {
t.Fatalf("capturedFilter.ChildSafe = false, want true (fail closed on resolver error)")
}
}
// TestNotificationsList_NextCursorSet asserts that when limit=2 and the store
// returns exactly 2 notifications, next_cursor is set to the last item's ID.
func TestNotificationsList_NextCursorSet(t *testing.T) {
now := time.Now().UTC()
store := &fakeNotificationsStore{
list: []*notifications.Notification{
{ID: 12, UserID: 1, Category: notifications.CategoryContent, Type: "content.added", Title: "New Movie", CreatedAt: now},
{ID: 11, UserID: 1, Category: notifications.CategoryRequest, Type: "request.approved", Title: "Approved", CreatedAt: now.Add(-time.Minute)},
},
}
h := newTestNotificationsHandler(store)
rec := httptest.NewRecorder()
req := notifUserRequest(http.MethodGet, "/notifications?limit=2", nil, 1, "prof-1")
h.HandleList(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
Items []*notifications.Notification `json:"items"`
NextCursor *int64 `json:"next_cursor"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(resp.Items) != 2 {
t.Fatalf("items count = %d, want 2", len(resp.Items))
}
// With limit=2 and 2 items returned, next_cursor should be set to the last item's ID (11).
if resp.NextCursor == nil {
t.Fatal("next_cursor is nil, want 11")
}
if *resp.NextCursor != 11 {
t.Fatalf("next_cursor = %d, want 11", *resp.NextCursor)
}
}
// Suppress unused import.
var _ = strings.Contains
+94 -84
View File
@@ -22,6 +22,7 @@ import (
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/clientip"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/markers"
"github.com/Silo-Server/silo-server/internal/models"
@@ -59,17 +60,6 @@ type sessionStarterWithFilesContext interface {
StartSessionWithFilesContext(ctx context.Context, userID int, profileID string, effectiveFileID int, requestedFileID int, method playback.PlayMethod, transcodeAudio bool) (*playback.Session, error)
}
// ProxyPicker selects a proxy node for stream routing.
type ProxyPicker interface {
Pick() *nodepool.Node
}
// TranscodeAcquirer selects a transcode node for transcoding jobs.
type TranscodeAcquirer interface {
Acquire() *nodepool.Node
FindByURL(url string) *nodepool.Node
}
type PlaybackItemAccessChecker interface {
EnsureAccessible(ctx context.Context, contentID string, filter catalog.AccessFilter) error
}
@@ -119,8 +109,7 @@ type PlaybackHandler struct {
SessionSyncer PlaybackSessionSyncer // optional; enables immediate session sync to shared admin view
EventsHub *evt.Hub
MissingMarker MissingFileMarker
ProxyPool ProxyPicker // optional; enables proxy-based stream URLs
TranscodePool TranscodeAcquirer // optional; enables transcode node selection
NodePlanner nodepool.SessionPlanner // optional; enables proxy/transcode node selection
JWTSecret string // needed for signing stream tokens
ItemAccess PlaybackItemAccessChecker // optional; enables file authorization checks
EpisodeLookup PlaybackEpisodeLookup // optional; resolves episode files to their series
@@ -141,11 +130,12 @@ type PlaybackHandler struct {
RealtimeHub *playback.RealtimeHub
CommandTracker *playback.CommandTracker
CommandDispatcher *playback.CommandDispatcher
FFmpegPath string
HWAccel string
HWDevice string
TranscodeDir string
FFmpegLogSink playback.FFmpegLogSink
// PlaybackConfig returns the current playback config (ffmpeg path,
// hwaccel, transcode dir). Wired to the live config in integrated mode
// so admin changes apply to newly started transcodes. Read it through
// playbackConfig(), which falls back to defaults when unset.
PlaybackConfig func() config.PlaybackConfig
FFmpegLogSink playback.FFmpegLogSink
transcodeMu sync.RWMutex
transcodes map[string]*playback.TranscodeSession
realtimeCommandMu sync.Mutex
@@ -168,7 +158,6 @@ type sessionExpirationHookSetter interface {
func NewPlaybackHandler(sessionMgr SessionManagerInterface, opts ...FilePathResolver) *PlaybackHandler {
h := &PlaybackHandler{
sessionMgr: sessionMgr,
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
transcodes: make(map[string]*playback.TranscodeSession),
realtimeCommands: make(map[string]playbackCommandRecord),
}
@@ -191,6 +180,19 @@ func (h *PlaybackHandler) SetProfileRefreshRequester(requester ProfileRefreshReq
h.profileRefreshRequester = requester
}
// playbackConfig returns the current playback config, falling back to the
// same defaults as config loading (transcode enabled, temp transcode dir)
// when no provider is wired (tests, minimal setups).
func (h *PlaybackHandler) playbackConfig() config.PlaybackConfig {
if h.PlaybackConfig != nil {
return h.PlaybackConfig()
}
return config.PlaybackConfig{
TranscodeEnabled: true,
TranscodeDir: filepath.Join(os.TempDir(), "silo-transcode"),
}
}
// CleanupOrphanedTranscodes removes stale per-session temp directories for
// transcodes that are no longer tracked in memory.
func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
@@ -201,7 +203,7 @@ func (h *PlaybackHandler) CleanupOrphanedTranscodes() (int, error) {
}
h.transcodeMu.RUnlock()
return playback.CleanupOrphanedTranscodeDirs(h.TranscodeDir, active)
return playback.CleanupOrphanedTranscodeDirs(h.playbackConfig().TranscodeDir, active)
}
// playbackThresholds reads the playback.watched_threshold and
@@ -445,6 +447,13 @@ func playbackStreamURL(s *playback.Session) string {
return fmt.Sprintf("/stream/%s", s.ID)
}
func fileBitrateKbps(file *models.MediaFile) int {
if file == nil || file.Bitrate <= 0 {
return 0
}
return file.Bitrate
}
func buildPlaybackInfo(session *playback.Session, file *models.MediaFile) *playbackInfoResult {
if session == nil {
return nil
@@ -619,9 +628,9 @@ func normalizeAudioTrackIndex(file *models.MediaFile, audioTrackIndex int) int {
return directPlayAudioTrackIndex(file)
}
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader) playback.AdminSettings {
func playbackAdminSettingsFromRequest(ctx context.Context, repo PlaybackSettingsReader, transcodeEnabled bool) playback.AdminSettings {
settings := playback.AdminSettings{
TranscodeEnabled: true,
TranscodeEnabled: transcodeEnabled,
}
if repo != nil {
if v, _ := repo.Get(ctx, "allow_4k_transcode"); v == "true" {
@@ -666,7 +675,7 @@ func (h *PlaybackHandler) resolveCapabilityPlaybackSelection(
}
audioTrackIndex = normalizeAudioTrackIndex(requestedFile, audioTrackIndex)
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo)
adminSettings := playbackAdminSettingsFromRequest(ctx, h.SettingsRepo, h.playbackConfig().TranscodeEnabled)
method, transcodeAudio := resolvePlaybackMethodForFile(requestedFile, req, audioTrackIndex, adminSettings)
if requestedFile.Resolution == "2160p" &&
@@ -1372,10 +1381,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
}
resp.SubtitleURLs = buildSubtitleURLs(session.ID, effectiveFile, downloadedSubs)
// If proxy nodes are available, generate proxy-based stream URLs.
if h.ProxyPool != nil && h.JWTSecret != "" {
proxyNode := h.ProxyPool.Pick()
if proxyNode != nil {
// If stream nodes are available, generate proxy-based stream URLs.
// Remux and transcode both use HLS via a transcode node, so the planner
// picks the transcode node and its group's proxy together.
if h.NodePlanner != nil && h.JWTSecret != "" {
needsTranscode := session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux
plan := h.NodePlanner.PlanSession(session.ID, "", needsTranscode, fileBitrateKbps(effectiveFile))
proxyNode := plan.ProxyNode
if proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
tokenClaims := streamtoken.Claims{
SessionID: session.ID,
PlayMethod: string(session.PlayMethod),
@@ -1389,12 +1402,9 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
tokenClaims.TranscodeAudio = session.TranscodeAudio
tokenClaims.AudioTrackIndex = session.AudioTrackIndex
// Remux and transcode both use HLS via a transcode node.
if (session.PlayMethod == playback.PlayTranscode || session.PlayMethod == playback.PlayRemux) && h.TranscodePool != nil {
if tcNode := h.TranscodePool.Acquire(); tcNode != nil {
tokenClaims.TranscodeNode = tcNode.URL
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, tcNode.URL)
}
if plan.TranscodeNode != nil {
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
_ = h.sessionMgr.SetTranscodeNodeURL(session.ID, plan.TranscodeNode.URL)
}
token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour)
@@ -1434,10 +1444,14 @@ func (h *PlaybackHandler) HandleStartPlayback(w http.ResponseWriter, r *http.Req
// subtitleURLExt returns the URL file extension for a subtitle codec.
// ASS/SSA tracks get ".ass" so the frontend can request raw ASS data for
// client-side rendering (JASSUB); all other text formats get ".vtt".
// client-side rendering (JASSUB); PGS tracks get ".sup" for client-side
// bitmap rendering (libpgs); all other text formats get ".vtt".
func subtitleURLExt(codec string) string {
if playback.IsASS(codec) {
switch {
case playback.IsASS(codec):
return ".ass"
case playback.IsPGS(codec):
return ".sup"
}
return ".vtt"
}
@@ -1464,7 +1478,10 @@ func buildSubtitleURLs(sessionID string, file *models.MediaFile, downloaded []su
embeddedOffset := len(file.ExternalSubtitles)
for i, track := range file.SubtitleTracks {
if playback.NeedsBurnIn(track.Codec) {
// PGS bitmap tracks are deliverable as .sup streams for
// client-side rendering; DVD/DVB bitmap tracks still have no
// non-burn-in delivery path, so they stay hidden.
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
continue
}
@@ -1771,8 +1788,14 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
PlaybackInfo: buildPlaybackInfo(&updatedSession, file),
}
if h.ProxyPool != nil && h.JWTSecret != "" {
if proxyNode := h.ProxyPool.Pick(); proxyNode != nil {
if h.NodePlanner != nil && h.JWTSecret != "" {
needsTranscode := updatedSession.PlayMethod == playback.PlayTranscode
estKbps := updatedSession.TargetBitrateKbps
if estKbps <= 0 {
estKbps = fileBitrateKbps(file)
}
plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps)
if proxyNode := plan.ProxyNode; proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) {
tokenClaims := streamtoken.Claims{
SessionID: sessionID,
PlayMethod: string(updatedSession.PlayMethod),
@@ -1780,11 +1803,9 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
TranscodeAudio: updatedSession.TranscodeAudio,
AudioTrackIndex: req.AudioTrackIndex,
}
if updatedSession.PlayMethod == playback.PlayTranscode && h.TranscodePool != nil {
if tcNode := h.TranscodePool.Acquire(); tcNode != nil {
tokenClaims.TranscodeNode = tcNode.URL
_ = h.sessionMgr.SetTranscodeNodeURL(sessionID, tcNode.URL)
}
if plan.TranscodeNode != nil {
tokenClaims.TranscodeNode = plan.TranscodeNode.URL
_ = h.sessionMgr.SetTranscodeNodeURL(sessionID, plan.TranscodeNode.URL)
}
if token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, 24*time.Hour); signErr == nil {
switch updatedSession.PlayMethod {
@@ -2037,7 +2058,15 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
}
// Determine whether to run locally or forward to a remote transcode node.
tcNode := h.PickTranscodeNode(session.TranscodeNodeURL)
var plan nodepool.Plan
if h.NodePlanner != nil {
estKbps := req.TargetBitrateKbps
if estKbps <= 0 {
estKbps = fileBitrateKbps(file)
}
plan = h.NodePlanner.PlanSession(req.SessionID, session.TranscodeNodeURL, true, estKbps)
}
tcNode := plan.TranscodeNode
if tcNode != nil {
// Remote transcode: forward to the assigned node.
@@ -2056,7 +2085,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
HWAccel: h.HWAccel,
HWAccel: h.playbackConfig().HWAccel,
AudioTrackIndex: session.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
@@ -2102,7 +2131,7 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel)
}
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL)
manifestURL := h.buildProxyManifestURL(req.SessionID, session, tcNode.URL, plan.ProxyNode)
h.finalizeTranscodeStart(r, transcodeStartState{
req: req,
file: file,
@@ -2115,14 +2144,24 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
}
// Local transcode (integrated mode — no transcode nodes available).
if err := os.MkdirAll(h.TranscodeDir, 0o755); err != nil {
// In distributed mode admins can disable this fallback so the API server
// never transcodes when no eligible node exists.
if h.NodePlanner != nil && !nodepool.LocalTranscodeFallbackAllowed(r.Context(), h.SettingsRepo) {
writeError(w, http.StatusServiceUnavailable, "no_transcode_node",
"No transcode node is available and local transcode fallback is disabled")
return
}
// Snapshot once so the directory, ffmpeg path, and hwaccel of this
// session stay consistent even if the config reloads mid-start.
playbackCfg := h.playbackConfig()
if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory")
return
}
transcodeSession, err := playback.StartTranscode(context.WithoutCancel(r.Context()), playback.TranscodeOpts{
InputPath: file.FilePath,
OutputDir: filepath.Join(h.TranscodeDir, req.SessionID),
OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID),
SessionID: req.SessionID,
SourceVideoCodec: file.CodecVideo,
SeekSeconds: req.SeekSeconds,
@@ -2132,9 +2171,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
TargetCodecAudio: req.TargetCodecAudio,
TargetBitrateKbps: req.TargetBitrateKbps,
SegmentDuration: req.SegmentDuration,
FFmpegPath: h.FFmpegPath,
HWAccel: h.HWAccel,
HWDevice: h.HWDevice,
FFmpegPath: playbackCfg.FFmpegPath,
HWAccel: playbackCfg.HWAccel,
HWDevice: playbackCfg.HWDevice,
AudioTrackIndex: session.AudioTrackIndex,
SubtitleTrackIndex: req.SubtitleTrackIndex,
SubtitleBurnIn: req.SubtitleBurnIn,
@@ -2456,40 +2495,11 @@ func (h *PlaybackHandler) closeTranscodeSession(sessionID, transcodeNodeURL stri
}
}
// PickTranscodeNode selects a transcode node with soft affinity.
// If currentURL is set and that node is healthy, reuse it unless another node
// has significantly fewer active jobs (2+ difference).
func (h *PlaybackHandler) PickTranscodeNode(currentURL string) *nodepool.Node {
if h.TranscodePool == nil {
return nil
}
best := h.TranscodePool.Acquire()
if best == nil {
return nil
}
if currentURL == "" || best.URL == currentURL {
return best
}
// Reuse current node unless the best alternative has significantly fewer jobs.
current := h.TranscodePool.FindByURL(currentURL)
if current != nil && current.Healthy && current.Enabled &&
best.ActiveJobs+2 <= current.ActiveJobs {
return best // switch to less-loaded node
}
if current != nil && current.Healthy && current.Enabled {
return current // soft affinity — stay on current
}
return best // current node is gone or unhealthy
}
// buildProxyManifestURL signs a stream token and builds the proxy-based manifest URL.
func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string) string {
if h.ProxyPool == nil {
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID)
}
proxyNode := h.ProxyPool.Pick()
// buildProxyManifestURL signs a stream token and builds the proxy-based
// manifest URL. proxyNode is the planner's pick for this session; when nil
// the URL falls back to the API-local path.
func (h *PlaybackHandler) buildProxyManifestURL(sessionID string, session *playback.Session, transcodeNodeURL string, proxyNode *nodepool.Node) string {
if proxyNode == nil {
// No proxy — fall back to API-local path.
return fmt.Sprintf("/playback/transcode/%s/master.m3u8", sessionID)
}
@@ -0,0 +1,81 @@
package handlers
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestSubtitleURLExt(t *testing.T) {
cases := []struct {
codec string
want string
}{
{"ass", ".ass"},
{"ssa", ".ass"},
{"pgs", ".sup"},
{"hdmv_pgs_subtitle", ".sup"},
{"subrip", ".vtt"},
{"srt", ".vtt"},
{"", ".vtt"},
}
for _, tc := range cases {
if got := subtitleURLExt(tc.codec); got != tc.want {
t.Errorf("subtitleURLExt(%q) = %q, want %q", tc.codec, got, tc.want)
}
}
}
func TestBuildSubtitleURLs_IncludesPGSButNotOtherBitmaps(t *testing.T) {
file := &models.MediaFile{
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "en", Codec: "subrip"},
{Index: 1, Language: "en", Codec: "hdmv_pgs_subtitle"},
{Index: 2, Language: "fr", Codec: "dvd_subtitle"},
{Index: 3, Language: "de", Codec: "dvb_subtitle"},
},
}
urls := buildSubtitleURLs("sess-1", file, nil)
if len(urls) != 2 {
t.Fatalf("expected 2 subtitle URLs (text + PGS), got %d: %+v", len(urls), urls)
}
srt := urls[0]
if srt.Codec != "subrip" || srt.URL != "/stream/sess-1/subtitles/0.vtt" {
t.Errorf("unexpected text track entry: %+v", srt)
}
pgs := urls[1]
if pgs.Codec != "hdmv_pgs_subtitle" {
t.Errorf("expected PGS track to be included, got %+v", pgs)
}
if pgs.URL != "/stream/sess-1/subtitles/1.sup" {
t.Errorf("PGS track should get a .sup URL, got %q", pgs.URL)
}
if pgs.FontBundleURL != "" {
t.Errorf("PGS track must not advertise a font bundle, got %q", pgs.FontBundleURL)
}
}
func TestBuildSubtitleURLs_PGSIndexAccountsForExternalOffset(t *testing.T) {
file := &models.MediaFile{
ExternalSubtitles: []models.ExternalSubtitle{
{Path: "/media/movie.en.srt", Language: "en", Format: "srt"},
},
SubtitleTracks: []models.SubtitleTrack{
{Index: 0, Language: "en", Codec: "pgs"},
},
}
urls := buildSubtitleURLs("sess-2", file, nil)
if len(urls) != 2 {
t.Fatalf("expected 2 subtitle URLs, got %d: %+v", len(urls), urls)
}
pgs := urls[1]
if pgs.Index != 1 || pgs.URL != "/stream/sess-2/subtitles/1.sup" {
t.Errorf("PGS track index should include the external offset, got %+v", pgs)
}
}
+15 -6
View File
@@ -18,6 +18,7 @@ import (
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/config"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -182,6 +183,16 @@ func writePlaybackTestFFmpeg(t *testing.T) string {
return path
}
func playbackTestConfig(ffmpegPath, transcodeDir string) func() config.PlaybackConfig {
return func() config.PlaybackConfig {
return config.PlaybackConfig{
FFmpegPath: ffmpegPath,
TranscodeDir: transcodeDir,
TranscodeEnabled: true,
}
}
}
func writePlaybackTestMediaFile(t *testing.T, name string) string {
t.Helper()
@@ -1117,7 +1128,7 @@ func TestHandleStartTranscode_PreservesRecomputedBaseMethodAfterFallback(t *test
Healthy: true,
ActiveJobs: 0,
}})
handler.TranscodePool = pool
handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), pool)
transcodeReq := httptest.NewRequest(
"POST",
@@ -1173,8 +1184,7 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
handler.TranscodeDir = t.TempDir()
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
req := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","audio_track_index":1,"codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
req = req.WithContext(newAuthorizedPlaybackContext())
@@ -1239,8 +1249,7 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) {
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.FFmpegPath = writePlaybackTestFFmpeg(t)
handler.TranscodeDir = t.TempDir()
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
transcodeReq := httptest.NewRequest(
"POST",
@@ -1403,7 +1412,7 @@ func TestHandleStartTranscode_KeepsSessionWhenStartupFailsForNonMissingReason(t
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.SessionSyncer = syncer
handler.AdminStore = adminStore
handler.TranscodeDir = writePlaybackTestMediaFile(t, "occupied-transcode-dir")
handler.PlaybackConfig = playbackTestConfig("", writePlaybackTestMediaFile(t, "occupied-transcode-dir"))
startReq := httptest.NewRequest("POST", "/api/v1/playback/start", strings.NewReader(`{"file_id":42,"profile_id":"profile-1","codecs_video":["h264"],"codecs_audio":["aac"],"containers":["mp4"],"max_resolution":"2160p","hdr":false}`))
startReq = startReq.WithContext(newAuthorizedPlaybackContext())
+8 -7
View File
@@ -48,6 +48,7 @@ type createProfileRequest struct {
MaxContentRating string `json:"max_content_rating,omitempty"`
QualityPreference string `json:"quality_preference,omitempty"`
Language string `json:"language,omitempty"`
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage string `json:"subtitle_language,omitempty"`
SubtitleMode string `json:"subtitle_mode,omitempty"`
AutoSkipIntro bool `json:"auto_skip_intro"`
@@ -68,6 +69,7 @@ type updateProfileRequest struct {
MaxContentRating *string `json:"max_content_rating,omitempty"`
QualityPreference *string `json:"quality_preference,omitempty"`
Language *string `json:"language,omitempty"`
PreferredMetadataLanguage *string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage *string `json:"subtitle_language,omitempty"`
SubtitleMode *string `json:"subtitle_mode,omitempty"`
AutoSkipIntro *bool `json:"auto_skip_intro,omitempty"`
@@ -96,6 +98,7 @@ type profileResponse struct {
MaxContentRating string `json:"max_content_rating,omitempty"`
QualityPreference string `json:"quality_preference,omitempty"`
Language string `json:"language,omitempty"`
PreferredMetadataLanguage string `json:"preferred_metadata_language,omitempty"`
SubtitleLanguage string `json:"subtitle_language,omitempty"`
SubtitleMode string `json:"subtitle_mode,omitempty"`
AutoSkipIntro bool `json:"auto_skip_intro"`
@@ -121,11 +124,6 @@ type verifyPINResponse struct {
ExpiresAt string `json:"expires_at,omitempty"`
}
func isAdminProfileRequest(ctx context.Context) bool {
claims := apimw.GetClaims(ctx)
return claims != nil && claims.Role == "admin"
}
// canManageHouseholdProfiles reports whether the caller may create/update/delete
// profiles belonging to their user. Server admins always can. Otherwise the
// caller's active profile must be the primary profile for the household.
@@ -135,7 +133,7 @@ func isAdminProfileRequest(ctx context.Context) bool {
// bypass the profile lock by sending only X-Profile-Id.
func (h *ProfileHandler) canManageHouseholdProfiles(r *http.Request, store userstore.UserStore) (bool, error) {
ctx := r.Context()
if isAdminProfileRequest(ctx) {
if apimw.IsAdmin(ctx) {
return true, nil
}
activeProfileID := apimw.GetProfileID(ctx)
@@ -313,7 +311,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
// Access-policy fields only make sense when set by a manager on a managed
// profile. On bootstrap the caller is becoming primary themselves, so non-
// admin bootstrap creations must leave those fields at their defaults.
if isBootstrap && !isAdminProfileRequest(r.Context()) &&
if isBootstrap && !apimw.IsAdmin(r.Context()) &&
(req.IsChild || req.MaxContentRating != "" ||
req.LibraryRestrictionsEnabled || len(req.AllowedLibraryIDs) > 0 ||
req.MaxPlaybackQuality != "") {
@@ -356,6 +354,7 @@ func (h *ProfileHandler) HandleCreateProfile(w http.ResponseWriter, r *http.Requ
MaxContentRating: req.MaxContentRating,
QualityPreference: req.QualityPreference,
Language: req.Language,
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
SubtitleLanguage: req.SubtitleLanguage,
SubtitleMode: req.SubtitleMode,
AutoSkipIntro: req.AutoSkipIntro,
@@ -505,6 +504,7 @@ func (h *ProfileHandler) HandleUpdateProfile(w http.ResponseWriter, r *http.Requ
MaxContentRating: req.MaxContentRating,
QualityPreference: req.QualityPreference,
Language: req.Language,
PreferredMetadataLanguage: req.PreferredMetadataLanguage,
SubtitleLanguage: req.SubtitleLanguage,
SubtitleMode: req.SubtitleMode,
AutoSkipIntro: req.AutoSkipIntro,
@@ -684,6 +684,7 @@ func (h *ProfileHandler) toProfileResponse(ctx context.Context, p userstore.Prof
MaxContentRating: p.MaxContentRating,
QualityPreference: p.QualityPreference,
Language: p.Language,
PreferredMetadataLanguage: p.PreferredMetadataLanguage,
SubtitleLanguage: p.SubtitleLanguage,
SubtitleMode: p.SubtitleMode,
AutoSkipIntro: p.AutoSkipIntro,
+234
View File
@@ -0,0 +1,234 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/push"
)
// maxPushTokenLen bounds a registered push token. APNs/FCM device tokens are
// short; a Web Push subscription JSON (endpoint + keys) is ~1 KB.
const maxPushTokenLen = 8192
// pushRegistry is the minimal interface PushHandler needs from *push.Store.
type pushRegistry interface {
RegisterToken(ctx context.Context, userID int, profileID, deviceID, transport, token string) error
RevokeToken(ctx context.Context, userID int, profileID, deviceID string) error
SetDeviceEnabled(ctx context.Context, userID int, deviceID string, enabled bool) error
ListDevices(ctx context.Context, userID int) ([]push.DeviceInfo, error)
}
// pushConfigReader is the minimal interface PushHandler needs from *push.Config.
type pushConfigReader interface {
WebPush(ctx context.Context) push.WebPushConfig
Status(ctx context.Context) push.Status
}
// pushNotifier is the minimal interface PushHandler needs to send system notifications.
type pushNotifier interface {
CreateSystem(ctx context.Context, userID int, typ, title, body string)
}
// PushHandler handles push notification registration, management, and status endpoints.
type PushHandler struct {
reg pushRegistry
config pushConfigReader
notifier pushNotifier
}
// NewPushHandler creates a new PushHandler. notifier may be nil (test-push endpoint
// returns 503 when nil).
func NewPushHandler(reg pushRegistry, config pushConfigReader, notifier pushNotifier) *PushHandler {
return &PushHandler{reg: reg, config: config, notifier: notifier}
}
// registerRequest is the request body for HandleRegister.
type registerRequest struct {
Transport string `json:"transport"`
Token string `json:"token"`
}
// deviceContext extracts the authenticated user, device id, and active profile
// required by the profile-scoped push device endpoints. It writes a 400 and
// returns ok=false when any of the three is missing.
func (h *PushHandler) deviceContext(w http.ResponseWriter, r *http.Request) (userID int, deviceID, profileID string, ok bool) {
userID = apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Authentication required")
return 0, "", "", false
}
deviceID = strings.TrimSpace(r.Header.Get(deviceIDHeader))
if deviceID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Silo-Device-Id header is required")
return 0, "", "", false
}
profileID = strings.TrimSpace(apimw.GetProfileID(r.Context()))
if profileID == "" {
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
return 0, "", "", false
}
return userID, deviceID, profileID, true
}
// HandleRegister handles PUT /notifications/push/device.
// Requires userID (from JWT claims), X-Silo-Device-Id, and X-Profile-Id headers.
// Body: {"transport": "apns"|"fcm"|"webpush", "token": "..."}
// Returns 204 on success.
func (h *PushHandler) HandleRegister(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
switch req.Transport {
case push.TransportAPNs, push.TransportFCM, push.TransportWebPush:
// valid
default:
writeError(w, http.StatusBadRequest, "bad_request", "transport must be one of apns, fcm, webpush")
return
}
if req.Token == "" {
writeError(w, http.StatusBadRequest, "bad_request", "token must not be empty")
return
}
// Cap token length: APNs/FCM device tokens are short; a Web Push subscription
// JSON (endpoint + keys) is ~1 KB. 8 KB is generous and bounds the stored row.
if len(req.Token) > maxPushTokenLen {
writeError(w, http.StatusBadRequest, "bad_request", "token too large")
return
}
if err := h.reg.RegisterToken(r.Context(), userID, profileID, deviceID, req.Transport, req.Token); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to register push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleRevoke handles DELETE /notifications/push/device.
// Requires userID, X-Silo-Device-Id, and X-Profile-Id headers.
// Returns 204 on success.
func (h *PushHandler) HandleRevoke(w http.ResponseWriter, r *http.Request) {
userID, deviceID, profileID, ok := h.deviceContext(w, r)
if !ok {
return
}
if err := h.reg.RevokeToken(r.Context(), userID, profileID, deviceID); err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke push token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleListDevices handles GET /notifications/push/devices.
// Response: {"devices": [...]}
func (h *PushHandler) HandleListDevices(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
devices, err := h.reg.ListDevices(r.Context(), userID)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list devices")
return
}
if devices == nil {
devices = []push.DeviceInfo{}
}
writeJSON(w, http.StatusOK, struct {
Devices []push.DeviceInfo `json:"devices"`
}{Devices: devices})
}
// toggleDeviceRequest is the request body for HandleToggleDevice.
type toggleDeviceRequest struct {
Enabled *bool `json:"enabled"`
}
// HandleToggleDevice handles PUT /notifications/push/devices/{device_id}.
// Body: {"enabled": true|false}
// Returns 204 on success, 404 if device not found.
func (h *PushHandler) HandleToggleDevice(w http.ResponseWriter, r *http.Request) {
userID := apimw.GetUserID(r.Context())
deviceID := chi.URLParam(r, "device_id")
var req toggleDeviceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if req.Enabled == nil {
writeError(w, http.StatusBadRequest, "bad_request", "enabled is required")
return
}
if err := h.reg.SetDeviceEnabled(r.Context(), userID, deviceID, *req.Enabled); err != nil {
if errors.Is(err, push.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Device not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update device")
return
}
w.WriteHeader(http.StatusNoContent)
}
// HandleWebPushKey handles GET /notifications/push/webpush-key.
// Response: {"vapid_public_key": "..."} — empty string when not configured.
func (h *PushHandler) HandleWebPushKey(w http.ResponseWriter, r *http.Request) {
cfg := h.config.WebPush(r.Context())
writeJSON(w, http.StatusOK, struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}{VAPIDPublicKey: cfg.VAPIDPublic})
}
// HandleAdminStatus handles GET /admin/push/status.
// Response: {"apns": bool, "fcm": bool, "webpush": bool}
func (h *PushHandler) HandleAdminStatus(w http.ResponseWriter, r *http.Request) {
status := h.config.Status(r.Context())
writeJSON(w, http.StatusOK, status)
}
// HandleGenerateVAPIDKeys handles POST /admin/push/generate-vapid-keys.
// Generates a fresh VAPID key pair and returns it as JSON.
// Response: {"vapid_public": "...", "vapid_private": "..."}
func (h *PushHandler) HandleGenerateVAPIDKeys(w http.ResponseWriter, r *http.Request) {
// GenerateVAPIDKeys returns (privateKey, publicKey string, err error).
priv, pub, err := webpush.GenerateVAPIDKeys()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "failed to generate keys")
return
}
writeJSON(w, http.StatusOK, map[string]string{"vapid_public": pub, "vapid_private": priv})
}
// HandleSendTestPush handles POST /admin/push/test.
// Sends a system test push notification to the authenticated admin user.
func (h *PushHandler) HandleSendTestPush(w http.ResponseWriter, r *http.Request) {
if h.notifier == nil {
writeError(w, http.StatusServiceUnavailable, "unavailable", "notifications unavailable")
return
}
userID := apimw.GetUserID(r.Context())
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "auth required")
return
}
h.notifier.CreateSystem(r.Context(), userID, "system.test_push", "Test push",
"If you can see this on a device, push delivery is working.")
w.WriteHeader(http.StatusAccepted)
}
+496
View File
@@ -0,0 +1,496 @@
package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"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/push"
)
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
type fakePushRegistry struct {
registerErr error
revokeErr error
enableErr error
devices []push.DeviceInfo
// capture last RegisterToken call
lastUserID int
lastProfileID string
lastDeviceID string
lastTransport string
lastToken string
}
func (f *fakePushRegistry) RegisterToken(_ context.Context, userID int, profileID, deviceID, transport, token string) error {
f.lastUserID = userID
f.lastProfileID = profileID
f.lastDeviceID = deviceID
f.lastTransport = transport
f.lastToken = token
return f.registerErr
}
func (f *fakePushRegistry) RevokeToken(_ context.Context, _ int, _, _ string) error {
return f.revokeErr
}
func (f *fakePushRegistry) SetDeviceEnabled(_ context.Context, _ int, _ string, _ bool) error {
return f.enableErr
}
func (f *fakePushRegistry) ListDevices(_ context.Context, _ int) ([]push.DeviceInfo, error) {
return f.devices, nil
}
type fakePushConfig struct {
webPush push.WebPushConfig
status push.Status
}
func (f *fakePushConfig) WebPush(_ context.Context) push.WebPushConfig { return f.webPush }
func (f *fakePushConfig) Status(_ context.Context) push.Status { return f.status }
type fakeNotifier struct {
calls []fakeNotifierCall
}
type fakeNotifierCall struct {
userID int
typ string
title string
body string
}
func (f *fakeNotifier) CreateSystem(_ context.Context, userID int, typ, title, body string) {
f.calls = append(f.calls, fakeNotifierCall{userID: userID, typ: typ, title: title, body: body})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// pushUserRequest builds a request with JWT claims (userID) and optionally
// sets the device-id and profile-id headers.
func pushUserRequest(method, target string, body []byte, userID int, profileID, deviceID string) *http.Request {
var req *http.Request
if len(body) > 0 {
req = httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, target, nil)
}
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: userID, Role: "user"})
if profileID != "" {
ctx = apimw.SetProfileID(ctx, profileID)
}
req = req.WithContext(ctx)
if deviceID != "" {
req.Header.Set(deviceIDHeader, deviceID)
}
return req
}
// pushChiRequest injects a chi URL parameter into the request context.
func pushChiRequest(r *http.Request, key, value string) *http.Request {
rctx := chi.NewRouteContext()
rctx.URLParams.Add(key, value)
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
}
// newPushHandler builds a PushHandler backed by the given fakes.
func newPushHandler(reg *fakePushRegistry, cfg *fakePushConfig) *PushHandler {
return NewPushHandler(reg, cfg, nil)
}
// ---------------------------------------------------------------------------
// HandleRegister
// ---------------------------------------------------------------------------
func TestPushHandleRegister_MissingDeviceID_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
// no device header
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_MissingProfileID_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": "tok"})
// device header set but no profile
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_BadTransport_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "smoke-signals", "token": "tok"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_EmptyToken_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "apns", "token": ""})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestPushHandleRegister_HappyPath_Returns204(t *testing.T) {
reg := &fakePushRegistry{}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "fcm", "token": "my-fcm-token"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 42, "prof-99", "dev-abc")
h.HandleRegister(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
if reg.lastUserID != 42 {
t.Errorf("userID = %d, want 42", reg.lastUserID)
}
if reg.lastProfileID != "prof-99" {
t.Errorf("profileID = %q, want prof-99", reg.lastProfileID)
}
if reg.lastDeviceID != "dev-abc" {
t.Errorf("deviceID = %q, want dev-abc", reg.lastDeviceID)
}
if reg.lastTransport != "fcm" {
t.Errorf("transport = %q, want fcm", reg.lastTransport)
}
if reg.lastToken != "my-fcm-token" {
t.Errorf("token = %q, want my-fcm-token", reg.lastToken)
}
}
func TestPushHandleRegister_WebPushTransport_Returns204(t *testing.T) {
reg := &fakePushRegistry{}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]string{"transport": "webpush", "token": "{}"})
req := pushUserRequest(http.MethodPut, "/notifications/push/device", body, 1, "prof-1", "dev-1")
h.HandleRegister(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
// ---------------------------------------------------------------------------
// HandleRevoke
// ---------------------------------------------------------------------------
func TestPushHandleRevoke_HappyPath_Returns204(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "dev-1")
h.HandleRevoke(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", rec.Code)
}
}
func TestPushHandleRevoke_MissingDevice_Returns400(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodDelete, "/notifications/push/device", nil, 1, "prof-1", "")
h.HandleRevoke(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
// ---------------------------------------------------------------------------
// HandleListDevices
// ---------------------------------------------------------------------------
func TestPushHandleListDevices_ReturnsDevicesJSON(t *testing.T) {
now := time.Now().UTC()
reg := &fakePushRegistry{
devices: []push.DeviceInfo{
{DeviceID: "dev-1", Name: "iPhone", Platform: "ios", Transport: push.TransportAPNs, PushEnabled: true, RegisteredAt: &now},
{DeviceID: "dev-2", Name: "Chrome", Platform: "web", Transport: push.TransportWebPush, PushEnabled: false},
},
}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 5, "prof-1", "")
h.HandleListDevices(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
Devices []push.DeviceInfo `json:"devices"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if len(resp.Devices) != 2 {
t.Fatalf("devices count = %d, want 2", len(resp.Devices))
}
if resp.Devices[0].DeviceID != "dev-1" {
t.Errorf("devices[0].DeviceID = %q, want dev-1", resp.Devices[0].DeviceID)
}
}
func TestPushHandleListDevices_EmptySlice_ReturnsEmptyArray(t *testing.T) {
reg := &fakePushRegistry{devices: nil}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/devices", nil, 1, "", "")
h.HandleListDevices(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
// Must produce {"devices":[]} not {"devices":null}
if !containsJSON(body, `"devices":[]`) {
t.Errorf("expected empty array in response, got: %s", body)
}
}
// ---------------------------------------------------------------------------
// HandleToggleDevice
// ---------------------------------------------------------------------------
func TestPushHandleToggleDevice_HappyPath_Returns204(t *testing.T) {
h := newPushHandler(&fakePushRegistry{}, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": true})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-1", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-1")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", rec.Code, rec.Body.String())
}
}
func TestPushHandleToggleDevice_NotFound_Returns404(t *testing.T) {
reg := &fakePushRegistry{enableErr: push.ErrNotFound}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": false})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-x")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
func TestPushHandleToggleDevice_WrappedNotFound_Returns404(t *testing.T) {
reg := &fakePushRegistry{enableErr: errors.Join(errors.New("outer"), push.ErrNotFound)}
h := newPushHandler(reg, &fakePushConfig{})
rec := httptest.NewRecorder()
body := mustMarshal(t, map[string]bool{"enabled": false})
req := pushUserRequest(http.MethodPut, "/notifications/push/devices/dev-x", body, 1, "", "")
req = pushChiRequest(req, "device_id", "dev-x")
h.HandleToggleDevice(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// HandleWebPushKey
// ---------------------------------------------------------------------------
func TestPushHandleWebPushKey_ReturnsConfiguredKey(t *testing.T) {
cfg := &fakePushConfig{webPush: push.WebPushConfig{VAPIDPublic: "BPublicKey123", VAPIDPrivate: "priv", Subject: "mailto:a@b.com"}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 1, "", "")
h.HandleWebPushKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublicKey != "BPublicKey123" {
t.Errorf("vapid_public_key = %q, want BPublicKey123", resp.VAPIDPublicKey)
}
}
func TestPushHandleWebPushKey_Unconfigured_ReturnsEmptyString(t *testing.T) {
cfg := &fakePushConfig{webPush: push.WebPushConfig{}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/notifications/push/webpush-key", nil, 0, "", "")
h.HandleWebPushKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp struct {
VAPIDPublicKey string `json:"vapid_public_key"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublicKey != "" {
t.Errorf("vapid_public_key = %q, want empty string", resp.VAPIDPublicKey)
}
}
// ---------------------------------------------------------------------------
// HandleAdminStatus
// ---------------------------------------------------------------------------
func TestPushHandleAdminStatus_ReturnsBooleans(t *testing.T) {
cfg := &fakePushConfig{status: push.Status{APNs: true, FCM: false, WebPush: true}}
h := newPushHandler(&fakePushRegistry{}, cfg)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodGet, "/admin/push/status", nil, 1, "", "")
h.HandleAdminStatus(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var resp push.Status
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.APNs {
t.Error("apns = false, want true")
}
if resp.FCM {
t.Error("fcm = true, want false")
}
if !resp.WebPush {
t.Error("webpush = false, want true")
}
}
// ---------------------------------------------------------------------------
// HandleGenerateVAPIDKeys
// ---------------------------------------------------------------------------
func TestPushHandleGenerateVAPIDKeys_ReturnsPair(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/admin/push/generate-vapid-keys", nil)
h.HandleGenerateVAPIDKeys(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
}
var resp struct {
VAPIDPublic string `json:"vapid_public"`
VAPIDPrivate string `json:"vapid_private"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.VAPIDPublic == "" {
t.Error("vapid_public is empty")
}
if resp.VAPIDPrivate == "" {
t.Error("vapid_private is empty")
}
if resp.VAPIDPublic == resp.VAPIDPrivate {
t.Errorf("vapid_public and vapid_private are identical: %q", resp.VAPIDPublic)
}
}
// ---------------------------------------------------------------------------
// HandleSendTestPush
// ---------------------------------------------------------------------------
func TestPushHandleSendTestPush_CreatesForCaller(t *testing.T) {
n := &fakeNotifier{}
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, n)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 7, "", "")
h.HandleSendTestPush(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("status = %d, want 202; body: %s", rec.Code, rec.Body.String())
}
if len(n.calls) != 1 {
t.Fatalf("CreateSystem called %d times, want 1", len(n.calls))
}
call := n.calls[0]
if call.userID != 7 {
t.Errorf("userID = %d, want 7", call.userID)
}
if call.typ != "system.test_push" {
t.Errorf("typ = %q, want system.test_push", call.typ)
}
}
func TestPushHandleSendTestPush_NilNotifier503(t *testing.T) {
h := NewPushHandler(&fakePushRegistry{}, &fakePushConfig{}, nil)
rec := httptest.NewRecorder()
req := pushUserRequest(http.MethodPost, "/admin/push/test", nil, 1, "", "")
h.HandleSendTestPush(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503; body: %s", rec.Code, rec.Body.String())
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func mustMarshal(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal: %v", err)
}
return b
}
// containsJSON checks that s contains the substring (after stripping whitespace
// equivalents that json.Marshal doesn't emit — good enough for our payloads).
func containsJSON(s, sub string) bool {
return len(s) > 0 && (len(sub) == 0 || len(s) >= len(sub) && func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
+5 -1
View File
@@ -48,6 +48,7 @@ type RecommendationsHandler struct {
EpisodeRepo *catalog.EpisodeRepository
WatchTonightFetcher watchTonightSectionFetcher
CastFetcher cardsCastFetcher
EbookProgress EbookReaderProgressLister
// RecWorker enqueues asynchronous taste-profile refreshes after writes
// (taste seeding). Optional — when nil, refresh is simply skipped.
RecWorker ProfileRefreshRequester
@@ -643,7 +644,10 @@ func (h *RecommendationsHandler) loadItemEnrichment(
if h.storeProvider != nil {
store, storeErr := h.storeProvider.ForUser(ctx, userID)
if storeErr == nil && store != nil {
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if stateErr == nil {
out.states = states
}
+5 -1
View File
@@ -34,6 +34,7 @@ type SectionHandler struct {
DetailSvc *catalog.DetailService
Settings catalog.SettingsStore
CollectionRepo *catalog.LibraryCollectionRepository
EbookProgress EbookReaderProgressLister
}
// NewSectionHandler creates a new SectionHandler.
@@ -1380,7 +1381,10 @@ func (h *SectionHandler) listSectionItemUserStates(r *http.Request, items []*mod
if err != nil || store == nil {
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: userID,
EbookProgressStore: h.EbookProgress,
})
if err != nil {
return map[string]*itemUserStateResponse{}
}
+42 -13
View File
@@ -12,6 +12,7 @@ import (
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/config"
evt "github.com/Silo-Server/silo-server/internal/events"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -31,10 +32,20 @@ type StreamHandler struct {
EventsHub *evt.Hub
AdminStore PlaybackAdminStore
SessionSyncer PlaybackSessionSyncer
FFmpegPath string
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
S3Bucket string // bucket for subtitle storage
// PlaybackConfig returns the current playback config; read it through
// ffmpegPath(). May be nil (tests).
PlaybackConfig func() config.PlaybackConfig
SubtitleRepo subtitles.Repository // optional; enables S3-sourced subtitles
S3Client subtitles.S3Client // optional; needed for fetching S3 subtitles
S3Bucket string // bucket for subtitle storage
}
// ffmpegPath returns the currently configured ffmpeg binary path.
func (h *StreamHandler) ffmpegPath() string {
if h.PlaybackConfig != nil {
return h.PlaybackConfig().FFmpegPath
}
return ""
}
// NewStreamHandler creates a new StreamHandler backed by the given session
@@ -193,7 +204,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
return
}
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.FFmpegPath)
vttData, err := playback.LoadExternalSubtitleAsVTT(r.Context(), sub.Path, sub.Format, h.ffmpegPath())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error",
"Failed to load external subtitle")
@@ -208,7 +219,10 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
// Check embedded tracks.
if embeddedIndex < len(file.SubtitleTracks) {
track := file.SubtitleTracks[embeddedIndex]
if playback.NeedsBurnIn(track.Codec) {
// PGS is the one bitmap codec we can deliver without burn-in: the
// track is copied losslessly into a .sup stream and rendered
// client-side. DVD/DVB bitmap subs still require burn-in.
if playback.NeedsBurnIn(track.Codec) && !playback.IsPGS(track.Codec) {
writeError(w, http.StatusBadRequest, "bad_request",
"Bitmap subtitle tracks cannot be extracted as text")
return
@@ -324,7 +338,7 @@ func (h *StreamHandler) HandleSubtitleFonts(w http.ResponseWriter, r *http.Reque
return
}
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.FFmpegPath)
fonts, err := playback.ExtractAttachedSubtitleFonts(r.Context(), file.FilePath, h.ffmpegPath())
if err != nil {
slog.WarnContext(r.Context(), "subtitle font extraction failed",
"file_id", file.ID,
@@ -406,12 +420,24 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session) {
track := file.SubtitleTracks[embeddedIndex]
outFormat := "vtt"
if playback.IsASS(track.Codec) {
switch {
case playback.IsASS(track.Codec):
outFormat = "ass"
case playback.IsPGS(track.Codec):
outFormat = "sup"
}
seek := subtitleSeekPosition(r, session)
duration := subtitleWindowDuration(r)
// ASS and PGS are fetched exactly once and consumed whole by their
// client-side renderers (JASSUB / libpgs), so they must never be
// windowed. Note subtitleSeekPosition falls back to the session's
// last reported position even without a ?position= query — relying
// on StreamExtractSubtitle's codec guard alone would still log a
// misleading nonzero seek here.
var seek, duration float64
if outFormat == "vtt" {
seek = subtitleSeekPosition(r, session)
duration = subtitleWindowDuration(r)
}
slog.InfoContext(r.Context(), "subtitle stream requested",
"file_id", file.ID,
"embedded_index", embeddedIndex,
@@ -422,9 +448,12 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
"duration_seconds", duration,
)
if outFormat == "ass" {
switch outFormat {
case "ass":
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
} else {
case "sup":
w.Header().Set("Content-Type", "application/octet-stream")
default:
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
}
w.Header().Set("Access-Control-Allow-Origin", "*")
@@ -437,7 +466,7 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
SourceCodec: track.Codec,
SeekSeconds: seek,
DurationSeconds: duration,
FFmpegPath: h.FFmpegPath,
FFmpegPath: h.ffmpegPath(),
Writer: w,
})
if err != nil {
+84 -7
View File
@@ -3,6 +3,7 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
@@ -12,6 +13,7 @@ import (
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/subtitles/ai"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// SubtitleAIHandler exposes on-demand AI subtitle translation backed by the
@@ -21,6 +23,9 @@ import (
type SubtitleAIHandler struct {
service *ai.Service
FileAuthorizer *MediaFileAuthorizer
// StoreProvider resolves household profiles for the transcription quota
// exemption check; when nil the whole admin account is exempt.
StoreProvider userstore.UserStoreProvider
}
// NewSubtitleAIHandler creates a handler backed by the given service.
@@ -48,7 +53,12 @@ func authorizeMediaFileAccess(w http.ResponseWriter, r *http.Request, authorizer
}
type translateSubtitleRequest struct {
MediaFileID int `json:"media_file_id"`
MediaFileID int `json:"media_file_id"`
// Kind selects the job: "translate" (default), "transcribe", or
// "transcribe_translate". For ASR kinds, source_index is the audio track
// index (-1 = default track) and target_language is optional for plain
// transcribe (acts as a language hint).
Kind string `json:"kind"`
SourceIndex int `json:"source_index"`
SourceLanguage string `json:"source_language"`
TargetLanguage string `json:"target_language"`
@@ -56,10 +66,14 @@ type translateSubtitleRequest struct {
StartPosition float64 `json:"start_position"`
}
// HandleStatus reports whether AI subtitle translation is available, so the
// player can show or hide the entry point. GET /api/v1/subtitles/ai/status
// HandleStatus reports whether AI subtitle translation / ASR generation are
// available, so the player can show or hide the entry points.
// GET /api/v1/subtitles/ai/status
func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"enabled": h.service.Enabled()})
writeJSON(w, http.StatusOK, map[string]any{
"enabled": h.service.Enabled(),
"transcribe_enabled": h.service.TranscribeEnabled(),
})
}
// WriteSubtitleAIDisabledStatus answers the AI status capability probe with a
@@ -67,7 +81,7 @@ func (h *SubtitleAIHandler) HandleStatus(w http.ResponseWriter, r *http.Request)
// negative instead of a 404 (the 2-segment /ai/status path is not shadowed by the
// 1-segment /{media_file_id} route — they never compete in chi's router).
func WriteSubtitleAIDisabledStatus(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"enabled": false})
writeJSON(w, http.StatusOK, map[string]any{"enabled": false, "transcribe_enabled": false})
}
// HandleTranslate enqueues a translation job. POST /api/v1/subtitles/ai/translate
@@ -81,7 +95,13 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
writeError(w, http.StatusBadRequest, "bad_request", "media_file_id is required")
return
}
if req.TargetLanguage == "" {
kind := ai.JobKind(req.Kind)
if kind == "" {
kind = ai.JobKindTranslate
}
// A plain transcribe produces a track in the spoken language, so the
// target is optional; every other kind needs one.
if req.TargetLanguage == "" && kind != ai.JobKindTranscribe {
writeError(w, http.StatusBadRequest, "bad_request", "target_language is required")
return
}
@@ -97,11 +117,12 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
job, err := h.service.Enqueue(r.Context(), ai.JobRequest{
MediaFileID: req.MediaFileID,
Kind: ai.JobKindTranslate,
Kind: kind,
SourceIndex: req.SourceIndex,
SourceLanguage: req.SourceLanguage,
TargetLanguage: req.TargetLanguage,
RequestedBy: requestedBy,
QuotaExempt: h.quotaExempt(r),
SessionID: req.SessionID,
StartPosition: req.StartPosition,
})
@@ -110,6 +131,8 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
case errors.Is(err, ai.ErrEngineNotConfigured):
writeError(w, http.StatusServiceUnavailable, "not_configured",
"AI subtitle translation is not configured on this server")
case errors.Is(err, ai.ErrQuotaExceeded):
writeError(w, http.StatusTooManyRequests, "quota_exceeded", quotaExceededMessage(err))
case errors.Is(err, ai.ErrInvalidRequest):
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
default:
@@ -123,6 +146,60 @@ func (h *SubtitleAIHandler) HandleTranslate(w http.ResponseWriter, r *http.Reque
writeJSON(w, http.StatusAccepted, map[string]any{"job": job})
}
// quotaExempt reports whether the caller is exempt from the transcription
// quota: an admin account acting as itself (no profile context) or through
// its primary (household parent) profile. Other profiles on an admin account
// — e.g. a kid's profile — stay subject to the quota even though the account
// holds the admin role. X-Profile-Id is client-asserted, so this is a
// budgeting gate, not a security boundary; the quota itself is account-scoped.
func (h *SubtitleAIHandler) quotaExempt(r *http.Request) bool {
ctx := r.Context()
if !apimw.IsAdmin(ctx) {
return false
}
profileID := apimw.GetProfileID(ctx)
if profileID == "" {
profileID = r.Header.Get("X-Profile-Id")
}
if profileID == "" || h.StoreProvider == nil {
return true
}
store, err := h.StoreProvider.ForUser(ctx, apimw.GetUserID(ctx))
if err != nil {
return false // fail closed: the quota still applies
}
profile, err := store.GetProfile(ctx, profileID)
if err != nil || profile == nil {
return false
}
return profile.IsPrimary
}
// quotaExceededMessage turns a quota error into a user-facing message with the
// limit and window when available.
func quotaExceededMessage(err error) string {
var qe *ai.QuotaExceededError
if errors.As(err, &qe) {
return fmt.Sprintf(
"You've reached your transcription limit (%d per %s). Try again later or ask an admin to raise it.",
qe.Limit, qe.Period)
}
return "You've reached your transcription limit. Try again later or ask an admin to raise it."
}
// HandleQuota reports the calling user's transcription quota usage, so the
// player can show how many jobs they have left before they start one.
// GET /api/v1/subtitles/ai/quota
func (h *SubtitleAIHandler) HandleQuota(w http.ResponseWriter, r *http.Request) {
quota, err := h.service.TranscribeQuota(r.Context(), apimw.GetUserID(r.Context()), h.quotaExempt(r))
if err != nil {
slog.Error("failed to load transcription quota", "error", err)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load transcription quota")
return
}
writeJSON(w, http.StatusOK, quota)
}
// HandleGetJob returns a job's current state. GET /api/v1/subtitles/ai/jobs/{job_id}
func (h *SubtitleAIHandler) HandleGetJob(w http.ResponseWriter, r *http.Request) {
job, ok := h.loadAuthorizedJob(w, r)
+4 -1
View File
@@ -181,7 +181,10 @@ func (h *RecommendationsHandler) resolveTasteSeedUserStates(ctx context.Context,
if err != nil || store == nil {
return nil
}
states, err := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, err := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if err != nil {
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package handlers
import (
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
)
// decodedURLParam returns the named chi route parameter with percent-encoding
// removed. chi matches routes against the raw (escaped) request path, so
// parameters arrive still encoded when clients escape reserved characters —
// e.g. encodeURIComponent("plugin:6:introdb") arrives as
// "plugin%3A6%3Aintrodb". Returns an error for malformed escape sequences.
func decodedURLParam(r *http.Request, name string) (string, error) {
return url.PathUnescape(chi.URLParam(r, name))
}
+34
View File
@@ -14,12 +14,28 @@ type itemUserStateResponse struct {
InWatchlist bool `json:"in_watchlist"`
}
type itemUserStateOptions struct {
UserID int
EbookProgressStore EbookReaderProgressLister
}
func resolveItemUserStates(
ctx context.Context,
store userstore.UserStore,
profileID string,
episodeRepo *catalog.EpisodeRepository,
items []*models.MediaItem,
) (map[string]*itemUserStateResponse, error) {
return resolveItemUserStatesWithOptions(ctx, store, profileID, episodeRepo, items, itemUserStateOptions{})
}
func resolveItemUserStatesWithOptions(
ctx context.Context,
store userstore.UserStore,
profileID string,
episodeRepo *catalog.EpisodeRepository,
items []*models.MediaItem,
options itemUserStateOptions,
) (map[string]*itemUserStateResponse, error) {
result := make(map[string]*itemUserStateResponse, len(items))
if store == nil || profileID == "" || len(items) == 0 {
@@ -111,6 +127,10 @@ func resolveItemUserStates(
if err != nil {
return nil, err
}
ebookProgressMap, err := resolveEbookProgressForUserStates(ctx, options, profileID, contentIDs)
if err != nil {
return nil, err
}
for _, item := range items {
if item == nil || item.ContentID == "" {
@@ -125,6 +145,8 @@ func resolveItemUserStates(
state.Played = allEpisodesCompleted(seriesEpisodes[item.ContentID], progressMap)
case "season":
state.Played = allEpisodesCompleted(seasonEpisodes[item.ContentID], progressMap)
case "ebook":
state.Played = ebookProgressMap[item.ContentID].Progress >= models.EbookFinishedProgressThreshold
default:
state.Played = progressMap[item.ContentID].Completed
}
@@ -134,6 +156,18 @@ func resolveItemUserStates(
return result, nil
}
func resolveEbookProgressForUserStates(
ctx context.Context,
options itemUserStateOptions,
profileID string,
contentIDs []string,
) (map[string]EbookReaderProgress, error) {
if options.EbookProgressStore == nil || options.UserID <= 0 || profileID == "" || len(contentIDs) == 0 {
return nil, nil
}
return options.EbookProgressStore.ListByContentIDs(ctx, options.UserID, profileID, contentIDs)
}
func allEpisodesCompleted(episodes []*models.Episode, progressMap map[string]userstore.WatchProgress) bool {
if len(episodes) == 0 {
return false
+96
View File
@@ -0,0 +1,96 @@
package handlers
import (
"context"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/models"
)
func TestResolveItemUserStatesIncludesCompletedEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
items := []*models.MediaItem{
{ContentID: "ebook-complete", Type: "ebook", Title: "Complete Ebook"},
{ContentID: "ebook-progress", Type: "ebook", Title: "Partial Ebook"},
}
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
UserID: 42,
EbookProgressStore: &fakeEbookReaderProgressLister{
progress: map[string]EbookReaderProgress{
"ebook-complete": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-complete",
Progress: 0.95,
UpdatedAt: time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC),
},
"ebook-progress": {
UserID: 42,
ProfileID: "profile-1",
ContentID: "ebook-progress",
Progress: 0.45,
UpdatedAt: time.Date(2026, 6, 4, 9, 0, 0, 0, time.UTC),
},
},
},
})
if err != nil {
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
}
if states["ebook-complete"] == nil || !states["ebook-complete"].Played {
t.Fatalf("completed ebook state = %+v, want played", states["ebook-complete"])
}
if states["ebook-progress"] == nil || states["ebook-progress"].Played {
t.Fatalf("partial ebook state = %+v, want not played", states["ebook-progress"])
}
}
func TestResolveItemUserStatesExcludesHiddenEbookReaderProgress(t *testing.T) {
ctx := context.Background()
store := newProfileTestStore(t)
updatedAt := time.Date(2026, 6, 4, 10, 0, 0, 0, time.UTC)
items := []*models.MediaItem{
{ContentID: "ebook-hidden", Type: "ebook", Title: "Hidden Ebook"},
{ContentID: "ebook-reread", Type: "ebook", Title: "Reread Ebook"},
}
states, err := resolveItemUserStatesWithOptions(ctx, store, "profile-1", nil, items, itemUserStateOptions{
UserID: 42,
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),
},
},
})
if err != nil {
t.Fatalf("resolveItemUserStatesWithOptions: %v", err)
}
if states["ebook-hidden"] == nil || states["ebook-hidden"].Played {
t.Fatalf("hidden ebook state = %+v, want not played", states["ebook-hidden"])
}
if states["ebook-reread"] == nil || !states["ebook-reread"].Played {
t.Fatalf("ebook updated after hidden_before = %+v, want played again", states["ebook-reread"])
}
}
+5 -3
View File
@@ -7,8 +7,6 @@ import (
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
@@ -220,7 +218,11 @@ func (h *WatchProviderHandler) connectionStatus(w http.ResponseWriter, r *http.R
func watchProviderRequestScope(w http.ResponseWriter, r *http.Request) (int, string, string, bool) {
userID := apimw.GetUserID(r.Context())
profileID := apimw.GetProfileID(r.Context())
provider := chi.URLParam(r, "provider")
provider, err := decodedURLParam(r, "provider")
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid provider name")
return 0, "", "", false
}
if userID == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return 0, "", "", false
+4 -1
View File
@@ -362,7 +362,10 @@ func (h *RecommendationsHandler) enrichItems(r *http.Request, userID int, profil
if h.storeProvider != nil {
store, storeErr := h.storeProvider.ForUser(ctx, userID)
if storeErr == nil && store != nil {
states, stateErr := resolveItemUserStates(ctx, store, profileID, h.EpisodeRepo, mediaItems)
states, stateErr := resolveItemUserStatesWithOptions(ctx, store, profileID, h.EpisodeRepo, mediaItems, itemUserStateOptions{
UserID: userID,
EbookProgressStore: h.EbookProgress,
})
if stateErr == nil {
stateMap = states
}
+8
View File
@@ -181,6 +181,14 @@ func GetClaims(ctx context.Context) *auth.Claims {
return claims
}
// IsAdmin reports whether the context's authenticated user account has the
// admin role. Returns false when no claims are present. Note this is the
// account-level role; it says nothing about which household profile is active.
func IsAdmin(ctx context.Context) bool {
claims := GetClaims(ctx)
return claims != nil && claims.Role == "admin"
}
// GetUserID retrieves the user ID from the JWT claims in the context.
// Returns 0 if no claims are present.
func GetUserID(ctx context.Context) int {
+320 -22
View File
@@ -20,6 +20,8 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/activitylog"
"github.com/Silo-Server/silo-server/internal/adminjob"
"github.com/Silo-Server/silo-server/internal/ai/jobrunner"
"github.com/Silo-Server/silo-server/internal/ai/llm"
"github.com/Silo-Server/silo-server/internal/api/handlers"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/auth"
@@ -40,11 +42,14 @@ import (
"github.com/Silo-Server/silo-server/internal/metadata"
"github.com/Silo-Server/silo-server/internal/metadata/tmdb"
metatrakt "github.com/Silo-Server/silo-server/internal/metadata/trakt"
metadatatranslation "github.com/Silo-Server/silo-server/internal/metadata/translation"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/notifications"
"github.com/Silo-Server/silo-server/internal/opslog"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/presence"
"github.com/Silo-Server/silo-server/internal/push"
"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"
@@ -70,7 +75,13 @@ import (
// Dependencies holds all shared dependencies that handlers need.
type Dependencies struct {
Config *config.Config
Config *config.Config
// LiveConfig returns the current hot-reloaded config. May be nil (tests,
// worker modes); read through CurrentConfig(), which falls back to Config.
LiveConfig func() *config.Config
// OnConfigChange registers a callback fired after a live config reload
// actually changes the config. May be nil when hot reload is not wired.
OnConfigChange func(fn func(old, updated *config.Config))
BootstrapSensitiveConfigured map[string]bool
BootstrapSensitiveValues map[string]string
AppContext context.Context
@@ -95,6 +106,7 @@ type Dependencies struct {
NodeRepo *nodepool.Repository // stream node repository (may be nil)
ProxyPool *nodepool.ProxyPool // proxy node pool (may be nil)
TranscodePool *nodepool.TranscodePool // transcode node pool (may be nil)
NodePlanner *nodepool.Planner // group/cap-aware node selection (may be nil)
SessionSyncer handlers.PlaybackSessionSyncer // optional; immediate playback session sync trigger
EventBus cache.EventBus
AdminStatsProvider handlers.AdminStatsSource
@@ -165,6 +177,23 @@ type Dependencies struct {
// clients hitting /login, /api/*, /abs/api/*, and /abs/socket.io/* all
// resolve correctly. May be nil; no ABS routes are registered in that case.
ABSHandler absHandler
// NotificationsService is the notifications domain service used to power
// the inbox, preferences, and announcement endpoints. May be nil; routes
// are not registered in that case.
NotificationsService *notifications.Service
// PushStore is the push token/device registry. May be nil; push routes
// are not registered in that case.
PushStore *push.Store
// PushConfig reads push provider configuration from the settings repo.
// May be nil; push routes are not registered when PushStore is nil.
PushConfig *push.Config
// PresenceRegistry tracks live WebSocket connections for presence-aware
// push suppression. May be nil; a no-op fallback is used in that case.
PresenceRegistry presence.Registry
}
// absHandler is the narrow interface the router needs from the ABS handler.
@@ -173,6 +202,17 @@ type absHandler interface {
Mount(r chi.Router)
}
// CurrentConfig returns the live config when hot reload is wired, falling
// back to the startup snapshot otherwise.
func (d *Dependencies) CurrentConfig() *config.Config {
if d.LiveConfig != nil {
if cfg := d.LiveConfig(); cfg != nil {
return cfg
}
}
return d.Config
}
// NewRouter creates a chi.Router with all middleware and routes mounted
// under /api/v1/. ABS-compat routes (/abs/*, /login, /socket.io/*) are
// mounted at the root level when deps.ABSHandler is non-nil.
@@ -191,6 +231,10 @@ func NewRouter(deps Dependencies) chi.Router {
r.Use(middleware.Recoverer)
r.Use(apimw.Metrics)
// Compress text-like responses (JSON, SVG, …); media content types are
// not in the middleware's allowlist and stream through untouched.
r.Use(middleware.Compress(5))
// Activity logging (before auth — captures all requests including failed auth).
if deps.ActivityLogWriter != nil {
r.Use(activitylog.NewMiddleware(deps.ActivityLogWriter, deps.NodeID))
@@ -254,6 +298,12 @@ func NewRouter(deps Dependencies) chi.Router {
deps.Config.Auth.AccessTokenExpiry,
deps.Config.Auth.RefreshTokenExpiry,
)
if deps.OnConfigChange != nil {
jwtForReload := jwtService
deps.OnConfigChange(func(_, updated *config.Config) {
jwtForReload.SetExpiries(updated.Auth.AccessTokenExpiry, updated.Auth.RefreshTokenExpiry)
})
}
provider := auth.NewLocalProvider(userRepo, sessionRepo)
authService = auth.NewService(
provider,
@@ -370,7 +420,14 @@ func NewRouter(deps Dependencies) chi.Router {
var webhookSyncHandler *handlers.WebhookSyncHandler
var requestHandler *handlers.RequestsHandler
var autoscanHandler *handlers.AutoscanHandler
var ebookReaderHandler *handlers.EbookReaderHandler
var ebookProgressStore *handlers.PGEbookReaderProgressStore
var ebookConfigStore *handlers.PGEbookReaderConfigStore
var ebookAnnotationStore *handlers.PGEbookReaderAnnotationStore
if deps.DB != nil {
ebookProgressStore = handlers.NewPGEbookReaderProgressStore(deps.DB)
ebookConfigStore = handlers.NewPGEbookReaderConfigStore(deps.DB)
ebookAnnotationStore = handlers.NewPGEbookReaderAnnotationStore(deps.DB)
browseRepo := catalog.NewBrowseRepository(deps.DB)
itemRepo = catalog.NewItemRepository(deps.DB)
episodeRepo = catalog.NewEpisodeRepository(deps.DB)
@@ -421,6 +478,25 @@ func NewRouter(deps Dependencies) chi.Router {
if dispatcher, ok := deps.WatchProviderService.(handlers.LocalWatchEventDispatcher); ok {
itemsHandler.SetLocalWatchEventDispatcher(dispatcher)
}
if ebookProgressStore != nil {
itemsHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
if deps.FileRepo != nil {
ebookReaderHandler = handlers.NewEbookReaderHandler(&handlers.MediaFileAuthorizer{
FileResolver: deps.FileRepo,
ItemAccess: itemRepo,
EpisodeLookup: episodeRepo,
})
if ebookProgressStore != nil {
ebookReaderHandler.ProgressStore = ebookProgressStore
}
if ebookConfigStore != nil {
ebookReaderHandler.ConfigStore = ebookConfigStore
}
if ebookAnnotationStore != nil {
ebookReaderHandler.AnnotationStore = ebookAnnotationStore
}
}
catalogResourceHandler = handlers.NewCatalogResourceHandler(itemsHandler)
catalogHandler = handlers.NewCatalogHandler(
catalog.NewCatalogResolver(browseRepo, itemRepo).
@@ -440,6 +516,7 @@ func NewRouter(deps Dependencies) chi.Router {
mediarequests.NewCatalogPresence(itemRepo, providerIDRepo),
)
AttachRequestRouter(requestSvc, deps.PluginService)
requestSvc.SetEventsHub(deps.EventsHub)
requestSvc.SetRequesterIdentityResolver(plugins.RequesterIdentityFromLookup(plugins.NewPgUserIdentityLookup(deps.DB)))
if viewerResolver != nil {
requestSvc.SetEntitlementResolver(mediarequests.NewAccessEntitlements(viewerResolver))
@@ -499,6 +576,9 @@ func NewRouter(deps Dependencies) chi.Router {
if detailSvc != nil {
personalDataHandler.SetDetailService(detailSvc)
}
if ebookProgressStore != nil {
personalDataHandler.SetEbookReaderProgressStore(ebookProgressStore)
}
personalDataHandler.SetEpisodeRepo(episodeRepo)
personalDataHandler.SetSeasonRepo(seasonRepo)
personalDataHandler.EventsHub = deps.EventsHub
@@ -618,24 +698,21 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
// Wire optional proxy/transcode pools and JWT secret for node-aware stream URLs.
if deps.ProxyPool != nil {
playbackHandler.ProxyPool = deps.ProxyPool
}
if deps.TranscodePool != nil {
playbackHandler.TranscodePool = deps.TranscodePool
// Wire the optional node planner and JWT secret for node-aware stream URLs.
if deps.NodePlanner != nil {
playbackHandler.NodePlanner = deps.NodePlanner
}
if deps.Config != nil && deps.Config.Auth.JWTSecret != "" {
playbackHandler.JWTSecret = deps.Config.Auth.JWTSecret
}
if deps.Config != nil {
playbackHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
playbackHandler.HWAccel = deps.Config.Playback.HWAccel
playbackHandler.TranscodeDir = deps.Config.Playback.TranscodeDir
playbackHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
if cleaned, err := playbackHandler.CleanupOrphanedTranscodes(); err != nil {
slog.Warn("playback transcode cleanup failed", "dir", playbackHandler.TranscodeDir, "error", err)
slog.Warn("playback transcode cleanup failed", "dir", deps.Config.Playback.TranscodeDir, "error", err)
} else if cleaned > 0 {
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", playbackHandler.TranscodeDir, "count", cleaned)
slog.Info("playback transcode cleanup removed orphaned dirs", "dir", deps.Config.Playback.TranscodeDir, "count", cleaned)
}
}
playbackHandler.ProbeEnsurer = deps.ProbeEnsurer
@@ -698,7 +775,9 @@ func NewRouter(deps Dependencies) chi.Router {
streamHandler.S3Bucket = deps.S3Public.Bucket()
}
if streamHandler != nil && deps.Config != nil {
streamHandler.FFmpegPath = deps.Config.Playback.FFmpegPath
streamHandler.PlaybackConfig = func() config.PlaybackConfig {
return deps.CurrentConfig().Playback
}
}
serverControlHandler := handlers.NewServerControlHandler(deps.RequestServerRestart, playbackCommandDispatcher)
@@ -878,35 +957,102 @@ func NewRouter(deps Dependencies) chi.Router {
// Build the AI subtitle handler (on-demand translation). Generated tracks are
// stored as ordinary downloaded subtitles, so they reach every client through
// the existing subtitle pipeline with no client changes.
// Shared AI endpoint client + dispatch semaphore: subtitle translation/ASR
// and metadata translation draw from one client and one concurrency bound.
// Connection settings, models, toggles, and quotas hot-reload through
// OnConfigChange; only the semaphore size (ai.max_concurrent_jobs) is
// fixed at construction.
var aiClient *llm.Client
var aiSem chan struct{}
if deps.Config != nil {
aiClient = llm.NewClient(llmConfigFromServer(deps.Config))
aiSem = jobrunner.NewSemaphore(deps.Config.AI.MaxConcurrentJobs)
if deps.OnConfigChange != nil {
clientForReload := aiClient
deps.OnConfigChange(func(_, updated *config.Config) {
clientForReload.UpdateConfig(llmConfigFromServer(updated))
})
}
}
var subtitleAIHandler *handlers.SubtitleAIHandler
if subtitleManager != nil && subtitleRepo != nil && deps.FileRepo != nil && deps.DB != nil && deps.Config != nil {
aiCfg := subtitleai.Config{
Enabled: deps.Config.SubtitleAI.Enabled,
BaseURL: deps.Config.SubtitleAI.BaseURL,
APIKey: deps.Config.SubtitleAI.APIKey,
ChatModel: deps.Config.SubtitleAI.ChatModel,
MaxConcurrentJobs: deps.Config.SubtitleAI.MaxConcurrentJobs,
BatchSize: deps.Config.SubtitleAI.BatchSize,
ContextNeighbors: deps.Config.SubtitleAI.ContextNeighbors,
aiCfg, disabledGateway := effectiveSubtitleAIConfig(deps.Config)
if disabledGateway != "" {
warnChatOnlyGateway(disabledGateway)
}
var aiNotifier subtitleai.Notifier
if subtitleAINotifier != nil {
aiNotifier = subtitleAINotifier
}
aiTranslator := subtitleai.NewLLMTranslator(aiClient, aiCfg.BatchSize, aiCfg.ContextNeighbors)
aiTranscriber := subtitleai.NewWhisperTranscriber(aiClient, deps.Config.Playback.FFmpegPath, deps.Config.SubtitleAI.ASRChunkSeconds)
aiService := subtitleai.NewService(
deps.AppContext,
aiCfg,
subtitleai.NewPgJobRepository(deps.DB),
subtitleai.NewLLMTranslator(subtitleai.NewClient(aiCfg), aiCfg.BatchSize, aiCfg.ContextNeighbors),
aiTranslator,
aiTranscriber,
subtitleManager,
subtitleRepo,
deps.FileRepo,
aiNotifier,
deps.Config.Playback.FFmpegPath,
slog.Default(),
aiSem,
)
aiService.Recover()
if deps.OnConfigChange != nil {
deps.OnConfigChange(func(old, updated *config.Config) {
newCfg, newDisabled := effectiveSubtitleAIConfig(updated)
aiService.UpdateConfig(newCfg)
aiTranslator.SetBatching(updated.SubtitleAI.BatchSize, updated.SubtitleAI.ContextNeighbors)
aiTranscriber.SetExtraction(updated.Playback.FFmpegPath, updated.SubtitleAI.ASRChunkSeconds)
// Warn only when the gateway-disable condition newly appears,
// not on every unrelated settings change.
if newDisabled != "" && old != nil {
if _, oldDisabled := effectiveSubtitleAIConfig(old); oldDisabled == "" {
warnChatOnlyGateway(newDisabled)
}
}
})
}
subtitleAIHandler = handlers.NewSubtitleAIHandler(aiService)
subtitleAIHandler.StoreProvider = deps.UserStoreProvider
}
// Metadata AI translation (descriptions into the localization tables).
var metadataAIHandler *handlers.MetadataAIHandler
if deps.DB != nil && deps.Config != nil && aiClient != nil {
mtRepo := metadatatranslation.NewPgRepository(deps.DB)
mtService := metadatatranslation.NewService(
deps.AppContext,
metadataAIConfigFromServer(deps.Config),
mtRepo,
mtRepo,
&metadatatranslation.CatalogLocalizationStore{
Items: catalog.NewMediaItemLocalizationRepository(deps.DB),
Seasons: catalog.NewSeasonLocalizationRepository(deps.DB),
Episodes: catalog.NewEpisodeLocalizationRepository(deps.DB),
},
aiClient.SystemUserChat,
aiSem,
slog.Default(),
)
mtService.Recover()
if deps.OnConfigChange != nil {
deps.OnConfigChange(func(_, updated *config.Config) {
mtService.UpdateConfig(metadataAIConfigFromServer(updated))
})
}
metadataAIHandler = handlers.NewMetadataAIHandler(mtService)
// Wire the refresh fallback: libraries with auto_translate_metadata get
// missing localizations filled after each metadata refresh.
if mt, ok := deps.MetadataService.(interface {
SetAutoTranslator(metadata.AutoTranslator)
}); ok {
mt.SetAutoTranslator(mtService)
}
}
// Build section handler if DB is available.
@@ -923,6 +1069,7 @@ func NewRouter(deps Dependencies) chi.Router {
sectionFetcher.StoreProvider = deps.UserStoreProvider
sectionFetcher.CollectionRepo = catalog.NewLibraryCollectionRepository(deps.DB)
sectionFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
sectionFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
if deps.DB != nil {
sectionFetcher.RecommendationRepo = recommendations.NewRepo(deps.DB)
if ratingsRepo != nil {
@@ -938,6 +1085,9 @@ func NewRouter(deps Dependencies) chi.Router {
}
sectionHandler.EpisodeRepo = episodeRepo
sectionHandler.DetailSvc = detailSvc
if ebookProgressStore != nil {
sectionHandler.EbookProgress = ebookProgressStore
}
if userRepo != nil {
sectionHandler.UserRepo = userRepo
}
@@ -1111,6 +1261,7 @@ func NewRouter(deps Dependencies) chi.Router {
recsFetcher := sections.NewFetcher(deps.DB)
recsFetcher.StoreProvider = deps.UserStoreProvider
recsFetcher.NextUpRepo = catalog.NewNextUpRepository(deps.DB, deps.UserStoreProvider)
recsFetcher.AudiobookNextRepo = catalog.NewAudiobookNextRepository(deps.DB)
recsHandler.Fetcher = recsFetcher
recsHandler.WatchTonightFetcher = recsFetcher
}
@@ -1119,6 +1270,9 @@ func NewRouter(deps Dependencies) chi.Router {
}
recsHandler.CalendarRepo = calendarRepo
recsHandler.EpisodeRepo = episodeRepo
if ebookProgressStore != nil {
recsHandler.EbookProgress = ebookProgressStore
}
if deps.PersonRepo != nil {
recsHandler.CastFetcher = deps.PersonRepo
}
@@ -1359,6 +1513,8 @@ func NewRouter(deps Dependencies) chi.Router {
deps.ScanRegistry,
deps.LibraryScanQueue,
historyImportSvc,
deps.NotificationsService,
deps.PresenceRegistry,
)
r.Get("/events/ws", eventsHandler.HandleWebSocket)
}
@@ -1419,6 +1575,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/catalog", catalogHandler.HandleGetCatalog)
r.Get("/catalog/filters", catalogHandler.HandleGetCatalogFilters)
r.Get("/catalog/filters/search", catalogHandler.HandleGetCatalogFacetSearch)
r.Get("/catalog/audiobook-groups", catalogHandler.HandleGetAudiobookGroups)
r.Post("/catalog/query", catalogHandler.HandlePostCatalogQuery)
if catalogResourceHandler != nil {
r.Get("/catalog/items/{id}", catalogResourceHandler.HandleGetItemDetail)
@@ -1506,6 +1663,36 @@ func NewRouter(deps Dependencies) chi.Router {
}
}
// Notifications inbox and preferences (profile-scoped).
if deps.NotificationsService != nil {
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
r.Route("/notifications", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/", notificationsHandler.HandleList)
r.Get("/unread-count", notificationsHandler.HandleUnreadCount)
r.Post("/read", notificationsHandler.HandleMarkRead)
r.Post("/{id}/dismiss", notificationsHandler.HandleDismiss)
r.Get("/preferences", notificationsHandler.HandleGetPreferences)
r.Put("/preferences", notificationsHandler.HandlePutPreferences)
})
}
// Push registration, device management, and VAPID key.
// Register and revoke are profile-scoped: they store a profileID alongside the
// push token so that notifications are delivered to the correct profile.
// RequireProfile is applied per-route (not via r.Use) to leave the three
// user-scoped endpoints (devices list, toggle, webpush-key) without it.
if deps.PushStore != nil && deps.PushConfig != nil {
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
r.Route("/notifications/push", func(r chi.Router) {
r.With(apimw.RequireProfile).Put("/device", pushHandler.HandleRegister)
r.With(apimw.RequireProfile).Delete("/device", pushHandler.HandleRevoke)
r.Get("/devices", pushHandler.HandleListDevices)
r.Put("/devices/{device_id}", pushHandler.HandleToggleDevice)
r.Get("/webpush-key", pushHandler.HandleWebPushKey)
})
}
// Progress and sync routes (profile-scoped).
if progressHandler != nil {
r.Route("/progress", func(r chi.Router) {
@@ -1705,6 +1892,35 @@ func NewRouter(deps Dependencies) chi.Router {
})
}
if ebookReaderHandler != nil {
r.Route("/ebooks", func(r chi.Router) {
r.Use(apimw.RequireProfile)
r.Get("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Head("/{content_id}/files/{file_id}/read", ebookReaderHandler.HandleReadFile)
r.Get("/{content_id}/progress", ebookReaderHandler.HandleGetProgress)
r.Put("/{content_id}/progress", ebookReaderHandler.HandleSaveProgress)
r.Get("/{content_id}/reader-config", ebookReaderHandler.HandleGetConfig)
r.Put("/{content_id}/reader-config", ebookReaderHandler.HandleSaveConfig)
r.Get("/{content_id}/annotations", ebookReaderHandler.HandleListAnnotations)
r.Post("/{content_id}/annotations", ebookReaderHandler.HandleCreateAnnotation)
r.Patch("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleUpdateAnnotation)
r.Delete("/{content_id}/annotations/{annotation_id}", ebookReaderHandler.HandleDeleteAnnotation)
})
}
// Metadata AI translation availability probe (the metadata editor
// and detail pages show or hide their translate actions based on
// this) plus the viewer-facing on-view translation trigger.
if metadataAIHandler != nil {
r.Get("/metadata/ai/status", metadataAIHandler.HandleStatus)
if itemRepo != nil {
metadataAIHandler.ItemAccess = itemRepo
r.Post("/items/{id}/translate-description", metadataAIHandler.HandleTranslateOnView)
}
} else {
r.Get("/metadata/ai/status", handlers.WriteMetadataAIDisabledStatus)
}
// Subtitle search + AI translation routes.
if subtitleSearchHandler != nil {
if deps.FileRepo != nil && itemRepo != nil {
@@ -1725,6 +1941,7 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/detect-language", subtitleSearchHandler.HandleDetectLanguage)
if subtitleAIHandler != nil {
r.Get("/ai/status", subtitleAIHandler.HandleStatus)
r.Get("/ai/quota", subtitleAIHandler.HandleQuota)
r.Post("/ai/translate", subtitleAIHandler.HandleTranslate)
r.Get("/ai/jobs", subtitleAIHandler.HandleListJobs)
r.Get("/ai/jobs/{job_id}", subtitleAIHandler.HandleGetJob)
@@ -1875,6 +2092,11 @@ func NewRouter(deps Dependencies) chi.Router {
r.Post("/items/{id}/match/search", adminMatchHandler.HandleSearchItemMatchCandidates)
r.Post("/items/{id}/match/apply", adminMatchHandler.HandleApplyItemMatch)
}
if metadataAIHandler != nil {
r.Post("/items/{id}/metadata-translation", metadataAIHandler.HandleTranslate)
r.Get("/items/{id}/metadata-translation/jobs", metadataAIHandler.HandleListJobs)
r.Post("/items/{id}/metadata-translation/jobs/{job_id}/cancel", metadataAIHandler.HandleCancelJob)
}
})
if adminJobsHandler != nil {
@@ -2225,6 +2447,22 @@ func NewRouter(deps Dependencies) chi.Router {
r.Get("/logs/audit", adminLogsHandler.HandleListAuditLogs)
r.Get("/logs/ws", adminLogsHandler.HandleLogStreamWebSocket)
}
if deps.NotificationsService != nil {
notificationsHandler := handlers.NewNotificationsHandler(deps.NotificationsService, deps.UserStoreProvider)
r.Route("/announcements", func(r chi.Router) {
r.Get("/", notificationsHandler.HandleListAnnouncements)
r.Post("/", notificationsHandler.HandleCreateAnnouncement)
r.Delete("/{id}", notificationsHandler.HandleDeleteAnnouncement)
})
}
if deps.PushStore != nil && deps.PushConfig != nil {
pushHandler := handlers.NewPushHandler(deps.PushStore, deps.PushConfig, deps.NotificationsService)
r.Get("/push/status", pushHandler.HandleAdminStatus)
r.Post("/push/generate-vapid-keys", pushHandler.HandleGenerateVAPIDKeys)
r.Post("/push/test", pushHandler.HandleSendTestPush)
}
if adminPlaybackControlHandler != nil {
r.Post("/sessions/{session_id}/pause", adminPlaybackControlHandler.HandlePauseSession)
r.Post("/sessions/{session_id}/resume", adminPlaybackControlHandler.HandleResumeSession)
@@ -2513,3 +2751,63 @@ func (a *traktCollectionAdapter) GetCollectionPreset(ctx context.Context, preset
}
return entries, nil
}
// llmConfigFromServer derives the shared AI client config from the server
// config. Used at construction and again on every config reload.
func llmConfigFromServer(cfg *config.Config) llm.Config {
return llm.Config{
BaseURL: cfg.AI.BaseURL,
APIKey: cfg.AI.APIKey,
ChatModel: cfg.AI.ChatModel,
ASRBaseURL: cfg.AI.ASRBaseURL,
ASRAPIKey: cfg.AI.ASRAPIKey,
ASRModel: cfg.AI.ASRModel,
}
}
// effectiveSubtitleAIConfig derives the subtitle AI service config from the
// server config. A chat-only gateway (e.g. OpenRouter) cannot produce
// timestamped transcriptions, so transcription is disabled rather than
// letting every job fail; the settings API rejects such values for the ASR
// URL, but the chat base URL legitimately may be one — this catches the
// blank-ASR-URL fallback case. The second return is the offending endpoint
// when that guard fired, empty otherwise.
func effectiveSubtitleAIConfig(cfg *config.Config) (subtitleai.Config, string) {
transcribeEnabled := cfg.SubtitleAI.TranscribeEnabled
effectiveASRBase := cfg.AI.ASRBaseURL
if effectiveASRBase == "" {
effectiveASRBase = cfg.AI.BaseURL
}
disabledGateway := ""
if transcribeEnabled && llm.IsChatOnlyGateway(effectiveASRBase) {
transcribeEnabled = false
disabledGateway = effectiveASRBase
}
return subtitleai.Config{
Configured: cfg.AI.BaseURL != "",
TranslateEnabled: cfg.SubtitleAI.Enabled,
TranscribeEnabled: transcribeEnabled,
ChatModel: cfg.AI.ChatModel,
ASRModel: cfg.AI.ASRModel,
BatchSize: cfg.SubtitleAI.BatchSize,
ContextNeighbors: cfg.SubtitleAI.ContextNeighbors,
TranscribeQuotaJobs: cfg.SubtitleAI.TranscribeQuotaJobs,
TranscribeQuotaPeriod: cfg.SubtitleAI.TranscribeQuotaPeriod,
}, disabledGateway
}
func warnChatOnlyGateway(endpoint string) {
slog.Warn("subtitle transcription disabled: the effective transcription endpoint is a chat-only gateway; "+
"set a Whisper-compatible Transcription base URL in AI Services", "endpoint", endpoint)
}
// metadataAIConfigFromServer derives the metadata translation service config
// from the server config. Used at construction and on every config reload.
func metadataAIConfigFromServer(cfg *config.Config) metadatatranslation.Config {
return metadatatranslation.Config{
Enabled: cfg.MetadataAI.Enabled,
Configured: cfg.AI.BaseURL != "",
ChatModel: cfg.AI.ChatModel,
OnView: cfg.MetadataAI.OnView,
}
}
+20 -4
View File
@@ -38,9 +38,13 @@ func (s *ABSProgressStore) GetProgress(ctx context.Context, userID, profileID, c
var completed bool
var progressPct *float64
// Completed rows store position_seconds = 0 (no resume point), so the
// percentage must come from the completed flag, not the position.
dbRow := s.Pool.QueryRow(ctx, `
SELECT media_item_id, position_seconds, duration_seconds, completed,
CASE WHEN duration_seconds > 0 THEN position_seconds / duration_seconds ELSE 0 END AS progress_pct,
CASE WHEN completed THEN 1.0
WHEN duration_seconds > 0 THEN position_seconds / duration_seconds
ELSE 0 END AS progress_pct,
updated_at
FROM user_watch_progress
WHERE user_id = $1 AND profile_id = $2 AND media_item_id = $3`,
@@ -85,7 +89,9 @@ func (s *ABSProgressStore) ListProgressForAudiobooks(ctx context.Context, userID
wp.position_seconds,
wp.duration_seconds,
wp.completed,
CASE WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds ELSE 0 END,
CASE WHEN wp.completed THEN 1.0
WHEN wp.duration_seconds > 0 THEN wp.position_seconds / wp.duration_seconds
ELSE 0 END,
wp.updated_at
FROM user_watch_progress wp
JOIN media_items mi ON mi.content_id = wp.media_item_id
@@ -138,17 +144,27 @@ func (s *ABSProgressStore) UpsertProgress(ctx context.Context, row abs.ProgressR
if updatedAt.IsZero() {
updatedAt = time.Now().UTC()
}
// Completed rows hold no resume point (position_seconds = 0) — the same
// invariant as the userstore write paths — so finished books can't leak
// into position-based continue-watching/listening queries. A later
// non-finished report (re-listen) moves position forward from 0 while
// the completed latch stays.
position := row.CurrentSeconds
if row.IsFinished {
position = 0
}
_, err = s.Pool.Exec(ctx, `
INSERT INTO user_watch_progress
(user_id, profile_id, media_item_id, position_seconds, duration_seconds, completed, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, profile_id, media_item_id) DO UPDATE SET
position_seconds = GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds),
position_seconds = CASE WHEN EXCLUDED.completed THEN 0
ELSE GREATEST(user_watch_progress.position_seconds, EXCLUDED.position_seconds) END,
duration_seconds = GREATEST(user_watch_progress.duration_seconds, EXCLUDED.duration_seconds),
completed = user_watch_progress.completed OR EXCLUDED.completed,
updated_at = GREATEST(user_watch_progress.updated_at, EXCLUDED.updated_at)`,
uid, row.ProfileID, row.ContentID,
row.CurrentSeconds, row.DurationSeconds, row.IsFinished, updatedAt,
position, row.DurationSeconds, row.IsFinished, updatedAt,
)
if err != nil {
return fmt.Errorf("abs_progress_store: upsert progress: %w", err)
+20 -11
View File
@@ -3,6 +3,7 @@ package auth
import (
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -36,29 +37,37 @@ const (
const PluginAccessCookieName = "silo_plugin_access"
// JWTService handles JWT token generation and validation using HMAC-SHA256.
// The expiry durations are atomics so they can be hot-reloaded from admin
// settings while tokens are being generated; the secret is fixed for the
// service lifetime (changing it would invalidate every outstanding token).
type JWTService struct {
secret []byte
accessExpiry time.Duration
refreshExpiry time.Duration
accessExpiry atomic.Int64 // nanoseconds
refreshExpiry atomic.Int64 // nanoseconds
}
// NewJWTService creates a new JWTService with the given secret and expiry durations.
func NewJWTService(secret string, accessExpiry, refreshExpiry time.Duration) *JWTService {
return &JWTService{
secret: []byte(secret),
accessExpiry: accessExpiry,
refreshExpiry: refreshExpiry,
}
j := &JWTService{secret: []byte(secret)}
j.SetExpiries(accessExpiry, refreshExpiry)
return j
}
// SetExpiries updates the token expiry durations. Safe for concurrent use;
// applies to tokens generated afterwards.
func (j *JWTService) SetExpiries(access, refresh time.Duration) {
j.accessExpiry.Store(int64(access))
j.refreshExpiry.Store(int64(refresh))
}
// AccessExpiry returns the configured access token expiry duration.
func (j *JWTService) AccessExpiry() time.Duration {
return j.accessExpiry
return time.Duration(j.accessExpiry.Load())
}
// RefreshExpiry returns the configured refresh token expiry duration.
func (j *JWTService) RefreshExpiry() time.Duration {
return j.refreshExpiry
return time.Duration(j.refreshExpiry.Load())
}
// GenerateAccessToken creates a signed JWT access token with the configured
@@ -93,11 +102,11 @@ func (j *JWTService) GeneratePluginAccessToken(userID int, role, sessionID strin
}
func (j *JWTService) generateAccessToken(claims Claims) (string, error) {
return j.generateToken(claims, TokenTypeAccess, j.accessExpiry)
return j.generateToken(claims, TokenTypeAccess, j.AccessExpiry())
}
func (j *JWTService) generateRefreshToken(claims Claims) (string, error) {
return j.generateToken(claims, TokenTypeRefresh, j.refreshExpiry)
return j.generateToken(claims, TokenTypeRefresh, j.RefreshExpiry())
}
// generateToken creates a signed JWT with the given claims and expiry duration.
+15 -1
View File
@@ -15,7 +15,11 @@ type AccessFilter struct {
DisabledLibraryIDs []int // user-disabled libraries (only set when AllowedLibraryIDs is nil)
PresentationLibraryID *int
PresentationLanguage string
MaxContentRating string
// ProfilePreferredLanguage is the viewer profile's preferred metadata
// language. Presentation language resolves: explicit PresentationLanguage
// → ProfilePreferredLanguage → the library's metadata_language.
ProfilePreferredLanguage string
MaxContentRating string
MaxPlaybackQuality string
SelectedFileID int
UserID int
@@ -25,6 +29,11 @@ type AccessFilter struct {
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
// the predicate can use idx_media_items_sort_key.
NamePrefix string
// ExcludedMediaTypes lists media_items.type values the viewer's surface
// never exposes (e.g. the Jellyfin compat layer excludes "audiobook" and
// "podcast" — they're served by the ABS-compat API instead). Applied by
// every query builder that consumes an AccessFilter.
ExcludedMediaTypes []string
}
func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string, args *[]any, argIdx *int) {
@@ -38,6 +47,11 @@ func applyAccessFilter(alias string, filter AccessFilter, conditions *[]string,
*argIdx = *argIdx + 1
}
}
if len(filter.ExcludedMediaTypes) > 0 {
*conditions = append(*conditions, fmt.Sprintf("NOT (%s.type = ANY($%d))", alias, *argIdx))
*args = append(*args, filter.ExcludedMediaTypes)
*argIdx = *argIdx + 1
}
}
// ApplySectionAccessFilter applies non-library access constraints to section queries.
+201
View File
@@ -0,0 +1,201 @@
package catalog
import (
"context"
"fmt"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/models"
)
// AudiobookGroupBy enumerates the supported audiobook grouping axes.
type AudiobookGroupBy string
const (
AudiobookGroupByAuthor AudiobookGroupBy = "author"
AudiobookGroupByNarrator AudiobookGroupBy = "narrator"
AudiobookGroupBySeries AudiobookGroupBy = "series"
)
// ParseAudiobookGroupBy validates a group_by request parameter.
func ParseAudiobookGroupBy(raw string) (AudiobookGroupBy, bool) {
switch AudiobookGroupBy(strings.ToLower(strings.TrimSpace(raw))) {
case AudiobookGroupByAuthor:
return AudiobookGroupByAuthor, true
case AudiobookGroupByNarrator:
return AudiobookGroupByNarrator, true
case AudiobookGroupBySeries:
return AudiobookGroupBySeries, true
default:
return "", false
}
}
// AudiobookGroupsQuery controls the grouped audiobook browse lookup.
type AudiobookGroupsQuery struct {
LibraryID int
GroupBy AudiobookGroupBy
// Sort is one of "name" (default), "count", "duration".
Sort string
Limit int
Offset int
}
// AudiobookGroup is one grouped browse row: an author, narrator, or series
// with aggregate stats over the audiobooks visible to the viewer.
type AudiobookGroup struct {
Name string
ItemCount int
TotalDurationSeconds int64
InProgressCount int
FinishedCount int
// PosterPaths holds up to four raw poster paths for cover stacks; the
// API layer presigns them.
PosterPaths []string
}
// ListAudiobookGroups returns grouped browse rows for an audiobook library.
// Groups are aggregated per person name (authors/narrators) or per series
// name, matching the case-insensitive semantics of the corresponding catalog
// filters, so a group name can be fed straight back into an author/narrator/
// series filter rule. Progress counts are scoped to the requesting profile via
// filter.UserID / filter.ProfileID.
func ListAudiobookGroups(ctx context.Context, pool *pgxpool.Pool, q AudiobookGroupsQuery, filter AccessFilter) ([]AudiobookGroup, int, error) {
if pool == nil {
return nil, 0, fmt.Errorf("audiobook groups: no database pool")
}
if q.LibraryID <= 0 {
return nil, 0, fmt.Errorf("audiobook groups: library id is required")
}
limit := q.Limit
if limit <= 0 {
limit = 200
}
if limit > 500 {
limit = 500
}
offset := max(q.Offset, 0)
args := []any{q.LibraryID}
argIdx := 2
conditions := []string{
"mi.type = 'audiobook'",
"mil.media_folder_id = $1",
}
if !appendAudiobookItemAccessConditions("mi", filter, &conditions, &args, &argIdx) {
return []AudiobookGroup{}, 0, nil
}
var joinClause, nameExpr, groupExpr, posterOrder string
switch q.GroupBy {
case AudiobookGroupByAuthor, AudiobookGroupByNarrator:
kind := models.PersonKindAuthor
if q.GroupBy == AudiobookGroupByNarrator {
kind = models.PersonKindNarrator
}
joinClause = fmt.Sprintf(`JOIN item_people ip ON ip.content_id = b.content_id AND ip.kind = $%d
JOIN people p ON p.id = ip.person_id`, argIdx)
args = append(args, int(kind))
argIdx++
nameExpr = "MIN(BTRIM(p.name))"
groupExpr = "LOWER(BTRIM(p.name))"
posterOrder = "b.sort_title"
case AudiobookGroupBySeries:
joinClause = "JOIN audiobook_series s ON s.content_id = b.content_id"
nameExpr = "MIN(BTRIM(s.series_name))"
groupExpr = "LOWER(BTRIM(s.series_name))"
posterOrder = "s.series_index NULLS LAST, b.sort_title"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported group_by %q", q.GroupBy)
}
userArg := fmt.Sprintf("$%d", argIdx)
args = append(args, filter.UserID)
argIdx++
profileArg := fmt.Sprintf("$%d", argIdx)
args = append(args, filter.ProfileID)
argIdx++
var orderClause string
switch strings.ToLower(strings.TrimSpace(q.Sort)) {
case "", "name":
orderClause = "LOWER(name)"
case "count":
orderClause = "item_count DESC, LOWER(name)"
case "duration":
orderClause = "total_duration_seconds DESC, LOWER(name)"
default:
return nil, 0, fmt.Errorf("audiobook groups: unsupported sort %q", q.Sort)
}
query := fmt.Sprintf(`
WITH books AS (
SELECT
mi.content_id,
mi.poster_path,
mi.sort_title,
COALESCE((
SELECT SUM(mf.duration)
FROM media_files mf
WHERE mf.content_id = mi.content_id AND mf.missing_since IS NULL
), 0) AS duration_seconds
FROM media_items mi
JOIN media_item_libraries mil ON mil.content_id = mi.content_id
WHERE %s
),
grouped AS (
SELECT
%s AS name,
COUNT(*)::int AS item_count,
COALESCE(SUM(b.duration_seconds), 0)::bigint AS total_duration_seconds,
COUNT(*) FILTER (WHERE uwp.media_item_id IS NOT NULL AND NOT uwp.completed)::int AS in_progress_count,
COUNT(*) FILTER (WHERE uwp.completed)::int AS finished_count,
(ARRAY_AGG(b.poster_path ORDER BY %s) FILTER (WHERE NULLIF(b.poster_path, '') IS NOT NULL))[1:4] AS poster_paths
FROM books b
%s
LEFT JOIN user_watch_progress uwp
ON uwp.media_item_id = b.content_id
AND uwp.user_id = %s
AND uwp.profile_id = %s
GROUP BY %s
)
SELECT name, item_count, total_duration_seconds, in_progress_count, finished_count, poster_paths,
COUNT(*) OVER ()::int AS total_groups
FROM grouped
ORDER BY %s
LIMIT $%d OFFSET $%d`,
strings.Join(conditions, " AND "),
nameExpr, posterOrder, joinClause, userArg, profileArg, groupExpr, orderClause,
argIdx, argIdx+1,
)
args = append(args, limit, offset)
rows, err := pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("querying audiobook groups: %w", err)
}
defer rows.Close()
total := 0
groups := make([]AudiobookGroup, 0, limit)
for rows.Next() {
var g AudiobookGroup
var posterPaths []string
if err := rows.Scan(&g.Name, &g.ItemCount, &g.TotalDurationSeconds, &g.InProgressCount, &g.FinishedCount, &posterPaths, &total); err != nil {
return nil, 0, fmt.Errorf("scanning audiobook group: %w", err)
}
if posterPaths == nil {
posterPaths = []string{}
}
g.PosterPaths = posterPaths
groups = append(groups, g)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterating audiobook groups: %w", err)
}
return groups, total, nil
}
+163
View File
@@ -0,0 +1,163 @@
package catalog
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// NextInSeriesQuery controls the audiobook next-in-series lookup.
type NextInSeriesQuery struct {
UserID int
ProfileID string
Limit int
// LibraryID / LibraryIDs / DisabledLibraryIDs scope the candidate books in
// SQL. Without this, a profile whose recently finished series live in a
// different library would consume the LIMIT with candidates the caller
// then filters out, starving a library-scoped section.
LibraryID *int
LibraryIDs []int
DisabledLibraryIDs []int
}
// NextInSeriesResult is one row from the next-in-series query: the next
// unstarted audiobook in a series the profile has finished a book of.
type NextInSeriesResult struct {
ContentID string
SeriesName string
SeriesIndex *float64
LastFinishedAt time.Time
}
// AudiobookNextRepository queries audiobook series progression for the
// next_in_series section.
type AudiobookNextRepository struct {
pool *pgxpool.Pool
}
// NewAudiobookNextRepository creates an AudiobookNextRepository.
func NewAudiobookNextRepository(pool *pgxpool.Pool) *AudiobookNextRepository {
return &AudiobookNextRepository{pool: pool}
}
// ListNextInSeries returns, for each audiobook series the profile has finished
// at least one book of, the lowest-indexed later book the profile has not
// started yet. Series surface in most-recently-finished order. Books already
// in progress are excluded — they belong to Continue Listening, not here.
//
// Candidate books are library-scoped in SQL (see NextInSeriesQuery); remaining
// access filtering (content rating) is applied by the caller when resolving
// content IDs to items.
func (r *AudiobookNextRepository) ListNextInSeries(ctx context.Context, q NextInSeriesQuery) ([]NextInSeriesResult, error) {
if r == nil || r.pool == nil || q.UserID <= 0 || q.ProfileID == "" {
return nil, nil
}
limit := q.Limit
if limit <= 0 {
limit = 20
}
args := []any{q.UserID, q.ProfileID}
argIdx := 3
candidateScope := ""
if q.LibraryID != nil {
candidateScope += fmt.Sprintf(`
AND EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = m.content_id AND mil.media_folder_id = $%d
)`, argIdx)
args = append(args, *q.LibraryID)
argIdx++
} else if q.LibraryIDs != nil {
if len(q.LibraryIDs) == 0 {
return nil, nil
}
candidateScope += fmt.Sprintf(`
AND EXISTS (
SELECT 1 FROM media_item_libraries mil
WHERE mil.content_id = m.content_id AND mil.media_folder_id = ANY($%d)
)`, argIdx)
args = append(args, q.LibraryIDs)
argIdx++
}
if len(q.DisabledLibraryIDs) > 0 {
candidateScope += fmt.Sprintf(`
AND NOT EXISTS (
SELECT 1 FROM media_item_libraries mil_disabled
WHERE mil_disabled.content_id = m.content_id
AND mil_disabled.media_folder_id = ANY($%d)
)`, argIdx)
args = append(args, q.DisabledLibraryIDs)
argIdx++
}
query := fmt.Sprintf(`
WITH finished_series AS (
SELECT
LOWER(BTRIM(s.series_name)) AS series_key,
MIN(BTRIM(s.series_name)) AS series_name,
MAX(s.series_index) AS max_finished_index,
MAX(uwp.updated_at) AS last_finished_at
FROM user_watch_progress uwp
JOIN audiobook_series s ON s.content_id = uwp.media_item_id
JOIN media_items mi ON mi.content_id = uwp.media_item_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = TRUE
AND mi.type = 'audiobook'
AND s.series_index IS NOT NULL
GROUP BY LOWER(BTRIM(s.series_name))
)
SELECT
next_book.content_id,
fs.series_name,
next_book.series_index,
fs.last_finished_at
FROM finished_series fs
JOIN LATERAL (
SELECT m.content_id, s2.series_index
FROM audiobook_series s2
JOIN media_items m ON m.content_id = s2.content_id
WHERE LOWER(BTRIM(s2.series_name)) = fs.series_key
AND s2.series_index IS NOT NULL
AND s2.series_index > fs.max_finished_index
AND m.type = 'audiobook'
AND EXISTS (
SELECT 1 FROM media_files mf
WHERE mf.content_id = m.content_id AND mf.missing_since IS NULL
)
AND NOT EXISTS (
SELECT 1 FROM user_watch_progress uwp2
WHERE uwp2.user_id = $1
AND uwp2.profile_id = $2
AND uwp2.media_item_id = m.content_id
)%s
ORDER BY s2.series_index, LOWER(m.sort_title)
LIMIT 1
) next_book ON true
ORDER BY fs.last_finished_at DESC
LIMIT $%d`, candidateScope, argIdx)
args = append(args, limit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying next-in-series audiobooks: %w", err)
}
defer rows.Close()
var results []NextInSeriesResult
for rows.Next() {
var res NextInSeriesResult
if err := rows.Scan(&res.ContentID, &res.SeriesName, &res.SeriesIndex, &res.LastFinishedAt); err != nil {
return nil, fmt.Errorf("scanning next-in-series row: %w", err)
}
results = append(results, res)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating next-in-series rows: %w", err)
}
return results, nil
}
+13 -6
View File
@@ -767,9 +767,9 @@ func listDistinctPeopleByKindWithSource(
}
// listDistinctAudiobookSeriesWithSource returns distinct series_name values
// from audiobook_series joined onto the scoped result set. Names are trimmed
// from the active book series table joined onto the scoped result set. Names are trimmed
// and case-folded for sort so the picker doesn't show duplicates that differ
// only by whitespace or casing — the literal trimmed series_name is still
// only by whitespace or casing; the literal trimmed series_name is still
// returned so existing rules continue to match.
//
// The DISTINCT happens in an inline subquery (rather than directly on the
@@ -791,14 +791,14 @@ func listDistinctAudiobookSeriesWithSource(
SELECT name FROM (
SELECT DISTINCT BTRIM(s.series_name) AS name
FROM %s
JOIN audiobook_series s ON s.content_id = mi.content_id
JOIN %s s ON s.content_id = mi.content_id
%s
AND s.series_name IS NOT NULL
AND BTRIM(s.series_name) <> ''
) names
ORDER BY LOWER(name) ASC
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), catalogFacetMaxValues)
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), catalogFacetMaxValues)
return queryDistinctStrings(ctx, pool, query, args)
}
@@ -954,7 +954,7 @@ func searchDistinctAudiobookSeriesWithSource(
SELECT name FROM (
SELECT DISTINCT BTRIM(s.series_name) AS name
FROM %s
JOIN audiobook_series s ON s.content_id = mi.content_id
JOIN %s s ON s.content_id = mi.content_id
%s
AND s.series_name IS NOT NULL
AND BTRIM(s.series_name) <> ''
@@ -962,10 +962,17 @@ func searchDistinctAudiobookSeriesWithSource(
) names
ORDER BY LOWER(name) ASC
LIMIT %d
`, fromClause, browseFilterPrefix(whereClause), prefixIdx, limit+1)
`, fromClause, bookSeriesTableForMediaScope(mediaScope), browseFilterPrefix(whereClause), prefixIdx, limit+1)
return queryFacetSearchResults(ctx, pool, query, args, limit)
}
func bookSeriesTableForMediaScope(mediaScope string) string {
if mediaScope == "ebook" {
return "ebook_series"
}
return "audiobook_series"
}
// queryFacetSearchResults executes a facet search query that was built
// with LIMIT N+1 and returns the first N rows plus a hasMore flag
// indicating whether an additional row was present (i.e. the result set
+9
View File
@@ -0,0 +1,9 @@
package catalog
import "testing"
func TestParseCatalogMediaScope_AllowsEbook(t *testing.T) {
if got := parseCatalogMediaScope(" ebook "); got != "ebook" {
t.Fatalf("expected ebook media scope, got %q", got)
}
}
+21 -18
View File
@@ -39,10 +39,9 @@ type CatalogFiltersResult struct {
Resolutions []string
AudioLanguages []string
SubtitleLanguages []string
// Authors, Narrators and Series are audiobook-native facets. Populated
// whenever the scope contains audiobook items; empty for video-only
// scopes. They share the item_people / audiobook_series tables so
// callers don't need to switch on media type.
// Authors, Narrators and Series are book-native facets. Authors and
// Narrators use item_people; Series uses the active book series table.
// Video-only scopes return these empty.
Authors []string
Narrators []string
Series []string
@@ -64,8 +63,8 @@ type facetFetcher interface {
// PeopleByKind returns distinct people names for the given PersonKind
// across the scoped result set. Used for Authors / Narrators facets.
PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// AudiobookSeries returns distinct series_name values from
// audiobook_series joined onto the scoped result set.
// AudiobookSeries returns distinct series_name values from the active
// book series table joined onto the scoped result set.
AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error)
// SearchDistinctArrayColumn prefix-searches an array-column facet
// (genres, studios, networks, countries). Returns up to limit
@@ -710,7 +709,7 @@ func (r *CatalogResolver) ListFiltersWithOptions(ctx context.Context, req Catalo
return r.listFiltersForSource(ctx, filters, options, episodeCatalogBaseRelation, req.Query.MediaScope)
}
return r.listFiltersForSource(ctx, filters, options, "media_items mi", "")
return r.listFiltersForSource(ctx, filters, options, "media_items mi", req.Query.MediaScope)
}
// CatalogFacetSearchResult is the typed return for SearchFacet. Matches
@@ -798,10 +797,9 @@ func (r *CatalogResolver) SearchFacet(ctx context.Context, req CatalogRequest, a
}
baseRelation := "media_items mi"
mediaScope := ""
mediaScope := req.Query.MediaScope
if isEpisodeCatalogScope(req.Query.MediaScope) {
baseRelation = episodeCatalogBaseRelation
mediaScope = req.Query.MediaScope
}
facets := r.facets
@@ -837,6 +835,9 @@ func dispatchFacetSearch(ctx context.Context, facets facetFetcher, facet string,
case "author":
return facets.SearchPeopleByKind(ctx, models.PersonKindAuthor, filters, baseRelation, mediaScope, prefix, limit)
case "narrator":
if mediaScope == "ebook" {
return nil, false, fmt.Errorf("%w: narrator facet is not available for ebook scope", ErrInvalidCatalogRequest)
}
return facets.SearchPeopleByKind(ctx, models.PersonKindNarrator, filters, baseRelation, mediaScope, prefix, limit)
case "series":
return facets.SearchAudiobookSeries(ctx, filters, baseRelation, mediaScope, prefix, limit)
@@ -958,14 +959,16 @@ func (r *CatalogResolver) listFiltersForSource(
authors = out
return nil
}))
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
if mediaScope != "ebook" {
eg.Go(withLimit(func() error {
out, err := facets.PeopleByKind(gctx, models.PersonKindNarrator, filters, baseRelation, mediaScope)
if err != nil {
return fmt.Errorf("listing catalog narrators: %w", err)
}
narrators = out
return nil
}))
}
eg.Go(withLimit(func() error {
out, err := facets.AudiobookSeries(gctx, filters, baseRelation, mediaScope)
if err != nil {
@@ -1087,7 +1090,7 @@ func validateCatalogExactCollectionRequest(req CatalogRequest) error {
func validateCatalogOverlayQuery(searchQuery string, def QueryDefinition, ruleFields, sortFields map[string]bool, allowRelevance bool) error {
if !IsValidMediaScope(def.MediaScope) {
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', or 'video'", ErrInvalidCatalogRequest)
return fmt.Errorf("%w: media_scope must be 'movie', 'series', 'episode', 'audiobook', 'ebook', or 'video'", ErrInvalidCatalogRequest)
}
if def.Match != "" && def.Match != "all" && def.Match != "any" {
return fmt.Errorf("%w: match must be 'all' or 'any'", ErrInvalidCatalogRequest)
+165
View File
@@ -39,6 +39,21 @@ func TestValidateCatalogQueryRequest_AllowsEpisodeMediaScope(t *testing.T) {
}
}
func TestValidateCatalogQueryRequest_AllowsEbookMediaScope(t *testing.T) {
req := CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{
MediaScope: "ebook",
Match: "all",
Sort: QuerySort{Field: "title", Order: "asc"},
},
}
if err := validateCatalogQueryRequest(req, true); err != nil {
t.Fatalf("expected ebook media scope to be accepted, got %v", err)
}
}
func TestValidateCatalogQueryRequest_AllowsAddedAtFilter(t *testing.T) {
req := CatalogRequest{
Source: CatalogSourceQuery,
@@ -244,6 +259,67 @@ func (s *stubFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters Br
return nil, false, nil
}
type recordingFacetFetcher struct {
mu sync.Mutex
peopleKinds []models.PersonKind
searchPeopleKind models.PersonKind
searchMediaScope string
}
func (s *recordingFacetFetcher) DistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) DistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) Resolutions(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) JSONBLanguages(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) SubtitleLanguages(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) PeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
s.mu.Lock()
s.peopleKinds = append(s.peopleKinds, kind)
s.mu.Unlock()
if kind == models.PersonKindAuthor {
return []string{"Author"}, nil
}
return []string{"Narrator"}, nil
}
func (s *recordingFacetFetcher) AudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string) ([]string, error) {
return nil, nil
}
func (s *recordingFacetFetcher) SearchDistinctArrayColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
func (s *recordingFacetFetcher) SearchDistinctScalarColumn(ctx context.Context, column string, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
func (s *recordingFacetFetcher) SearchPeopleByKind(ctx context.Context, kind models.PersonKind, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
s.mu.Lock()
s.searchPeopleKind = kind
s.searchMediaScope = mediaScope
s.mu.Unlock()
return []string{"Author"}, false, nil
}
func (s *recordingFacetFetcher) SearchAudiobookSeries(ctx context.Context, filters BrowseFilters, baseRelation string, mediaScope string, prefix string, limit int) ([]string, bool, error) {
return nil, false, nil
}
// countingExecutor is a previewExecutor stub that records the number of
// PreviewPage calls and the AccessFilter passed in (so tests can confirm
// NamePrefix made it through). It returns an empty result set so the resolver
@@ -327,3 +403,92 @@ func TestListFiltersWithOptions_RunsFacetQueriesConcurrently(t *testing.T) {
barrier.assertConcurrent(t)
}
func TestListFiltersWithOptions_EbookScopeSkipsNarratorFacet(t *testing.T) {
facets := &recordingFacetFetcher{}
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: facets,
}
result, err := resolver.ListFiltersWithOptions(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
CatalogFilterOptions{},
)
if err != nil {
t.Fatalf("ListFiltersWithOptions returned error: %v", err)
}
facets.mu.Lock()
defer facets.mu.Unlock()
for _, kind := range facets.peopleKinds {
if kind == models.PersonKindNarrator {
t.Fatal("ebook filters should not request narrator facets")
}
}
if len(result.Narrators) != 0 {
t.Fatalf("ebook filters returned narrators = %v, want empty", result.Narrators)
}
}
func TestSearchFacet_EbookScopeForwardsMediaScope(t *testing.T) {
facets := &recordingFacetFetcher{}
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: facets,
}
result, err := resolver.SearchFacet(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
"author",
"Au",
20,
)
if err != nil {
t.Fatalf("SearchFacet returned error: %v", err)
}
if len(result.Matches) != 1 || result.Matches[0] != "Author" {
t.Fatalf("matches = %v, want Author", result.Matches)
}
facets.mu.Lock()
defer facets.mu.Unlock()
if facets.searchPeopleKind != models.PersonKindAuthor {
t.Fatalf("searchPeopleKind = %v, want author", facets.searchPeopleKind)
}
if facets.searchMediaScope != "ebook" {
t.Fatalf("search mediaScope = %q, want ebook", facets.searchMediaScope)
}
}
func TestSearchFacet_EbookScopeRejectsNarratorFacet(t *testing.T) {
resolver := &CatalogResolver{
browseRepo: &BrowseRepository{},
facets: &recordingFacetFetcher{},
}
_, err := resolver.SearchFacet(
context.Background(),
CatalogRequest{
Source: CatalogSourceQuery,
Query: QueryDefinition{MediaScope: "ebook"},
},
AccessFilter{},
"narrator",
"Na",
20,
)
if err == nil {
t.Fatal("expected narrator facet to be rejected for ebook scope")
}
}
@@ -0,0 +1,212 @@
package catalog
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ProgressLister pages watch-progress rows for one profile.
// userstore.UserStore satisfies it.
type ProgressLister interface {
ListProgress(ctx context.Context, profileID, status string, limit, offset int) ([]userstore.WatchProgress, error)
}
// ProgressSnapshot pairs a media item with the time its progress row last changed.
type ProgressSnapshot struct {
ContentID string
UpdatedAt time.Time
}
// ContinueWatchingProgressFilter identifies in-progress entries that Continue
// Watching surfaces should hide: episodes superseded by a later-completed
// episode in the same series. The first-party sections fetcher and the
// jellycompat Resume endpoint share it so both surfaces agree on what "still
// watching" means.
type ContinueWatchingProgressFilter struct {
pool *pgxpool.Pool
}
// NewContinueWatchingProgressFilter creates a filter. A nil pool disables the
// superseded-episode check, leaving entries unfiltered.
func NewContinueWatchingProgressFilter(pool *pgxpool.Pool) *ContinueWatchingProgressFilter {
return &ContinueWatchingProgressFilter{pool: pool}
}
const supersededProgressPageSize = 500
// SupersededEpisodeProgressIDs returns the content IDs of in-progress entries
// whose series has a later episode completed more recently than the entry's
// own progress. Those entries are stale — the viewer already moved past them.
// Non-episode entries never match.
func (f *ContinueWatchingProgressFilter) SupersededEpisodeProgressIDs(ctx context.Context, store ProgressLister, profileID string, entries []userstore.WatchProgress) (map[string]struct{}, error) {
if f == nil || f.pool == nil {
return map[string]struct{}{}, nil
}
inProgress := ProgressSnapshots(entries)
if len(inProgress) == 0 {
return map[string]struct{}{}, nil
}
completed, err := CompletedProgressSnapshots(ctx, store, profileID)
if err != nil {
return nil, err
}
if len(completed) == 0 {
return map[string]struct{}{}, nil
}
inProgressIDs, inProgressUpdatedAts := splitProgressSnapshots(inProgress)
completedIDs, completedUpdatedAts := splitProgressSnapshots(completed)
query := buildSupersededEpisodeProgressQuery()
rows, err := f.pool.Query(ctx, query, inProgressIDs, inProgressUpdatedAts, completedIDs, completedUpdatedAts)
if err != nil {
return nil, fmt.Errorf("querying superseded episode progress: %w", err)
}
defer rows.Close()
superseded := make(map[string]struct{})
for rows.Next() {
var mediaItemID string
if err := rows.Scan(&mediaItemID); err != nil {
return nil, fmt.Errorf("scanning superseded episode progress: %w", err)
}
superseded[mediaItemID] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating superseded episode progress: %w", err)
}
return superseded, nil
}
// CompletedProgressSnapshots pages through all completed progress rows for the
// profile and returns deduplicated snapshots.
func CompletedProgressSnapshots(ctx context.Context, store ProgressLister, profileID string) ([]ProgressSnapshot, error) {
seen := make(map[string]struct{})
snapshots := make([]ProgressSnapshot, 0)
for offset := 0; ; offset += supersededProgressPageSize {
entries, err := store.ListProgress(ctx, profileID, "completed", supersededProgressPageSize, offset)
if err != nil {
return nil, fmt.Errorf("listing completed progress for superseded episodes: %w", err)
}
for _, snapshot := range ProgressSnapshots(entries) {
contentID := snapshot.ContentID
if _, ok := seen[contentID]; ok {
continue
}
seen[contentID] = struct{}{}
snapshots = append(snapshots, snapshot)
}
if len(entries) < supersededProgressPageSize {
return snapshots, nil
}
}
}
// ProgressSnapshots converts progress rows to snapshots, dropping rows with a
// blank media item ID or an unparseable timestamp.
func ProgressSnapshots(entries []userstore.WatchProgress) []ProgressSnapshot {
snapshots := make([]ProgressSnapshot, 0, len(entries))
for _, entry := range entries {
contentID := strings.TrimSpace(entry.MediaItemID)
if contentID == "" {
continue
}
updatedAt, err := time.Parse(time.RFC3339, entry.UpdatedAt)
if err != nil || updatedAt.IsZero() {
continue
}
snapshots = append(snapshots, ProgressSnapshot{
ContentID: contentID,
UpdatedAt: updatedAt.UTC(),
})
}
return snapshots
}
func splitProgressSnapshots(snapshots []ProgressSnapshot) ([]string, []time.Time) {
contentIDs := make([]string, len(snapshots))
updatedAts := make([]time.Time, len(snapshots))
for i, snapshot := range snapshots {
contentIDs[i] = snapshot.ContentID
updatedAts[i] = snapshot.UpdatedAt
}
return contentIDs, updatedAts
}
// The snapshots arrive as unnest arrays instead of joins against
// user_watch_progress because per-user progress may live in a SQLite store
// rather than this Postgres database.
func buildSupersededEpisodeProgressQuery() string {
return `
WITH in_progress(content_id, updated_at) AS (
SELECT * FROM unnest($1::text[], $2::timestamptz[])
),
completed(content_id, updated_at) AS (
SELECT * FROM unnest($3::text[], $4::timestamptz[])
)
SELECT DISTINCT ip.content_id
FROM in_progress ip_progress
JOIN episodes ip ON ip.content_id = ip_progress.content_id
JOIN episodes done
ON done.series_id = ip.series_id
AND (done.season_number, done.episode_number) > (ip.season_number, ip.episode_number)
JOIN completed done_progress
ON done_progress.content_id = done.content_id
WHERE done_progress.updated_at > ip_progress.updated_at`
}
// FilterSupersededProgress drops entries whose media item ID is in the
// superseded set.
func FilterSupersededProgress(entries []userstore.WatchProgress, superseded map[string]struct{}) []userstore.WatchProgress {
if len(entries) == 0 || len(superseded) == 0 {
return entries
}
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
if _, ok := superseded[entry.MediaItemID]; ok {
continue
}
filtered = append(filtered, entry)
}
return filtered
}
// HomeDismissalIndex maps media item ID to its dismissal row for one home surface.
type HomeDismissalIndex map[string]userstore.HomeItemDismissal
// NewHomeDismissalIndex builds an index from dismissal rows.
func NewHomeDismissalIndex(dismissals []userstore.HomeItemDismissal) HomeDismissalIndex {
index := make(HomeDismissalIndex, len(dismissals))
for _, dismissal := range dismissals {
index[dismissal.MediaItemID] = dismissal
}
return index
}
// FilterProgress drops entries still covered by a dismissal. A dismissal only
// holds while the entry's progress timestamp matches the one captured when the
// user dismissed it; resuming playback re-surfaces the item.
func (idx HomeDismissalIndex) FilterProgress(entries []userstore.WatchProgress) []userstore.WatchProgress {
if len(entries) == 0 || len(idx) == 0 {
return entries
}
filtered := make([]userstore.WatchProgress, 0, len(entries))
for _, entry := range entries {
dismissal, ok := idx[entry.MediaItemID]
if !ok || dismissal.ProgressUpdatedAt == nil || *dismissal.ProgressUpdatedAt != entry.UpdatedAt {
filtered = append(filtered, entry)
}
}
return filtered
}
@@ -0,0 +1,189 @@
package catalog
import (
"context"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/userstore"
)
func TestFilterSupersededProgressDropsOlderPartialsAfterLaterCompletedEpisode(t *testing.T) {
t.Parallel()
entries := []userstore.WatchProgress{
{MediaItemID: "boys-s1e1"},
{MediaItemID: "boys-s5e3"},
{MediaItemID: "movie-1"},
}
superseded := map[string]struct{}{
"boys-s1e1": {},
"boys-s5e3": {},
}
filtered := FilterSupersededProgress(entries, superseded)
if len(filtered) != 1 || filtered[0].MediaItemID != "movie-1" {
t.Fatalf("filtered entries = %+v, want only movie-1", filtered)
}
}
func TestCompletedProgressSnapshotsPagesThroughConfiguredStore(t *testing.T) {
t.Parallel()
entries := make([]userstore.WatchProgress, supersededProgressPageSize+1)
for i := range entries {
entries[i] = userstore.WatchProgress{
MediaItemID: "done-" + time.Unix(int64(i), 0).Format("150405"),
UpdatedAt: time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC).Format(time.RFC3339),
}
}
store := &stubProgressLister{entries: entries}
snapshots, err := CompletedProgressSnapshots(context.Background(), store, "p1")
if err != nil {
t.Fatalf("CompletedProgressSnapshots: %v", err)
}
if len(snapshots) != len(entries) {
t.Fatalf("completed snapshots count = %d, want %d", len(snapshots), len(entries))
}
if len(store.calls) != 2 {
t.Fatalf("ListProgress calls = %+v, want 2 paged calls", store.calls)
}
if store.calls[0] != (progressListCall{profileID: "p1", status: "completed", limit: supersededProgressPageSize, offset: 0}) {
t.Fatalf("first ListProgress call = %+v", store.calls[0])
}
if store.calls[1] != (progressListCall{profileID: "p1", status: "completed", limit: supersededProgressPageSize, offset: supersededProgressPageSize}) {
t.Fatalf("second ListProgress call = %+v", store.calls[1])
}
}
func TestBuildSupersededEpisodeProgressQueryUsesStoreSnapshotsWithFreshnessGate(t *testing.T) {
t.Parallel()
query := buildSupersededEpisodeProgressQuery()
expectedFragments := []string{
"unnest($1::text[], $2::timestamptz[])",
"unnest($3::text[], $4::timestamptz[])",
"FROM in_progress ip_progress",
"done_progress.updated_at > ip_progress.updated_at",
}
for _, fragment := range expectedFragments {
if !strings.Contains(query, fragment) {
t.Fatalf("expected superseded progress query to contain %q, got:\n%s", fragment, query)
}
}
unexpectedFragments := []string{
"user_watch_progress",
"user_history_hidden_items",
}
for _, fragment := range unexpectedFragments {
if strings.Contains(query, fragment) {
t.Fatalf("superseded progress query contains %q, got:\n%s", fragment, query)
}
}
}
func TestSupersededEpisodeProgressIDsWithoutPoolReturnsEmptySet(t *testing.T) {
t.Parallel()
filter := NewContinueWatchingProgressFilter(nil)
entries := []userstore.WatchProgress{{
MediaItemID: "ep-1",
UpdatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
}}
store := &stubProgressLister{}
superseded, err := filter.SupersededEpisodeProgressIDs(context.Background(), store, "p1", entries)
if err != nil {
t.Fatalf("SupersededEpisodeProgressIDs: %v", err)
}
if len(superseded) != 0 {
t.Fatalf("superseded = %v, want empty set", superseded)
}
if len(store.calls) != 0 {
t.Fatalf("ListProgress calls = %+v, want none without a pool", store.calls)
}
}
func TestHomeDismissalIndexFilterProgressDropsOnlyMatchingTimestamps(t *testing.T) {
t.Parallel()
dismissedAt := "2025-01-01T00:00:00Z"
resumedAt := "2025-01-02T00:00:00Z"
idx := NewHomeDismissalIndex([]userstore.HomeItemDismissal{
{MediaItemID: "still-dismissed", ProgressUpdatedAt: &dismissedAt},
{MediaItemID: "resumed-since", ProgressUpdatedAt: &dismissedAt},
{MediaItemID: "no-timestamp"},
})
entries := []userstore.WatchProgress{
{MediaItemID: "still-dismissed", UpdatedAt: dismissedAt},
{MediaItemID: "resumed-since", UpdatedAt: resumedAt},
{MediaItemID: "no-timestamp", UpdatedAt: dismissedAt},
{MediaItemID: "never-dismissed", UpdatedAt: dismissedAt},
}
filtered := idx.FilterProgress(entries)
got := make([]string, 0, len(filtered))
for _, entry := range filtered {
got = append(got, entry.MediaItemID)
}
want := []string{"resumed-since", "no-timestamp", "never-dismissed"}
if len(got) != len(want) {
t.Fatalf("filtered = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("filtered = %v, want %v", got, want)
}
}
}
func TestProgressSnapshotsSkipsBlankIDsAndBadTimestamps(t *testing.T) {
t.Parallel()
valid := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
entries := []userstore.WatchProgress{
{MediaItemID: "ok", UpdatedAt: valid.Format(time.RFC3339)},
{MediaItemID: " ", UpdatedAt: valid.Format(time.RFC3339)},
{MediaItemID: "bad-time", UpdatedAt: "not-a-time"},
}
snapshots := ProgressSnapshots(entries)
if len(snapshots) != 1 || snapshots[0].ContentID != "ok" || !snapshots[0].UpdatedAt.Equal(valid) {
t.Fatalf("snapshots = %+v, want single valid snapshot for %q", snapshots, "ok")
}
}
type progressListCall struct {
profileID string
status string
limit int
offset int
}
type stubProgressLister struct {
entries []userstore.WatchProgress
calls []progressListCall
}
func (s *stubProgressLister) ListProgress(_ context.Context, profileID, status string, limit, offset int) ([]userstore.WatchProgress, error) {
s.calls = append(s.calls, progressListCall{
profileID: profileID,
status: status,
limit: limit,
offset: offset,
})
if offset >= len(s.entries) {
return nil, nil
}
end := offset + limit
if end > len(s.entries) {
end = len(s.entries)
}
return s.entries[offset:end], nil
}
+211 -96
View File
@@ -68,34 +68,38 @@ type ItemDetail struct {
Type string `json:"type"`
// Metadata (served inline from Postgres).
Title string `json:"title"`
SortTitle string `json:"sort_title,omitempty"`
OriginalTitle string `json:"original_title,omitempty"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
Tagline string `json:"tagline,omitempty"`
Runtime int `json:"runtime,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
Genres []string `json:"genres"`
RatingIMDB *float64 `json:"rating_imdb,omitempty"`
RatingTMDB *float64 `json:"rating_tmdb,omitempty"`
RatingRTCritic *int `json:"rating_rt_critic,omitempty"`
RatingRTAudience *int `json:"rating_rt_audience,omitempty"`
ImdbID string `json:"imdb_id,omitempty"`
TmdbID string `json:"tmdb_id,omitempty"`
TvdbID string `json:"tvdb_id,omitempty"`
Cast []CastCredit `json:"cast"`
Crew []CrewCredit `json:"crew"`
Studios []string `json:"studios"`
Networks []string `json:"networks"`
Countries []string `json:"countries,omitempty"`
LockedFields []int `json:"locked_fields,omitempty"`
FirstAirDate *string `json:"first_air_date,omitempty"`
LastAirDate *string `json:"last_air_date,omitempty"`
ReleaseDate *string `json:"release_date,omitempty"`
AirTime *string `json:"air_time,omitempty"`
AirTimezone *string `json:"air_timezone,omitempty"`
ShowStatus string `json:"show_status,omitempty"`
Title string `json:"title"`
SortTitle string `json:"sort_title,omitempty"`
OriginalTitle string `json:"original_title,omitempty"`
Year int `json:"year,omitempty"`
Overview string `json:"overview,omitempty"`
Tagline string `json:"tagline,omitempty"`
// PendingTranslationLanguage, when set, is the viewer's presentation
// language that the description is missing — the on-view AI translation
// affordance keys off it.
PendingTranslationLanguage string `json:"pending_translation_language,omitempty"`
Runtime int `json:"runtime,omitempty"`
ContentRating string `json:"content_rating,omitempty"`
Genres []string `json:"genres"`
RatingIMDB *float64 `json:"rating_imdb,omitempty"`
RatingTMDB *float64 `json:"rating_tmdb,omitempty"`
RatingRTCritic *int `json:"rating_rt_critic,omitempty"`
RatingRTAudience *int `json:"rating_rt_audience,omitempty"`
ImdbID string `json:"imdb_id,omitempty"`
TmdbID string `json:"tmdb_id,omitempty"`
TvdbID string `json:"tvdb_id,omitempty"`
Cast []CastCredit `json:"cast"`
Crew []CrewCredit `json:"crew"`
Studios []string `json:"studios"`
Networks []string `json:"networks"`
Countries []string `json:"countries,omitempty"`
LockedFields []int `json:"locked_fields,omitempty"`
FirstAirDate *string `json:"first_air_date,omitempty"`
LastAirDate *string `json:"last_air_date,omitempty"`
ReleaseDate *string `json:"release_date,omitempty"`
AirTime *string `json:"air_time,omitempty"`
AirTimezone *string `json:"air_timezone,omitempty"`
ShowStatus string `json:"show_status,omitempty"`
// Presigned image URLs.
PosterURL string `json:"poster_url,omitempty"`
@@ -157,6 +161,9 @@ type ItemDetail struct {
// Audiobook-specific detail. Present only when Type == "audiobook".
Audiobook *AudiobookDetailExtension `json:"audiobook,omitempty"`
// Ebook-specific detail. Present only when Type == "ebook".
Ebook *EbookDetailExtension `json:"ebook,omitempty"`
}
type AudiobookDetailExtension struct {
@@ -201,6 +208,13 @@ type AudiobookNarration struct {
Narrators []string `json:"narrators"`
}
type EbookDetailExtension struct {
Authors []AudiobookPerson `json:"authors"`
Publisher string `json:"publisher,omitempty"`
Series *AudiobookSeriesGroup `json:"series,omitempty"`
Related AudiobookRelatedContent `json:"related"`
}
// ItemUserState is per-profile viewer state included in item detail responses.
type ItemUserState struct {
Played bool `json:"played"`
@@ -566,10 +580,16 @@ func cloneEpisode(ep *models.Episode) *models.Episode {
return &cp
}
// resolvePresentationLanguage picks the display language for a request:
// explicit request language → viewer profile preference → the presentation
// library's metadata language.
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter) (string, error) {
if strings.TrimSpace(filter.PresentationLanguage) != "" {
return strings.TrimSpace(filter.PresentationLanguage), nil
}
if strings.TrimSpace(filter.ProfilePreferredLanguage) != "" {
return strings.TrimSpace(filter.ProfilePreferredLanguage), nil
}
if filter.PresentationLibraryID == nil || s.folderRepo == nil {
return "", nil
}
@@ -583,6 +603,27 @@ func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter
return strings.TrimSpace(folder.MetadataLanguage), nil
}
// PendingTranslationLanguage reports the presentation language the item's
// description is missing: non-empty when the resolved language differs from
// the item's base metadata language, the base overview has text, and no
// localized overview exists yet. Clients use it to offer (or auto-run)
// on-view AI translation; it is pure data, independent of whether the AI
// feature is enabled.
func (s *DetailService) PendingTranslationLanguage(ctx context.Context, item *models.MediaItem, filter AccessFilter) string {
if item == nil || strings.TrimSpace(item.Overview) == "" || s.itemLocRepo == nil {
return ""
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
return ""
}
loc, err := s.itemLocRepo.Get(ctx, item.ContentID, language)
if err != nil || (loc != nil && loc.Overview != "") {
return ""
}
return language
}
func (s *DetailService) validatePresentationItemAccess(ctx context.Context, filter AccessFilter, contentID string) error {
if filter.PresentationLibraryID == nil {
return nil
@@ -613,17 +654,7 @@ func (s *DetailService) LocalizeItemModel(ctx context.Context, item *models.Medi
if err != nil || loc == nil {
return cloneMediaItem(item), err
}
localized := cloneMediaItem(item)
localized.Title = loc.Title
localized.SortTitle = loc.SortTitle
localized.Overview = loc.Overview
localized.Tagline = loc.Tagline
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
localized.BackdropPath = loc.BackdropPath
localized.BackdropThumbhash = loc.BackdropThumbhash
localized.LogoPath = loc.LogoPath
return localized, nil
return applyItemLocalization(item, loc), nil
}
func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.Season, filter AccessFilter) (*models.Season, error) {
@@ -638,12 +669,7 @@ func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.
if err != nil || loc == nil {
return cloneSeason(season), err
}
localized := cloneSeason(season)
localized.Title = loc.Title
localized.Overview = loc.Overview
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
return localized, nil
return applySeasonLocalization(season, loc), nil
}
func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *models.Episode, filter AccessFilter) (*models.Episode, error) {
@@ -658,9 +684,44 @@ func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *model
if err != nil || loc == nil {
return cloneEpisode(episode), err
}
localized := cloneEpisode(episode)
localized.Title = loc.Title
localized.Overview = loc.Overview
return applyEpisodeLocalization(episode, loc), nil
}
// LocalizeEpisodeModels applies presentation-language localization to a batch
// of episodes with a single lookup. Episodes without a localization row are
// returned unchanged; the result preserves input order and length.
func (s *DetailService) LocalizeEpisodeModels(ctx context.Context, episodes []*models.Episode, filter AccessFilter) ([]*models.Episode, error) {
if len(episodes) == 0 {
return episodes, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || s.episodeLocRepo == nil {
return episodes, err
}
ids := make([]string, 0, len(episodes))
for _, ep := range episodes {
if ep == nil || sameMetadataLanguage(ep.DefaultMetadataLanguage, language) {
continue
}
ids = append(ids, ep.ContentID)
}
if len(ids) == 0 {
return episodes, nil
}
locs, err := s.episodeLocRepo.GetByEpisodeIDs(ctx, ids, language)
if err != nil || len(locs) == 0 {
return episodes, err
}
localized := make([]*models.Episode, len(episodes))
for i, ep := range episodes {
localized[i] = ep
if ep == nil {
continue
}
if loc := locs[ep.ContentID]; loc != nil {
localized[i] = applyEpisodeLocalization(ep, loc)
}
}
return localized, nil
}
@@ -818,6 +879,7 @@ func (s *DetailService) fetchCredits(ctx context.Context, contentID string) ([]C
}
func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.MediaItem, contentID string, filter AccessFilter) (*ItemDetail, error) {
pendingTranslation := s.PendingTranslationLanguage(ctx, item, filter)
localizedItem, err := s.LocalizeItemModel(ctx, item, filter)
if err != nil {
return nil, fmt.Errorf("localizing item detail: %w", err)
@@ -825,42 +887,43 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
item = localizedItem
castCredits, crewCredits := s.fetchCredits(ctx, contentID)
detail := &ItemDetail{
ContentID: item.ContentID,
Type: item.Type,
Title: item.Title,
SortTitle: item.SortTitle,
OriginalTitle: item.OriginalTitle,
Year: item.Year,
Overview: item.Overview,
Tagline: item.Tagline,
Runtime: item.Runtime,
ContentRating: item.ContentRating,
Genres: item.Genres,
RatingIMDB: item.RatingIMDB,
RatingTMDB: item.RatingTMDB,
RatingRTCritic: item.RatingRTCritic,
RatingRTAudience: item.RatingRTAudience,
ImdbID: item.ImdbID,
TmdbID: item.TmdbID,
TvdbID: item.TvdbID,
Cast: castCredits,
Crew: crewCredits,
Studios: item.Studios,
Networks: item.Networks,
Countries: item.Countries,
LockedFields: item.LockedFields,
FirstAirDate: item.FirstAirDate,
LastAirDate: item.LastAirDate,
ReleaseDate: item.ReleaseDate,
AirTime: item.AirTime,
AirTimezone: item.AirTimezone,
ShowStatus: item.ShowStatus,
PosterThumbhash: item.PosterThumbhash,
BackdropThumbhash: item.BackdropThumbhash,
SeasonCount: item.SeasonCount,
Versions: []FileVersion{},
PlaybackVariants: []PlaybackVariant{},
Subtitles: []SubtitleInfo{},
ContentID: item.ContentID,
Type: item.Type,
Title: item.Title,
SortTitle: item.SortTitle,
OriginalTitle: item.OriginalTitle,
Year: item.Year,
Overview: item.Overview,
Tagline: item.Tagline,
PendingTranslationLanguage: pendingTranslation,
Runtime: item.Runtime,
ContentRating: item.ContentRating,
Genres: item.Genres,
RatingIMDB: item.RatingIMDB,
RatingTMDB: item.RatingTMDB,
RatingRTCritic: item.RatingRTCritic,
RatingRTAudience: item.RatingRTAudience,
ImdbID: item.ImdbID,
TmdbID: item.TmdbID,
TvdbID: item.TvdbID,
Cast: castCredits,
Crew: crewCredits,
Studios: item.Studios,
Networks: item.Networks,
Countries: item.Countries,
LockedFields: item.LockedFields,
FirstAirDate: item.FirstAirDate,
LastAirDate: item.LastAirDate,
ReleaseDate: item.ReleaseDate,
AirTime: item.AirTime,
AirTimezone: item.AirTimezone,
ShowStatus: item.ShowStatus,
PosterThumbhash: item.PosterThumbhash,
BackdropThumbhash: item.BackdropThumbhash,
SeasonCount: item.SeasonCount,
Versions: []FileVersion{},
PlaybackVariants: []PlaybackVariant{},
Subtitles: []SubtitleInfo{},
}
// Resolve image URLs: full URLs (TVDB/TMDB) pass through; S3 cached base paths get
@@ -895,6 +958,9 @@ func (s *DetailService) buildMediaItemDetail(ctx context.Context, item *models.M
if item.Type == "audiobook" {
detail.Audiobook = s.buildAudiobookExtension(ctx, item, detail.Versions, crewCredits, filter)
}
if item.Type == "ebook" {
detail.Ebook = s.buildEbookExtension(ctx, item, crewCredits, filter)
}
// Series folder paths from confirmed claims when available, otherwise from
// the file links that currently belong to the item. This keeps provisional
@@ -1036,6 +1102,26 @@ func (s *DetailService) buildAudiobookExtension(
}
}
func (s *DetailService) buildEbookExtension(
ctx context.Context,
item *models.MediaItem,
crew []CrewCredit,
filter AccessFilter,
) *EbookDetailExtension {
if item == nil {
return nil
}
return &EbookDetailExtension{
Authors: audiobookPeopleFromCrew(crew, models.PersonKindAuthor.String()),
Publisher: firstNonEmptyString(item.Studios),
Series: s.fetchEbookSeries(ctx, item.ContentID, filter),
Related: AudiobookRelatedContent{
AlsoByAuthor: s.fetchEbookAlsoByAuthor(ctx, item.ContentID, filter),
Similar: s.fetchEbookSimilarByGenres(ctx, item.ContentID, filter),
},
}
}
func audiobookPeopleFromCrew(crew []CrewCredit, job string) []AudiobookPerson {
out := make([]AudiobookPerson, 0)
for _, credit := range crew {
@@ -1107,15 +1193,23 @@ func appendAudiobookItemAccessConditions(
}
func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookAlsoByAuthor(ctx, contentID, "audiobook", filter)
}
func (s *DetailService) fetchEbookAlsoByAuthor(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookAlsoByAuthor(ctx, contentID, "ebook", filter)
}
func (s *DetailService) fetchBookAlsoByAuthor(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor}
argIdx := 3
args := []any{contentID, models.PersonKindAuthor, mediaType}
argIdx := 4
conditions := []string{
"ip1.content_id = $1",
"ip1.kind = $2",
"m2.type = 'audiobook'",
"m2.type = $3",
}
if !appendAudiobookItemAccessConditions("m2", filter, &conditions, &args, &argIdx) {
return []AudiobookRelatedItem{}
@@ -1157,13 +1251,21 @@ func (s *DetailService) fetchAudiobookAlsoByAuthor(ctx context.Context, contentI
}
func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookSimilarByGenres(ctx, contentID, "audiobook", filter)
}
func (s *DetailService) fetchEbookSimilarByGenres(ctx context.Context, contentID string, filter AccessFilter) []AudiobookRelatedItem {
return s.fetchBookSimilarByGenres(ctx, contentID, "ebook", filter)
}
func (s *DetailService) fetchBookSimilarByGenres(ctx context.Context, contentID string, mediaType string, filter AccessFilter) []AudiobookRelatedItem {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return []AudiobookRelatedItem{}
}
args := []any{contentID, models.PersonKindAuthor}
argIdx := 3
args := []any{contentID, models.PersonKindAuthor, mediaType}
argIdx := 4
conditions := []string{
"m.type = 'audiobook'",
"m.type = $3",
"m.content_id <> $1",
"m.genres && (SELECT array_agg(g) FROM this_genres)",
`NOT EXISTS (
@@ -1216,14 +1318,27 @@ func (s *DetailService) fetchAudiobookSimilarByGenres(ctx context.Context, conte
}
func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
return s.fetchBookSeries(ctx, contentID, "audiobook", "audiobook_series", filter)
}
func (s *DetailService) fetchEbookSeries(ctx context.Context, contentID string, filter AccessFilter) *AudiobookSeriesGroup {
return s.fetchBookSeries(ctx, contentID, "ebook", "ebook_series", filter)
}
func (s *DetailService) fetchBookSeries(ctx context.Context, contentID string, mediaType string, tableName string, filter AccessFilter) *AudiobookSeriesGroup {
if s == nil || s.itemRepo == nil || s.itemRepo.pool == nil {
return nil
}
args := []any{contentID}
argIdx := 2
switch tableName {
case "audiobook_series", "ebook_series":
default:
return nil
}
args := []any{contentID, mediaType}
argIdx := 3
conditions := []string{
"root.content_id = $1",
"m.type = 'audiobook'",
"m.type = $2",
}
if !appendAudiobookItemAccessConditions("m", filter, &conditions, &args, &argIdx) {
return nil
@@ -1236,13 +1351,13 @@ func (s *DetailService) fetchAudiobookSeries(ctx context.Context, contentID stri
COALESCE(m.poster_path, ''),
s.series_name,
s.series_index
FROM audiobook_series root
JOIN audiobook_series s ON LOWER(s.series_name) = LOWER(root.series_name)
FROM %s root
JOIN %s s ON LOWER(s.series_name) = LOWER(root.series_name)
JOIN media_items m ON m.content_id = s.content_id
WHERE %s
ORDER BY s.series_index NULLS LAST, LOWER(m.sort_title)
LIMIT 30
`, strings.Join(conditions, " AND "))
`, tableName, tableName, strings.Join(conditions, " AND "))
rows, err := s.itemRepo.pool.Query(ctx, query, args...)
if err != nil {
return nil
+24
View File
@@ -76,3 +76,27 @@ func (r *recordingCatalogImageResolver) ResolveImageURLs(_ context.Context, path
}
return out
}
func TestBuildEbookExtensionUsesAuthorsAndPublisherWithoutNarrators(t *testing.T) {
crew := []CrewCredit{
{Name: "Becky Chambers", Job: models.PersonKindAuthor.String(), PersonID: "10"},
{Name: "Narrator Should Not Apply", Job: models.PersonKindNarrator.String(), PersonID: "20"},
}
item := &models.MediaItem{
ContentID: "ebook-1",
Type: "ebook",
Studios: []string{"Tor"},
}
extension := (&DetailService{}).buildEbookExtension(nil, item, crew, AccessFilter{})
if extension == nil {
t.Fatal("buildEbookExtension returned nil")
}
if len(extension.Authors) != 1 || extension.Authors[0].Name != "Becky Chambers" {
t.Fatalf("Authors = %+v, want Becky Chambers", extension.Authors)
}
if extension.Publisher != "Tor" {
t.Fatalf("Publisher = %q, want Tor", extension.Publisher)
}
}
+15 -1
View File
@@ -69,7 +69,11 @@ var SensitiveSettingKeys = map[string]bool{
"mdblist.api_key": true,
"introdb.api_key": true,
// Subtitle AI (on-demand translation) API key.
// Shared AI endpoint API keys (+ legacy subtitle_ai alias the loader still
// falls back to; the legacy row is never renamed because ciphertext is
// GCM-bound to its key).
"ai.api_key": true,
"ai.asr_api_key": true,
"subtitle_ai.api_key": true,
// Recommendations embedding auth (+ legacy openai alias at db_loader L409).
@@ -87,6 +91,16 @@ var SensitiveSettingKeys = map[string]bool{
// still referenced by older request_integrations rows until backfilled).
"requests.radarr.api_key": true,
"requests.sonarr.api_key": true,
// Push notification provider credentials.
"push.apns.p8_key": true,
"push.apns.key_id": true,
"push.apns.team_id": true,
"push.apns.bundle_id": true,
"push.fcm.service_account_json": true,
"push.webpush.vapid_private": true,
"push.webpush.vapid_public": true,
"push.webpush.subject": true,
}
// EncryptedSettingsRepo decorates a raw settings store, transparently
@@ -563,7 +563,6 @@ func episodeCatalogUserStateCTE(plan episodeCatalogUserStatePlan) string {
AND hhi.media_item_id = uwp.media_item_id
WHERE uwp.user_id = $1
AND uwp.profile_id = $2
AND uwp.completed = FALSE
AND uwp.position_seconds > 0%s
AND (hhi.media_item_id IS NULL OR uwp.updated_at > hhi.hidden_before)
)`, progressRatioGate)
+26
View File
@@ -638,6 +638,32 @@ func (r *EpisodeRepository) GetByIDs(ctx context.Context, contentIDs []string) (
return scanEpisodes(rows)
}
// HasFilesByIDs reports which of the given episodes are backed by at least one
// live (non-missing) media file. Episodes absent from the result map have no
// file — e.g. provider-metadata-only entries for unaired episodes.
func (r *EpisodeRepository) HasFilesByIDs(ctx context.Context, contentIDs []string) (map[string]bool, error) {
result := make(map[string]bool, len(contentIDs))
if len(contentIDs) == 0 {
return result, nil
}
query := `SELECT episode_id FROM media_files
WHERE episode_id = ANY($1) AND missing_since IS NULL
GROUP BY episode_id`
rows, err := r.pool.Query(ctx, query, contentIDs)
if err != nil {
return nil, fmt.Errorf("checking episode file presence: %w", err)
}
defer rows.Close()
for rows.Next() {
var episodeID string
if err := rows.Scan(&episodeID); err != nil {
return nil, fmt.Errorf("scanning episode file presence: %w", err)
}
result[episodeID] = true
}
return result, rows.Err()
}
// ListBySeries returns all episodes for a given series, ordered by season and
// episode number.
func (r *EpisodeRepository) ListBySeries(ctx context.Context, seriesID string) ([]*models.Episode, error) {
+4 -3
View File
@@ -24,8 +24,9 @@ type BrowseFavoritesFilters struct {
AllowedLibraryIDs []int // nil = no allowlist, []int{} = empty result
DisabledLibraryIDs []int // user-disabled libraries to exclude
MaxContentRating string
SortField string // "added_at" (default), "title"/"sort_title", "year", "release_date"
SortOrder string // "asc" or "desc" (default desc)
ExcludedMediaTypes []string // media types the caller's surface never exposes
SortField string // "added_at" (default), "title"/"sort_title", "year", "release_date"
SortOrder string // "asc" or "desc" (default desc)
Limit int
Offset int
}
@@ -230,7 +231,7 @@ func buildBrowseFavoritesPlan(f BrowseFavoritesFilters) (browseFavoritesPlan, er
argIdx++
}
applyAccessFilter("mi", AccessFilter{MaxContentRating: f.MaxContentRating}, &conditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: f.MaxContentRating, ExcludedMediaTypes: f.ExcludedMediaTypes}, &conditions, &args, &argIdx)
orderBy := buildBrowseFavoritesOrderBy(f.SortField, f.SortOrder)
+9 -1
View File
@@ -112,6 +112,7 @@ type UpdateFolderInput struct {
Name *string
Enabled *bool
MetadataLanguage *string
AutoTranslateMetadata *bool
ChapterThumbnailsEnabled *bool
IntroDetectionEnabled *bool
}
@@ -142,7 +143,7 @@ func (r *FolderRepository) Pool() *pgxpool.Pool {
// folderColumns is the list of columns returned by all SELECT queries.
// Kept in one place so scanFolder stays in sync.
const folderColumns = `id, type, name, enabled, metadata_language, chapter_thumbnails_enabled, intro_detection_enabled, poster_path, last_scanned_at,
const folderColumns = `id, type, name, enabled, metadata_language, auto_translate_metadata, chapter_thumbnails_enabled, intro_detection_enabled, poster_path, last_scanned_at,
scan_warning_code, scan_warning_message, scan_warning_at, allow_empty_cleanup_once, sort_order`
// scanFolder scans a single row into a *models.MediaFolder.
@@ -155,6 +156,7 @@ func scanFolder(row pgx.Row) (*models.MediaFolder, error) {
&f.Name,
&f.Enabled,
&f.MetadataLanguage,
&f.AutoTranslateMetadata,
&f.ChapterThumbnailsEnabled,
&f.IntroDetectionEnabled,
&f.PosterPath,
@@ -187,6 +189,7 @@ func scanFolders(rows pgx.Rows) ([]*models.MediaFolder, error) {
&f.Name,
&f.Enabled,
&f.MetadataLanguage,
&f.AutoTranslateMetadata,
&f.ChapterThumbnailsEnabled,
&f.IntroDetectionEnabled,
&f.PosterPath,
@@ -400,6 +403,11 @@ func (r *FolderRepository) Update(ctx context.Context, id int, input UpdateFolde
args = append(args, *input.MetadataLanguage)
argIndex++
}
if input.AutoTranslateMetadata != nil {
setClauses = append(setClauses, fmt.Sprintf("auto_translate_metadata = $%d", argIndex))
args = append(args, *input.AutoTranslateMetadata)
argIndex++
}
if input.ChapterThumbnailsEnabled != nil {
setClauses = append(setClauses, fmt.Sprintf("chapter_thumbnails_enabled = $%d", argIndex))
args = append(args, *input.ChapterThumbnailsEnabled)
+47 -4
View File
@@ -35,6 +35,49 @@ func NewItemRepository(pool *pgxpool.Pool) *ItemRepository {
return &ItemRepository{pool: pool}
}
// GetPoster returns the current poster path and thumbhash for a media item.
// Missing or NULL values are returned as empty strings.
func (r *ItemRepository) GetPoster(ctx context.Context, contentID string) (posterPath string, posterThumbhash string, err error) {
if r == nil || r.pool == nil {
return "", "", ErrItemNotFound
}
if err := r.pool.QueryRow(ctx, `
SELECT COALESCE(poster_path, ''), COALESCE(poster_thumbhash, '')
FROM media_items
WHERE content_id = $1
`, contentID).Scan(&posterPath, &posterThumbhash); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", ErrItemNotFound
}
return "", "", fmt.Errorf("getting media item poster: %w", err)
}
return posterPath, posterThumbhash, nil
}
// SetLocalPoster records a locally extracted poster without ever overwriting
// provider or manually applied artwork: the row is updated only when the item
// has no poster yet or its current poster lives under localPrefix. The
// condition is evaluated atomically in SQL so concurrent metadata writers
// (e.g. the ebook enrichment sweep) cannot be clobbered between a read and a
// write. Returns true when a row was updated.
func (r *ItemRepository) SetLocalPoster(ctx context.Context, contentID, posterPath, thumbhash, localPrefix string) (bool, error) {
if r == nil || r.pool == nil {
return false, ErrItemNotFound
}
tag, err := r.pool.Exec(ctx, `
UPDATE media_items
SET poster_path = $2,
poster_thumbhash = NULLIF($3, ''),
updated_at = NOW()
WHERE content_id = $1
AND (poster_path IS NULL OR poster_path = '' OR poster_path LIKE $4 || '%')
`, contentID, posterPath, thumbhash, localPrefix)
if err != nil {
return false, fmt.Errorf("setting local poster: %w", err)
}
return tag.RowsAffected() > 0, nil
}
// GetPosterPath returns the current poster path for a media item. Missing or
// NULL poster values are returned as an empty string.
func (r *ItemRepository) GetPosterPath(ctx context.Context, contentID string) (string, error) {
@@ -620,9 +663,9 @@ func (r *ItemRepository) buildGetByIDsWithAccessSQL(contentIDs []string, access
argIdx++
}
// Apply MaxContentRating like applyAccessFilter does.
// Apply MaxContentRating and type exclusions like applyAccessFilter does.
var ratingConditions []string
applyAccessFilter("mi", AccessFilter{MaxContentRating: access.MaxContentRating}, &ratingConditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: access.MaxContentRating, ExcludedMediaTypes: access.ExcludedMediaTypes}, &ratingConditions, &args, &argIdx)
for _, c := range ratingConditions {
sql += " AND " + c
}
@@ -947,7 +990,7 @@ func (r *ItemRepository) buildSearchSQL(query string, itemTypes []string, limit,
if needsLibJoin {
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
}
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating}, &conditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating, ExcludedMediaTypes: filter.ExcludedMediaTypes}, &conditions, &args, &argIdx)
whereClause := "WHERE " + strings.Join(conditions, " AND ")
@@ -1161,7 +1204,7 @@ func (r *ItemRepository) EnsureAccessible(ctx context.Context, contentID string,
fromClause = "media_items mi JOIN media_item_libraries mil ON mi.content_id = mil.content_id"
}
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating}, &conditions, &args, &argIdx)
applyAccessFilter("mi", AccessFilter{MaxContentRating: filter.MaxContentRating, ExcludedMediaTypes: filter.ExcludedMediaTypes}, &conditions, &args, &argIdx)
query := fmt.Sprintf("SELECT 1 FROM %s WHERE %s LIMIT 1", fromClause, strings.Join(conditions, " AND "))
var found int
+179 -8
View File
@@ -10,6 +10,13 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
)
// The localization tables carry per-field provenance (provider | ai | manual)
// on their AI-writable fields (overview, tagline) so writes from different
// origins can never regress quality: manual beats provider beats ai. The
// precedence is enforced here, in single-statement upserts, so concurrent
// writers (a metadata refresh racing an AI translation job) cannot interleave
// a read-modify-write.
type MediaItemLocalizationRepository struct {
pool *pgxpool.Pool
}
@@ -18,6 +25,11 @@ func NewMediaItemLocalizationRepository(pool *pgxpool.Pool) *MediaItemLocalizati
return &MediaItemLocalizationRepository{pool: pool}
}
// Upsert writes a provider localization. Title/sort-title/artwork fields are
// taken wholesale (provider data is authoritative for them); overview and
// tagline respect provenance — a manual value is never overwritten, and an
// empty incoming value never blanks an existing one (so a provider with no
// translated overview does not erase an AI or manual translation).
func (r *MediaItemLocalizationRepository) Upsert(ctx context.Context, loc *models.MediaItemLocalization) error {
if loc == nil || loc.ContentID == "" || loc.Language == "" {
return fmt.Errorf("invalid media item localization")
@@ -34,8 +46,22 @@ func (r *MediaItemLocalizationRepository) Upsert(ctx context.Context, loc *model
ON CONFLICT (content_id, language) DO UPDATE SET
title = EXCLUDED.title,
sort_title = EXCLUDED.sort_title,
overview = EXCLUDED.overview,
tagline = EXCLUDED.tagline,
overview = CASE
WHEN media_item_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN media_item_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN media_item_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN media_item_localizations.overview_source
ELSE 'provider' END,
tagline = CASE
WHEN media_item_localizations.tagline_source = 'manual' OR EXCLUDED.tagline = ''
THEN media_item_localizations.tagline
ELSE EXCLUDED.tagline END,
tagline_source = CASE
WHEN media_item_localizations.tagline_source = 'manual' OR EXCLUDED.tagline = ''
THEN media_item_localizations.tagline_source
ELSE 'provider' END,
poster_path = EXCLUDED.poster_path,
poster_thumbhash = EXCLUDED.poster_thumbhash,
backdrop_path = EXCLUDED.backdrop_path,
@@ -50,10 +76,69 @@ func (r *MediaItemLocalizationRepository) Upsert(ctx context.Context, loc *model
return nil
}
// UpsertAITranslation writes AI-translated overview/tagline values. A nil
// pointer leaves that field untouched. Per field, the write lands when the
// existing value is empty or already AI-sourced; force additionally overwrites
// provider values (the admin explicitly asked to re-translate). Manual values
// are never overwritten. Rows created here carry empty titles/artwork — the
// serving layer falls back to the base item for empty localized fields.
func (r *MediaItemLocalizationRepository) UpsertAITranslation(ctx context.Context, contentID, language string, overview, tagline *string, force bool) error {
if contentID == "" || language == "" {
return fmt.Errorf("invalid media item AI localization")
}
if overview == nil && tagline == nil {
return nil
}
_, err := r.pool.Exec(ctx, `
INSERT INTO media_item_localizations (
content_id, language, title, sort_title, overview, tagline,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
overview_source, tagline_source
) VALUES (
$1, $2, '', '', COALESCE($3, ''), COALESCE($4, ''),
'', '', '', '', '',
CASE WHEN $3::text IS NULL THEN 'provider' ELSE 'ai' END,
CASE WHEN $4::text IS NULL THEN 'provider' ELSE 'ai' END
)
ON CONFLICT (content_id, language) DO UPDATE SET
overview = CASE
WHEN $3::text IS NULL OR media_item_localizations.overview_source = 'manual'
THEN media_item_localizations.overview
WHEN $5 OR media_item_localizations.overview_source = 'ai' OR media_item_localizations.overview = ''
THEN EXCLUDED.overview
ELSE media_item_localizations.overview END,
overview_source = CASE
WHEN $3::text IS NULL OR media_item_localizations.overview_source = 'manual'
THEN media_item_localizations.overview_source
WHEN $5 OR media_item_localizations.overview_source = 'ai' OR media_item_localizations.overview = ''
THEN 'ai'
ELSE media_item_localizations.overview_source END,
tagline = CASE
WHEN $4::text IS NULL OR media_item_localizations.tagline_source = 'manual'
THEN media_item_localizations.tagline
WHEN $5 OR media_item_localizations.tagline_source = 'ai' OR media_item_localizations.tagline = ''
THEN EXCLUDED.tagline
ELSE media_item_localizations.tagline END,
tagline_source = CASE
WHEN $4::text IS NULL OR media_item_localizations.tagline_source = 'manual'
THEN media_item_localizations.tagline_source
WHEN $5 OR media_item_localizations.tagline_source = 'ai' OR media_item_localizations.tagline = ''
THEN 'ai'
ELSE media_item_localizations.tagline_source END,
updated_at = NOW()
`, contentID, language, overview, tagline, force)
if err != nil {
return fmt.Errorf("upserting media item AI localization: %w", err)
}
return nil
}
func (r *MediaItemLocalizationRepository) Get(ctx context.Context, contentID, language string) (*models.MediaItemLocalization, error) {
row := r.pool.QueryRow(ctx, `
SELECT content_id, language, title, sort_title, overview, tagline,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
overview_source, tagline_source,
created_at, updated_at
FROM media_item_localizations
WHERE content_id = $1 AND language = $2
@@ -69,6 +154,7 @@ func (r *MediaItemLocalizationRepository) GetByContentIDs(ctx context.Context, c
rows, err := r.pool.Query(ctx, `
SELECT content_id, language, title, sort_title, overview, tagline,
poster_path, poster_thumbhash, backdrop_path, backdrop_thumbhash, logo_path,
overview_source, tagline_source,
created_at, updated_at
FROM media_item_localizations
WHERE language = $1 AND content_id = ANY($2)
@@ -98,6 +184,8 @@ func NewSeasonLocalizationRepository(pool *pgxpool.Pool) *SeasonLocalizationRepo
return &SeasonLocalizationRepository{pool: pool}
}
// Upsert writes a provider localization; overview respects provenance (see
// MediaItemLocalizationRepository.Upsert).
func (r *SeasonLocalizationRepository) Upsert(ctx context.Context, loc *models.SeasonLocalization) error {
if loc == nil || loc.SeasonContentID == "" || loc.Language == "" {
return fmt.Errorf("invalid season localization")
@@ -108,7 +196,14 @@ func (r *SeasonLocalizationRepository) Upsert(ctx context.Context, loc *models.S
) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (season_content_id, language) DO UPDATE SET
title = EXCLUDED.title,
overview = EXCLUDED.overview,
overview = CASE
WHEN season_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN season_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN season_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN season_localizations.overview_source
ELSE 'provider' END,
poster_path = EXCLUDED.poster_path,
poster_thumbhash = EXCLUDED.poster_thumbhash,
updated_at = NOW()
@@ -119,9 +214,41 @@ func (r *SeasonLocalizationRepository) Upsert(ctx context.Context, loc *models.S
return nil
}
// UpsertAIOverview writes an AI-translated season overview (see
// MediaItemLocalizationRepository.UpsertAITranslation for the precedence).
func (r *SeasonLocalizationRepository) UpsertAIOverview(ctx context.Context, seasonContentID, language, overview string, force bool) error {
if seasonContentID == "" || language == "" {
return fmt.Errorf("invalid season AI localization")
}
_, err := r.pool.Exec(ctx, `
INSERT INTO season_localizations (
season_content_id, language, title, overview, poster_path, poster_thumbhash, overview_source
) VALUES ($1, $2, '', $3, '', '', 'ai')
ON CONFLICT (season_content_id, language) DO UPDATE SET
overview = CASE
WHEN season_localizations.overview_source = 'manual'
THEN season_localizations.overview
WHEN $4 OR season_localizations.overview_source = 'ai' OR season_localizations.overview = ''
THEN EXCLUDED.overview
ELSE season_localizations.overview END,
overview_source = CASE
WHEN season_localizations.overview_source = 'manual'
THEN season_localizations.overview_source
WHEN $4 OR season_localizations.overview_source = 'ai' OR season_localizations.overview = ''
THEN 'ai'
ELSE season_localizations.overview_source END,
updated_at = NOW()
`, seasonContentID, language, overview, force)
if err != nil {
return fmt.Errorf("upserting season AI localization: %w", err)
}
return nil
}
func (r *SeasonLocalizationRepository) Get(ctx context.Context, seasonContentID, language string) (*models.SeasonLocalization, error) {
row := r.pool.QueryRow(ctx, `
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash, created_at, updated_at
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash,
overview_source, created_at, updated_at
FROM season_localizations
WHERE season_content_id = $1 AND language = $2
`, seasonContentID, language)
@@ -134,7 +261,8 @@ func (r *SeasonLocalizationRepository) GetBySeasonIDs(ctx context.Context, seaso
return result, nil
}
rows, err := r.pool.Query(ctx, `
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash, created_at, updated_at
SELECT season_content_id, language, title, overview, poster_path, poster_thumbhash,
overview_source, created_at, updated_at
FROM season_localizations
WHERE language = $1 AND season_content_id = ANY($2)
`, language, seasonIDs)
@@ -163,6 +291,8 @@ func NewEpisodeLocalizationRepository(pool *pgxpool.Pool) *EpisodeLocalizationRe
return &EpisodeLocalizationRepository{pool: pool}
}
// Upsert writes a provider localization; overview respects provenance (see
// MediaItemLocalizationRepository.Upsert).
func (r *EpisodeLocalizationRepository) Upsert(ctx context.Context, loc *models.EpisodeLocalization) error {
if loc == nil || loc.EpisodeContentID == "" || loc.Language == "" {
return fmt.Errorf("invalid episode localization")
@@ -172,7 +302,14 @@ func (r *EpisodeLocalizationRepository) Upsert(ctx context.Context, loc *models.
VALUES ($1, $2, $3, $4)
ON CONFLICT (episode_content_id, language) DO UPDATE SET
title = EXCLUDED.title,
overview = EXCLUDED.overview,
overview = CASE
WHEN episode_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN episode_localizations.overview
ELSE EXCLUDED.overview END,
overview_source = CASE
WHEN episode_localizations.overview_source = 'manual' OR EXCLUDED.overview = ''
THEN episode_localizations.overview_source
ELSE 'provider' END,
updated_at = NOW()
`, loc.EpisodeContentID, loc.Language, loc.Title, loc.Overview)
if err != nil {
@@ -181,9 +318,39 @@ func (r *EpisodeLocalizationRepository) Upsert(ctx context.Context, loc *models.
return nil
}
// UpsertAIOverview writes an AI-translated episode overview (see
// MediaItemLocalizationRepository.UpsertAITranslation for the precedence).
func (r *EpisodeLocalizationRepository) UpsertAIOverview(ctx context.Context, episodeContentID, language, overview string, force bool) error {
if episodeContentID == "" || language == "" {
return fmt.Errorf("invalid episode AI localization")
}
_, err := r.pool.Exec(ctx, `
INSERT INTO episode_localizations (episode_content_id, language, title, overview, overview_source)
VALUES ($1, $2, '', $3, 'ai')
ON CONFLICT (episode_content_id, language) DO UPDATE SET
overview = CASE
WHEN episode_localizations.overview_source = 'manual'
THEN episode_localizations.overview
WHEN $4 OR episode_localizations.overview_source = 'ai' OR episode_localizations.overview = ''
THEN EXCLUDED.overview
ELSE episode_localizations.overview END,
overview_source = CASE
WHEN episode_localizations.overview_source = 'manual'
THEN episode_localizations.overview_source
WHEN $4 OR episode_localizations.overview_source = 'ai' OR episode_localizations.overview = ''
THEN 'ai'
ELSE episode_localizations.overview_source END,
updated_at = NOW()
`, episodeContentID, language, overview, force)
if err != nil {
return fmt.Errorf("upserting episode AI localization: %w", err)
}
return nil
}
func (r *EpisodeLocalizationRepository) Get(ctx context.Context, episodeContentID, language string) (*models.EpisodeLocalization, error) {
row := r.pool.QueryRow(ctx, `
SELECT episode_content_id, language, title, overview, created_at, updated_at
SELECT episode_content_id, language, title, overview, overview_source, created_at, updated_at
FROM episode_localizations
WHERE episode_content_id = $1 AND language = $2
`, episodeContentID, language)
@@ -196,7 +363,7 @@ func (r *EpisodeLocalizationRepository) GetByEpisodeIDs(ctx context.Context, epi
return result, nil
}
rows, err := r.pool.Query(ctx, `
SELECT episode_content_id, language, title, overview, created_at, updated_at
SELECT episode_content_id, language, title, overview, overview_source, created_at, updated_at
FROM episode_localizations
WHERE language = $1 AND episode_content_id = ANY($2)
`, language, episodeIDs)
@@ -231,6 +398,8 @@ func scanMediaItemLocalization(row pgx.Row) (*models.MediaItemLocalization, erro
&loc.BackdropPath,
&loc.BackdropThumbhash,
&loc.LogoPath,
&loc.OverviewSource,
&loc.TaglineSource,
&loc.CreatedAt,
&loc.UpdatedAt,
); err != nil {
@@ -251,6 +420,7 @@ func scanSeasonLocalization(row pgx.Row) (*models.SeasonLocalization, error) {
&loc.Overview,
&loc.PosterPath,
&loc.PosterThumbhash,
&loc.OverviewSource,
&loc.CreatedAt,
&loc.UpdatedAt,
); err != nil {
@@ -269,6 +439,7 @@ func scanEpisodeLocalization(row pgx.Row) (*models.EpisodeLocalization, error) {
&loc.Language,
&loc.Title,
&loc.Overview,
&loc.OverviewSource,
&loc.CreatedAt,
&loc.UpdatedAt,
); err != nil {
+75
View File
@@ -0,0 +1,75 @@
package catalog
import "github.com/Silo-Server/silo-server/internal/models"
// applyItemLocalization merges a localization onto a clone of item. Only
// non-empty localized fields override the base — localization rows are
// legitimately partial (an AI translation carries only overview/tagline; a
// provider row may lack a tagline or logo), and an empty field must fall back
// to the base value rather than blank it.
func applyItemLocalization(item *models.MediaItem, loc *models.MediaItemLocalization) *models.MediaItem {
localized := cloneMediaItem(item)
if localized == nil || loc == nil {
return localized
}
if loc.Title != "" {
localized.Title = loc.Title
}
if loc.SortTitle != "" {
localized.SortTitle = loc.SortTitle
}
if loc.Overview != "" {
localized.Overview = loc.Overview
}
if loc.Tagline != "" {
localized.Tagline = loc.Tagline
}
if loc.PosterPath != "" {
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
}
if loc.BackdropPath != "" {
localized.BackdropPath = loc.BackdropPath
localized.BackdropThumbhash = loc.BackdropThumbhash
}
if loc.LogoPath != "" {
localized.LogoPath = loc.LogoPath
}
return localized
}
// applySeasonLocalization merges a localization onto a clone of season; see
// applyItemLocalization for the empty-field semantics.
func applySeasonLocalization(season *models.Season, loc *models.SeasonLocalization) *models.Season {
localized := cloneSeason(season)
if localized == nil || loc == nil {
return localized
}
if loc.Title != "" {
localized.Title = loc.Title
}
if loc.Overview != "" {
localized.Overview = loc.Overview
}
if loc.PosterPath != "" {
localized.PosterPath = loc.PosterPath
localized.PosterThumbhash = loc.PosterThumbhash
}
return localized
}
// applyEpisodeLocalization merges a localization onto a clone of episode; see
// applyItemLocalization for the empty-field semantics.
func applyEpisodeLocalization(episode *models.Episode, loc *models.EpisodeLocalization) *models.Episode {
localized := cloneEpisode(episode)
if localized == nil || loc == nil {
return localized
}
if loc.Title != "" {
localized.Title = loc.Title
}
if loc.Overview != "" {
localized.Overview = loc.Overview
}
return localized
}
+109
View File
@@ -0,0 +1,109 @@
package catalog
import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
)
func baseItem() *models.MediaItem {
return &models.MediaItem{
ContentID: "c1",
Title: "Base Title",
SortTitle: "Base Title, The",
Overview: "Base overview.",
Tagline: "Base tagline.",
PosterPath: "posters/base.jpg",
PosterThumbhash: "hash-poster",
BackdropPath: "backdrops/base.jpg",
BackdropThumbhash: "hash-backdrop",
LogoPath: "logos/base.png",
}
}
// An AI-created localization row carries only overview/tagline; everything
// else must fall back to the base item rather than be blanked.
func TestApplyItemLocalizationPartialRowFallsBackToBase(t *testing.T) {
loc := &models.MediaItemLocalization{
ContentID: "c1",
Language: "fr",
Overview: "Résumé traduit.",
Tagline: "Slogan traduit.",
}
got := applyItemLocalization(baseItem(), loc)
if got.Overview != "Résumé traduit." || got.Tagline != "Slogan traduit." {
t.Errorf("translated fields not applied: %q / %q", got.Overview, got.Tagline)
}
if got.Title != "Base Title" || got.SortTitle != "Base Title, The" {
t.Errorf("empty localized titles blanked the base: %q / %q", got.Title, got.SortTitle)
}
if got.PosterPath != "posters/base.jpg" || got.PosterThumbhash != "hash-poster" {
t.Errorf("empty localized poster blanked the base: %q / %q", got.PosterPath, got.PosterThumbhash)
}
if got.BackdropPath != "backdrops/base.jpg" || got.LogoPath != "logos/base.png" {
t.Errorf("empty localized artwork blanked the base: %q / %q", got.BackdropPath, got.LogoPath)
}
}
func TestApplyItemLocalizationFullRowOverridesEverything(t *testing.T) {
loc := &models.MediaItemLocalization{
ContentID: "c1",
Language: "fr",
Title: "Titre",
SortTitle: "Titre, Le",
Overview: "Résumé.",
Tagline: "Slogan.",
PosterPath: "posters/fr.jpg",
PosterThumbhash: "hash-fr",
BackdropPath: "backdrops/fr.jpg",
BackdropThumbhash: "hash-fr-bd",
LogoPath: "logos/fr.png",
}
got := applyItemLocalization(baseItem(), loc)
if got.Title != "Titre" || got.Overview != "Résumé." || got.PosterPath != "posters/fr.jpg" ||
got.PosterThumbhash != "hash-fr" || got.LogoPath != "logos/fr.png" {
t.Errorf("full localization not applied: %+v", got)
}
}
func TestApplyItemLocalizationDoesNotMutateBase(t *testing.T) {
item := baseItem()
_ = applyItemLocalization(item, &models.MediaItemLocalization{Title: "Titre"})
if item.Title != "Base Title" {
t.Errorf("base item mutated: %q", item.Title)
}
}
func TestApplyItemLocalizationNilLocalizationClones(t *testing.T) {
item := baseItem()
got := applyItemLocalization(item, nil)
if got == item {
t.Fatal("expected a clone, got the same pointer")
}
if got.Title != item.Title {
t.Errorf("clone differs: %q", got.Title)
}
}
func TestApplySeasonLocalizationPartialRow(t *testing.T) {
season := &models.Season{ContentID: "s1", Title: "Season 1", Overview: "Base.", PosterPath: "p.jpg", PosterThumbhash: "h"}
got := applySeasonLocalization(season, &models.SeasonLocalization{Overview: "Saison résumé."})
if got.Overview != "Saison résumé." {
t.Errorf("overview not applied: %q", got.Overview)
}
if got.Title != "Season 1" || got.PosterPath != "p.jpg" || got.PosterThumbhash != "h" {
t.Errorf("empty fields blanked the base: %+v", got)
}
}
func TestApplyEpisodeLocalizationPartialRow(t *testing.T) {
ep := &models.Episode{ContentID: "e1", Title: "Pilot", Overview: "Base."}
got := applyEpisodeLocalization(ep, &models.EpisodeLocalization{Overview: "Épisode résumé."})
if got.Overview != "Épisode résumé." {
t.Errorf("overview not applied: %q", got.Overview)
}
if got.Title != "Pilot" {
t.Errorf("empty localized title blanked the base: %q", got.Title)
}
}

Some files were not shown because too many files have changed in this diff Show More