Commit Graph
18 Commits
Author SHA1 Message Date
QuickandGitHub 73488d1bfa feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
2026-07-31 15:23:30 -04:00
7ab393fc3a fix(jellycompat): resolve item duration probed-first with runtime fallback (#493)
* fix(jellycompat): resolve item duration probed-first with runtime fallback

Jellyfin-protocol clients received no runtime at all for items whose catalog
runtime is 0. RunTimeTicks is omitempty, so a zero value is dropped from the
JSON entirely rather than sent as 0, and strict clients (Infuse) abandon
playback on those items. On the production deployment 5,245 movies have
media_items.runtime = 0 while 5,239 of them have a correct probed
media_files.duration.

Resolve duration at read time the way /api/v1 already does: probed file
duration first, catalog runtime as the fallback. The item row is deliberately
not backfilled — one item can have several versions of different lengths, so
per-file data does not belong there.

- scanner: FirstDurationsByContentIDs / FirstDurationsByEpisodeIDs, batched
  lookups using the same "first live file with duration > 0, ordered by id"
  rule as the v1 API's contentDurationSeconds. The episode_id IS NULL guard on
  the content-id query is load-bearing: every episode file carries its series'
  content_id, so without it a series row would report an episode's duration.
- catalog: optional batchDurationFetcher extension on DetailService, following
  the existing extraFileFetcher pattern so test fakes need no changes.
  Nil-receiver safe and fail-soft — a failed lookup logs and degrades to the
  catalog runtime rather than failing the page.
- jellycompat: DurationSeconds on upstreamListItem/upstreamEpisode, a shared
  runtimeTicks resolver, and fillListItemDurations wired into the nine page
  producers. Fixes the three sites that had no fallback (itemFromList,
  episodeFromUpstream, HandleSearchHints); the detail and PlaybackInfo paths
  were already correct.

This is additive within the v1 rules — it populates a field that was
previously omitted. No field is renamed, removed, retyped, or repurposed.

* fix(jellycompat): avoid duplicate duration lookups

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-28 21:33:30 -04:00
CoffeeKnyteandGitHub a2ef26bece perf: root-cause fixes for endpoints still slow after #292 (NextUp, series badges, resume tail, subtitle fonts) (#350)
* docs(plans): root-cause analysis for endpoints still slow after PR #292

Five endpoint groups stayed slow after the home/Continue Watching/Latest
latency work shipped: Resume (110s p95), NextUp (17s p95), Latest (17s),
/Items, and the home sections routes. The caps and caches from PR #292 are
live in the deployed binary; they bounded how many rows the loops touch but
not what each underlying query costs. Documents the four confirmed root
causes (4.3M stale completed-with-position progress rows + missing resume
index, unbounded next-up anchor scan, per-episode series rollup fanout, two
index-starved history/scanner paths) with live EXPLAIN ANALYZE measurements
and the fix plan implemented by the follow-up commits.

AI-use disclosure: analysis and doc produced with AI (Claude) assistance.

* perf(catalog): bound the global next-up anchor scan to recent completions

The completed_episodes CTE in buildListNextUpQuery derived per-series
anchors from the profile's ENTIRE completed history — DISTINCT ON over 233k
rows joined to episodes for the worst bulk-import profile, then a per-series
LATERAL that scans every episode of a fully-watched series before yielding
nothing. 648 slow executions in a 19h window, 44.7s worst; this drove
/Shows/NextUp (17.1s p95) and the next-up injection on the native home
sections aggregate.

Global queries now derive anchors from the profile's nextUpAnchorMaxRows
(500) most recent completed rows — an ordered index walk on
idx_uwp_profile_completed, with the hidden-items exclusion and date cutoff
applied inside the bounded scan so hidden/old rows never consume the anchor
budget. A next-up rail surfaces ~24 series; the 500 most recent completions
cover every series that can realistically rank on it. Series-scoped calls
(the show-detail tile) keep the unbounded shape: they must anchor on the
series' last completed episode no matter how long ago it was watched, and
are naturally bounded by one series.

Measured on the live worst-case profile with the exact generated SQL:
44.7s worst / ~2.6s avg before; 10ms after (together with the one-time
stale-resume-point data repair applied directly to the deployment DB — see
docs/superpowers/plans/2026-07-06-slow-endpoint-root-causes.md).

AI-use disclosure: implemented with AI (Claude) assistance.

* perf(jellycompat,userstore): aggregate series watch-state rollup in SQL

The series Played/UnplayedItemCount badge on list rails (per-library Latest,
library browse, search results) and series detail pages was computed by
materializing EVERY episode of every series on the page
(episodeRepo.ListBySeriesIDs) and then batching per-episode progress+history
lookups in 500-id chunks. A 50-series page of an episode-heavy library
(Sports) expanded to 32,467 episode rows and ~65 sequential queries —
measured 17-18s per /Items/Latest request, and PR #292's cached Latest fast
path pays it on every response for series libraries. The same fanout made
/Items?searchTerm=... slow whenever the result set was mostly series
(Meilisearch itself answers in milliseconds).

New optional store capability userstore.SeriesEpisodeRollupStore, implemented
by PostgresUserStore as one GROUP BY e.series_id aggregate with semantics
identical to the chunked path (episode availability via episode_libraries,
hidden-items visibility on progress rows, completed-history fold, in-progress
= not watched with position > 0 — verified value-for-value against the old
semantics on a real 1,586-episode series). enrichSeriesListUserData and
enrichDetailUserData use it when present; SQLite-backed stores and rollup
query failures keep the existing chunked path as fallback.
catalog.SeasonUserDataFromCounts pins the counts-to-DTO mapping to
EpisodeRollupUserData.

Measured on the live worst-case profile against the real 50-series Sports
Latest page: ~17s of chunked round-trips before, 119ms in one query after.

Part of docs/superpowers/plans/2026-07-06-slow-endpoint-root-causes.md.

AI-use disclosure: implemented with AI (Claude) assistance.

* perf(catalog): bound superseded-episode completed walk to recent history

The Resume / Continue Watching superseded-episode filter loaded a
profile's *entire* completed history into memory on every request that
contained an in-progress episode: CompletedProgressSnapshots paged
user_watch_progress WHERE completed=TRUE with no upper bound. The
2026-07-06 slow-query comparison showed this surviving as a 60-116s
Resume tail even after the in-progress index landed live, because the
4.3M zeroed Plex-import rows are still completed=TRUE and were re-walked
every load.

A completed episode can only supersede an in-progress one it was
finished more recently than (the query gates on
done_progress.updated_at > ip_progress.updated_at), so only completed
rows newer than the oldest in-progress entry can matter. Compute that
cutoff in SupersededEpisodeProgressIDs and pass it to
CompletedProgressSnapshots, which — since the completed listing is
ordered updated_at DESC — stops paging as soon as it crosses the cutoff.
Import-heavy profiles whose back-catalogue predates their current
in-progress items now stop on the first page instead of paging hundreds
of thousands of irrelevant rows. Correctness is unchanged: no relevant
superseding row is excluded.

* perf(catalog): hard-cap superseded-episode completed walk at 5 pages

The updated_at cutoff added in the previous commit bounds the completed
walk on the relevance axis, but a very old in-progress entry sitting
behind a large volume of newer completions could still page deep. Add a
5-page (2,500-row) hard backstop on top of the cutoff: normal profiles
still stop on page one via the cutoff, and only the adversarial tail hits
the cap. When it engages the tail of the completed set goes unscanned, so
a superseded episode could momentarily survive on Continue Watching — we
log a warning when that happens (with profile_id + rows scanned) rather
than mis-filter silently, and it self-corrects once the stale in-progress
entry ages out of the scanned window.

* perf(playback): extract subtitle fonts in a single ffmpeg pass

Embedded ASS/SSA font extraction spawned one ffmpeg process per font
attachment, each re-opening the (usually CephFS-backed) media file. Anime
releases carry 15-47 fonts, so the per-spawn file-open cost dominated and
pushed GET /api/v1/stream/{sid}/subtitles/{track}/fonts to a 17-60 s plateau
(p95 ~33 s in the live logs).

Collapse the N spawns into one ffmpeg invocation that dumps every attachment
to a temp dir (-dump_attachment:idx path ... -i file -map 0:t? -c copy), then
read the files back. The file is opened once instead of N times, taking p95
from ~30 s to ~1-2 s with no change to output.

Safety is preserved. The 32-attachment / 32 MiB caps still apply: attachment
size is stat'd before read so an over-limit font never enters memory, and a
watchdog polls the dump dir and kills ffmpeg if its on-disk output crosses the
cap -- restoring the hard bound the old pipe-per-attachment reader enforced by
killing at maxBytes+1, so a container with oversized "font" attachments can't
fill the disk.

Part of the slow-endpoint follow-up; see
slow-query-analysis/subtitle-fonts-extraction-findings.md.

* fix(review): report enforced font-byte cap; correct doc subtitle scope

Address PR #350 review:
- dumpFontAttachments reported the maxSubtitleFontBytes package constant in
  both over-limit errors instead of the maxBytes argument the caller passed,
  so the message misstated the enforced bound whenever a different cap was in
  effect (as the tests use). Interpolate maxBytes in both messages.
- The root-cause plan claimed subtitle extraction was 'out of scope' while the
  branch actually optimizes /subtitles/{track}/fonts. Scope the out-of-scope
  note to subtitle *track* conversion and record the fonts single-pass work as
  deliverable 5.
2026-07-09 09:02:45 -04:00
203a18ae83 feat(observability): OpenTelemetry logs+traces with secret redaction and slog standardization (#290)
* feat(observability): OpenTelemetry logs+traces with secret redaction

Part of #265. Adds opt-in OpenTelemetry (logs + traces) alongside the existing
stderr + opslog pipeline, plus secret redaction on all sinks. Default-off: with
no OTEL_* / SILO_OTEL_ENABLED config, behavior is unchanged.

Bootstrap (internal/telemetry):
- Setup() builds one shared resource, a TracerProvider (parent-based trace-id
  ratio sampler), a LoggerProvider, and the W3C TraceContext+Baggage propagator
  from env. It installs NO MeterProvider — metrics stay on Prometheus, and the
  built-in no-op global MeterProvider keeps the trace instrumentation libs from
  double-emitting. Shutdown is deferred with a flush timeout.
- Logs are bridged via otelslog fan-out (slog.MultiHandler), level-gated by the
  shared LevelVar and best-effort so a failing collector can't break the console
  or DB branches. stderr + opslog stay untouched.

Secret redaction (internal/logredact):
- A slog.Handler masks secret-keyed attributes (password, token, api_key,
  authorization, cookie, ...) — including .With-bound attrs, nested groups,
  secret-keyed group subtrees, and values behind a LogValuer — on the console
  and OTLP sinks, with a no-op fast path when a record has no secret keys.
  opslog.shouldRedact delegates to logredact.SecretKey so all sinks share one
  marker list.

Rotation is infra-managed (no custom file sink): container runtime for stderr,
collector/backend for OTLP, opslog partition-pruning for the DB. Documented in
docs/architecture/observability.md.

Verification: go build ./..., go vet, gofmt -l — clean; go test
./internal/telemetry/ ./internal/logredact/ -race pass.

AI-use disclosure: implemented with AI assistance (Claude Code), including
adversarial reviews that hardened the bootstrap and fixed two redaction leak
paths; reviewed by the author.

* refactor(observability): slog context+component sweep, sloglint gate (phase 3)

Part of #265. Builds on the OTel bootstrap + redaction commit.

Standardizes every log call site onto the context-carrying slog variants so
records correlate with the active OpenTelemetry trace, and locks the standard
in with a machine gate so future code (human- or AI-authored) can't drift back.

- Call-site sweep: converted the remaining slog.<Level>(...) calls to the
  slog.<Level>Context(ctx, ...) form wherever a context.Context is in scope
  (background/init calls with no ctx are left as-is), across 183 files. Applied
  via a type-aware AST codemod. Log levels and message strings are preserved
  verbatim; a component attr (canonical per-package name) is added to direct
  package-level slog calls. Bound-logger calls keep their existing .With
  bindings. The main.go and telemetry package conversions rode with their file
  in the previous commit to keep each file within a single commit.
- Enforcement (.golangci.yml): enable sloglint with context=scope, static-msg,
  key-naming-case=snake, no-mixed-args. After the sweep all four report zero
  violations repo-wide (tests included), so make lint / CI now blocks any
  regression to the non-context form. The gate ships with the sweep because it
  cannot be green until the legacy sites are converted.

Metrics remain on Prometheus; no behavior change to /metrics or Grafana.

Verification: go build ./..., go vet ./..., gofmt -l — clean; sloglint (all 4
rules) 0 violations repo-wide; log levels verified unchanged.

AI-use disclosure: implemented with AI assistance (Claude Code), including the
codemod; reviewed by the author.

* fix(observability): honor per-signal OTLP protocol and secret WithGroup names

Two Codex review findings on PR #290:

- telemetry: OTEL_EXPORTER_OTLP_{TRACES,LOGS}_PROTOCOL now override the
  generic OTEL_EXPORTER_OTLP_PROTOCOL per signal, so mixed collector
  setups (e.g. HTTP logs + gRPC traces) build the right exporter.
- logredact: entering a group whose name is secret-bearing (e.g.
  WithGroup("authorization")) now masks every leaf in that subtree,
  matching how slog.Group("authorization", ...) is masked as a whole.

* fix(observability): address review feedback on telemetry bootstrap

- Telemetry setup failure no longer kills boot: Setup returns usable
  no-op providers alongside the error and main logs and continues with
  telemetry disabled, honoring the best-effort contract.
- Honor OTEL_TRACES_SAMPLER (always_on/off, traceidratio, parentbased_*
  variants); unsupported values fall back to parentbased_traceidratio.
- Attach node identity as semconv service.instance.id instead of the
  non-semconv node.name.
- Rename opslog retention-scope log attrs to target_component/target_level
  so they no longer collide with the canonical component routing key, and
  tag those lines with component=opslog.
- Fix stale levelGated comment casing; use WarnContext in the telemetry
  shutdown defer; document the LogValuer double-resolve on the redaction
  slow path.

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-07-09 08:53:52 -04:00
e140bd9424 feat(metadata,scanner): trailers and extras for movies and series (#322)
* feat(metadata,scanner): trailers and extras for movies and series

Remote provider videos (TMDB trailers/teasers/featurettes/...) are fetched
through the unified match/refresh pipeline into the new item_videos table,
filtered per-library via media_folders.trailer_kinds, merged across
providers with site/provider dedup, and lockable via FieldVideos.

The movie scanner stops discarding supplemental directories (Trailers/,
Featurettes/, Behind The Scenes/, ...) and classifies them — plus
Jellyfin-style filename suffixes (-trailer, -behindthescenes, ...) and
series-root supplemental dirs — into the new media_extras entity backed by
ordinary media_files rows (extra_id ownership, content_id/episode_id NULL so
existing version/matching queries stay structurally blind to extras).
Series Extras/SxxExx season-0 mapping is unchanged. Extras are playable
watch targets via a GetWatchDetail fallback tier (episodes precedent),
with contentid.ForLocal minting stable ids.

API: ItemDetail gains additive videos/extras arrays (single + batch parity);
library settings expose trailer_kinds. jellycompat now populates
RemoteTrailers, LocalTrailerCount/SpecialFeatureCount, and serves real
/LocalTrailers + /SpecialFeatures items playable through PlaybackInfo.

Requires silo-plugin-sdk v0.9.0 (VideoRecord) before go.mod can bump;
builds locally via go.work against the SDK feat/metadata-videos branch.

Part of trailers/extras capability work.

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

* feat(web): trailers and extras sections, library trailer-kinds setting

TrailersSection (YouTube thumbnails + youtube-nocookie modal) and
ExtrasSection (plays extras through the standard watch controller) on movie
and series detail pages; admin library form gains a trailer-kinds
allow-list synced with the server default (all provider kinds), now also
honored on library create.

Part of trailers/extras capability work.

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

* fix(scanner): scan extra_id in scanMediaFiles; review cleanups

scanMediaFiles (the plural row scanner behind GetByContentID/GetByFolder/
GetByExtraID and 20+ other queries) was missing the scan destination for
the new extra_id column, which would have failed every media-file read at
runtime with a column/destination count mismatch.

Also: extend the batch equivalence test to seed item_videos/media_extras so
the new videos/extras prefetch wiring is actually proven; drop the one-off
pgxRows interface for the repo-wide pgx.Rows convention; reuse formatClock
instead of a third duration formatter in ExtrasSection.

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

* chore(deps): bump silo-plugin-sdk to v0.9.0 for VideoRecord

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

* fix(matching): exclude extras files from match queues and bulk content linking

Dev verification caught extras media_files rows (content_id NULL by design)
being swept into the movie/series match queues and the root-claim bulk
relink: a '-featurette' suffix extra was matched onto its parent as a
version, and a Trailers/ file minted a spurious local skeleton item that
shadowed the extra's watch target. Add 'extra_id IS NULL' to the queue
eligibility conditions, root/group claim relinks, observed-root content
assignment, and the admin unmatched-files listing.

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

* fix(playback): authorize local extras files through their parent item

Dev verification: playback/start (and the shared MediaFileAuthorizer used
by markers/subtitles/ebook reader) resolved file ownership only via
episode_id/content_id, so extras files (extra_id only) 404ed. Add an
ExtraLookup tier that resolves media_extras and gates on the parent item's
access, mirroring the episode->series pattern.

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

* fix(catalog): resolve local extras through GetItemDetail for compat playback

jellycompat PlaybackInfo (and any per-item consumer resolving arbitrary
content ids) goes through GetItemDetail, which lacked the extras tier that
GetWatchDetail has — so Jellyfin clients got zero MediaSources for extras.
Add buildExtraItemDetail (minimal detail + ordinary playback surface,
parent-gated access) as the fourth resolution tier, and map the extra type
to Jellyfin's Video kind.

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

* fix(web): allow youtube-nocookie embeds in CSP; trailer modal a11y

The frontend CSP's frame-src blocked the trailer modal's
youtube-nocookie.com iframe (found on dev verification). Also add the
missing sr-only DialogDescription and drop the redundant allowFullScreen
attribute.

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

* fix: address PR review findings for trailers/extras

- Extras watch/item detail no longer stamp SeriesID/SeriesTitle for
  movie-owned extras (players key episodic post-roll flows off series_id);
  series-owned extras keep them (Codex).
- processExtraFiles resolves the parent and upserts media_extras before
  the unchanged fast-path, and the fast-path now also compares mtime, so
  rematched parents / reclassified kinds / same-size replacements converge
  (Codex + CodeRabbit).
- media_files upsert clears content/episode linkage atomically when
  extra_id is set (ownership mutual exclusion in one statement); the
  now-redundant MarkFileAsExtra helper is removed (CodeRabbit).
- ScanFile's extras branch runs syncPresentLibraryState +
  reconcileLibraryMemberships so converting a primary file to an extra
  cleans stale library membership immediately (CodeRabbit).
- media_extras migration adds the media_files FK as NOT VALID + VALIDATE
  to avoid a full-scan exclusive lock on large tables (CodeRabbit).
- trailer_kinds input is trimmed/lowercased/deduped and unknown values are
  dropped instead of silently widening the allow-list to 'other'
  (CodeRabbit).
- Extras authorization branches match the episode branch's posture:
  unconfigured lookup is a config error, nil extra is a 404 (CodeRabbit).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:52:44 -04:00
430224a1b9 perf: cut home-screen, Continue Watching, and Latest latency; cache shared home rails (#292)
* perf(jellycompat,sections): bound resume scan, batch leaf detail progress, widen section concurrency

Three low-risk fixes from the section-fetch performance investigation
(docs/superpowers/plans/2026-07-03-section-fetch-performance.md):

- jellycompat: bound loadProgressPage at resumeScanMaxRows=300 so a single
  request never pages through more than that many in-progress rows. The cap is
  unconditional: it also covers the sparse-visible-set case (a heavy watcher
  whose recent rows are mostly dismissed/superseded, or a Series/Season-only
  request that matches no leaf in-progress row), where the page never fills and
  the loop would otherwise scan the entire history — previously an O(history)
  scan reaching tens of seconds. In the common case the loop exits far earlier,
  so the cap only bounds the pathological worst case; 300 leaves ample headroom
  to fill a ~20-item Continue Watching page. Beyond the cap the reported total
  is a clamped lower bound. Covered by TestLoadProgressPage_BoundsScanForSparseVisibleSet.
- jellycompat: batch the leaf-item (movie/episode) progress lookup in
  GetItemDetailsByIDs via ListProgressWithCompletedHistory instead of a
  per-item GetProgressWithCompletedHistory (~100 sequential queries for a
  50-item detail page). Series keep the per-item episode-rollup path (they own
  no progress row). Output is unchanged; a batch-lookup failure is now logged
  rather than silently dropping played state for the whole page.
- sections: raise fetchAllMaxConcurrency 4 -> 6 to cut FetchAll wave count for
  large home layouts, staying within the default 20-conn pool.

Part of the home/continue-watching latency work.

AI-use disclosure: implemented with AI (Claude) assistance.

* perf(jellycompat): keep Latest browse on the cross-library fast path under isPlayed

/Items/Latest with isPlayed=false is the highest-frequency compat browse
(~10.8k calls/day). The played overlay can't be pushed into SQL, so browse
over-fetches and filters locally. The cross-library recently_added fast path
(BrowseRecentlyAddedAcrossLibraries: one ~1ms index walk per library) was
gated on Offset==0, so a heavy watcher who had already seen the newest items
needed a 2nd chunk and fell through to BrowsePage — a whole-catalog
MIN(first_seen_at) + GROUP BY HashAggregate over ~147k movies measured at
~755ms per call (0.8-1.6s observed end-to-end).

Fetch the entire over-fetch budget (maxScannedRows) in a single merged
fast-path walk instead of paging into BrowsePage, so the loop fills from one
call. The clamp caveat (MaxLimit=1000 leaves a fall-through only for
requestedLimit>200, off the Latest hot path) is documented inline.

Part of the home/browse latency work.

AI-use disclosure: implemented with AI (Claude) assistance.

* fix(jellycompat): scope resume scan cap to resume path and bound the fast-path loop

Addresses PR #292 review feedback:

- Codex (P2): the resumeScanMaxRows cap was applied unconditionally in the
  general loop, which also paginates the completed (watched-items) list. Gate it
  on resumeFiltered so the completed path keeps exact TotalRecordCount and deep
  StartIndex pagination. Covered by TestLoadProgressPage_CompletedScanNotCapped.
- CodeRabbit (Critical): the earlier raw-offset fast-path loop — the default
  Continue Watching shape and the sections-fallback route — had the same
  unbounded-scan bug and was not covered by the cap (the existing test forces
  EnableTotalRecordCount=true, routing around it). Bound it with the same
  resumeScanMaxRows guard. Covered by
  TestLoadProgressPage_BoundsFastPathScanForSparseVisibleSet.
- CodeRabbit (Minor): tag the doc's fenced example blocks as text to satisfy
  markdownlint MD040.

AI-use disclosure: implemented with AI (Claude) assistance.

* perf(sections): cache shared user-agnostic home rails per access scope

Home-screen rails that are identical for everyone who can see the same
libraries (recently added, recently released, genre, trending on server,
most watched, new to library, critically acclaimed, award winners, format
showcase, seasonal, mood, trending discover, admin-curated lists, and
library collections) were rebuilt from Postgres once per request, per user.
Only the overlay on top of each row (watched flags, play position, presigned
poster URLs) is actually per-user.

Insert a process-global resolved-list cache at the FetchOne choke point in
internal/sections. Each cacheable row is built once per access scope, held
with a 15m TTL, and refreshed in the background 3m before expiry;
singleflight collapses cold-miss stampedes into a single build. The per-user
overlay still runs fresh in buildSectionsResponse, so no profile state is
ever shared. Random and per-user rows (continue watching, next up,
recommendations, hidden gems, forgotten favorites, activity feed, user
collections) bypass the cache.

The access-scope key captures every access boundary the fetch path enforces
-- section identity (type + id + config hash) + item limit + accessible and
disabled libraries + max content rating + excluded media types + name prefix
+ allowed-content-id allowlist -- and nothing per-user, so entries are safely
shared. Empty membership is never cached (avoids freezing a transiently empty
rail); background refreshes are bounded by a timeout.

Scale (analytical, derived from the cache behavior -- not a measured
latency): for the user-agnostic rows, Postgres section-query volume collapses
from O(rows x concurrent requests) to O(rows x distinct access scopes) per
15m refresh window, because most users share a handful of access scopes.
Illustrative -- 40 cacheable rows on a home screen, 1000 concurrent users
falling into ~5 distinct access scopes:

  - before: ~40 x 1000 = ~40,000 section queries per wave of home loads
  - after:  ~40 x 5    = ~200 builds per 15m window (plus one background
            refresh per row per scope), i.e. a warm home load runs zero
            section queries for these rows.

That is a ~99% reduction in shared section-query load at that concurrency;
the win grows with concurrency and shrinks as access-scope diversity rises.

Design/plan doc added under docs/superpowers/plans/.

* perf(jellycompat): serve per-library Latest via the cached recently-added section

A jellyfin-compat per-library /Items/Latest rail is the same user-agnostic
list as the native "recently added" library rail -- both order by
mil.first_seen_at DESC. It was rebuilt on every request through
directContentService.BrowseItems, missing the resolved-list cache entirely.

Route per-library Latest for movies and series libraries through the native
section fetch instead, so it reuses the shared cache. HandleLatest resolves
the library's type once, and for a movies/series library builds a synthetic
SectionRecentlyAdded with the same type + config + limit + access scope the
native rail uses and calls FetchOne; the per-user overlay (favorites,
progress, episode targets, presign) is extracted into buildLatestItemDTOs and
shared by both the native and BrowseItems paths, so no overlay logic is
duplicated. Cached *models.MediaItem values are read-only -- LocalizeItemModels
deep-copies before any presign mutation.

To let the two surfaces share one entry, resolvedListCacheKey no longer
includes the arbitrary section ID: every cacheable section type derives its
membership from type + config + limit + scope, never from its own ID (audited
all 14 cacheable types plus the library-collection path; the sole s.ID read
lives in the non-cacheable user-collection branch). A native recently-added
rail and the compat Latest for the same library + scope now collapse to ONE
cache entry, built once and reused. Access-scope isolation is unchanged --
the removed ID never carried access information, and every access boundary
(libraries, rating cap, excluded types, content allow-list, name prefix)
still keys the entry.

Guardrails: the native path is restricted to movies and series libraries;
every other library type (ebook, music, manga, mixed) is ignored and keeps
its exact BrowseItems behavior -- important because an unfiltered
recently-added fetch would otherwise surface non-video items to Jellyfin
clients that only expect video. Deeper pages, played-filter and
backdrop-required requests, a client asking for a type other than the
library's own, and any FetchOne error also fall back to BrowseItems.

Chosen over an alternative that gave the synthetic section a deterministic ID
(which kept two separate cache entries): both returned identical data with
similar complexity, so the shared-entry design won.

* fix(sections,jellycompat): post-review fixes for the shared-list cache and Latest path

Consolidates fixes from the branch's adversarial review and PR #292 review
comments into one commit:

- Latest fast path: fall back to BrowseItems when a request carries a genre,
  name-prefix, or person filter (the synthetic recently-added section cannot
  express these, so serving it unfiltered would return a wrong, broader set).
  Eligibility is decided by latestFastPathEligible and covered by a test.
- Clamp the /Items/Latest page size to compatBrowseMaxLimit before building the
  section, matching the BrowseItems fallback, so a large client Limit can't drive
  an oversized recently-added fetch or explode the shared cache key with unbounded
  ItemLimit values.
- Evict expired entries from the process-global resolvedListCache: resolvedListSet
  sweeps expired keys at most once per minute, bounding the map to scopes seen
  within one TTL window. Covered by TestResolvedListCacheEvictsExpiredEntries.
- Log a short digest of the cache key (resolvedListLogKey) instead of the raw key
  in the background-refresh panic/error paths, since the key embeds
  user-controlled access-scope fields such as NamePrefix.

Skipped review comments (verified already fixed or stale against current code):
the resume fast-path scan bound and watched-items cap (04d2e795) and the docs
fence-language tags (already addressed).

Build, vet, and go test -race pass for internal/sections and internal/jellycompat.

* perf(plugins): cache plugin installations in-memory, invalidated on lifecycle change

## Problem
Every poster/image on a warm home rail re-read plugin_installations from
Postgres to answer "is this plugin enabled?" and to acquire the plugin client
(Source A: metadata chain buildProviders enabled-check; Source B: ensureClient
-> loadInstallation). Plugin-resolved image URLs are never URL-cached, so the
plugin source and the DB read behind it fired again on every identical warm
request; 100% of images in the target library are plugin-backed.

## Solution
- Guarded in-memory installation cache (map[int]*Installation + RWMutex) in
  plugins.Service. loadInstallation reads through it; the requireEnabled gate
  stays after the cache read so ErrInstallationDisabled semantics are unchanged.
  invalidateInstallationCache clears it and is self-registered as a lifecycle
  hook, so Service.OnLifecycleChange wipes it on install/enable/disable/update/
  uninstall.
- A generation counter closes an invalidate-vs-repopulate race: captured before
  installations.GetByID and re-checked under the write lock, so a row fetched
  before a lifecycle mutation is never written into a freshly cleared cache
  (would otherwise resurrect a just-disabled plugin).
- Route the metadata chain enabled-check through the same cache via a structural
  InstallationEnabledChecker interface (nil-safe: falls back to the pool query
  when no checker is injected), wired in cmd/silo/main.go.

## Post-review fix (auto-update reliability blocker)
AutoUpdateService mutated installations (new InstallPath, old dir deleted) on
the default auto update policy without firing OnLifecycleChange, leaving the
cache stale and breaking plugins with "stored plugin manifest mismatch" until
restart. It now takes an onChange callback wired to Service.OnLifecycleChange
and fires it once per Check run that mutated a row.

## Verification
go build/vet, go test ./internal/plugins/... ./internal/metadata/... (-race).
Tests: cache hit/invalidation, racing-invalidation guard, IsInstallationEnabled,
auto-update fires onChange.

## AI-use disclosure
Implemented with AI assistance (Claude).

* perf(jellycompat): batch per-item presign, and enrich series on the cached Latest path

## Problem
List rails presigned each item's poster/backdrop/logo/still image individually
(~160 singular resolver calls for a 40-item page where 4 batched calls suffice),
and ItemsHandler carried a near-verbatim duplicate of the batch presigner.

## Solution (batching)
Promote the batch presigner to a shared package-level presignCompatListItems
(presign_list.go) with a generic collectImagePaths[T]; convert the per-item
loops (cached home/Latest rail, favorites, batch loaders, userdata favorites) to
one batched PresignImageURLsWithExpiry per image type per page; batch the
season/episode collections; delete the three duplicate presign helpers. URL
output is unchanged (verified byte-for-byte).

## Post-review fix (series Latest data-parity regression)
The native cached Latest fast path built items via compatListItemsFromModels +
buildLatestItemDTOs and never ran the series watch-state rollup, so a series
library's Latest lost Played / UnplayedItemCount and page 1 disagreed with the
BrowseItems fallback. enrichSeriesUserData is promoted to the ContentService
interface and called on the native path (reused, not duplicated).

## Verification
go build/vet, go test ./internal/jellycompat/... ./internal/catalog/...
Tests: bounded presign invocation counts + per-item URL mapping; series rollup
populated on the native Latest path.

## AI-use disclosure
Implemented with AI assistance (Claude).

* perf(sections): gate personalized rails out of the shared cache; widen refresh lead

## Problem
1. The shared home-rail cache whitelisted custom_filter/genre sections by TYPE
   alone, but those route through fetchFiltered -> ParseQueryDefinition and can
   carry personalized (per-profile) rules/sorts (watched, favorited,
   in_watchlist, in_progress, last_watched; sorts progress/date_viewed/plays).
   Their membership is per-profile yet the cache key excludes userID/profileID,
   so a personalized rail built for one profile was served to others in the same
   access scope for up to 15m -- a cross-profile watchlist/watch-state leak.
2. The background-refresh lead was tuned so steady traffic is served a warm
   entry from a longer soft window.

## Solution
- Add QueryDefinition.IsPersonalized() (reusing the existing
  QueryFieldRequiresProfile/QuerySortRequiresProfile helpers).
  isCacheableSectionType now parses the section QueryDefinition and refuses to
  cache custom_filter/genre when personalized; non-personalized definitions stay
  cacheable. Seasonal/mood/trending build their definitions server-side and stay
  unconditionally cacheable.
- resolvedListRefreshLead 3m -> 10m (soft threshold builtAt+5min instead of
  builtAt+12min).

## Verification
go build/vet, go test ./internal/sections/... ./internal/catalog/... (-race).
Test: personalized custom_filter/genre not cacheable; non-personalized are.

## AI-use disclosure
Implemented with AI assistance (Claude).

* fix(sections,metadata): post-review fixes for shared cache and plugin chain staleness

Addresses three review findings on PR #292:

- sections: canonicalize section config JSON before hashing so configs
  differing only in whitespace/field order share a cache entry (native +
  jellycompat rail sharing). Added TestHashSectionConfigCanonicalizes.
- metadata: invalidate the resolved-chain cache on plugin lifecycle
  changes; the installation-enabled check already reads the invalidated
  plugin cache, but resolveChainCached could serve a stale provider chain
  for up to chainCacheTTL after a provider's availability changed.
- jellycompat: move ctx to the first parameter of presignCompatListItems
  for consistency with the other presign helpers.

Skipped the episode-image presign batching nitpick: the resolver already
dedupes+singleflights, so it is a Minor perf-only item not worth the
two-pass refactor risk in this pass.

* fix(sections,jellycompat): harden shared rail cache and Latest fast path per review

Addresses the eight findings from the deep review of this PR:

- Detach the blocking cold-miss rebuild from the singleflight leader's
  request context (context.WithoutCancel + the shared 30s build timeout)
  so one client disconnect no longer fails every collapsed waiter and
  leaves the entry uncached.
- Stop client-controlled values minting unbounded cache entries: the
  compat Latest fast path now always fetches a fixed 100-row budget and
  slices to the requested limit (one entry per scope+library instead of
  one per Limit value), and an unrecognized MaxOfficialRating string
  disqualifies the fast path instead of entering the global cache key.
- Add release_date to the sections item projection/scan so movies served
  via the Latest fast path keep PremiereDate (Jellyfin default-set field)
  in parity with the BrowseItems fallback.
- Fall back to per-item progress lookups when the batched leaf progress
  query fails, restoring one-item-at-a-time degradation instead of
  blanking played state for the whole page.
- Derive cache eligibility from a single source of truth: fetchSection
  and isCacheableSectionType now share the userAgnosticSectionFetcher
  table, whose no-userID/profileID signature makes a fetcher drop out of
  the cacheable set at compile time if it ever gains per-profile inputs.
- Decide Latest fast-path eligibility off the actual browse params the
  fallback would receive, so any filter later added to buildBrowseParams
  automatically disqualifies the cached path; share one
  compatDefaultBrowseLimit constant between both paths.
- Extract AccessFilter.WriteAccessScopeCacheKey as the shared, security-
  critical serializer for all access-scoped caches (resolved-list,
  editorial candidates, audiobook groups); the editorial key now captures
  ExcludedMediaTypes, which its loaders already applied in SQL.
- Strip leaked agent-transcript markup from the section-fetch plan doc.

go build ./..., go vet, gofmt clean; go test -race on
internal/sections, internal/catalog, internal/jellycompat passes
(TestBeginWebOperation* failures are the known pre-existing flakes).

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-07-05 02:01:30 -04:00
CoffeeKnyteandGitHub 652b5d1e9f perf(jellycompat): fast-paths for slow Jellyfin-compat queries (#247)
* 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.
2026-07-01 09:06:24 -04:00
QuickandGitHub a0fbe2cdda [codex] feat(jellycompat): use catalog search provider (#236)
* feat(jellycompat): use catalog search provider

* fix(jellycompat): route video search buckets to provider
2026-06-27 20:10:48 -04:00
QuickandGitHub cac435c4b9 Fix watch-state unwatch sync across user data and Jellyfin mappings (#179)
* Refine playback session handling and API responses

* fix(watchstate): harden completed-history visibility
2026-06-18 19:17:22 -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
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
cf4e080bf4 fix(jellycompat): Wholphin (jellyfin-sdk-kotlin) playback & genre compatibility (#100)
* fix(jellycompat): match MediaSourceId across UUID formats (compact vs dashed)

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

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

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

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

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

* fix(jellycompat): support Wholphin season item queries

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:05:33 -04:00
d3v1l1989andGitHub ecd85f9ca1 fix(jellycompat): enforce account-level library restrictions (#72)
* fix(jellycompat): enforce account-level library restrictions

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

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

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

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

Extract a folderListSource interface for the two FolderRepository
methods directContentService uses (matching the existing browseSource/
itemAccessSource pattern) so ListUserLibraries is unit-testable, and
add coverage for the restricted-allowlist path and the user-disabled
library filtering in the views list.
2026-06-07 17:19:40 -04:00
d3v1l1989andGitHub 3353456d46 fix(jellycompat): aggregate series watch state from episode progress (#70)
A series never has a user_watch_progress row of its own, so series items
always rendered Played:false with no UnplayedItemCount — every show looked
unwatched in compat clients' library views, and the IsPlayed browse filter
treated all series as unplayed. Roll episode progress up to series rows in
the browse/search/detail paths, mirroring applySeasonUserData and the
native API's rollup semantics (WatchedCount/UnplayedCount/InProgressCount).
Progress lookups are chunked at 500 ids (per-user stores may be
SQLite-backed) and the rollup is capped at 250 series per page so
max-limit browses of series-only libraries stay bounded.
2026-06-07 11:42:44 -04:00
Quick de89f0136c perf(jellycompat): compress and batch list image work 2026-06-05 17:47:36 -04:00
Quick 08a0704a11 fix(jellycompat): fill large browse pages 2026-06-05 16:33:40 -04:00
Quick 101aa8c429 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
Silo Server Migration c085b12fd1 Initial Silo migration 2026-05-22 23:26:56 -04:00