* fix(api): allow HEAD on /api/v1/direct-download
Firefox (and some download managers) issue a HEAD request before
starting a download. The route only registered GET, so HEAD returned
405 Method Not Allowed and the browser aborted the download.
Mirrors the pattern already used by /stream/{session_id}, which
registers both GET and HEAD on the same handler. ServeDirect is built
on http.ServeContent / ServeFile, which natively handle HEAD by
writing headers without a body, so no handler changes are needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs: design jellyfin autoscan scan compatibility
* refactor(scan): extract scan trigger resolver
* refactor(api): share scan target resolution
* feat(jellycompat): accept admin api keys for autoscan
* feat(jellycompat): add autoscan media update route
* docs: document jellyfin autoscan setup
* fix(jellycompat): harden autoscan auth and batch scan enqueue
- Reject nil API keys and bound last-used update with a 5s timeout
- Stop leaking internal queue errors in autoscan responses
- Batch scan enqueues via new CreateBatch and reuse folder list across path resolves
* chore: add planning docs and requests updates
- Add plans for date-named episodes and Jellyfin autoscan compat
- Update requests handlers, service, and UI hooks
- Remove Makefile.local.example
* refactor(scantrigger): drop redundant Target.LibraryID field
- Read library ID from Target.Folder.ID everywhere
- Guard scan queue enqueue against nil Folder
- Simplify admin API key auth error plumbing
* fix(catalog): gate search overview-only matches behind title FTS
- Always apply stats CTE + CROSS JOIN so single-word queries no longer flood results with description-only hits
- Require overview_rank >= 0.15 for overview-only fallback rows
- Switch title gate from contiguous LIKE to title_rank > 0 so reordered-token title matches aren't demoted
* ci(docker): build image on push to main via self-hosted runner
- Trigger Docker image builds on pushes to main instead of nightly cron
- Run on self-hosted Linux runner
- Drop the `nightly` tag
* docs(specs): add design for TMDB-backed request section in search
Adds the design for surfacing requestable TMDB results inside the main
catalog search (Cmd+K dialog and full results page) as a clearly
delimited "Request to Add" section that never blocks or displaces
library results.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(specs): address Codex adversarial review for search request section
Splits discovery eligibility from submission eligibility so blocked
and quota-exhausted viewers still see the requestable section with
disabled per-row CTAs, matching the documented behavior. Documents
the required extensions to useRequestSearch — signal forwarding,
viewer-identity-keyed cache, and invalidation on auth/profile/
settings/limit changes — so the planned 5-minute staleTime is
safe and cancellation works as described.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(plans): add implementation plan for search request section
Twelve TDD tasks covering: api() signal contract test, useCanRequest
hook, viewer-keyed requestKeys.search, useRequestSearch extension
(signal + viewer key + 5min staleTime + enabled override), invalidation
cascade tests, RequestPosterCard optional onRequest, RequestToAddSection
component (dialog + grid variants), GlobalSearch and Catalog wiring
with empty-state suppression for the library-0/TMDB-pending edge case,
final lint/test pass, and manual smoke. Notes a single deviation from
the spec: submitDisabledReason is null in the initial implementation,
with per-row request data driving disabled UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(plans): address Codex adversarial review for search request section
Fixes the high-severity finding that RequestToAddSection's internal
useRequestSearch call was not gated on discoveryEnabled, allowing
/api/v1/requests/search and TMDB lookups to fire for users without
request access. The plan now (1) passes { enabled: discoveryEnabled }
to the section's hook, (2) gates the parent mount in GlobalSearch and
Catalog on canRequest.discoveryEnabled as defense in depth, and (3)
adds tests asserting both the enabled forwarding and the no-mount
behavior when discovery is disabled.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(api): pin AbortSignal forwarding contract on api()
* feat(hooks): add useCanRequest gating hook for discovery eligibility
* refactor(keys): add viewerKey to requestKeys.search
* feat(requests): key useRequestSearch by viewer, forward signal, raise staleTime
* test(requests): document viewer-keyed cache isolation and invalidation cascade
* feat(request-card): make onRequest optional on discover variant
* feat(search): add RequestToAddSection dialog variant
* feat(search): add RequestToAddSection grid variant for Catalog page
* feat(search): render RequestToAddSection in the Cmd+K dialog with empty-state suppression
* feat(catalog): render RequestToAddSection grid with empty-state suppression
* chore(web): format request search section
* test(web): avoid unsupported Array.at in search request tests
* commit message
{"subject":"fix(search): prevent empty-state flash before TMDB fallback renders","body":"- Add isResolving to useCanRequest and gate empty states on it across GlobalSearch and Catalog\n- Debounce TMDB query in Catalog and hide ItemGrid when the request section may rescue an empty library\n- Track per-card submit state in RequestToAddSection grid so concurrent requests don't trample each other\n- Suppress anonymous TMDB request-search fetches to avoid cross-viewer cache leakage"}
* fix(jellycompat): tolerate autoscan sidecar updates
* fix(webhooksync): skip events for unmapped external users
- Require explicit profile mapping instead of falling back to the default profile
- Update settings UI copy to reflect that unmapped users are ignored
* refactor(admin): show per-section loading and error states
- Replace page-level loading gate with skeletons per section on dashboard and stats
- Surface query errors inline instead of blocking the whole page
- Disable "Scan All Libraries" when no libraries are configured
* fix(search): address request search review feedback
* feat(subtitles): restore upload management
* feat(auth): add assignable user permissions
* feat(auth): expose user permissions
* feat(api): authorize item metadata curation
* feat(api): route metadata curation by permission
* feat(web): add permission helpers
* feat(web): assign metadata curation permission
* fix(web): keep device profile hooks unconditional
* feat(web): show metadata tools to curators
* fix(auth): address metadata curation review issues
* fix(auth): tighten curator job response review fixes
* docs: add metadata curation permission plan
* test(auth): expand session revocation coverage
* docs: add PageBack component design spec
* fix(auth): gate media file paths on metadata curation permission
- Allow curators (not just admins) to view media file paths and locations
- Apply library access filter to file-level access checks
* fix(metadata): break duplicate provider candidate ties
- Score candidate metadata completeness and auto-match the richer duplicate when title/year/type tie
- Enrich near-duplicate candidates via the provider chain before initial match selection
- Seed both movie and series match queues for mixed-type libraries and wait for TV queue settle
- Add taskmanager worker test coverage and a plan doc for the tie-breaker work
* feat(ui): add shared PageBack component for consistent back navigation
Replace the eight inconsistent back affordances across user-facing pages
with a single absolute-positioned chevron pill, so the control lives in
the same screen position regardless of title length or hero content.
DetailBreadcrumb keeps its textual hierarchy path but no longer owns the
back chevron; PageBack does. DetailHero gains a topNav slot consumed by
Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop
their bespoke back buttons and add PageBack inside a relative wrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ui): add floating variant to PageBack for sticky nav
- Add `floating` prop to pin PageBack to viewport on lg+ screens
- Switch styling from glass-subtle to glass with shadow for better contrast
- Use floating variant on SettingsLayout
* fix(ui): make PageBack destinations deterministic
* feat(episode-carousel): highlight currently viewed episode
- Add "Now Viewing" badge with pulsing indicator on current episode
- Replace border with primary-color ring for current episode card
- Set aria-current="page" on links to the current episode
* feat(jellycompat): sign image tags and accept them without session
- HMAC-sign image tags using the configured JWT secret
- Serve item/season/episode images via signed tag without requiring a session or cache hit
* fix(jellycompat): harden signed image tags
* fix(jellycompat): stabilize signed image tags across restarts
{"subject":"fix(jellycompat): stabilize signed image tags across restarts","body":"- Sign library poster and episode parent series image tags from canonical paths/thumbhashes instead of presigned URLs so tags survive restarts\n- Accept signed canonical tags in the image handler without a session and fall back to legacy URL-derived cache tags\n- Always fetch series detail for episodes to build stable parent image tags"}
* fix(metadata): accept exact cross-provider match ties
* Optimize episode added_at sorting
* fix(libraryingest): treat drainer shutdown cancel as clean stop
TV/series full scans (libraries with new or updated items) were recorded as
"cancelled" with an empty error message and never completed matching.
When the file-walk finishes, the ingest executor waits out a settle window and
then calls stopDrainers() to shut down the concurrent match goroutines. That
cancels the drainer context while a ProcessBatchByFolderAndPathPrefix call may
still be in flight. The drainer treated the resulting context.Canceled as a
fatal error: it pushed the error to drainerErrCh and called cancel() on the
whole scan context, so scanqueue.process() mapped it to cancelRun().
Large/slow libraries (many series, slow provider lookups) keep a batch in
flight continuously, so stopDrainers() almost always landed mid-call and the
scan was cancelled; small/fast libraries were usually idle at that instant and
completed normally.
Treat a cancelled drainer context as a deliberate shutdown: return cleanly
without escalating. Genuine external cancellation still reaches the run via the
main goroutine's scanCtx checks, so real cancels are not swallowed.
Adds a regression test (settle window made injectable) that fails against the
old handler with 'concurrent match scope ...: context canceled' and passes
with the fix.
* perf(catalog): add episode browse index fast path
* fix(catalog): address episode catalog review feedback
* Fix settle-window drainer cancellation in library ingest
* fix(catalog): support relative date filters
* fix(collections): cap smart collection results
* fix(library): preserve episode browse url
* fix(catalog): use season posters for episode cards
* feat(sections): show episode context in cards
* feat(calendar): show local episode airtimes
* Enable DRI passthrough in docker compose
Co-authored-by: Codex <noreply@openai.com>
* feat(ui): paginate the Ambiguous Roots table
Match the sibling tables on the Admin Libraries page (Troubleshooting/unmatched):
use the existing usePagination hook + PaginationBar (10/page, auto-hidden when
<=10 rows), render pag.rows, and reset to page 0 when the library selector or
search filter changes.
* feat(admin): enlarge match-candidate posters + hover-to-enlarge
Unmatched-item match dialog rendered candidate posters at 44x64px, too small
to identify a film. Bump to 64x96 (2:3) and add a portaled Tooltip hover preview
(192x288) using the existing poster image, so operators can tell candidates apart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): search unmatched items across the whole table, not just the page
The unmatched-items search filtered only the current page's rows client-side.
Push the query server-side: HandleListUnmatchedItems takes an optional 'q' param
and filters title/library/type/status with parameterized ILIKE across all rows,
paginating the filtered set. Frontend hook takes a debounced search, resets to
page 1 on change, keeps the section mounted while searching. Also fixes stale
test mocks that returned the pre-pagination array shape instead of {items,total}.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* style(admin): prettier-format match dialog; reset unmatched page in onChange
Run prettier over the Tooltip-wrapped poster JSX, and reset the unmatched-items
page in the search input's onChange rather than a useEffect (avoids the
react-hooks/set-state-in-effect warning / cascading renders).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(admin): wire QueryClientProvider + missing hook mocks so the suite runs
The AdminLibraries test file failed all 6 tests with 'No QueryClient set' on
this branch and at the parent commit -- pre-existing infrastructure gap. With
that fixed, several hooks that the page imports (useCancelLibraryScans,
useLibraryRoots, useUpsertLibraryRootOverride, useDeleteLibraryRootOverride,
useActiveScans) and the UNMATCHED_PAGE_SIZE constant also needed mocking. One
stale assertion on the renamed 'Root path' header is updated; the deeper
troubleshooting test, which mocked useSkippedLibraryRoots but the section was
refactored to useLibraryRoots(_, 'ambiguous'), is skipped with a TODO -- a real
rewrite is needed and is out of scope for this MR.
5 of 6 tests now run and pass; the 6th is properly flagged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(admin): search all unmatched item library memberships
* fix(naming): strip unsubstituted Sonarr tokens ({TvdbId}/{imdb-}) from titles
These tokens survived the provider-tag regex ([\w]+ doesn't match braces),
polluting parsed titles (e.g. 'A Girl & Her Guard Dog [tvdb-{TvdbId}]') so
they could not score-match. Broaden the regex to drop {...} and empty tokens.
* fix(naming): numeric-only titles are not bare provider IDs
'86' / '22 7' were parsed as trailing tvdb ids, tripping the trusted-ID gate
so the correct title match was rejected. Require a letter in the name before
treating a trailing number as a bare id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(naming): document bare-id trade-off; cover CJK title + movies numeric
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(matcher): auto-accept a year-corroborated single distinct show
A search that resolves to one distinct show (one candidate, or the same
title+year returned once per source as unmerged TVDB/TMDB rows) whose year
matches the parsed year is now auto-accepted via the existing top-ranked
candidate, even when the fuzzy title score is in the 55-69 band. The 55/70/15
thresholds are unchanged; this only adds a year-gated acceptance for
effectively-unique results (recovers lone-correct-result items like 1201 (1993)).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(matcher): tolerate concurrent-merge ErrItemNotFound in series episode-link ensure
A scan drainer and the background MatchWorker can process the same folder
concurrently. When a provider-ID merge moves a series' episodes to the survivor
and deletes the source, an in-flight ensureSeriesEpisodeLinks(sourceID) hits
catalog.ErrItemNotFound and was failing the whole scan. The episodes are already
reattached, so this is benign: log and continue (matching the lenient call sites)
instead of failing. Genuine errors still abort.
* diag(matcher): debug-log per-candidate match scores
Adds a DEBUG-gated log in selectInitialMatchCandidate printing each scored
candidate (title/year/type/sources/provider_ids/score) against the hint, so
operators can see why an item did or didn't auto-match. Zero-cost when debug
logging is off; no change to matching behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(matcher): resolve cross-source ties by library provider priority
Accept a year-corroborated single distinct show when the TOP tie-group (within
15 pts of best) is one show across providers, ignoring low-score noise below it,
and pick the winner by the library's metadata-provider chain order (providerPriority,
highest-first; falls back to top-scored). Recovers items like '100 Days Wild'
that are returned identically by TVDB and TMDB. Thresholds (55/70/15) unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(matcher): accept cross-source-corroborated ties without a hint year
When the top tie-group is one distinct show returned by 2+ distinct providers
(candidatesAreSingleDistinctShow already verifies matching title+year), accept it
even if the hint has no parsed year (year-less folders like '100 Deeds for Eddie
McDowd'). Multi-source agreement substitutes for the year guard; lone single-source
no-year results stay subject to the single-candidate >=70 gate. Thresholds unchanged.
* diag(matcher): debug-log provider search query + per-provider result counts
Adds DEBUG logs in the ModeInitialMatch search path: each provider's result
count for the query, and the assembled raw/candidate totals. Lets us see when a
provider search returns zero ('no metadata found') vs a scoring/tie issue.
Zero behavior change.
* fix(naming): parse bare bracketed IMDb IDs ([tt10011226]/{tt...})
Folders tagged with a bare IMDb id in brackets (Plex/Kodi style, e.g.
'17 Blocks (2021) [tt10011226]') had the id silently dropped — folderIDPattern
needs an 'imdb-' prefix and trailingImdbIDPattern needs an un-bracketed trailing
tt-id. Recognize bracketed bare tt-ids so these items get the trusted-ID match
path instead of falling to title+year scoring.
* feat(metadata): match sole exact-title candidate despite year off by <=2
Folder years routinely differ from provider release years by a year or two
(festival vs wide release, regional dates), zeroing the year bonus and leaving
a lone exact-title candidate at 63-68 — just under the single-candidate >=70
gate (e.g. Dead Reckoning 1947 vs 1946, 17 Blocks 2021 vs 2019, Stasi FC). Add
title corroboration to the existing lone-result rule: a sole distinct show whose
normalized title exactly matches and whose year is within +/-2 is accepted. The
55 floor still rejects low-similarity titles (e.g. Hotel Transylvania Puppy! vs
Puppy!). No 55/70/15 threshold change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(lint): gofmt single-space alignment in root_inference.go var block
When inferProviderTagRe was broadened to handle unsubstituted Sonarr token
placeholders ({TvdbId}/{imdb-}), the regex grew long enough that gofmt prefers
single-space rather than column-aligned spacing across the var block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(metadata): tighten matcher and bare IMDb parsing
* docs: add resilient library deletion design spec
Batched, deadlock-retrying rewrite of delete_library to replace the
single multi-minute transaction that deadlocks on large libraries.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs: add implementation plan for resilient library deletion
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(catalog): add deadlock-retry helper for batched deletes
* test(catalog): clarify cancel-path expectation in retry test
* feat(catalog): add deleteInBatches loop helper
* refactor(catalog): make image-dir helpers querier-agnostic
Add rowQuerier interface satisfied by both *pgxpool.Pool and pgx.Tx.
Split collectImageDirs into collectRawImageDirs (raw collection) and a
thin wrapper that filters via filterUnreferencedImageDirs. Both helpers
now accept rowQuerier so a later task can call them from pool-level
batch deletes without an open transaction.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(catalog): delete libraries in deadlock-retrying batches
Replaces the single multi-minute delete transaction with phased, batched
autocommit deletes (orphan items, media files, memberships, folder row),
each retried on deadlock. Holds only short locks, survives concurrent
writers, and is resumable on failure.
* refactor(catalog): wrap orphan-batch iteration error
* fix(catalog): clamp still/poster/logo backdrops to largest cached variant
Episode stills used as backdrops only exist at w500/w300 in the cache, so
requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath
+ imageTypeFromCachedPath and route featured (w1920) and Continue Watching /
Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their
type's largest cached variant while real backdrops keep the requested width.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(startup): defer non-critical init off the HTTP listener path
Collect catalog-size-dependent seeding (metadata match queues, legacy
series-group cleanup) and the watch-provider scrobble sweep into a
backgroundInit slice that runs sequentially in a background goroutine after
the server is ready, instead of blocking startup before the listener accepts
connections. Steps log failures and stop early on shutdown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(migrations): make air_timezone column add idempotent
Use ADD COLUMN IF NOT EXISTS so re-running 162 on a database that already has
the column is a no-op.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* build: stamp git revision via Makefile ldflags
`make build` did not inject buildinfo's `revisionOverride`/`dirtyOverride`
ldflags (the Dockerfile already does), so binaries built via make report
their version as "unavailable" in the admin Build panel whenever Go's VCS
metadata isn't embedded. Mirror the Dockerfile by computing the git
revision + dirty state and passing them through `-ldflags -X`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(web): accessibility & UX fixes from visual QA pass
Accessibility (WCAG AA):
- Lighten the Standard-theme `--muted-foreground` (#6e6e78 -> #9696a0,
~3.4:1 -> >=5.3:1) and darken the light-theme equivalent so secondary
text meets 1.4.3 contrast app-wide; the opt-in High Contrast mode is no
longer the only conformant path.
- Give icon-only controls accessible names (4.1.2): the password show/hide
toggle (also drop tabIndex={-1} so it's keyboard reachable), and the
Edit/Delete/health/copy/refresh actions across the Users, Libraries,
Nodes, API Keys, Catalog Maintenance and Job History admin tables.
- Fix the Switch off-state (invisible track -> visible border + fill) and
the PlaybackSettings SettingRow label association (the <label htmlFor>
pointed at a wrapping <div>; the id now lands on the Switch/SelectTrigger).
- Login: wrap the card in <main> and add an <h1>; Profiles: add an
accessible PIN-protected label and a corner lock badge.
- Player + catalog: role="status" on the initial loading overlay; scope the
catalog count ("0 in library" for search) and announce it via aria-live;
trim the verbose poster-link name to the title.
UX / consistency:
- Emphasize overdue scheduled tasks (warning colour + icon + word, not
colour alone).
- Per-source catalog subtitles instead of one shared string.
- Add a Reconnect affordance when the admin log stream drops (it does not
auto-retry).
- Page titles for Watch Party + all admin sub-pages (incl. plugins); admin
heading capitalisation normalised to Title Case.
- Show "dev build" instead of "unavailable" when no build revision is
stamped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(catalog): re-check orphan status when deleting library items
Orphan detection moved outside the media_items delete in the batched
library-delete rewrite, opening a TOCTOU race: a concurrent scan/import
could attach one of the collected content IDs to another library between
collectOrphanBatch and the delete, after which the unconditional
`DELETE FROM media_items WHERE content_id = ANY($1)` would still remove the
shared row and cascade away the newly-added membership — dropping the item
from the other library. Re-check the orphan invariant inside the delete
(NOT EXISTS a membership in another folder) and count rows actually deleted.
Addresses PR #21 review (P1).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(metadata): persist a cleared air_timezone instead of skipping it
Clearing a previously-set air timezone sent JSON null, which decodes to a
nil *string that UpdateMetadata treats as "skip this column", so the old
value remained. The dialog now sends "" (accepted by ValidateAirTimezone),
and UpdateMetadata maps air_timezone through NULLIF so an empty value
persists as SQL NULL (matching the nullable column) rather than "".
Addresses PR #21 review (P2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(startup): sweep open scrobbles before accepting playback
The open-scrobble sweep was queued in the deferred background-init list,
which runs concurrently with the HTTP listener; a resume immediately after
restart could start new scrobbles before the previous process's open
sessions were stopped, leaving overlapping/stale scrobbles on remote
providers. Run the sweep synchronously before the listener starts, bounded
by a 30s timeout so an unreachable provider can't hang startup (the heavier
non-critical init stays deferred).
Addresses PR #21 review (P2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(catalog): scan air_timezone in paginated item queries
scanItemsWithTotal was not updated for the new air_timezone column, yet the
shared column lists it reads (itemColumns, qualifiedListItemColumns) include
it. Search and BrowseFavorites build their SELECTs from those lists with
COUNT(*) OVER (), so each row carried one more column than the scan had
destinations and every call failed at scan time with a pgx mismatch. Add the
missing &item.AirTimezone target between AirTime and ShowStatus.
Found during PR #21 review (critical: Search/Favorites runtime regression).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* revert(startup): keep open-scrobble sweep deferred for fast startup
Reverts 62563f5c. Running the sweep synchronously before the listener could
add up to 30s to restart-before-playback when a watch provider is
unreachable, which regresses the deliberate startup-deferral from a1a6c6cf.
Prefer the fast-startup behavior and accept the small window where a resume
immediately after restart may create a duplicate scrobble; the sweep returns
to the deferred background-init list. (Panic-safety for that list is added in
a follow-up commit.)
Per PR #21 review decision.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(startup): recover from panics in deferred background init
The deferred background-init steps run in a detached goroutine after the
HTTP listener is already accepting connections. An unrecovered panic in any
step (queue seeding, legacy cleanup, scrobble sweep) would crash the entire
live server. Wrap each step in a recover that logs the panic with a stack
and continues to the next step.
Found during PR #21 review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(auth): make usernames and emails case-insensitive
Login identifiers were compared case-sensitively, so "John" and "john"
were distinct accounts and a user could not log in unless they matched the
exact casing used at registration.
Convert users.username and users.email to the citext type (migration 165).
citext compares case-insensitively while preserving the originally stored
casing for display, so the existing unique constraints become
case-insensitive and `WHERE username = $1` / `email = $1` lookups match
regardless of case with no change to the query code itself.
Also add auth.NormalizeUsername/NormalizeEmail (trim-only; case preserved),
applied at the repository chokepoints (Create, Update, GetByUsername,
GetByEmail) and before validation in the create paths, so surrounding
whitespace no longer defeats matching or creates lookalike accounts.
Verified non-destructively against the dev DB: mixed-case lookups resolve
to the same row, case-variant inserts are rejected by the unique
constraint, and the down migration cleanly reverts to text.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(sections): add trending_discover home section
A library-agnostic home section that surfaces external global trending
(TMDB or Trakt, admin-selectable) mixing movies + series, matched to
titles in the viewer's enabled libraries. TMDB uses /trending/all/{window}
(natively mixed); Trakt merges trending movies + shows. Fetched live with
a 1h in-process cache, so no background job or stored collection — and no
per-library duplication.
Appears in the admin section gallery via its recipe presets (TMDB Trending
Today/This Week, Trakt Trending); featured -> hero via the existing flag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add trending_discover persistent snapshot design
Replace the in-process 1h trending cache with a background-refreshed,
persisted snapshot for reliability under upstream failure and sync-run
observability.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs: add trending_discover persistent snapshot implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(sections): tidy trending_discover fetch and cache helpers
Extract newTrendingEntry, reuse orderMediaItems, and collapse concurrent
cache-miss loads with singleflight. Baseline for the persistent snapshot work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(sections): add trending_discover_snapshots table
* feat(sections): add trending snapshot model and repository
* feat(sections): list enabled trending_discover section configs
* feat(sections): add trending refresher with persisted snapshots
* feat(tasks): add refresh_trending_discover task
* refactor(sections): read trending_discover from persisted snapshot
* feat: wire trending refresh task and snapshot reader
* chore(sections): satisfy lint (wrap trakt errors, lift source/window constants)
* fix(migrations): renumber trending_discover_snapshots 166 -> 167
The shared dev DB already recorded version 166 (166_trending_blend_collection_type
from another branch), so the integer-version migration runner silently skipped our
166 and the table was never created — the trending section errored out empty.
167 is the next free version.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sections): harden trending refresher per PR review
- Interleave Trakt movies/shows by rank so the mixed row shows both types
instead of burying all series past the display limit.
- Treat any Trakt sub-fetch failure as fatal (errors.Join) so a partial
result never overwrites the last-good snapshot with a media type missing.
- Skip non-title entries (TMDB trending/all returns media_type "person") in
both ID batching and ordering so they can't match an unrelated library title.
- Guard the refresh task against a nil refresher.
- Tests: person skip, Trakt interleave, Trakt partial-failure preserves
last-good, snapshot read error propagation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(catalog): project air_timezone in episode catalog subquery
episodeCatalogSelectBody is the derived "mi" relation that episode catalog
hydration and preview read qualifiedListItemColumns("mi") from. The local
episode airtimes feature (808f265f) added air_timezone to the shared column
lists but not to this hand-written subquery, so the outer projection
referenced mi.air_timezone, which the subquery never exposed.
Postgres returns SQLSTATE 42703 (undefined_column), which is not one of the
codes episodeCatalogEntriesUnavailable treats as "fast path unavailable" (it
only catches 42P01/42883), so episode catalog requests failed with HTTP 500
instead of degrading. movie and series scopes query media_items directly, so
the column is present there and only episode scope broke.
Add si.air_timezone to the subquery, and add a regression test asserting that
episodeCatalogSelectBody exposes every column qualifiedListItemColumns reads
off mi, so future additions to the shared column lists cannot silently drift
from the episode read model again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(calendar): order events by viewer-local wall-clock time
The local-airtime change re-sorted calendar events in Go using air_at,
the absolute UTC instant, which is nil whenever air_timezone is unset.
Since air_timezone is only inferred for a few networks/countries, most
events fell through to the alphabetical title tiebreak while still
displaying their raw air_time, so each day appeared scrambled.
Sort each local day by the wall-clock time the viewer actually sees,
mirroring the client: zoned events convert air_at into the viewer
timezone, unzoned events use the raw air_time, and date-only entries
(no air_time) sort last. The timezone reasoning lives in the new
catalog.CalendarEventLocalTime helper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(calendar): add presets design spec and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): generalize personal filter to an id-set restriction
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): add per-profile followed/favorites/watchlist/watched resolvers
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): resolve presets to id-sets and overlay watched status
Also drops the now-unused Filter/UserID/ProfileID fields from the
blendUpcomingIntoDiscoverRows CalendarFilter literal in recommendations.go,
which only wants an unrestricted windowed query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): wire popular and trending sources into calendar handler
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): add watched field to CalendarEvent type
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): preset selector with responsive pills, persistence, empty-state nudge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): dim and check-mark already-watched event cards
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(calendar): hide server-wide Popular preset in web UI for now
Popular reflects server-wide watch counts, which are sparse on a
low-traffic server. Hidden from the selector, URL allowlist, and
empty-state nudge; backend filter and the CalendarFilter type are
left intact so re-enabling is a one-line change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(calendar): simplify preset handler and reuse storage util
- Extract hardcoded trending snapshot source/window to named constants.
- Collapse the three identical personal-preset nil-checks into one case.
- Persist the selected preset through the shared storage util (try/catch
wrapped) instead of raw localStorage with manual SSR guards.
- Derive KNOWN_FILTERS from PRESET_OPTIONS so the lists can't drift.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(jellycompat): add /Users endpoint and unified sa_ admin-key auth
A user could not connect Tunarr to the Jellyfin-compat API. Tunarr's media-source
health check probes both /Users/Me and /Users; for API-key connections (its
recommended method) there is no "me", so it relies on GET /Users, which we did
not implement. Adding it alone was insufficient: a Silo sa_ admin key only
authorized two autoscan routes, so an API-key source would pass the ping but 401
on every browse/stream call.
This adds GET /Users and unifies auth so an sa_ admin key authorizes the same
browse/stream routes a session token does — matching Jellyfin, where an API key
authorizes every endpoint:
- GET /Users returns the caller's own user as a single-element list
(current-profile-only; Silo is multi-account, so listing all users would leak
across households). Behavior verified against real Jellyfin 10.11.8.
- An sa_ admin key synthesizes a compat session bound to the account's primary
profile, injected into request context so existing handlers work unchanged.
Applied to the browse group (RequireSessionOrAPIKeySession) and the stream
group (PlaybackSessionAuth); /Library/VirtualFolders keeps its admin-bool path.
- The key + owning user are re-validated on every request (revocation is
immediate); only the primary-profile lookup is cached. HLS follow-ups that
carry only PlaySessionId resolve the negotiated session's sa_ CompatToken.
Validated end-to-end on dev: connect -> list user -> libraries -> browse ->
PlaybackInfo -> HLS stream, including PlaySessionId-only follow-ups.
Security notes: an admin key acts as the primary (parent) profile, so it bypasses
child-profile parental/PIN restrictions and can mutate the primary profile's
watch state — acceptable for an admin-trust key.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Code <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Silo Server Migration <noreply@silo-server.invalid>
Co-authored-by: zZebrahz <zzebrahz@gmail.com>
Co-authored-by: CoffeeKnyte <67730400+CoffeeKnyte@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
338 lines
12 KiB
Go
338 lines
12 KiB
Go
package jellycompat
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/models"
|
|
"github.com/Silo-Server/silo-server/internal/userstore"
|
|
)
|
|
|
|
type adminAPIKeyContextKey string
|
|
|
|
const adminAPIKeyKey adminAPIKeyContextKey = "jellycompat_admin_api_key"
|
|
|
|
type apiKeyValidator interface {
|
|
GetByKey(ctx context.Context, key string) (*models.APIKey, error)
|
|
UpdateLastUsed(ctx context.Context, id int64) error
|
|
}
|
|
|
|
type apiKeyUserLoader interface {
|
|
GetByID(ctx context.Context, id int) (*models.User, error)
|
|
}
|
|
|
|
type AdminAPIKeyAuthenticator struct {
|
|
keys apiKeyValidator
|
|
users apiKeyUserLoader
|
|
provider userstore.UserStoreProvider
|
|
now func() time.Time
|
|
|
|
// profiles caches only the primary-profile lookup per user — never an
|
|
// authorization decision, which is re-checked on every request.
|
|
profiles *apiKeyProfileCache
|
|
|
|
lastUsedMu sync.Mutex
|
|
lastUsedAt map[int64]time.Time
|
|
}
|
|
|
|
type adminAPIKeyAuthResult struct {
|
|
ctx context.Context
|
|
status int
|
|
code string
|
|
message string
|
|
ok bool
|
|
}
|
|
|
|
// NewAdminAPIKeyAuthenticator builds the authenticator. keys and users are
|
|
// required (a nil return disables API-key auth entirely). provider is optional:
|
|
// when present, an admin key can synthesize a compat session (see
|
|
// resolveSession); when nil, only the admin-bool path (RequireAdminAPIKey /
|
|
// RequireSessionOrAdminAPIKey) is available.
|
|
func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader, provider userstore.UserStoreProvider, now func() time.Time) *AdminAPIKeyAuthenticator {
|
|
if keys == nil || users == nil {
|
|
return nil
|
|
}
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
return &AdminAPIKeyAuthenticator{
|
|
keys: keys,
|
|
users: users,
|
|
provider: provider,
|
|
now: now,
|
|
profiles: newAPIKeyProfileCache(),
|
|
lastUsedAt: make(map[int64]time.Time),
|
|
}
|
|
}
|
|
|
|
func AdminAPIKeyFromContext(ctx context.Context) bool {
|
|
ok, _ := ctx.Value(adminAPIKeyKey).(bool)
|
|
return ok
|
|
}
|
|
|
|
func (a *AdminAPIKeyAuthenticator) RequireAdminAPIKey(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
result := a.authenticate(r)
|
|
if !result.ok {
|
|
writeError(w, result.status, result.code, result.message)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(result.ctx))
|
|
})
|
|
}
|
|
|
|
func RequireSessionOrAdminAPIKey(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token, ok := ExtractToken(r)
|
|
if ok && strings.HasPrefix(token, "sa_") {
|
|
result := keyAuth.authenticate(r)
|
|
if !result.ok {
|
|
writeError(w, result.status, result.code, result.message)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r.WithContext(result.ctx))
|
|
return
|
|
}
|
|
sessionAuth.RequireSession(next).ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// RequireSessionOrAPIKeySession authenticates either a compat session token or
|
|
// an sa_ admin API key. For an API key it injects a session synthesized for the
|
|
// key user's primary profile, so ordinary handlers (which read
|
|
// SessionFromContext) work unchanged for both auth modes — matching Jellyfin,
|
|
// where an API key authorizes the same endpoints a user token does. When
|
|
// API-key auth is unavailable (nil authenticator or no UserStoreProvider) an
|
|
// sa_ token falls through to session-token auth and is rejected there.
|
|
func RequireSessionOrAPIKeySession(sessionAuth *Authenticator, keyAuth *AdminAPIKeyAuthenticator) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if token, ok := ExtractToken(r); ok && strings.HasPrefix(token, "sa_") {
|
|
if session, result, handled := keyAuth.resolveSession(r.Context(), token); handled {
|
|
if !result.ok {
|
|
writeError(w, result.status, result.code, result.message)
|
|
return
|
|
}
|
|
serveWithSession(next, w, r, session)
|
|
return
|
|
}
|
|
}
|
|
sessionAuth.RequireSession(next).ServeHTTP(w, r)
|
|
})
|
|
}
|
|
}
|
|
|
|
// authenticate validates an admin API key and, on success, returns a result
|
|
// carrying the admin-API-key marker in context. Used by the server-to-server
|
|
// admin routes that act on the admin identity rather than a compat session.
|
|
func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuthResult {
|
|
token, _ := ExtractToken(r)
|
|
apiKey, _, res := a.validate(r.Context(), token)
|
|
if !res.ok {
|
|
return res
|
|
}
|
|
a.touchLastUsed(apiKey.ID)
|
|
return adminAPIKeyAuthResult{
|
|
ctx: context.WithValue(r.Context(), adminAPIKeyKey, true),
|
|
status: http.StatusOK,
|
|
ok: true,
|
|
}
|
|
}
|
|
|
|
// validate authenticates an admin API key by token and returns the key plus its
|
|
// owning admin user. The full authorization check — key lookup, user load,
|
|
// enabled, admin role — runs on EVERY call, so revocation (key deletion, user
|
|
// disable, role change) takes effect on the very next request. On failure the
|
|
// result carries the HTTP status/code/message: 401 for a missing/unknown/
|
|
// disabled key, 403 for a non-admin key.
|
|
func (a *AdminAPIKeyAuthenticator) validate(ctx context.Context, token string) (*models.APIKey, *models.User, adminAPIKeyAuthResult) {
|
|
unauthorized := adminAPIKeyAuthResult{
|
|
status: http.StatusUnauthorized,
|
|
code: "Unauthorized",
|
|
message: "Invalid API key",
|
|
}
|
|
if a == nil || a.keys == nil || a.users == nil {
|
|
return nil, nil, unauthorized
|
|
}
|
|
if !strings.HasPrefix(token, "sa_") {
|
|
return nil, nil, unauthorized
|
|
}
|
|
apiKey, err := a.keys.GetByKey(ctx, token)
|
|
if err != nil || apiKey == nil {
|
|
return nil, nil, unauthorized
|
|
}
|
|
user, err := a.users.GetByID(ctx, apiKey.UserID)
|
|
if err != nil || user == nil || !user.Enabled {
|
|
return nil, nil, unauthorized
|
|
}
|
|
if user.Role != "admin" {
|
|
return nil, nil, adminAPIKeyAuthResult{
|
|
status: http.StatusForbidden,
|
|
code: "Forbidden",
|
|
message: "Admin access required",
|
|
}
|
|
}
|
|
return apiKey, user, adminAPIKeyAuthResult{ok: true}
|
|
}
|
|
|
|
// touchLastUsed records key usage without blocking the request, throttled to at
|
|
// most once per apiKeyLastUsedInterval per key so per-request validation on the
|
|
// hot path (e.g. HLS segments) does not issue a DB write each time.
|
|
func (a *AdminAPIKeyAuthenticator) touchLastUsed(id int64) {
|
|
now := a.now()
|
|
a.lastUsedMu.Lock()
|
|
if last, ok := a.lastUsedAt[id]; ok && now.Sub(last) < apiKeyLastUsedInterval {
|
|
a.lastUsedMu.Unlock()
|
|
return
|
|
}
|
|
a.lastUsedAt[id] = now
|
|
a.lastUsedMu.Unlock()
|
|
|
|
go func(id int64) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := a.keys.UpdateLastUsed(ctx, id); err != nil {
|
|
slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err)
|
|
}
|
|
}(id)
|
|
}
|
|
|
|
// resolveSession authenticates an sa_ API key and returns a compat session
|
|
// synthesized for the key user's primary profile, so ordinary browse/stream
|
|
// handlers (which read SessionFromContext) work unchanged for an API key.
|
|
//
|
|
// handled reports whether this authenticator can serve API-key session auth at
|
|
// all: false when the authenticator is nil or has no UserStoreProvider wired, in
|
|
// which case the caller must fall through to session-token auth. When handled is
|
|
// true, result.ok reports success; on failure result carries what to return.
|
|
//
|
|
// The key and owning user are re-validated on EVERY call (no cached
|
|
// authorization), so revocation is immediate. Only the primary-profile lookup is
|
|
// cached (see primaryProfile), sparing the hot path the ListProfiles round-trip.
|
|
func (a *AdminAPIKeyAuthenticator) resolveSession(ctx context.Context, token string) (*Session, adminAPIKeyAuthResult, bool) {
|
|
if a == nil || a.provider == nil {
|
|
return nil, adminAPIKeyAuthResult{}, false
|
|
}
|
|
apiKey, user, res := a.validate(ctx, token)
|
|
if !res.ok {
|
|
return nil, res, true
|
|
}
|
|
a.touchLastUsed(apiKey.ID)
|
|
|
|
profile, err := a.primaryProfile(ctx, user.ID)
|
|
if err != nil {
|
|
slog.Warn("jellycompat api key session synthesis failed", "user_id", user.ID, "error", err)
|
|
return nil, adminAPIKeyAuthResult{
|
|
status: http.StatusUnauthorized,
|
|
code: "Unauthorized",
|
|
message: "Unable to resolve a profile for this API key",
|
|
}, true
|
|
}
|
|
|
|
// Upstream Silo token fields are left empty and the expiry zero: an API key
|
|
// has no refreshable Silo token, and a zero expiry makes RequireSession's
|
|
// refresh path a no-op. Downstream services scope by StreamAppUserID/ProfileID.
|
|
return &Session{
|
|
Token: token,
|
|
Username: user.Username,
|
|
AccountUsername: user.Username,
|
|
ProfileID: profile.ID,
|
|
ProfileName: profile.Name,
|
|
PseudoUserID: PseudoUserID(user.ID, profile.ID),
|
|
StreamAppUserID: user.ID,
|
|
CreatedAt: a.now(),
|
|
}, adminAPIKeyAuthResult{ok: true}, true
|
|
}
|
|
|
|
// primaryProfile returns the account's primary profile, caching the result per
|
|
// user for apiKeyProfileCacheTTL. This caches profile DATA only — never an
|
|
// authorization decision — so it is safe against revocation (validated
|
|
// separately on every request).
|
|
func (a *AdminAPIKeyAuthenticator) primaryProfile(ctx context.Context, userID int) (userstore.Profile, error) {
|
|
if profile, ok := a.profiles.get(userID, a.now()); ok {
|
|
return profile, nil
|
|
}
|
|
store, err := a.provider.ForUser(ctx, userID)
|
|
if err != nil {
|
|
return userstore.Profile{}, fmt.Errorf("open user store for user %d: %w", userID, err)
|
|
}
|
|
profiles, err := store.ListProfiles(ctx)
|
|
if err != nil {
|
|
return userstore.Profile{}, fmt.Errorf("list profiles for user %d: %w", userID, err)
|
|
}
|
|
profile, err := selectPrimaryProfile(userID, profiles)
|
|
if err != nil {
|
|
return userstore.Profile{}, err
|
|
}
|
|
a.profiles.put(userID, profile, a.now().Add(apiKeyProfileCacheTTL))
|
|
return profile, nil
|
|
}
|
|
|
|
// selectPrimaryProfile returns the account's primary profile, falling back to
|
|
// the first profile (with a warning) when none is flagged primary, and erroring
|
|
// when the account has no profiles.
|
|
func selectPrimaryProfile(userID int, profiles []userstore.Profile) (userstore.Profile, error) {
|
|
if len(profiles) == 0 {
|
|
return userstore.Profile{}, fmt.Errorf("user %d has no profiles", userID)
|
|
}
|
|
for _, p := range profiles {
|
|
if p.IsPrimary {
|
|
return p, nil
|
|
}
|
|
}
|
|
slog.Warn("jellycompat api key: no primary profile found, using first",
|
|
"user_id", userID, "profile_id", profiles[0].ID)
|
|
return profiles[0], nil
|
|
}
|
|
|
|
const (
|
|
// apiKeyProfileCacheTTL bounds how long a primary-profile lookup is reused.
|
|
// Profiles are stable, so this is generous; it only affects how quickly a
|
|
// reassigned/renamed primary profile is picked up, never authorization
|
|
// (the key and user are re-validated on every request).
|
|
apiKeyProfileCacheTTL = 5 * time.Minute
|
|
// apiKeyLastUsedInterval throttles api_keys.last_used_at writes so that
|
|
// per-request validation on the hot path does not write each time.
|
|
apiKeyLastUsedInterval = time.Minute
|
|
)
|
|
|
|
type apiKeyProfileCacheEntry struct {
|
|
profile userstore.Profile
|
|
expiresAt time.Time
|
|
}
|
|
|
|
// apiKeyProfileCache caches the primary-profile lookup per user so the hot path
|
|
// (e.g. HLS segment requests) skips the ListProfiles round-trip. It caches only
|
|
// profile data, never an authorization decision.
|
|
type apiKeyProfileCache struct {
|
|
mu sync.RWMutex
|
|
entries map[int]apiKeyProfileCacheEntry
|
|
}
|
|
|
|
func newAPIKeyProfileCache() *apiKeyProfileCache {
|
|
return &apiKeyProfileCache{entries: make(map[int]apiKeyProfileCacheEntry)}
|
|
}
|
|
|
|
func (c *apiKeyProfileCache) get(userID int, now time.Time) (userstore.Profile, bool) {
|
|
c.mu.RLock()
|
|
entry, ok := c.entries[userID]
|
|
c.mu.RUnlock()
|
|
if !ok || !entry.expiresAt.After(now) {
|
|
return userstore.Profile{}, false
|
|
}
|
|
return entry.profile, true
|
|
}
|
|
|
|
func (c *apiKeyProfileCache) put(userID int, profile userstore.Profile, expiresAt time.Time) {
|
|
c.mu.Lock()
|
|
c.entries[userID] = apiKeyProfileCacheEntry{profile: profile, expiresAt: expiresAt}
|
|
c.mu.Unlock()
|
|
}
|