From d3626fca0063fb84bf445f1f7c59d1d402f31961 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:25:10 -0400 Subject: [PATCH] feat(jellycompat): add /Users endpoint and unified sa_ admin-key auth (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) * 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) * 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) * 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 * 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) * 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) * 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) * 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) * 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) * test(naming): document bare-id trade-off; cover CJK title + movies numeric Co-Authored-By: Claude Opus 4.7 (1M context) * 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) * test(matcher): exercise the single-distinct-show guard properly + conflicting-ID case; doc notes Co-Authored-By: Claude Opus 4.7 (1M context) * 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) * 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) * 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) * 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) * 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 * docs: add implementation plan for resilient library deletion Co-Authored-By: Claude Opus 4.7 * 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 * 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 * 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 * 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 * 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) * 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