Commit Graph
25 Commits
Author SHA1 Message Date
2dedf3ff26 feat(metadata): user-triggered trailer refresh with weekly per-item cooldown (#531)
* feat(metadata): user-triggered trailer refresh with weekly per-item cooldown

Adds POST /api/v1/items/{id}/trailers/refresh so any viewer with access to a
movie or series can ask the server to fetch its remote trailers, bounded by a
one-week per-item cooldown enforced server-side.

The cooldown lives in a new nullable media_items.trailers_refresh_requested_at
column rather than the refresh debt queue, whose last_attempt_at evaporates on
success (MarkTargetSuccess deletes the row when the reason mask clears). The
gate is a single UPDATE that writes NOW() only when the stored timestamp is
NULL or older than the window, so concurrent viewers cannot both win it; a
losing caller reads the stored timestamp back to compute next_allowed_at.

MetadataService.RequestTrailersRefresh resolves the per-library trailer_kinds
allow-list first: a non-nil empty map means every containing library disabled
remote videos, which answers "disabled" without consuming the cooldown slot
(a nil map is allow-all and must not short-circuit). On winning the gate it
reuses startOnDemandMetadataRefresh, whose scheduled mode merges fill-empty,
so this non-admin trigger cannot clobber unlocked admin edits while found
videos still persist.

The handler checks item access before calling the service, so an unauthorized
caller can never burn an item's slot, and rejects non movie/series types since
those detail responses never carry videos. cooldown and disabled are expected
client-rendered states and answer 200; 429 is reserved for the per-user
in-memory limiter.

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

* fix(metadata): release trailer-refresh slot on failed refresh; resolve episode ids to 400

Three review findings on the viewer-facing trailer fetch.

The weekly per-item slot was consumed unconditionally on winning the gate,
but the refresh it started ran detached and only logged on failure — nothing
ever put the slot back. A brief TMDb outage therefore answered 202 queued,
failed 30s later, and then answered cooldown for seven days over work that
never happened. The repository gains an equality-guarded release
(trailers_refresh_requested_at = NULL only while it still equals the
timestamp this request wrote, so a later claim is never clobbered), and
TryClaimTrailersRefresh now RETURNINGs the timestamp it stored so a winner
holds the key to its own slot. startOnDemandMetadataRefresh splits into a
claim step and runOnDemandMetadataRefresh, which takes an optional failure
hook; only the trailer path passes one, so the existing callers are
unchanged. A timeout counts as failure. A refresh that succeeds but finds
nothing still keeps the slot — that semantics was chosen deliberately.

The in-process dedup claim (shared with the item-detail view's stale nudge)
silently dropped the start while the slot had already been consumed, so the
caller was told queued for a refresh that never began. It is now taken
before the durable slot: a request landing while an equivalent refresh is
already in flight reports queued without consuming the slot, which is both
honest and retryable if that refresh fails.

Real episode and season content IDs answered 404 rather than the contracted
400, because neither is a media_items row and GetByID queries media_items
alone. The handler now falls through to the same season/episode lookups
HandleTranslateOnView uses, authorizing through the parent series, so a
genuine episode ID reports unsupported-type and only unknown content 404s.
The type-check test no longer fabricates a MediaItem{Type: "episode"} row
that production never writes; it covers the types that do exist as
media_items rows, with the episode and season paths tested through the
lookups.

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

* fix(metadata): address PR review on the trailer refresh action

Six review findings on the viewer-facing trailer fetch, all verified against
the current code before changing anything.

Durable claim no longer rides the request context. A cancellation landing
after Postgres commits the gate UPDATE but before pgx returns would consume
the item's weekly slot with no refresh started and nothing holding the
timestamp needed to release it. The claim now runs on
context.WithoutCancel with its own deadline, mirroring the release.

The cooldown gate retries once when the follow-up read finds the slot free.
Classification spans two statements, so a concurrent failure-release can
land between them; the old code reported that as a cooldown with no
next_allowed_at while the slot was in fact free. A NULL read now retries the
claim, and the doubly-lost case answers "queued" (an equivalent refresh is
running) rather than an undateable cooldown.

A failed item_videos write now releases the slot. mergeAndPersist logs and
continues when the write fails, so the refresh reported success and the
viewer was locked out for a week having stored nothing. A context-scoped
observer, installed only by this action, surfaces that failure to the
existing release hook.

Winning the gate also records durable refresh debt, so a restart that kills
the detached goroutine leaves work the refresh worker picks up instead of a
consumed slot and no fetch. Uses a new reason bit rather than the generic
failure reason: nothing is wrong with the item, so it must not sit in the
failure band ahead of real debt or count as a failure in operator metrics.

Any library lookup failure now degrades the video-kind scope to unknown. An
item in two libraries where one resolved with trailers off and the other
could not be read reported "disabled" — a guess made on behalf of a library
that might be the one enabling trailers. A library that is genuinely gone is
still skipped.

Adds GET /api/v1/items/trailers/capability, following the existing
per-subsystem probe convention. The action route is registered conditionally,
so "this build has the feature" is not the same question as "this deployment
serves it", and a 404 on the POST is indistinguishable from a missing item.
The probe is registered unconditionally and answers refresh:false when
unwired.

Not changed: content-ID canonicalization mid-refresh stranding the cooldown
on the old row. The re-anchor path is manual-refresh only and this action
runs in scheduled mode, so only local-skeleton promotion can fire, and the
rename carries the timestamp and the debt row to the new id along with
everything else.

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

* style(web): format overlays schema after merging main

The line came in over-length from main's card_overlays merge and the Web
CI format check runs prettier across all of src, not just changed files.

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

* fix(metadata): address second review round — recovery-debt lease, locked-videos preflight, shared limiter

The restart mitigation added in the first round reintroduced two of the
problems it was closing, and the reviewer was right to push again.

Lease the recovery debt behind the fast path. The row was enqueued due
now, so refresh_metadata could claim it while the detached goroutine was
still running the same refresh — RefreshScheduledTarget does not consult
the in-process claim, so both would fetch the item at once. It is now due
5 minutes out, comfortably past the 2-minute on-demand timeout, and the
goroutine settles the row on success so it fires only when the fast path
really did not finish. Settling clears just the trailers-requested bit,
keeping any real debt the item still carries.

Release the cooldown after a failed recovery. A recovery runs in a worker
that never saw the claim, so a failure left the viewer blocked for the
week having stored nothing. RefreshScheduledTarget now adopts the claim
when the debt row carries the trailers-requested reason, reading the
stored timestamp so the release stays equality-guarded, and hands the
slot back on the same failures the fast path's hook covers — including a
videos write that failed and was only logged.

Preflight the videos lock. locked_fields containing FieldVideos makes
mergeAndPersist skip the item_videos write, so the refresh "succeeded"
and kept the cooldown while never being able to save trailers. It now
answers disabled before consuming the claim; reusing that status rather
than adding one is deliberate, since clients treat an unknown status as a
dead end and "trailers cannot be fetched for this item" is what disabled
already means to a viewer.

Use the shared limiter. A private MemoryLimiter gave every instance an
independent per-user allowance on Redis deployments, and the per-item
cooldown cannot compensate — it bounds one item, while this budget bounds
how many distinct items a user can start refreshes for. The action now
takes the middleware's configured limiter, with namespaced keys, and
falls back to a private one only when rate limiting is off.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 20:53:21 -04:00
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
f377be9d6e feat(catalog): include per-episode overlay summaries on season episode listings (#364)
* feat(catalog): include per-episode overlay summaries on season episode listings

Episode cards already render user-configurable overlay badges everywhere
except the series/season detail pages: the season-episodes endpoint never
included overlay_summary, so SeasonEpisodeGrid and EpisodeRow had nothing
to render.

Server: add overlay_summary (omitempty) to episodeResponse and populate it
in buildEpisodeResponses via overlays.BuildSummary over the episode's
already-loaded media files, access-filtered with FilterMediaFilesByAccess
to match the browse/sections paths. No extra queries; the files were
already batch-fetched for the files[] payload. Additive-only API change.

Web: extend EpisodeListItem with overlay_summary, add
overlayDataFromEpisodeListItem (shared extract helper), and render
CardOverlays (variant="wide") on the episode stills in SeasonEpisodeGrid
and EpisodeRow using useOverlayPrefs, matching the ContinueWatchingCard
pattern. The hook already honors the admin kill switch and per-user prefs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(catalog): apply library restrictions in FilterMediaFilesByAccess without a quality ceiling

FilterMediaFilesByAccess short-circuited whenever MaxPlaybackQuality was
empty, skipping the AllowedLibraryIDs/DisabledLibraryIDs checks that
FileAllowedByAccess enforces. For viewers with library restrictions but
no quality ceiling, callers (episode listings, browse/section overlay
summaries, item detail versions) received files from restricted
libraries. Short-circuit only when no access criteria are set at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:37:34 -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
02e62767a1 feat(watchsync): sync watchlists with Trakt/Simkl/MDBList (#227)
* feat(watchsync): sync watchlists with Trakt/Simkl/MDBList

Extend the watch-providers feature to sync a user's watchlist, generalizing
the existing favorites pipeline rather than duplicating it.

What changed
- Generalize the favorites sync into one ListKind-parameterized pipeline
  (internal/watchsync/lists.go) driving both favorites and watchlist; the
  per-favorites service methods are replaced by kind-generic ones. The shadow
  table watch_provider_favorite_items becomes watch_provider_list_items with a
  list_kind discriminator.
- Providers: Trakt gains watchlist sync (/sync/watchlist, distinct from
  favorites); Simkl gains plan-to-watch sync; MDBList is re-mapped from
  favorites to watchlist (its only list is a watchlist) — its capabilities now
  report import_favorites=false / import_watchlist=true, and the migration
  re-binds existing MDBList connections.
- Auto-remove watched items from the watchlist: a standalone, default-on
  profile preference (user_profiles.remove_watched_from_watchlist) removes a
  movie when watched and a series once every episode is watched. Implemented as
  watchstate.CompletionObserver (internal/watchlist.Maintainer), wired into the
  manual mark-watched, playback-stop, and jellycompat mark-played paths.
- Optional MDBList sort-order mirroring: an opt-in, capability-gated toggle
  mirrors MDBList's watchlist order into Silo via user_watchlist.sort_index;
  ListWatchlist orders by sort_index then added_at, so both /api/v1/watchlist
  and the catalog watchlist view inherit it.
- Real-time + scheduled: local add/remove pushes to connected providers
  immediately (removals gated by the opt-in removals toggle); the hourly job is
  the inbound/import + retry/reconcile path.
- Web: watch-provider settings gain watchlist import/export/removals and
  "mirror watchlist order" toggles plus watchlist sync stats.

Why
- The favorites and watchlist pipelines are ~90% identical; generalizing keeps
  one code path (per CLAUDE.md's anti-duplication guidance) instead of cloning.

API/compat
- All new fields on ConnectionStatus/Capabilities/ConnectionUpdate/SyncRun and
  the web types are additive (Silo v1 additive-only rule). No existing field is
  renamed, removed, or retyped.

Risks / follow-up
- MDBList capability flip is intentional and client-visible: silo-android /
  silo-apple may need to surface MDBList under the watchlist (not favorites) UI.
- MDBList existing users: their MDBList list previously mirrored Silo favorites
  and now mirrors Silo watchlist; the first post-migration sync is a union
  (removals default off), so nothing is destructively purged.
- Order mirroring reflects the order MDBList returns from /watchlist/items
  (couldn't confirm against their docs — Cloudflare-blocked); if it ever
  diverges from the UI sort, a sort param is the small follow-up.

Tests: new maintainer (auto-remove) and watchlist-order unit tests; provider +
service tests updated. go build, go test (affected pkgs), migrate-validate,
verify-local-paths, web prettier/eslint/tsc all pass.

AI-use disclosure: implemented with Claude Code (Claude Opus 4.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(watchsync): update list shadow table references

* fix(watchsync): address review — retry/progress + error propagation

Addresses CodeRabbit review on #227:
- maintainer: propagate transient catalog lookup errors instead of silently
  treating every items.GetByID failure as "maybe an episode".
- exportList: mark every queued item not confirmed sent (not_found, failed, or
  omitted) so the pending loop always advances; the next run's upsert clears the
  error and re-attempts, so transient failures still retry.
- removePendingListItems + realtime removal: treat Sent and NotFound as
  reconciled; leave true failures pending (no last_error, which would strand
  them from the removal query) so the scheduled run retries, using in-memory
  dedupe to terminate the loop.
- exportLocalListItems: send the normalized items (with computed
  ProviderItemKey), not the original event slice.
- UpdateConnection: clear mirrored watchlist order before persisting the disable
  and propagate failures, so a failed clear can't report "disabled" while
  sort_index ordering is still active.
- web: include favorite + watchlist removal counts in the exported "sent" total.
- test: align serviceFakeRepo list-state with Postgres (clear last_error on
  successful transitions); add maintainer error-propagation test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:05:53 -04:00
QuickandClaude Opus 4.8 ed6c084c68 feat(search): gate index events by active provider and harden rebuild reconcile
Completes the search-provider-interface wiring that the catalog hardening
commits already call into:

- Skip the transactional search-index-event write path when Meilisearch is
  not the active provider (ItemRepository.WithActiveSearchProvider /
  SearchIndexEventRepository.disabledByActiveProvider).
- Dead-letter catalog_search_index_events after 10 attempts instead of
  retrying forever.
- Track the rebuild high-water mark (MaxEventID / MarkProcessedThrough) and
  persist last_processed_event_id in UpdateStateAfterRebuild so a rebuild
  reconciles events enqueued during the rebuild.
- Validate (read-only) the embedding lock when embedding a search query
  instead of establishing/mutating it.
- Surface total_exact on the legacy /items browse response.
- Wire the active catalog search provider into the scanner and item repo at
  startup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 08:34:04 -04:00
Quick 40329f616d perf(search): speed up catalog query results 2026-06-25 20:46:08 -04:00
Quick 6ca427096b Add catalog search provider support 2026-06-25 16:20:14 -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
QuickandGitHub 7e532ea982 [codex] fix season page loading (#178)
* fix(catalog): speed up season page loads

* fix: address season loading review feedback
2026-06-18 14:18:35 -04:00
f207b87ff7 fix(catalog): return user_data for audiobooks on /watch/{id} (#144)
HandleGetWatchDetail populated user_data (resume position) only for
movie/episode/ebook, so an audiobook fetched via /watch returned a null
position_seconds even though progress is recorded the same way. The
/catalog/items/{id} path already includes audiobooks; this brings the watch
endpoint in line so both expose the saved position.

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 20:32:48 -04:00
c4cbcddeae feat(manga): manga library type — series grouping, reading loop, AniList/MangaDex metadata + status badge (#138)
* docs: design spec for manga library type (host sub-project)

Forks the ebooks library type into a 'manga' type: series detected from the
folder tree as a first-class type='manga' item, .cbz/.cbr chapters stay
readable ebook items linked via a new manga_chapters table, browse shows series
cards, enrichment targets the series item at content level 'manga'. Hands off to
a follow-on plugin spec for the manga metadata source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for manga library type (host)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): manga filename index/volume parser

* feat(scanner): manga series-name-from-folder detection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(plan): align manga DB/scanner tasks to scanner pure-planner pattern (no test-DB)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(scanner): manga parser corpus regression

Add TestParseMangaIndexCorpus — 36 real-world scanlation filenames
covering bare chapter, decimal chapter, v/vol-prefix volume, and
c/ch-prefix chapter patterns; asserts <5% miss rate.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(db): manga_chapters link table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): manga_chapters repository + pure chapter-write mapping

Adds mangaChapterWrite (pure, unit-tested), upsertMangaChapter, and
listMangaChapters following the ebook/audiobook thin-SQL pattern.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): recognize manga library type

Add isMangaLibraryType helper (unexported, matching the style of
isEbookLibraryType / isAudiobookLibraryType) with a corresponding
TestIsMangaLibraryType unit test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(api): manga library content level

Map library type "manga" to content level ["manga"] in
metadataContentLevelsForLibraryType so that seedDefaultChain seeds a
manga-level metadata provider chain when a manga library is created.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(scanner): route manga libraries to a manga scan path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): group manga chapters under a manga series item

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): give manga series item a library membership so it browses

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): browse manga libraries as series

Accept "manga" as a valid media_scope so a manga library browses only its
type='manga' series items; the per-chapter type='ebook' items are naturally
excluded because MediaScopeItemTypes("manga") expands to {"manga"}. Add the
manga default library sections (scoped to media_scope='manga') so the library
feed shows series cards. Refresh the two media_scope validation error messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): manga series detail lists chapters

For a type='manga' item, attach its chapters to the detail response via a new
MangaDetailExtension. fetchMangaChapters joins manga_chapters to media_items on
the chapter content ID, scopes to the series, and orders by chapter_index
(NULLS LAST) then sort_title — matching the scanner's chapter ordering.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga detail types + library browse scoping

Add MangaChapter/MangaDetailExtension TS types mirroring the host
catalog structs, wire manga? onto ItemDetail, and admit "manga" as a
QueryDefinition.media_scope. Scope manga libraries to media_scope=manga
in browse (host expands it to type=manga series items) while reusing the
ebook sort universe via getLibrarySortRelevanceScope. Add isMangaLibraryType.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga series detail with volume-grouped chapter list

Add MangaContent detail view: a DetailHero series header plus a chapter
list grouped by volume. groupMangaChapters (pure, unit-tested) buckets
chapters by their volume token, orders chapters within a group by
chapter_index (nulls last) and orders groups by their minimum index;
loose (volume-less) chapters collapse into a trailing "Chapters" group.
Each chapter links to the existing ebook reader by content_id alone
(file_id is optional — the reader resolves the file server-side), reusing
buildMediaPlayHref. Admit "manga" into ItemDetail.type and wire the
detail switch. Continue-reading is deferred (needs per-chapter progress
fan-out / a last-read timestamp not in the current payload).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): handle manga in playable-type + collection filter-scope unions

Adding "manga" to the shared ItemDetail["type"] and
QueryDefinition["media_scope"] unions leaked into consumers with narrower
local types, breaking the production tsc build. Fixes:

- mediaNavigation: admit "manga" into PlayableMediaType. Manga series are
  not directly playable (you open the detail page and read a chapter,
  itself an ebook item), so buildMediaPlayHref falls through to the item
  href for them, like series/season.
- FilterRuleEditor: add "manga" to FilterRuleMediaScope and relabel
  "watched" -> "Read" for manga as well as ebook (manga is read).
- CollectionGuidedRulesEditor: add "manga" to GuidedFormState.mediaScope,
  a "Manga" media-type option, ebook-like "Read Status" labels, and map
  manga -> ebook sort-relevance scope (manga has no dedicated sort scope).
- CatalogFilterBar (cascading leak surfaced after the above): add a
  "Manga" scope option and map manga -> ebook sort-relevance scope in both
  scope handlers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): offer manga as a library type in the create dialog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(scanner): strip scene-release junk from manga series names

Add cleanMangaSeriesName which repeatedly strips trailing parenthetical
groups (year, year-range, Digital, release-group tags) then trims any
dangling dash, so folder names like "404 Demons (Digital) (Oak)" resolve
to "404 Demons". Wire it into mangaSeriesFromPath so both the series
title and the mangaSeriesGroupKey identity key use the cleaned value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): flat volume/chapter manga list; nest only multi-chapter volumes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): parse manga index after stripping series-name prefix

Numbers inside a series title (e.g. "404 Demons", "365 Days to the
Wedding") were wrongly grabbed as the chapter number because
parseMangaIndex matched the first bare number in the full filename.
mangaIndexForFile now strips the series-name prefix before delegating
to parseMangaIndex, so only the number that follows the title is used.
reconcileMangaFile in manga_scan.go is updated to call mangaIndexForFile
instead of parseMangaIndex directly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): stop missing-file reconcile from deleting manga series items

Manga series items are file-less virtual parents; the shared
ReconcileFolderMembership swept them every scan because they have no
media_file. Exclude type='manga' from file-presence membership reconciliation,
and add a manga-scan step that deletes only series with zero remaining chapters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ebooks): exclude manga chapters from individual ebook enrichment

Manga chapters are type='ebook' parts of a series; the ebook enrichment sweep
was searching each one against book sources (Gutenberg/Anna's/etc.) and failing
in a pointless storm. Exclude items with a manga_chapters link; series-level
enrichment is handled separately.

* docs: design spec for manga metadata plugin + series enrichment (sub-project 2)

New silo-plugin-manga-metadata (AniList, high-confidence matching) + a host
MangaEnricher for type='manga' series; default-enabled metadata source for manga
libraries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for manga metadata plugin + series enrichment

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(db): manga_enrichment_state table

Mirrors ebook_enrichment_state: dedicated failure counter for the manga
enrichment sweep so it does not contend with media_items.refresh_failures.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(manga): series enricher (claims type='manga', resolves manga chain)

* feat(manga): sync_manga_metadata task + enricher wiring

* feat(catalog): expose manga chapter/volume counts in browse

Add manga_chapter_count and manga_volume_count to browse cards so the
frontend can render a Vols N / Ch N chip on manga series. The counts come
from two index-backed correlated subqueries over manga_chapters in the
browse SELECT (mangaCountColumns), scanned positionally before added_at and
nilled out for non-manga rows. Threaded through models.MediaItem and exposed
on the itemListResponse JSON card.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sections): scope manga home recent sections to type=manga series

A manga library mixes type='manga' series with type='ebook' chapters, so
the auto-generated home 'Recently Added/Released in <Library>' rows surfaced
the junk chapter filenames. Add GeneratedHomeLibraryRecentConfigScoped which
emits the modern QueryDefinition shape (library_ids + media_scope) so a manga
library's generated home rows filter to type='manga' only. Other library
types are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(catalog): exclude manga chapters from browse/section/search surfaces

Manga CHAPTER items (type='ebook' rows linked into a type='manga' series
via manga_chapters) were leaking into catalog browse, section resolution,
and search as standalone items showing junk filenames. They are internal
sub-units of the series and only the series should appear.

There is no single shared item-listing chokepoint: browse, the query/preview
executor, and search each build their own WHERE. Add a shared, index-backed
anti-join predicate (manga_chapters.chapter_content_id is the PK) via
mangaChapterExclusionWhere and wire it into all three builders. By-id fetch
paths that legitimately resolve chapters (ebook reader, continue-reading,
series detail chapter list) use separate queries and are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(scanner): use #NN as the manga volume for Vol.YYYY #NN releases

mangaVolYearIssue early-return was returning the year token (e.g. "Vol.2003")
as the volume label, which the frontend couldn't prettify to "Volume N".
Now returns "v<issue>" (e.g. "v04") so the existing frontend regex ^v?(\d+)$
renders it as "Volume 4" correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): manga count chip on posters

Add an optional manga_chapter_count / manga_volume_count to the browse
item type and render a top-right "Vols N" / "Ch N" chip on ItemCard,
strictly gated on type==='manga'. The label prefers "Vols" when the
volume count dominates, "Ch" otherwise; the chip is hidden when the
chapter count is missing or non-positive. No other card type renders it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): manga reader back returns to series (no loop)

The ebook reader's back action defaulted to the chapter's own item
detail (/item/<chapter>), whose back returned to the reader — an
infinite loop for manga chapters. The reader now honors an explicit
backTo search param when present, navigating there instead. Absent for
normal ebooks, so their back behavior is unchanged. Only manga chapter
rows pass backTo, keeping the fix manga-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga chapter row actions (read/mark-read/download)

Each manga chapter/volume row now offers Read (the existing reader link,
now carrying a backTo to the series), Mark-read (the shared watched-state
mutation per chapter content_id), and Download (lazily fetches the
chapter's file versions on demand and opens the shared
DownloadVersionPicker, gated on user.download_allowed). The
volume-unit / loose-chapter / section structure from buildMangaList is
unchanged. Scoped to MangaContent only; EbookContent is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): validate reader backTo param is a safe in-app relative path

Prevents open-redirect / javascript:-URI XSS from a crafted ?backTo= URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(catalog): include per-chapter read state in manga detail

Manga chapters are ebook items, so a chapter is "read" when the viewer's
ebook_reader_progress row crosses the finished threshold. fetchMangaChapters
now LEFT JOINs that table scoped to the AccessFilter's user_id/profile_id and
exposes a per-chapter Read bool on MangaChapter, threaded through
buildMangaExtension. The detail payload previously carried no read state, so
the row toggle always started unread.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): manga rows reflect read state on load

MangaChapter now carries an optional read flag from the detail payload, and
MangaRow seeds its mark-read toggle from chapter.read instead of always
starting unread. The optimistic toggle + shared watched mutation are
unchanged; only the initial value is seeded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sections): exclude manga chapters from recently-added/released/random + other library-listing sections

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sections): manga recently-added/released cards show the latest volume's cover

* fix(manga): keep enrichment honest about no-match vs enriched, batch 50->200

- sweep stats now separate enriched / no_match / failed: a stamped no-match
  was counted (and logged) as an enrichment, which masked a collapse of the
  real match rate during the backfill
- batch size 50 -> 200 (SILO_MANGA_ENRICH_BATCH overrides): with the plugin
  serving GetMetadata from its search cache an item costs one rate-limited
  AniList request, so a sweep still fits the 5-minute task interval

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

* fix(manga): size enrich batch to the 5-minute interval at AniList's real budget

140 items x ~2.1s/request fits the interval; an overlong sweep makes the task
manager drop the next trigger and the effective rate falls below the AniList
budget.

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

* fix(catalog): manga count chip data missing from library browse

manga_chapter_count/manga_volume_count were only added to BrowseRepository,
but /library/{id}?tab=library flows through previewQuerySource ->
QueryExecutor.PreviewPage, which selects qualifiedListItemColumns and scans
with scanItems - so manga cards never carried the counts and the Vols/Ch
poster chip stayed hidden.

Append mangaCountColumns to the preview-page SELECT and scan them via a new
scanItemsWithMangaCounts (nil for non-manga rows, mirroring scanBrowseItems).
Extract listItemScanDests so the three scan variants share one destination
list instead of duplicating the 48-column scan.

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

* feat(web): manga chip reads 'X Volumes · X Chapters', menu verbs say Read

- chip: show distinct-volume and loose-chapter counts side by side instead
  of the single 'Vols N'/'Ch N' heuristic; mangaCountColumns now counts
  DISTINCT volume tokens (rows sharing a volume are one volume) and only
  un-volumed rows as chapters
- watched-state labels: type='manga' fell through to the video default, so
  the card dot menu and detail page said 'Mark Watched' - manga now uses
  the ebook reading verbs (Mark Read / Mark Unread, 'Marked as read' toast)
- format MangaContent.test.tsx (pre-existing prettier miss)

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

* feat(manga): backdrop enrichment - banner hero art + backdrop-only backfill

- cache remote backdrops like posters (cacheRemoteImages generalizes the
  poster-only path; failures keep the provider URL, which still renders)
- claim arm for enriched items missing a backdrop: fetched by stored
  provider ID (search skipped - no rate spend, no re-match risk) and only
  the backdrop is written; stamping after the attempt keeps banner-less
  series from being re-claimed every sweep
- backfill = one-time SQL clearing last_refreshed for poster-set/
  backdrop-empty manga

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

* feat(manga): reading-loop UX - continue CTA, next chapter, series-aware cards, file details

Fixes the four high-priority findings from the manga UX review plus a
file-inspector request:

- H1: series hero gets a Continue / Start Reading / Read Again CTA
  targeting the first unread chapter (firstUnreadChapter over the ordered
  list), plus an overflow menu (View Details, admin Refresh Metadata)
- H2: the reader resolves its owning manga series (chapter detail now
  carries series_id/series_title) and offers next-chapter navigation: a
  header next button and an end-of-book floating CTA at >=99.5% progress;
  back defaults to the series even without a backTo param
- H3: chapter rows show a persistent read check + muted title, and the
  mark-read mutation carries series_id so the series detail cache
  invalidates (read states no longer revert on revisit)
- H4: continue-reading cards for manga chapters present the series:
  sections payload resolves chapter->series linkage, the card heading/image
  link to the series, and meta lines launch the reader
- View Details: manga series menus (card dot menu + detail overflow) open
  a file inspector showing folder paths and per-chapter file names/sizes
  via GET /catalog/items/{id}/manga-files; paths are stripped for viewers
  without file-path visibility (item-versions policy)

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

* feat(manga): UX mediums - richer detail page, smarter list, manga sort scope

Second batch from the manga UX review (M1-M7):

- M1: multi-chapter volume sections are collapsible (fully read sections
  start collapsed) with sticky headers, and long series get a 'Jump to
  <next unread>' anchor above the list
- M2: the series hero shows the author line (HeroCrewLine learns Author
  credits with person links; DetailHero now renders crewLine and genre
  chips independently) and Volumes/Chapters badges
- M3: browse-card count chip abbreviates to '12 Vol - 3 Ch' so it fits
  narrow cards without occluding covers
- M4: manga gets its own sort scope: Duration/Bitrate (meaningless for
  file-less series rows) disappear, reading labels (Date Read / Reads)
  apply, Author stays
- M5: global search labels manga results 'Manga' instead of the raw type
- M6: chapters carry the viewer's reading fraction; part-read rows show an
  inline progress bar + percent
- M7: chapter rows show the extracted cover thumbnail (presigned
  poster_url on the chapters payload) instead of a generic icon

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

* fix(manga): UX lows - volume token dedupe, comic reader chrome, empty-state hint

- buildMangaList buckets volumes by canonical numeric token so mixed
  release naming (v01 + 1) yields one Volume 1 instead of duplicates
- cbz/cbr readers start with the side panel closed and hide prose-only
  chrome (reading ruler, TTS, typography/font controls, hyphenation,
  writing mode) while keeping comic-relevant settings (theme, brightness,
  margin, right-to-left, spread, flow)
- manga empty state mentions chapters appear after the library scan

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

* feat(manga): publication status badge via new SDK status field

- vendor the unpublished plugin SDK (adds MetadataItem.status) under
  internal/compat/ with a relative go.mod replace, following the
  zishang520-webtransport-go convention; swap to the published module
  before the upstream PR
- map plugin status into MetadataResult.ShowStatus, persist it during
  manga enrichment, and show it as the hero status badge (show_status was
  already on the detail payload and MetadataBadges)

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

* feat(manga): generalize backdrop pass to secondary fields (backdrop + status)

The backdrop-only claim arm becomes a secondary-fields pass: enriched items
missing a backdrop and/or publication status are claimed, fetched by stored
provider ID, and only the missing secondary fields are written. Lets the
new status field backfill across the already-enriched library instead of
applying only to future enrichments.

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

* fix(metadata): merge ShowStatus through MergeMetadata/MergeGlobalMetadata

The new MetadataResult.ShowStatus never reached the accumulated result the
manga enricher persists from - the field-by-field merges didn't know it, so
the status backfill pass obtained nothing. Regression-tested on both paths.

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

* fix(manga): keep scanner identity IDs out of the metadata flow

filterMangaProviderIDs passed the scanner's manga_series identity row
through, so the search-skip-when-already-matched guard saw provider IDs on
every item and never searched: unmatched items went straight to a by-ID
fetch with no usable ID and were stamped as terminal no-match without a
single provider request (and the MangaDex fallback was never consulted).

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

* chore: gitignore docker-compose.override.yml (local deployment override)

The override unpublishes the bundled redis/postgres host ports
(ports: !override []). It is a per-deployment, local-only file: ignoring it
keeps a rebase from main and git clean -fd from disturbing it, and keeps it
out of any PR. Its accidental absence once exposed Redis to the internet.

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

* fix(manga): code-review fixes — no-match guard, sort comparator, volume-count consistency

- enrichWithProviders: set accumulator.HasMetadata after a provider result
  merges (MergeMetadata doesn't propagate it). Without this, a confident
  match carrying only genres/authors/status/year but no cover and no overview
  failed the no-match check and was discarded + terminally stamped.
- byChapterIndex: both un-indexed chapters yield POSITIVE_INFINITY, so the
  subtraction was Infinity-Infinity=NaN (Array.sort treats NaN as 0, leaving
  order undefined). Compare explicitly for a stable order.
- MangaContent volume/chapter badges: derive counts from the rendered
  buildMangaList entries (which canonicalize v01 ≡ 1) instead of raw distinct
  volume tokens, so the badge can no longer say '2 Volumes' over one row.

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

* docs(manga): clarify the enrichment claim's secondary arm is admin-reset-only

The secondary arm (poster present, backdrop/status missing) requires
last_refreshed IS NULL, so it is only reachable when an operator resets
last_refreshed to backfill a newly-added field — not an automatic periodic
re-check (which would re-fetch banner-less series every sweep). Documents the
intent so it does not read as dead code.

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

* fix(manga): collapse continue-reading chapters per series; batch provider-id lookup

- Continue Reading now collapses multiple in-progress chapters of the same
  manga into one card (most recently read kept), mirroring the episode→series
  collapse. The reading section resolves chapter→series linkage into itemMeta
  (applyMangaChapterSeriesMeta) and runs the shared
  collapseContinueWatchingSeriesCandidates, which the reading path previously
  skipped.
- claimBatch resolves provider IDs for the whole batch in one query via the
  new ProviderIDRepository.GetByContentIDs (content_id = ANY), replacing the
  per-item GetByContentID N+1.

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

* feat(web): manga publication-status chip on browse cards + more legible chips

- Color-coded publication status pill (Ongoing/Completed/Hiatus/Cancelled/
  Upcoming) in the manga card's top-left corner, mirroring the vol/chapter
  count chip top-right. Strictly manga-gated; show_status was already on the
  browse payload.
- New .glass-chip (78% surface vs glass-subtle's 40%) for the manga count +
  status pills so the labels stay legible over busy cover art.

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

* build(manga): depend on published silo-plugin-sdk v0.7.0

Replace the vendored internal/compat/silo-plugin-sdk copy with a normal
dependency on the published SDK module at v0.7.0, which adds
MetadataItem.status (publication/airing status) consumed by the manga
status badge at internal/metadata/plugin_provider.go.

- go.mod: pin v0.7.0, drop the local-path replace directive
- remove the vendored internal/compat/silo-plugin-sdk tree
- Dockerfile: drop the vendored-SDK COPY
- strip the manga design docs/plans from docs/superpowers (internal)

Requires Silo-Server/silo-plugin-sdk#4 merged and tagged v0.7.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manga): exclude chapters from the matcher's unmatched-item lister

Manga chapters are type='ebook' items that stay status='pending' by
design - provider metadata lives on the type='manga' series item. The
scan-final RetryUnmatchedItemsByFolderAndPathPrefix listed all of them
and ran a rate-limited ebook-plugin search per chapter: 31,564 chapters
x ~1s = 8h46m appended to a 2-minute manga library scan (observed
live), every one a guaranteed no-match. Earlier runs never survived to
completion, so the library's last_scanned_at stayed NULL forever.

Add the same manga_chapters NOT EXISTS guard the ebook enricher's
claim query already uses. Verified live: the same library now scans in
27s with retried_items=0.

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

* fix(scanner): never probe-repair ebook/comic files (ebook+manga detail-page killer)

NeedsCriticalProbeRepair was always true for BaseType 'ebook' files (epub, pdf,
cbz, cbr — incl. manga chapters): buildEbookMediaFile leaves ProbeUpdatedAt nil
and they have no audio/video, so probeEnsurer.Ensure spawned ffprobe per file on
every detail/watch load and never converged (ffprobe errors on zip/rar, result
never persisted). Short-circuit probe-repair for ebook base type — they're read
directly and never use the transcode/playback probe pipeline.

SHARED fix: benefits both the ebooks and manga library types.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf+fix(ebooks): parallelize detail extension + preserve finished read-state

- buildEbookExtension ran its 3 related-content queries (series, also-by-author,
  similar) sequentially; run them concurrently like buildAudiobookExtension so
  ebook detail latency is the slowest query, not their sum.
- PGEbookReaderProgressStore.Upsert did an unconditional SET progress=EXCLUDED;
  a routine autosave (e.g. reopening a finished book) could drop it below the
  0.9 finished threshold and silently un-mark it read (and clear the manga
  chapter checkmark, which rides on the same row). Guard: once finished,
  progress only moves on an explicit unread (row delete); below threshold it
  tracks freely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(manga): batch chapter presign, index volume counts, quiet scan log

- fetchMangaChapters presigned each chapter poster individually; a long-running
  series has hundreds of chapters. Batch them in one PresignImageURLs call, and
  add the missing rows.Err() check (was silently returning partial lists).
- The browse manga count chip's count(DISTINCT volume) subquery wasn't covered
  by manga_chapters_series (series_content_id, chapter_index); add
  idx_manga_chapters_series_volume (series_content_id, volume) so both count
  subqueries are index-only.
- Downgrade the per-chapter "manga scan: indexed" log from Info to Debug (one
  line per .cbz; the 500-file progress log already covers operator visibility).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(manga): address PR #138 code-review findings

Folds PR #142 into the manga branch (already done via fast-forward) and
remediates the issues surfaced in the #138 code review.

Correctness:
- Preserve the scanner's manga_series identity anchor through enrichment.
  ReplaceByContentID's DELETE was unconditional, so the first successful
  enrichment wiped the manga_series provider-id row the scanner relies on
  for idempotency, causing duplicate series + metadata loss on the next
  scan. excludedProviderIDs now also means "not deleted", and the DELETE
  preserves those rows. (internal/catalog/provider_id_repo.go)
- Fall back to the series cover when the latest chapter has no poster.
  Poster columns default to '' (not NULL), so the manga series-card poster
  override blanked cards via a plain COALESCE; wrap operands in NULLIF.
  (internal/sections/fetcher.go)
- Keep backTo a real query param on reader links when libraryId is absent.
  It was string-concatenated with '&', producing a malformed URL on
  deep-links; route it through the query helper instead.
  (web/src/lib/mediaNavigation.ts, EbookReader.tsx, MangaContent.tsx)

Quality:
- Hide manga chapters from favorites/watchlist browse, matching the
  exclusion enforced on every other listing surface.
  (internal/catalog/favorites_browse.go)
- Centralize the manga chapter exclusion predicate into a single exported
  catalog.MangaChapterExclusionWhere, removing four duplicated copies.
  (catalog, sections, ebooks)
- Skip the two manga count subqueries on browse scopes that cannot contain
  manga (non-manga type filters), substituting NULL placeholders.
  (internal/catalog/browse.go)
- Normalize provider publication status (AniList/MangaDex/SDK variants)
  into a stable label set so show_status carries one manga value-domain.
  (internal/manga/enrichment.go)

Adds unit tests for the poster NULLIF contract, browse gating, and status
normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: regenerate go.sum after rebase onto main

Drops stale silo-plugin-sdk v0.6.0 and other leftover hashes from the
intermediate rebased states; go.mod is now on the published v0.7.0 tag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(scanner): adapt manga scan to ebookFileShouldSkip 3-value signature

main changed ebookFileShouldSkip to also return the existing content ID;
the manga scan path only needs the unchanged flag, so discard the new
return. Resolves a silent semantic conflict from the rebase onto main.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Silo Server Developer <warmasterx555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 20:13:10 -04:00
e084cdd1d6 Add unified literary works for ebooks and audiobooks (#107)
* docs: add literary works design and plan

* feat(literary): add work link schema

* feat(literary): add work domain primitives

* feat(literary): persist work links

* feat(literary): score work matches

* feat(catalog): include literary work summary on item detail

* feat(literary): expose work detail API

* feat(literary): assemble work detail

* feat(literary): add admin work linking primitives

* feat(catalog): group literary items by work

* feat(literary): auto-link works during book scans

* fix(literary): narrow work match candidates

* fix(literary): address work merge blockers

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-16 18:17:08 -04:00
QuickandClaude Fable 5 e64b130bd3 perf(catalog): batch per-episode lookups in episodes endpoint
The item episodes endpoint issued 2-4 sequential round-trips per episode
(media files, watch progress, localization, still-image presigning),
putting season detail loads at ~500ms for typical seasons. Both the real
and synthetic season paths now share one builder that resolves each
concern in a single batched call, using the batch methods that already
existed (ListByEpisodeIDs, ListProgressByMediaItems,
PresignURLsWithExpiry) plus a new LocalizeEpisodeModels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:05:49 -04:00
39ba284c9d feat(ai): shared AI core — metadata translation, Whisper ASR, per-profile language, on-view translation (#127)
* docs: design + plan for shared AI core, metadata translation, Whisper ASR

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

* refactor(ai): shared LLM client, segment translator, and job runner packages

internal/ai/llm: OpenAI-compatible chat client moved out of subtitles/ai,
plus /v1/audio/transcriptions (verbose_json) for the ASR work; one shared
retry/backoff loop for both. internal/ai/translate: the batched indexed-JSON
translation protocol generalized to text segments. internal/ai/jobrunner:
dispatch/heartbeat/reaper/cancel lifecycle extracted behind a minimal store
interface, with a semaphore shareable across job services.

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

* refactor(subtitles): consume shared AI core

LLMTranslator becomes a thin cue<->segment adapter over aitranslate; the
service delegates dispatch/heartbeat/reaper/cancel to jobrunner; the local
OpenAI client is gone in favor of internal/ai/llm. Behavior (prompts, wire
protocol, job rows, recovery semantics) is unchanged. NewService now takes
the dispatch semaphore so all AI job services can share one bound.

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

* feat(config): shared ai.* settings, metadata translation job table, localization provenance columns

ai.* connection keys (chat + optional separate ASR endpoint) load with a
fallback to the legacy subtitle_ai.* rows — those are never renamed in SQL
because encrypted values are GCM-bound to their setting key. New toggles:
subtitle_ai.transcribe_enabled, metadata_ai.enabled. Migration adds
metadata_translation_jobs, per-field provenance (provider|ai|manual) on the
localization tables, and media_folders.auto_translate_metadata.

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

* feat(catalog): localization field provenance with provider/ai/manual precedence

Provider upserts keep manual values and never blank a field with an empty
incoming value; new UpsertAITranslation/UpsertAIOverview methods write AI
fields only over empty or ai-sourced values (force adds provider, never
manual) — all enforced in single-statement SQL. Serving now merges only
non-empty localized fields onto the base item, since localization rows are
legitimately partial (AI rows carry no titles/artwork).

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

* feat(metadata): AI translation service, refresh auto-fallback, and admin API

internal/metadata/translation: job service over the shared AI core that
expands an item to its season/episode overviews, skips already-localized
fields (zero model calls on repeat runs), batches paragraphs through the
generic translator, and persists per batch with provenance-aware upserts.
MetadataService gains an AutoTranslator seam invoked after each refresh for
libraries with auto_translate_metadata. Admin endpoints under the metadata
curation guard: enqueue, list (poll), cancel; plus a status probe.

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

* feat(subtitles): Whisper ASR transcribe and transcribe_translate jobs

New WhisperTranscriber: one ffmpeg pass extracts the audio track to 10-min
16kHz mono WAV chunks (temp dir cleaned on every exit path), each chunk goes
to the OpenAI-compatible /v1/audio/transcriptions endpoint (verbose_json,
per-request timeout sized to 3x chunk duration), segment timestamps are
offset and built into wrapped cues. Chunks process playhead-first and stream
live to the requesting session. The transcript is stored as an ordinary
downloaded subtitle (provider 'transcribed'); transcribe_translate chains
the existing translator and stores the translated track as the job result.
Enqueue accepts an optional kind; status reports transcribe_enabled.

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

* feat(web): AI services settings, metadata translate action, library auto-translate, generate-from-audio

New AI Services admin page hosts the shared endpoint config (reads fall back
to legacy subtitle_ai.* values, writes target ai.*) and the three feature
toggles; the AI card moves out of Subtitles settings. The metadata editor
gains a Translate-with-AI panel with job polling and force/re-translate. The
library form gains the auto-translate toggle (threaded through the libraries
API). The player translate modal gains a From-audio mode that lists audio
tracks and submits transcribe / transcribe_translate jobs.

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

* style: gofmt import grouping in router and translation tests

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

* feat(catalog): per-profile metadata language and viewer-triggered description translation

user_profiles.preferred_metadata_language threads through the access scope
into catalog serving: presentation language now resolves explicit param ->
profile preference -> library metadata language (native API and jellycompat).
ItemDetail gains pending_translation_language when the viewer's language is
missing a localized overview. New metadata_ai.on_view setting (off|button|
auto) gates POST /items/{id}/translate-description: any profile with item
access may request its language, with in-flight dedup and a 15-minute
failure cooldown so page views never hammer a broken endpoint.

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

* feat(web): on-view description translation with per-profile metadata language

Profile playback settings gain a Metadata language picker (library default
inherit). Detail pages: when the server reports pending_translation_language
and metadata_ai.on_view is 'auto', the description translates on view with a
pulse animation until the refetched detail comes back localized (45s
timeout); in 'button' mode a small Translate chip triggers the same flow.

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

* fix(web): expose metadata_ai.on_view in AI Services settings

The on-view translation mode had no UI control, so it could only ever be
'off' — viewers got neither the auto translation nor the fallback button.
Adds the off/button/auto selector to the Features card, and the config
loader now warns and falls back to 'off' on a bad row instead of refusing
to start.

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

* fix(ai): clear configuration hint when the transcription endpoint is chat-only

A blank Transcription base URL falls back to the chat endpoint; chat-only
gateways reject the multipart upload with an opaque 400 that reads like a
pipeline bug. 400/404/405 transcription failures now carry a hint to set a
Whisper-compatible endpoint in AI Services.

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

* fix(subtitles): wrap ASR cue text by rune count, not bytes

Arabic/Cyrillic/Greek text is 2+ bytes per character in UTF-8, so byte-based
wrapping broke lines at roughly half the intended visual width.

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

* docs(web): steer transcription base URL hint away from chat-only gateways

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

* feat(ai): block chat-only gateways for transcription, add endpoint presets

llm.IsChatOnlyGateway (OpenRouter et al — no timestamped transcription API)
is enforced in three layers: the settings API rejects ai.asr_base_url values
pointing at one, the router disables ASR with a warning when the blank-URL
fallback would land on one, and llm.Transcribe refuses outright. The AI
Services page gains one-click transcription presets (Groq turbo/accurate,
OpenAI, self-hosted speaches) plus the mirrored client-side check, and the
settings API now also validates metadata_ai.on_view.

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

* fix(subtitles): tighten ASR subtitle sync

Three systematic timing-error sources addressed: cue offsets now use the
segment muxer's exact per-chunk start times (segment_list CSV) instead of
assuming index*chunk_seconds; the audio stream's start delay relative to the
container timeline (common in TS remuxes) is probed via ffprobe and added to
every cue; and the chunk length is now operator-tunable via
subtitle_ai.asr_chunk_seconds (60-600s, default 600) since shorter chunks
bound Whisper's within-chunk timestamp drift. Playhead-first ordering now
pivots on real chunk starts, and a beyond-end playhead starts at the final
chunk instead of restarting from zero.

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

* fix(ai): tolerate base URLs that already include the /v1 segment

Providers like DeepInfra expose their OpenAI-compatible API under a base
that contains the version segment (api.deepinfra.com/v1/openai); always
appending /v1/... mangled those. endpointURL now appends bare paths when
the base already carries /v1.

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

* feat(web): prefer self-hosted transcription in presets and hints

Preset order becomes self-hosted (recommended) -> Groq turbo -> Groq
large-v3 -> OpenAI, and the settings hint plus the job-error hint lead with
the self-hosted option. The self-hosted preset now fills the turbo CT2 model
to match the recommended speaches setup.

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

* fix(subtitles): request VAD and word timestamps for ASR cue accuracy

Without vad_filter, faster-whisper servers report wall-to-wall segment
times: cues linger on screen through silence (verified up to 91s) and
paragraph-length segments become single 400+ char cues. Request
vad_filter=true (skipped for hosted providers that reject non-OpenAI
fields and run VAD server-side) plus timestamp_granularities word+segment,
and rebuild cues from word timings: split at speech pauses, sentence ends,
text capacity, and a 7s max duration; cap word-less segments instead of
trusting their reported end; stretch sub-second cues to a readable minimum.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:58:54 -04:00
8b70357703 feat(ebooks): first-class ebook libraries, scanner, and reader (#124)
* docs: define ebook architecture matching audiobooks

* docs: plan ebook audiobook-parity implementation

* feat: add ebook scanner parser foundation

* fix: harden ebook scanner foundation

* fix: handle ebook isbn labels

* fix: guard ebook subtree scans

* feat: scan ebook libraries in core

* fix: preserve ebook scan people credits

* fix: refresh ebook scan metadata safely

* feat: persist ebook series membership

* test: cover ebook series persistence decisions

* fix: address ebook scanner PR review

* docs: clarify ebook foundation PR scope

* feat: add ebook metadata enricher

* fix: harden ebook poster cache

* feat: wire ebook metadata sync task

* feat: expose ebook library metadata setup

* feat: add ebook catalog scope support

* feat: add ebook detail view

* feat: label ebook file versions by format

* feat: use file-size copy for downloads

* feat: use file language in download dialog

* test: cover ebook detail authors and downloads

* fix: drop narrator credits from ebook scanner merges

* fix: align ebook collection filters with book media

* fix: drop asin provider ids from ebook enrichment

* fix: force ebook people refresh for stale narrators

* chore: omit ebook planning docs from branch

* feat: add ebook detail related content

* feat: add ebook reader file entrypoint

* feat: render ebooks with foliate reader

* feat: persist ebook reader progress

* feat: add ebook reader controls

* feat: extract ebook pdf metadata

* feat: favor scanner isbn during ebook enrichment

* feat: extract fbz ebook metadata

* feat: count cbz ebook pages

* feat: show ebook file page counts

* feat: show ebook download summaries

* feat: switch ebook reader files

* feat: prefer epub for ebook read action

* feat: surface ebook reader progress

* feat: sync ebook reader progress cache

* feat: hide ebook read action for unsupported files

* feat: filter ebook reader file selector

* fix: serve fbz ebook archives with reader mime type

* fix: detect fbz ebooks from compound filename

* fix: authorize fbz ebooks from compound filename

* fix: scope ebook catalog facets

* fix: reject narrator queries for ebooks

* fix: build ebook recommendation text from authors

* fix: include ebooks in embedding eligibility

* fix: include ebooks in recommendation media mix

* fix: include ebooks in recently added recommendations

* feat: include ebook progress in recommendation signals

* feat: include ebooks in continue watching sections

* feat: include ebooks in catalog progress metrics

* fix: read ebook isbn from epub metadata

* fix: filter ebook asin provider aliases

* fix: fall back from unsupported ebook reader files

* fix: sort ebook catalogs by reader progress

* fix: filter ebook catalogs by reader progress

* fix: include ebooks in last watched catalog filters

* feat: reflect ebook reader progress in item user state

* feat: share ebook progress state across item surfaces

* feat: report ebook scan progress

* fix: include ebook activity in recommendations

* fix: expose ebook reader progress on item detail

* fix: support ebook subtree scans

* fix: honor profile header for ebook item progress

* fix: add ebook library default sections

* fix: route ebook continue cards to reader

* fix: hide watched toggle for ebooks

* fix: route ebook watch tonight cards to reader

* fix: route ebook hero actions to reader

* fix: detect archive ebook reader formats by filename

* feat: cache embedded ebook covers during scan

* fix: encode ebook hero reader links

* fix: persist non-epub ebook reader progress

* fix: scope narrator catalog badges to audiobooks

* fix: merge ebook reader progress during item repair

* fix: label ebook progress filters as read

* fix: show ebook related rails as book covers

* fix: remove txt ebook reader support

* fix: reject txt ebook reader files

* fix: label ebook advanced filters as read

* fix: label ebook personalized sorts as read

* fix: remove plain text reader loader path

* test: cover ebook unread catalog rules

* fix: preserve ebook reader library context

* fix: link ebook genres with library scope

* fix: encode related rail item links

* fix: encode catalog card item links

* fix: encode hero and continue item links

* fix: encode watch tonight item links

* fix: encode recommendation and search item links

* test: cover ebook scan format set

* fix: label ebook search results clearly

* fix: make global search prompt media neutral

* fix: encode catalog read API ids

* fix: encode item API ids

* fix: include ebook reader vendor in docker build

* fix: make ebook reader build clean

* fix: clean ebook embedded descriptions

* docs: plan ebook reader shell parity

* feat: add ebook reader shell controls

* fix: widen ebook scrolled reader flow

* fix: remove scrolled reader content width cap

* docs: plan ebook reader full parity

* feat: persist ebook reader config

* feat: add ebook annotations and bookmarks

* feat: add ebook reader tools and aids

* feat: add ebook advanced reader settings

* fix: keep ebook reader panel in viewport

* fix: use foliate sizing units for ebook scroll flow

* fix: keep ebook settings controls readable

* fix: simplify ebook reader settings controls

* feat(ebooks): extract local covers during scan (#98)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* fix(ebooks): harden local cover extraction and format grouping

Address review findings on the local cover scan:

- Restrict generic sidecar covers (cover.jpg, folder.png, ...) to
  single-book directories, always accept images named after the book
  file, and apply exactly one cover per reconcile with sidecar taking
  precedence over the embedded cover.
- Replace the read-then-write poster update with an atomic conditional
  UPDATE (ItemRepository.SetLocalPoster) so provider/admin artwork is
  never clobbered by concurrent writers, and refresh locally owned
  posters when the extracted cover bytes change (thumbhash compare).
- Preserve UTF-8 PDF Info strings (including a UTF-8 BOM) instead of
  forcing everything through Windows-1252; the cp1252 fallback now only
  applies to non-UTF-8 bytes.
- Select EPUB covers by manifest media-type with properties="cover-image"
  outranking the EPUB2 meta name="cover" id, so XHTML cover pages no
  longer shadow the real image.
- Order CBZ pages naturally (2.jpg before 10.jpg, ch2/ before ch10/)
  when picking the cover page, via a single O(n) min-scan.
- Bump the ebook content group key scheme to version 2 and reprocess
  rows written under older versions so pre-existing libraries gain
  sibling-format grouping instead of accumulating duplicates.
- Group different formats only (a same-format sibling with colliding
  sparse metadata stays a separate item) and stop a joining sibling's
  embedded metadata from overwriting a provider-matched item.
- Decode any IANA-labelled OPF/FB2 XML charset (windows-1251, koi8-r,
  shift_jis, ...) via x/net/html/charset, and wire the charset reader
  into FB2 parsing which previously had none.
- Strip the full .fb2.zip double extension from filename-derived titles
  and group keys.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(ebooks): add reader profiles and ruler (#99)

* feat(ebooks): extract local covers during scan

* fix(ebooks): read nullable poster paths during cover scan

* fix(catalog): coalesce nullable media artwork fields

* fix(ebooks): group sibling formats by book identity

* fix(ebooks): tolerate legacy ebook metadata encodings

* fix(ebooks): decode PDF hex metadata strings

* feat(ebooks): add reader profiles and ruler

* fix(ebooks): address reader ruler and profile review findings

- skip renderer setStyles/render when computed styles and attributes are
  unchanged, so ruler position updates no longer re-style the book view
- drag the ruler via a local draft that commits on release, with the
  surface rect cached at pointer-down
- migrate font values persisted before the generic stacks (Inter,
  Georgia, Merriweather, legacy serif) so the font select never renders
  blank, with a Custom fallback option for unknown values
- make the ruler band click-through and move dragging to a dedicated
  keyboard-accessible slider handle so links and text selection keep
  working under the band
- share font stacks between options and profiles via READER_FONT_STACKS
- surface the active reading profile, move presets to the top of the
  settings panel, and drop the redundant profile button aria-labels

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

* fix(ebooks): resolve prefer-const lint error in readest document lib

`pnpm run lint` failed on the branch because `direction` is never
reassigned in getDirection; split the destructure so only the
reassigned `writingMode` stays mutable.

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Merge branch 'main' into work/ebooks-reader-base

Brings the ebook integration branch up to date with main (audiobook
library redesign, continue-watching rework and card affordances,
quic-go bump, jellycompat fixes). Conflict resolutions favor main's
generalized mechanisms and register ebooks with them:

- media scope validation goes through IsValidMediaScope (now including
  "ebook" alongside main's "video" group scope), in Go and in the web
  filter/search types
- continue-watching uses main's typed rails; reading-type sections pull
  resume points from ebook_reader_progress and the ebook library default
  section is wired to ContinueTypeConfig(ContinueTypeReading)
- item_repo keeps main's derived select-list machinery (itemColumnExpr)
  and both poster accessors (GetPoster/SetLocalPoster for ebook covers,
  GetPosterPath for audiobook covers)
- web cards/hero/watch-tonight adopt main's buildMediaPlayHref helpers,
  which now route ebooks to /reader/ebook and encode content ids;
  ebook affordances (BookOpen icon, Read verb, percent-read subtitle)
  carry over onto main's reworked components
- LibraryForm ebook support ported into main's refactored
  useLibraryForm/libraryTypes modules

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

* fix(docker): copy foliate-js vendor into Dockerfile.dev frontend stage

foliate-js is a file:vendor/foliate-js dependency, so pnpm install needs
the vendor directory before the lockfile install layer. The production
Dockerfile already copies it; the dev image was missed, breaking
make dev-deploy with ENOENT on /app/web/vendor/foliate-js.

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

* feat(ebooks): render Continue Reading sections as upright poster cards

All-ebook continue sections previously fell through to the horizontal
16:9 wide card; include ebooks in the poster-variant check so book
covers render in their natural 2:3 framing.

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

* fix(ui): stop related-rail highlight ring clipping on detail pages

Move the current-item ring onto the cover artwork with a themed
ring-offset color (matching the sidebar profile highlight) and give
the scroll container top headroom so the ring is not cut off by
overflow-x-auto. Applies to both ebook and audiobook detail rails.

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

* fix(scanner): harden ebook scanning against data loss and bad metadata

- Reconcile missing ebook files like video/audio, with real per-root walk
  failure tracking (failed/unmounted roots are excluded from deletion),
  symlinked-root support via the shared logical walker, and the empty-root
  cleanup allowance before any destructive reconciliation.
- Create ebook items as 'pending' so enrichment can promote them to
  'matched' (backfill migration included), and protect matched items from
  re-scan clobbering: title/year skipped, people/series fill-empty only.
- PDF metadata: scan head + tail windows (non-linearized PDFs keep the Info
  dict at the end), require proper key delimiters, head values win.
- Cap plain .fb2 reads like .fbz entries; drop .md as an ebook format.
- gofmt internal/scanner/audiobook.go (pre-existing drift).

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

* fix(ebooks): make enrichment failures non-terminal with dedicated backoff state

- Provider errors now record a failure (capped retries) instead of stamping
  last_refreshed, which permanently excluded items after transient outages.
- Unconfigured metadata chains and the scan-window membership race skip the
  item without stamping or burning a retry.
- Failure tracking moves to a new ebook_enrichment_state table, decoupling
  it from media_items.refresh_failures (shared with metadata refresh debt).
- Preserve non-author people credits when persisting enrichment results.

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

* fix(catalog): gate ebook progress on hidden history and centralize threshold

- Apply user_history_hidden_items gating (video semantics) to the ebook
  watched/in-progress filters, progress sort plan, and Continue Reading.
- Continue Reading pages past dismissed items via the shared collector and
  dedupes items across pages (also fixes the video path's latent exposure).
- Centralize the 0.9 finished threshold as models.EbookFinishedProgressThreshold
  with a single SQL-interpolated mirror in catalog.

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

* fix(recommendations): correct watcher counting and wire ebook taste signals

- itemWatchersQuery dedupes to distinct (watcher, item) rows so one
  binge-watcher can no longer satisfy minWatchers; the eligibility floor
  now counts distinct accounts rather than profiles.
- Hidden-history gating on GetEbookReaderProgressForUser (signal reader).
- Ebook reading produces canonical implicit taste signals (weighted like
  the equivalent movie progress ratio); ebooks join taste-seed candidates.
- Stale GetRecentlyAddedItems doc comment corrected.

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

* fix(api): harden ebook reader endpoints and serve a Content-Security-Policy

- Serve a CSP on all SPA HTML responses: blob/srcdoc book iframes inherit
  it, so script-src 'self' 'wasm-unsafe-eval' blocks script execution from
  malicious book content (sandbox alone is defeated by the WebKit
  allow-scripts requirement). Threat model documented on the constant.
- X-Content-Type-Options: nosniff on frontend, jellycompat, and ebook file
  responses; MIME resolution can no longer fall through to octet-stream
  for an admitted ebook file.
- Annotation PATCH: presence-aware field semantics (absent keeps, present
  sets/clears), invariant re-validation on the merged row, and an atomic
  SELECT ... FOR UPDATE read-merge-write.
- Request size caps (413) on progress/config/annotation writes;
  Content-Disposition via mime.FormatMediaType; hidden-history gating in
  the shared ebook progress lister; FK-cascade indexes for reader tables.

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

* feat(api): native read-state endpoints for ebooks

- POST/DELETE /watched/{id} accepts ebook content IDs: mark read upserts
  progress 1.0 preserving the reader's file/location (or picks the
  preferred reader file for never-opened books); mark unread mirrors video
  unwatch semantics and deletes the progress row.
- /history/remove accepts ebooks: hides via user_history_hidden_items
  without touching the reading position (hidden != unread; next reading
  activity resurfaces the book, mirroring video re-watch).
- Access-filter checks match the video branch; shared logic lives in
  ebook_read_state.go. Sort metrics/user-state thresholds use the shared
  constant; profile-header fallback deduplicated.

Clients: response is {type: "ebook", affected_count: 1, played: bool};
the existing watched SSE event fires.

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

* fix(web): harden the ebook reader UI

- Open-flow race: cancellation checked after every await with full stale-run
  teardown (no wrong-file progress saves, no leaked views/blob URLs);
  book.destroy() on cleanup.
- Progress: monotonic stale-response guard; visibilitychange flush uses the
  refresh-capable client, pagehide uses keepalive; per-book cross-format
  progress documented as deliberate.
- Settings: side effects out of the setState updater; local edits no longer
  clobbered by late server config; pending saves flushed on unmount/pagehide.
- TTS: generation token so Stop actually stops (Chromium/Firefox synthetic
  events); Media Session uninstalled on unmount.
- External book links: http(s) only, opened with noopener,noreferrer.
- apiBlob 512 MiB guard with a user-facing error; fraction bookmarks
  navigable; search-result key collisions fixed; dead e-ink code removed;
  getLibrarySortRelevanceScope deduplicated; md format dropped.

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

* feat(web): mark read/unread affordances for ebooks

- Item detail gets a Mark Read/Unread button; card menus drop the ebook
  gate and share type-aware labels/toasts (also dedupes audiobook wording).
- Watched-state invalidation includes the reader progress query key so the
  Continue button and percent refresh after toggling.
- Continue Reading dismiss copy for ebooks; dismissal path now URL-encodes
  item IDs (ebook content IDs can contain reserved characters).

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

* docs: record the PR #124 review and hardening pass

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

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 08:18:35 -04:00
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
5855cebe75 fix(audiobooks): complete playback and catalog parity (#96)
* fix(audiobooks): complete playback and catalog parity

* fix(web): allow podcast continue targets

* fix(audiobooks): use folder sidecar covers during scan

* fix(audiobooks): read nullable poster paths during cover scan

* feat(home): split continue listening sections

* fix(catalog): coalesce nullable media artwork fields

* feat(playback): surface audiobook sessions in admin activity

* fix(audiobooks): address playback review feedback

---------

Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-06-08 21:55:35 -04:00
QuickandGitHub c16d876007 fix(metadata): gate episode refresh debt on provenance (#60) 2026-06-06 22:43:35 -04:00
9f73ac6f1a feat(realtime): improve web UI reactivity and admin visibility (#48)
* fix(web): scope realtime user state events

* feat(events): add canonical catalog event publishers

* feat(events): publish canonical catalog events

* refactor(web): centralize realtime events provider

* feat(events): normalize user state event name

* feat(web): patch item user state from realtime events

* fix(web): refetch active catalog on realtime changes

* fix(events): publish item changes during metadata enrichment

* fix(web): improve dashboard and mutation reactivity

* feat(admin): improve realtime session activity

* feat(admin): refine playback admin surfaces

* feat(admin): improve library task controls

* fix(collections): position defaults progress below header

* feat(library): surface matcher backlog

* fix(admin): hide matcher backlog from server activity

* chore(migrations): renumber branch migrations

* feat(admin): show registered devices without overrides

* feat(admin): improve scheduled task visibility

* fix(realtime): tighten admin update handling

* docs(admin): document library job id parsing

* docs(library): explain mount check feedback timing

* fix(library): guard metadata match queue handlers

* fix(admin): avoid stale queued job cancellation

* fix(settings): harden device registration and task timing

* fix(jellycompat): fill large browse pages

* perf(jellycompat): compress and batch list image work

* feat(autoscan): pluggable scan-source autoscan category (Sonarr/Radarr) (#44)

* docs: design spec for autoscan arr polling

Periodic poller over autoscan-enabled Radarr/Sonarr instances (reusing
request_integrations) that maps import paths to Silo media folders and
enqueues targeted scans via the existing scantrigger + scanqueue. Lean
single-service model: no cross-node fan-out guard or retry queue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for autoscan arr polling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): settings and sources schema

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): core types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): path rewrite helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): dedupe imported paths to parent folders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): arr import-history client

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): settings + sources repository

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): redis scan-suppression seam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): PollOnce poll cycle

* feat(autoscan): poll task and wiring

* feat(autoscan): admin API endpoints

* feat(autoscan): admin API endpoints

Adds ErrIntegrationNotFound sentinel (errors.Is) instead of string matching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): autoscan types and hooks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): autoscan admin tab

* fix(autoscan): release suppression claim on enqueue failure; reconfigure trigger on interval change; skip source on key-resolution error

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): update handler test for 3-arg NewAutoscanHandler

* fix(autoscan): per-path suppression key, bounded poll window + overlap, boundary-safe rewrites, GREATEST cursor guard, async trigger, quiet unresolved-path skip, FK->404

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): normalize Windows path separators, surface status errors, re-seed source editor on save

Addresses minor code-review findings: Windows backslash paths now normalized
before rewrite/dedupe; HandleStatus returns repository errors instead of 200;
the per-source editor re-seeds from server data after a save.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for autoscan rewrite-sync from arr root folders

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for autoscan rewrite-sync

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): suffix-match rewrite suggester

Add suggestRewrites / commonSuffixLen for Task 1 of the autoscan
arr-polling feature. Pure function: matches arr root-folder paths to
Silo media folder paths by longest common trailing segment count,
adjusted for depth-delta so coincidental same-named segments at
different structural levels don't inflate confidence. Categorises
each arr root as Proposed, Ambiguous, Unmatched, or Covered by an
existing PathRewrite rule. TDD: test file written first, verified
failing, then implementation added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): GetSource single-source lookup

* feat(autoscan): arr root-folder client + Silo folder lister

* feat(autoscan): Service.SuggestRewrites

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): rewrite-suggestions endpoint

Add GET /autoscan/sources/{id}/rewrite-suggestions admin endpoint: extend
the autoscanTriggerer interface with SuggestRewrites, wire SetRewriteResolvers
in the router, and add handler + test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(web): autoscan rewrite-suggestions types and hook

* feat(web): autoscan sync-rewrites preview

* fix(autoscan): normalize covered-rule paths, dedup roots/folders, skip no-op suggestions

Addresses final-review edge cases: coveredBy normalizes the existing rewrite's
From (so a stored Windows/dup-slash rule still covers a root); duplicate arr
roots and duplicate Silo folder paths are de-duplicated; an arr path that already
equals its Silo path is not proposed as a no-op rewrite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): vitest 4 compatible fetch spy in recipes.test (unblocks build after vitest 4.1.0 bump)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): non-null suggestion slices + move Sync into rewrites card

- suggestRewrites initializes Proposed/Unmatched/Ambiguous/Covered to empty
  slices so the JSON response is [] not null — fixes the 'Something went wrong'
  crash when every root is already covered (frontend mapped over null).
- Move the sync button into the Path rewrites card beside 'Add rewrite' and
  rename it 'Sync rewrites'; guard the proposed map with ?? [].

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): long root-folder timeout + sync spinner + collapse rewrites on load

- Root-folder fetch for sync uses a 2-min timeout: Radarr/Sonarr compute
  unmappedFolders by scanning all roots, so a large library's /rootfolder takes
  20-30s+ and tripped arrclient's 30s default (Sonarr 502'd at exactly 30s).
- Spin the sync icon + show 'Syncing…' while the request is in flight.
- Path rewrites card starts collapsed on page load.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): rescan on Sonarr/Radarr file renames

History polling previously only tracked downloadFolderImported events. A
rename in Sonarr/Radarr (episodeFileRenamed / movieFileRenamed) moves a
file without an import event, leaving the library folder stale until the
next full scan.

Extend the history client to also surface renamed paths: both the new
path and the old sourcePath, since a rename can move a file between
folders and both parents may need rescanning. Delete events are still
skipped — upgrade-deletes are covered by the paired import, and standalone
deletes carry no file path in arr history.

Renames the interface method ImportedPaths -> ChangedPaths to reflect the
broader scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(autoscan): synchronize trigger test with detached PollOnce goroutine

HandleTrigger dispatches PollOnce on a detached goroutine and responds 202
immediately. The test read trig.called straight after the handler returned,
racing the goroutine (usually 'PollOnce was not invoked') and reading the
field without synchronization (a data race under -race).

Signal completion through a channel the fake sends on when PollOnce runs;
the test waits on it (bounded) before asserting. The channel send
happens-before the receive, so the subsequent read of called is race-free.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: design spec for autoscan as a pluggable scan-source category

Reframes autoscan from a Requests-coupled, arr-only feature into a
standalone Autoscan category. Change-detection providers become
out-of-process plugins via a new additive scan_source.v1 capability
(client-pull, opaque marker); Sonarr/Radarr is the first provider.
Host keeps a provider-agnostic resolve/suppress/enqueue engine; all
arr-specific logic (and path rewrites) move into the plugin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for scan_source.v1 SDK capability

First of the per-repo plans from the autoscan-plugin-architecture spec.
Adds the additive scan_source.v1 capability to silo-plugin-sdk (proto +
codegen + capability allowlist + runtime wiring), TDD per task, tagged as
v0.5.0 so the host and arr-plugin plans can build against it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plan for autoscan host backend (part 1 of 2)

Backend for the standalone Autoscan category: scan_source.v1 plugin
plumbing (pluginhost client + plugins.Service resolver), generalized
engine driven by a provider seam, autoscan_connections + autoscan_sources
schema (decoupled from Requests), connection resolution (own or
Requests-linked), admin API. Depends on silo-plugin-sdk v0.5.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: implementation plans for autoscan arr plugin + host UI

arr plugin: new installable scan_source.v1 plugin (history imports+renames,
rewrites, Silo-native paths), structured like silo-plugin-tmdb; ports the
arr-specific logic from the closed PR #43.
host UI (part 2 of 2): standalone Autoscan admin category (connections,
sources, settings) extracted out of Requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build(autoscan): replace silo-plugin-sdk with local scan_source.v1 checkout

Temporary dev replace so the host backend can build against the unreleased
scan_source.v1 capability (silo-plugin-sdk PR #2). Finalize to v0.5.0 once
the SDK is tagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(pluginhost): scan_source.v1 capability client wrapper

Adds ScanSourceClient struct, the Client.ScanSource() accessor (mirrors
ScheduledTask pattern), and a PollChanges method. Also introduces
client_test.go with capability-gate tests for both scheduled_task.v1 and
scan_source.v1 using a lazy gRPC ClientConn.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test+fix(pluginhost): cover capability-id gate, dedicated scan_source timeout

Adds a "wrong id returns error" subtest to both capability-gate tests so the
capability-ID component is exercised independently of the type. Introduces
DefaultScanSourceTimeout (2m) for PollChanges, which polls an external arr API
that can be slow, instead of the generic 10s control timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): expose scan_source.v1 client resolver

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(migrations): autoscan v2 schema (connections + sources, no requests FK)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): v2 types and repository

Replace the request_integrations-coupled model with the decoupled v2
schema (autoscan_settings + autoscan_connections + autoscan_sources).
Connection CRUD, source upsert/list/get, and AdvanceMarker/RecordError
for opaque marker bookkeeping. ErrIntegrationNotFound becomes ErrNotFound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): resolve connections (own credentials or Requests-linked)

ConnectionResolver turns a stored Connection into concrete credentials,
reading a soft-linked Requests integration's live base URL/key when
RequestIntegrationID is set, then resolving the api-key ref to plaintext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): scan-source provider seam over the plugin resolver

ScanSourceProvider lets the engine poll changed paths without a live
plugin; pluginProvider adapts plugins.Service.ScanSourceClient in
production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): generic engine drives sources via scan_source provider

Rewrite PollOnce to iterate enabled sources, resolve each connection,
poll the provider for changed paths, and run the salvaged
resolve→suppress→enqueue loop (uniqueParentDirs, (folder,path)
suppression key, RequestError quiet-skip, release-claims-on-enqueue-fail)
verbatim. Store the opaque next marker via AdvanceMarker only after a
successful enqueue; RecordError + keep marker on provider failure.
Tests reworked onto a fakeProvider/fakeStore with an added
opaque-marker-verbatim assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): drop conflicting connection CHECK, add connection resolver tests

- migration 172: remove the autoscan_connections_source_present CHECK. It
  conflicted with request_integration_id ON DELETE SET NULL: deleting a
  Requests integration that a linked-only connection (base_url NULL) points
  at would null the FK and trip the CHECK, blocking the delete. The intended
  behavior is for the connection to survive as an orphaned 'needs attention'
  row. Creation-time validity is now enforced at the application layer.
  Verified on a throwaway DB: full chain applies and the delete-cascade
  leaves an orphaned (both-null) connection.
- connection.go: TrimSpace the api key ref + resolved secret before the
  empty-string checks, matching requests.resolveAPIKey parity.
- connection_test.go: fake-based tests for ConnectionResolver.Resolve
  (own creds, linked, linked-missing error, lookup error, trim/fallback).
- repository.go: bound RecordError's stored last_error to 2048 chars.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(api): autoscan v2 admin endpoints

Rewrite the autoscan admin HTTP handler against the v2 model: settings,
connection CRUD, source update, manual trigger (detached PollOnce), and
status. Connection/source responses omit api_key_ref and resolved keys
(has_api_key flag only); unknown connection/source ids map to 404 via
autoscan.ErrNotFound. Retire the host-side rewrite-suggestions endpoint
(now lives in the arr plugin).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): wire v2 service, routes, retire rewrite-suggestions

Export PollChangesClient/ScanSourceResolver from the autoscan provider so
the api package can declare a structurally-conformant plugin adapter (Go
has no return-type covariance, so the adapter must name the interface as
its return type). Add api.BuildAutoscanService with the requests-integration
lookup and plugin scan-source adapters, shared by the router (manual
trigger) and the background poll task. Re-wire router routes to the v2
connections/sources/settings/trigger/status surface and drop the
rewrite-suggestions route. Update cmd/silo to build the v2 poll task,
seeding its interval from Settings.DefaultPollIntervalSeconds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): enforce connection requires own URL or a Requests link

Migration 172 dropped the DB CHECK that required an autoscan connection to
carry either its own base_url or a request_integration_id, delegating that
invariant to the application layer — but the enforcement was never added, so
HandleCreateConnection/HandleUpdateConnection accepted both-NULL orphans that
ConnectionResolver.Resolve would hand a plugin as an empty base URL. Add a
shared validateConnectionInput helper (whitespace-only request_integration_id
counts as absent) and reject both-empty payloads with HTTP 400 on both the
create and update paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): deliver resolved connection to plugin

PollChanges now populates PollChangesRequest.Connection with the
resolved {base_url, api_key} instead of dropping the conn param on the
floor. Drops the stale doc comment claiming the connection was delivered
out-of-band at upsert time -- that mechanism never existed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): auto-discover sources from installed scan_source plugins

Auto-discovery seeds a disabled, connection-less source row per
installed scan_source.v1 capability before an operator binds a
connection, so connection_id is now nullable end to end:

- migration 172: connection_id drops NOT NULL (still ON DELETE RESTRICT)
- Source.ConnectionID becomes *string; repository scans/writes it as
  nullable and adds idempotent EnsureSource (INSERT ... ON CONFLICT DO
  NOTHING)
- new ScanSourceLister seam + Service.DiscoverSources, called at the
  start of PollOnce (errors logged, non-fatal); production adapter
  enumerates ListEnabled -> ListCapabilities filtered to scan_source.v1
- PollOnce skips an enabled source with no connection bound, recording
  'no connection bound' so the UI can surface it
- HandleUpdateSource rejects enabling a source with no effective
  connection (400); source DTOs expose connection_id as nullable
- BuildAutoscanService / NewService thread the installation store at
  both wiring sites (router + poll task)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): honor per-source poll interval

PollOnce now skips an enabled source that ran too recently: the floor is
source.PollIntervalSeconds when set, else
settings.DefaultPollIntervalSeconds. The global poll task fires at the
default cadence, so this makes the per-source interval a 'poll at most
every N seconds' floor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(autoscan): reconcile spec + arr-plugin plan with credential-in-request + auto-discovery

The credential-delivery mechanism changed during execution: the host now
passes resolved {base_url, api_key} in PollChangesRequest.connection each
poll (not plugin runtime config). Also records source auto-discovery,
nullable connection_id, and the per-source interval floor decided at the
final integration review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): autoscan v2 types and query hooks

Replace v1 autoscan types and hooks with v2 DTOs matching the backend
handler (autoscan.go): settings, connection (with has_api_key, no raw
key), source (installation_id/capability_id/connection_id), status.
Add connections CRUD hooks, useAutoscanStatus, update sources hook to
v2 input shape. Retain deprecated shims for AutoscanPathRewrite,
AutoscanRewriteSuggestions, and useAutoscanRewriteSuggestions so
AdminRequests.tsx continues to compile until Task 6 removes that tab.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): autoscan connections panel (reuse or own)

Card+Table listing connections with "Reused from Requests" / "Own" badges.
Add/edit dialog with two modes: reuse a Sonarr/Radarr Requests integration
or enter own name/URL/API-key credentials. Delete with alert-dialog confirm.
Never renders key material — only has_api_key is sent by the backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): autoscan sources panel

Table of auto-discovered scan sources (one row per installed scan_source
plugin capability). Operator can bind a connection via inline Select
(auto-saved on change), set a per-source poll interval (saved on blur),
and toggle enabled. Shows a "Needs connection" badge for unbound sources;
attempting to enable without a connection lets the backend 400 surface via
the existing toast in useUpdateAutoscanSource.onError. Status column shows
last_run_at relative time or last_error with icon.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): standalone Autoscan admin page

Tabs page (Sources | Connections | Settings) mirroring AdminRequests
header/layout. Settings tab exposes global enable switch, default poll
interval, and debounce — all auto-saved on blur or toggle. "Run now"
button calls useTriggerAutoscan and toasts "Autoscan triggered" on 202.
Route and sidebar nav are intentionally deferred to Task 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): route and sidebar nav for Autoscan category

Add /admin/autoscan route pointing to AdminAutoscan and a matching
"Autoscan" item in the Content group of the admin sidebar (with RefreshCw
icon), so the new standalone page is reachable from the nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(web): move Autoscan out of Requests into its own category

Remove the Autoscan tab, AutoscanTab/AutoscanSourceEditor component
definitions, and AutoscanSettingsFormState from AdminRequests.tsx.
Delete the Task-1 compatibility stubs: AutoscanPathRewrite and
AutoscanRewriteSuggestions types from api/types.ts, and the
useAutoscanRewriteSuggestions no-op shim from useAutoscan.ts. The
build confirms zero dangling references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): allow unbinding a source connection (full-state source update)

Change the source-update input struct's connection_id from string to *string so
the UI can send null to unbind, a UUID to bind, or omit (null) to clear. Remove
the fall-back-to-existing logic; the handler now sets the source's ConnectionID
directly from the input. The enable-guard fires when the resulting connection is
nil regardless of cause. Frontend sends the complete triple (connection_id,
enabled, poll_interval_seconds) on every mutation site; selecting "— No
connection —" sends null for a real unbind. Adds aria-label to connection Select
and interval Input for accessibility. Backend tests cover bind, unbind, unbind
while enabled → 400, and enable without connection → 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migrations): backfill autoscan v1 settings+connections instead of dropping

Migration 172 unconditionally DROPped the shipped v1 autoscan_settings/
autoscan_sources (migration 171), losing an upgraded operator's enable flag,
poll cadence, debounce, and arr server list — autoscan came back OFF.

Rewrite 172 up to be non-destructive of what can be carried: rename the v1
tables aside, create the v2 schema, backfill settings (poll minutes -> seconds)
and seed a reusable LINKED connection per distinct v1 source integration, then
drop the renamed v1 tables. v2 sources are keyed on a plugin
(installation_id, capability_id) that did not exist in v1, so they are left to
runtime discovery; path rewrites move to plugin config and are intentionally
not carried.

Verified against a throwaway DB: after 171 + v1 seed data, applying 172 yields
enabled=true, default_poll_interval_seconds=300, debounce_seconds=30, and one
autoscan_connections row linked to the v1 integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): preserve api key on metadata-only connection edit

UpdateConnection unconditionally wrote api_key_ref = nullable(c.APIKeyRef), so a
metadata-only edit (the UI omits the key when left blank — "leave blank to keep
existing") NULLed the stored key and broke the next poll. Mirror requests'
UpdateIntegration: api_key_ref = CASE WHEN $5 = '' THEN api_key_ref ELSE $5 END,
passing the raw trimmed string so a blank incoming ref keeps the existing value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): skip orphaned sources + add source delete endpoint

An enabled source whose scan_source plugin was uninstalled/disabled kept its
autoscan_sources row, which errored every poll cycle, and there was no way to
remove it.

DiscoverSources now returns the set of currently-discovered
(installation_id, capability_id) pairs; PollOnce skips any enabled source not in
that set quietly (no RecordError), stopping the per-cycle error spam for
orphans. A nil set (no lister / discovery failed) disables pruning so a transient
discovery failure does not silence live sources.

Adds DELETE /admin/autoscan/sources/{id} -> HandleDeleteSource ->
repo.DeleteSource so an operator can clear orphans (unknown id -> 404).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): reject reused connection when Requests integration is disabled

RequestIntegrationLookup.Get returned a linked integration's base_url/api_key
even when the integration was disabled or had a blank base_url (the v1 poll gate
`WHERE ri.enabled = true` was dropped in v2). Now Get surfaces a disabled or
unconfigured linked integration as an error, which the engine turns into a
logged skip / RecordError instead of polling an unusable target. The gating is
extracted into a pure checkRequestIntegrationUsable helper so it is unit-testable
without a DB-backed repo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): reschedule poll task on settings change

HandleUpdateSettings no longer rescheduled the poll task (the v1 triggerUpdater /
UpdateTriggers wiring was dropped in v2), so a default_poll_interval_seconds
change only applied after a restart.

Re-add an optional triggerUpdater (taskmanager.UpdateTriggers) on AutoscanHandler,
wired via SetTriggerUpdater from the router when a task manager is available. On a
successful settings update the handler recomputes the interval trigger from
default_poll_interval_seconds and calls UpdateTriggers("autoscan_poll", ...). The
dependency is optional: a nil updater skips rescheduling so tests need no task
manager, and a reschedule failure is non-fatal (the interval is persisted).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): disable enable toggle for unbound sources, add source delete + interval hint

- Disable the Enable switch when a source has no effective bound connection
  (connection_id null and no pending edit selection), re-enabling once bound.
- Add useDeleteAutoscanSource hook mirroring useDeleteAutoscanConnection pattern.
- Add per-row delete button (Trash2 icon → AlertDialog confirm) to let
  operators remove orphaned/unwanted source rows.
- Add interval floor helper text showing the global default poll interval
  so operators know values below it have no effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): consume source_paths from merged scan_source contract

The merged plugin SDK renamed PollChangesResponse.changed_paths to
source_paths and the plugin now returns RAW source-namespace paths.
pluginProvider.PollChanges reads GetSourcePaths(); the host applies
per-source path rewrites before resolving/enqueueing (separate commit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(migrations): add path_rewrites to autoscan_sources

Add path_rewrites jsonb NOT NULL DEFAULT '[]' to the autoscan_sources
CREATE in migration 172 (unreleased/branch-only, so amended in place).
The host now owns per-source prefix rewrites. v1 path_rewrites cannot be
backfilled (v2 sources key on a plugin installation/capability with no v1
mapping); documented that operators must re-enter rewrites post-upgrade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): host-owned per-source path rewrites

Rewrite ownership moved from the scan_source plugin to the host. The
plugin returns raw source-namespace paths; the host now normalizes
separators and applies the source's per-source prefix rewrites before
dedupe/resolve/enqueue.

- types: add PathRewrite{From,To} and Source.PathRewrites
- rewrite: re-add applyRewrites/normalizeSeparators; apply the
  MOST-SPECIFIC (longest From) match, not first-match, so a broad rule
  can't shadow a nested one regardless of ordering
- service.PollOnce: rewrite raw provider paths before resolveAndClaim
- repository: marshal/unmarshal path_rewrites jsonb in UpsertSource and
  all source scans (EnsureSource discovery rows take the DB default [])
- handlers: autoscanSourceInput/response + status DTO carry path_rewrites
  (full-state like connection_id); reject blank from/to with 400
- tests: rewrite unit tests, engine applies rewrites before enqueue,
  handler round-trips path_rewrites and 400s on a blank rewrite

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): discover installed scan_source plugins on sources-list view

A scan_source plugin installed via the normal /admin/plugins flow must show up
in the Autoscan component immediately, not only after a poll cycle (which runs
only when autoscan is enabled). HandleListSources now runs discovery (seeding a
disabled, connection-less source row per installed scan_source capability)
before listing. Best-effort: discovery failure does not block listing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): per-source path rewrites editor + plugins-page install hint

Add AutoscanPathRewrite type and path_rewrites fields to AutoscanSource/
AutoscanSourceInput. SourcesPanel gains an expandable rewrite editor per
source row (from→to pairs, Add/Remove/Save) threaded into the full-state
body so connection, interval, and rewrite changes always carry all fields.
Adds a Plugins-page install hint in both the empty state and above the
table for discoverability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(autoscan): host-owned path rewrites + install/discovery flow

Reconcile the spec with the merged SDK decision (rewrites moved host-side;
PollChangesResponse.source_paths carries raw provider paths). Document that
scan-source plugins install via the normal /admin/plugins page and surface in
Autoscan via discovery (run on poll cycles and on sources-list view).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build: depend on merged silo-plugin-sdk via pseudo-version (drop local replace)

PR #2 (scan_source.v1 + source_paths) is merged to silo-plugin-sdk main, so the
host can resolve the canonical module at the merged commit
(v0.4.1-0.20260603030807-807b07e785b2) instead of a local-path replace. The
branch now builds off-machine (CI/Docker). Bump to a clean v0.5.0 once tagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(migrations): single clean autoscan v2 migration (v1 never shipped)

The v1 in-process autoscan (migration 171) was never released to origin/main,
so no live system has v1 autoscan data to preserve. Collapse the v1-create +
v2-rename/backfill/drop dance into one clean 171 that creates the v2
connections-based schema directly. Removes 172 entirely.

The runner applies by version set-difference with no checksum validation, so
the already-migrated test instance (171+172 recorded) skips both and is
unaffected; fresh installs get the clean v2 schema in one step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): allow many sources per plugin + add-source enumeration

Drop the one-source-per-(installation, capability) model. A single installed
scan_source plugin capability can now back many sources, each bound to a
different connection (e.g. one Sonarr plugin fronting four arr servers).

- migration 171: remove the autoscan_sources UNIQUE(installation_id,
  capability_id) constraint; sources are operator-created, not auto-seeded.
- repository: replace UpsertSource (relied on the unique conflict) with a plain
  CreateSource (fresh uuid) + a by-id UpdateSource; remove EnsureSource.
- discovery: replace auto-seeding (DiscoverSources/RefreshDiscovered) with
  ListAvailableScanSources (the Add-source picker list, enriched with plugin id
  + display name) and an installedScanSources set used only for orphan-skip.
- service: PollOnce stops seeding and instead fetches the installed-capability
  set for orphan detection; Store gains GetSource and drops EnsureSource.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): connection test endpoint (engine)

Add Service.TestConnection / TestConnectionByID: resolve a connection (ad-hoc
input or an existing stored connection) to concrete credentials and probe the
arr GET /api/v3/system/status with a short timeout. A reachable/authorized
target yields OK=true plus the reported version; an unreachable / 401 / non-200
target yields OK=false with a human-readable error (the probe failure is part of
the result payload, never an error from the method itself).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): host-side rewrite suggester + admin API for new endpoints

Port the path-rewrite suggester back host-side (it had moved into the plugin):
suggestRewrites suffix-matches arr root folders against Silo media folders to
propose path rewrites, reporting proposed / unmatched / ambiguous / covered.
Service.SuggestRewrites resolves the source's bound connection, lists arr roots
(GET /api/v3/rootfolder) and Silo folder paths, and runs the matcher; a source
with no bound connection returns ErrNoConnection (400).

Admin API (all admin-gated):
- POST   /admin/autoscan/sources                       create a source
- GET    /admin/autoscan/scan-source-plugins           Add-source picker list
- POST   /admin/autoscan/connections/test              probe a connection
- GET    /admin/autoscan/sources/{id}/rewrite-suggestions  sync rewrites
HandleListSources no longer auto-seeds; create validates the capability is
currently installed and that enabling requires a connection.

Wiring threads the arr root-folder/status client and the catalog folder lister
through BuildAutoscanService; the lister now surfaces plugin id + display name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): autoscan hooks + types for sources, connection test, rewrites

Add types and React Query hooks backing the autoscan admin UI batch:
- AutoscanAvailableSource / useAvailableScanSources (scan-source plugins)
- AutoscanSourceCreateInput / useCreateAutoscanSource (POST sources)
- AutoscanConnectionTestResult / useTestAutoscanConnection (advisory test)
- AutoscanRewriteSuggestions / useAutoscanRewriteSuggestions (on-demand)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): add-source dialog + sync-from-arr rewrites in SourcesPanel

Add a "+ Add source" header action opening a dialog that creates a scan
source from any installed scan-source plugin bound to an arr connection,
so operators can add one source per connection (e.g. four arr instances).
Empty state links to /admin/plugins when no plugins are installed.

Add a "Sync from arr" button to each source's rewrite editor that fetches
root-folder rewrite suggestions and renders a preview: checkbox-selectable
Proposed rewrites plus collapsed Unmatched / Ambiguous / Already-mapped
sections. "Apply selected" merges the checked rewrites (dedupe by `from`)
and persists via the normal full-state source PUT. Sync is disabled until
the source has a bound connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): test-connection button in autoscan ConnectionsPanel dialog

Add an advisory "Test connection" button to the add/edit connection
dialog. It probes the current dialog input — connection_id when editing,
request_integration_id in reuse mode, or base_url/api_key_ref for own
credentials — and renders the result inline: green "Connected (vX.Y)" on
success, red error on failure. Never blocks save; stale results clear when
credential fields change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): autoscan page polish + global enable toggle in header

Surface a global Autoscan enable toggle and an enabled/disabled status
badge next to the page title, alongside the existing "Run now" header
action so primary controls are reachable without opening a tab. Remove the
now-redundant enable switch from the Settings tab (it points at the header
toggle instead). Tighten header layout for wrap on narrow widths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): right-align autoscan enable toggle + Run now in the page header

Drop the redundant nested justify-between wrapper so the header actions sit
directly under .page-header (space-between + bottom-align), matching the
/admin/libraries header layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): hold poll marker when paths return but none resolve

A freshly-enabled source whose path_rewrites aren't configured yet returns
provider paths that resolve to zero library folders. PollOnce previously
advanced the marker unconditionally on any successful poll, permanently
skipping those imports. Now the marker advances only when there is nothing to
do (zero paths) or at least one path resolved+enqueued; when paths come back
but none resolve, the marker is held and an explaining error recorded so the
operator can fix the rewrites and a later poll re-reads the same window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): don't prune sources of disabled-but-installed plugins

PluginScanSourceLister used the installation store's ListEnabled, so a
temporarily-disabled plugin dropped out of the discovered set and PollOnce
treated its sources as orphaned, skipping them with no last_error (silent
vanish). Switch to List so only a fully-uninstalled plugin counts as orphaned;
a disabled-but-installed plugin's sources are still attempted and surface a
visible RecordError when the client fails to load. The Add-source picker shares
the same all-installed set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): treat empty request_integration_id as no link

ConnectionResolver.Resolve gated the linked-integration path on a non-nil
RequestIntegrationID pointer, so a pointer-to-empty-string (from a both-NULL
orphan or a stripped link) called requests.Get(""). Guard on a non-empty
trimmed value so it falls back to the connection's own fields instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): align startup poll interval with reschedule computation

Startup seeded the poll task by integer-dividing default_poll_interval_seconds
by 60 (minutes), while HandleUpdateSettings reschedules with seconds*1000 ms;
the two diverged for sub-minute and non-60-multiple intervals. NewAutoscanPollTask
now takes the interval in milliseconds and main.go seeds it as seconds*1000,
matching the reschedule path so both agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): normalize stored rewrite From at poll time

applyRewrites matched the stored From after only TrimSpace/TrimSuffix, while
suggest.go coveredBy normalizes via normalizePath (backslash->slash, collapse
'//'). A Windows-style or dup-slash stored rewrite was thus reported 'covered'
at suggest time yet never matched at poll time. applyRewrites now normalizes
From through normalizePath so poll-time and suggest-time agree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(web): don't corrupt source poll interval on enable/connection change

Add a `parseInterval` helper that maps empty input to null (use global
default), valid positive integers to the integer, and any other
mid-edit-invalid value to the source's currently-persisted
`poll_interval_seconds` — so toggling the enable switch or changing the
connection cannot silently overwrite the interval with 0 or NaN.

Wire the helper through `fullBody()` (the single source of truth for PUT
payloads) and remove the two inline duplications in `handleConnectionChange`
and `handleRewriteSave` that both previously used raw `Number()`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): make the source connection optional (provider-agnostic)

A host connection is the credential/endpoint for server-based providers
(Sonarr/Radarr); other scan_source providers (e.g. a CephFS/filesystem watcher
that reads ceph.dir.r* xattrs) need none. PollOnce now polls connection-less
sources, passing an empty ResolvedConnection the plugin may ignore; a plugin
that requires credentials surfaces the error at poll time. Drops the
enable-requires-connection 400s. Provider-specific config lives in the plugin's
own global_config_schema, not a host connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(web): provider-agnostic autoscan copy + optional source connection

Replace arr-hardcoded framing in AdminAutoscan, SourcesPanel, and
ConnectionsPanel with neutral scan-source language. Remove the
connection-required gate on the source enable toggle so connectionless
providers (e.g. filesystem watchers) can be enabled; soften the badge
from "Needs connection" to "No connection". Sync-from-server button
remains gated on a bound connection (it needs a server to query).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: repo-relative paths in autoscan plans

Replace local absolute filesystem paths (/opt/silo, sibling checkouts,
/tmp/go/bin) in docs/superpowers/plans with repository-relative wording
per CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): rune-safe last_error truncation

Truncate RecordError messages on a UTF-8 rune boundary so a byte-bounded
cut can't split a multi-byte rune and store invalid UTF-8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): advance marker when resolved-but-suppressed (not unresolved)

resolveAndClaim now reports resolvedAny (whether any path mapped to a
Silo library folder, independent of suppression). PollOnce gates the
"none matched a Silo library folder" hold+RecordError on !resolvedAny
instead of len(targets)==0, so a poll whose paths resolved but were all
debounced/suppressed advances the marker instead of being treated as a
misconfiguration. Adds a regression test for the suppressed case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(api): normalize request_integration_id

Trim whitespace and collapse empty-after-trim request_integration_id to
nil on connection create and update, so a pointer-to-"" or "  " is never
persisted as a bogus Requests link. Also corrects a stale migration-172
comment to 171 (the collapsed migration number).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(autoscan): provider-agnostic poll-task copy

Rename the poll task to "Autoscan poll" with a provider-agnostic
description and progress message; drop Sonarr/Radarr/arr wording. Key()
(autoscan_poll) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(autoscan): fix typo in connectionless-source test name

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): add scan source management

* chore(deps): bump silo-plugin-sdk for structured scan source changes

Pins silo-plugin-sdk to 0d78651, which adds source_config on
PollChangesRequest plus the structured changes / ScanSourceChangeScope
fields on PollChangesResponse that internal/autoscan/provider.go already
consumes. Without this the branch fails to compile against the prior
pin (807b07e).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): label scan sources by connection name in admin UI

arr-plugin sources fan out one-per-connection under a single generic
"arr" capability, so every row in the Sources and Activity panels
rendered an identical "arr (plugin #N)" label. Lead with the bound
connection name (Radarr/Sonarr/...) instead, demoting capability +
plugin to a subtitle. Sources without a connection (e.g. cephfs) keep
the capability fallback.

Activity threads a source_id -> connection name lookup (built from the
existing sources + connections queries) through the scan/poll tables the
same way librariesByID is threaded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(autoscan): spec for generic + operator-editable source labels

Design for a shared label-resolution helper (operator label -> connection
name -> manifest display_name -> capability_id) consumed by the Sources and
Activity panels, plus an operator-editable per-source label backed by a new
autoscan_sources.label column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(autoscan): implementation plan for source labels

Task-by-task TDD plan: migration 174 (label column), Go domain/repo/handler
wiring with server-side normalization, shared frontend label helper, and
Sources/Activity panel integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): migration for source label column

* feat(autoscan): source label domain field + normalizer

* feat(autoscan): persist source label in repository

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): accept, normalize, and return source label

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): add label to source API types

* feat(autoscan): shared source-label resolution helper

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(autoscan): polish source-label helper per review

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(autoscan): label sources via shared helper + operator label input

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(autoscan): clarify source label naming per review

* feat(autoscan): resolve activity source labels via shared helper

Replace the sourceNames Map plumbing in ActivityPanel with SourceLabelLookups
and delegate both name functions to resolveEventSourceName from @/lib/autoscanLabels,
enabling the full label chain (operator label → connection name → manifest display_name
→ capability_id) for all Scan History and Poll log rows.

* fix(autoscan): carry label on status source + guard poll label

Final-review follow-ups: add the label field to the autoscanStatusSource
response (and AutoscanStatusSource type) so the status view matches the
source response per spec, and give pollSourceName a non-empty fallback for
symmetry with scanSourceName.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(autoscan): resolve source aria-labels through the label chain

Replace the legacy capability-only sourceLabel() helper with resolveSourceName()
(operator label -> connection -> display_name -> capability). Row controls now
announce the row's resolvedLabel (reflecting in-progress edits) and the delete
dialog announces the resolved name, so screen readers hear "4K Movies" instead
of "arr (plugin #4)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(autoscan): paginate queue + history with a shared table pager

Replace the card/table hybrid and 200-row "Load more" cap on the autoscan
Activity panel with proper tables and real pagination.

Backend: add offset + total-count to the scans/events list endpoints so
history pages through the full set instead of a capped window. Extract
shared event/scan WHERE-clause builders so list and count filter
identically, and add CountEvents / CountAutoscanScans.

Frontend: add a reusable TablePagination component (rows-per-page,
"showing X-Y of Z", numbered window with ellipses, responsive) and reuse
it for the server-paginated history (scans + polls) and the
client-paginated live queue. Unify all three tables behind one DataTable
shell so they read as one family.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>

* fix(migrations): renumber PR 48 migrations

* fix(migrations): tolerate stale device profile ids

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: fluxis <warmasterx555@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 22:43:20 -04:00
zZebrahz 20da980cc8 fix(catalog): include user sort metrics 2026-05-30 02:55:05 +00:00
zZebrahz bdc46b8c6c feat(catalog): show sort metadata on item cards 2026-05-30 02:07:37 +00:00
QuickandClaude Opus 4.7 196f753d1a fix(catalog): clamp still/poster/logo backdrops to largest cached variant
Episode stills used as backdrops only exist at w500/w300 in the cache, so
requesting a w1280/w1920 backdrop width 404s. Add catalog.BackdropVariantPath
+ imageTypeFromCachedPath and route featured (w1920) and Continue Watching /
Next Up (w1280) backdrops through it; still/poster/logo paths clamp to their
type's largest cached variant while real backdrops keep the requested width.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 17:11:11 -04:00
Quick a7f62020fd fix(auth): gate media file paths on metadata curation permission
- Allow curators (not just admins) to view media file paths and locations
- Apply library access filter to file-level access checks
2026-05-26 20:04:54 -04:00
Silo Server Migration c085b12fd1 Initial Silo migration 2026-05-22 23:26:56 -04:00