* perf(jellycompat): fast-paths for slow Jellyfin-compat queries
Rebuilds the jellycompat slow-query fast-paths cleanly on main, without
the hub-sections virtual-library feature (which is intentionally absent
on this branch). Each path is bounded so behavior stays predictable
under load.
Fast-paths:
- Episode catalog: add the load-bearing `si.type = 'series'` hydration
guard plus a matching entry-scan series-parent guard, so the page and
count scans stay aligned with hydration (no under-filled pages, no
inflated TotalRecordCount from podcast/audiobook episode rows).
- /Shows/{id}/Episodes AdjacentTo: serve a bounded prev/self/next
neighbor window with series-membership validation and a cross-series
id guard, falling back to the full listing.
- Resume: serve Continue Watching through the capped native
continue-watching fetcher (next-up suppressed, in-progress only)
instead of loadProgressPage's unbounded scan. Wires only the
sections read-time fetcher (StoreProvider) into ItemsHandler; no
hub-section/virtual-library exposure.
- Played-history: push the type + library filter down into SQL.
- recently_added: fan a no-parentId browse out across libraries with an
app-side dedup merge, clamped to BrowseFilters.MaxLimit, and exclude
compat-hidden (audiobook/podcast) libraries from the fan-out.
- Detail fields: batch per-item GetItemDetail in the Latest/browse
detail-field paths via GetItemDetailsByIDs, with per-item fallback.
- Search: term-length and result-count guards for aux search.
* perf(jellycompat): clamp Resume fast-path fetch to maxResumeItems
A large client-supplied StartIndex on /UserItems/Resume inflated
loadResumeViaSections' fetchLimit (pageSize + StartIndex) and fed it
straight into ResolvedSection.ItemLimit, letting the continue-watching
fetcher scan up to its continueProgressMaxScanned hard cap (1000 rows)
instead of stopping near maxResumeItems. Clamp fetchLimit to
maxResumeItems and short-circuit to an empty page when StartIndex is at
or beyond the cap (no visible row can land there), keeping the capped
fetcher's scan bounded and predictable under load.
Addresses CodeRabbit review finding on PR #247.
* feat(metadata): expand provider image cache queue
* fix(metadata): harden provider image cache queue
Addresses bug-review feedback from Codex/CodeRabbit on the metadata image
cache pipeline. All findings validated against the code before fixing;
false positives (rows/connection deadlock, PhotoSourcePath merge coupling)
were confirmed non-issues and left unchanged.
- Honor metadata.cache_images for the background processor. The
cache_metadata_images task was registered whenever S3 was configured,
so merely enabling object storage downloaded the entire provider-artwork
catalog even with caching disabled. Add ImageCacheProcessor.SetEnabled,
gate RunOnce/RunUntilIdle on it, and wire it (with hot reload) from
cfg.Metadata.CacheImages in main.go.
- Guard terminal job updates with lease ownership. EnqueueBatch can
repurpose a running row with a new source; MarkSucceeded/MarkFailed
keyed on id alone let a stale worker finalize the replacement job and
drop the new artwork. Thread locked_by through and add
status='running' AND locked_by=$n guards.
- Avoid uploading stale jobs onto the live artwork key. Verify the
target still references the job's source (CurrentTargetSourcePath)
before CacheImage, so a job whose source an admin/refresh already
replaced cannot overwrite the deterministic storage object.
- COALESCE nullable external IDs in EnqueueExistingProviderArtwork. A
NULL tmdb_id/tvdb_id/imdb_id on any candidate failed the scan and
aborted the whole cache run; matches the existing item_repo pattern.
- Stop re-downloading the catalog every 30 days. Discovery now skips
targets whose *_path is already a cached relative path, making the
cached row the durable dedup marker instead of the prunable job row.
- Decouple catalog sweeps from queue draining. RunOnce no longer runs
discovery per batch; RunUntilIdle sweeps only when the queue drains and
throttles full sweeps to every 15m, so idle installs stop full-scanning
every entity table each minute.
- Requeue claimed-but-unstarted jobs on cancellation. Acquire the
semaphore before spawning workers and RequeueClaimed any jobs not yet
started, instead of leaving them locked until the 15m lease expires.
- Skip the backoff sleep after the final upload attempt in
putObjectWithRetry (saves ~1.5s on permanent failures).
- Add the s3/file/local/upload/generated exclusion to the seasons and
episodes backfill in migration 20260617184537 for consistency with the
later migration (the bad backfill was inert downstream, but the
asymmetry is removed).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Upgrade all outbound Discord surfaces (personal webhooks, bot DMs, server
channels, request events) from bare title/description embeds to rich ones:
poster thumbnail, overview teaser, TMDB/IMDb/TVDB links, rating and genre
fields, content-rating footer, and a clickable title URL.
Artwork respects the v1 privacy contract via a new admin poster mode
(notifications.discord.poster_mode): "provider" (default) only emits public
provider-CDN URLs, "server" additionally presigns locally cached posters
from this server's image storage, "off" drops images entirely. Builders
never derive artwork URLs themselves; the sender layer resolves PosterURL
through System.discordPosterURL.
To keep provider-CDN URLs derivable after image caching rewrites
poster_path to a local storage key, media_items gains poster_source_path,
captured during cacheItemImages, preserved across refreshes that keep the
cached poster, and cleared on explicit poster overrides.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>