Commit Graph
11 Commits
Author SHA1 Message Date
QuickandClaude Opus 5 05af63ea2b feat(settings): add canonical typed storage for the settings contract
The cross-platform settings contract needs one typed store behind it before a
resolver, routes or a migration can exist. This adds that storage to both
user-store backends and holds them to identical behavior.

PostgreSQL gets user_setting_values with the scope CHECK constraints, the five
partial unique indexes that enforce one explicit value per identity, and the
covering indexes the one-query read path needs, plus user_setting_mutations for
mutation_id idempotency and the inert user_setting_migration_rejects audit
table. The per-user SQLite store gets the same shape minus user_id, since that
database is already user-scoped.

The UserStore interface grows the typed operations: read one explicit value at
one scope, collect every candidate row for a resolution request in a single
query, upsert with a revision increment, unset, and the idempotency receipt
operations. The resolution read deliberately returns unranked candidates so the
resolver can rank in Go — one query per request, never one per scope, which the
pgx query-count test pins.

Delete behavior is application-enforced. Neither backend can inherit it from
constraints: the SQLite store declares no foreign keys, and library, series and
device columns are not FK targets in Postgres either. Profile deletion cascades
to profile-anchored values while account scope survives, forgetting a device
clears its profile_device values alongside the legacy overrides, and the
library/series purges remove only what is scoped to that entity.

The shared conformance suite covers all of it, including the set-versus-unset
distinction for false, 0, "" and null, so a divergence between the two backends
fails a test rather than reaching a client.

Part of #376

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 01:22:23 +00:00
255b1be89c fix(history-import): import Emby favorites (#378)
* fix(history-import): import Emby favorites

* fix(history-import): tolerate Emby favorite errors

* fix(history-import): count atomic favorite inserts

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-16 14:34:35 -04:00
28c6ddc237 feat(playback): add per-user transcoding controls (#375)
* feat(playback): add per-user transcoding controls

* fix(playback): enforce forced video transcode permission

* chore: address transcode control review feedback

* fix(playback): recheck transcode permission on audio switch

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-10 22:30:04 -04:00
42602b7896 feat(policy): access groups + embedded OPA policy engine with decision audit log (#282)
* docs(policy): add OPA policy engine design spec and implementation plan

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

* build(deps): add OPA v1.18.2 SDK for the policy engine

Pulls github.com/open-policy-agent/opa v1.18.2 (policy engine core for
the upcoming internal/policy subsystem) and the transitive upgrades go
mod tidy applied (otel 1.44, grpc 1.81.1, prometheus/common 0.67.5).
Full build verified.

Part of #272

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

* feat(policy): add OPA engine core, vendor scope policy, and parity suite

New internal/policy package (dead code — nothing wires into request paths
yet): prepared-query Engine with 25ms eval timeout and fail-closed decode,
typed PDP.ResolveViewerScope, go:embed vendor bundle, capabilities lockdown
for future admin-authored Rego, and vendor scope.rego reproducing
access.Resolver.Resolve (library intersection, disabled-library handling,
quality/rating ceilings) with a narrowing-only silo_custom.scope.override
extension hook.

Parity proven by 1368 dual-execution subtests against the real
access.Resolver, including the nil-vs-empty AllowedLibraryIDs battery and
quality/rating variation; rank tables are test-pinned to internal/access.
Rego unit tests run via opa/v1/tester inside go test. Bench:
~106µs/op per scope decision incl. input marshaling.

Also restores the OPA requirement to go.mod (the earlier deps commit ran
go mod tidy before any import existed, so tidy dropped it).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed,
corrected (quality.allowed raw-file-rank divergence), and verified here.

Part of #272

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

* feat(policy): add policy document store, foundation schema, and compile-check

policy_foundation migration: policy_documents (one enabled doc per domain
via partial unique index — two enabled docs would define override twice
and conflict at eval), immutable policy_document_versions, single-row
policy_generation counter, and the partitioned policy_decisions log table
(daily range partitions, no FK, denial partial index).

PolicyStore: transactional version numbering (FOR UPDATE), activation
that verifies compiled_ok and bumps the generation in the same tx,
enable/disable with typed ErrDomainAlreadyEnabled, and a delete guard for
documents with an active version. CompileCheck sandboxes admin Rego:
locked capabilities (no http.send/net.*/opa.runtime), enforced
silo_custom.<domain> package path, vendor+stub layering, 2s budget,
structured row/col errors. Engine gains NewEngineWithCustom /
NewEngineFromStore with WARN-and-skip for invalid custom rows.

DB-backed tests verified against a migrated Postgres (concurrent version
numbering, atomic generation bumps, activation guards).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here (domain constants extracted).

Part of #272

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

* feat(policy): add policy System lifecycle with hot reload and cross-node invalidation

policy.System owns one long-lived Engine and reloads it in place when
policy documents change: EventPolicyChanged on the existing ChannelAdmin
bus (new cache event constant) plus a 60s generation-poll fallback for
Redis-less deployments, with a generation-consistent snapshot read.
Vendor compile failure is startup-fatal; store/custom failures degrade
to vendor-only and the poll loop heals them; runtime reload failures
keep the last known-good engine. NotifyChanged gives the future admin
handlers synchronous local reload + cross-node publish.

Wiring: constructed in integrated/api modes only, PolicySystem field on
api.Dependencies (unused by routes yet), policy.eval_timeout_ms setting
(hot-reloaded via configWatcher.OnChange; default 25ms). Verified by a
full server boot smoke and DB-backed convergence tests (event + poll
paths, degraded boot, last-known-good).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here.

Part of #272

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

* feat(policy): add async decision logging with sampling, retention, and query repo

DecisionLogger batch-inserts each node's policy decisions straight to
the partitioned policy_decisions table via a non-blocking buffered
channel (drop-and-count on overflow — logging never adds latency to or
fails a decision). Scope decisions sample 1-in-N (default 50, setting
policy.decision_log_scope_sample_rate); denials and eval errors always
log; input/result JSON samples only at policy.decision_log_verbosity=
verbose. Cursor-paginated DecisionRepository backs the upcoming admin
log viewer. Retention via partman (daily partitions) and a
PolicyDecisionLogCleanupTask honoring policy.decision_log_retention_days
(default 14). PDP emits entries per evaluation; the System owns the
logger lifecycle and settings hot-reload.

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here (removed an unused, unsynchronized PDP setter).

Part of #272

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

* feat(api): add admin policy management API and capability endpoint

/api/v1/policy/capability (authenticated feature detection) plus the
acting-admin /api/v1/admin/policy surface: vendor Rego viewer, document
CRUD with the one-enabled-per-domain conflict mapped to 409, immutable
version creation (compile-checked; failed versions persist as audit
history with structured row/col errors and can never activate),
activate/rollback with synchronous reload + cross-node invalidation via
System.NotifyChanged, stateless validate, throwaway-bundle simulate
(never touches the live engine, never logs decisions), and
cursor-paginated decision-log queries. Routes mount only when the
policy system is wired, keeping proxy/transcode modes untouched.

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here (seeded the FK'd test user; replaced an unchecked
fmt.Sscanf with strconv).

Part of #272

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

* feat(web): add /admin/policy workspace with Rego editor, simulate, and decision log

New Policy admin page (System nav group): documents list with
one-enabled-per-domain conflict handling, CodeMirror 6 Rego editor
(hand-rolled StreamLanguage mode) with server compile issues rendered as
inline lint diagnostics, explicit Save-version vs Activate flow with
confirm, read-only vendor module viewer, simulate panel with seeded
example inputs, version history with rollback, and a cursor-paginated
decision-log browser. Capability-gated via /policy/capability. Adds the
three decision-log settings to Log Retention. First code-editor
dependency in web/ (@uiw/react-codemirror + @codemirror/*), decided in
the design spec.

Implementation drafted by Codex (GPT-5.5) via codex exec; verified here
(lint, format:check, tsc --noEmit, vitest policy suites).

Part of #272

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

* feat(policy): make OPA authoritative for viewer scope resolution

policy.ViewerResolver implements the ViewerResolver interface backed by
PDP.ResolveViewerScope and replaces access.Resolver at all five
construction sites: router viewer middleware, notifications scopes, the
reconciler, jellycompat's scope filter, and the ABS resolver (which now
accepts a pre-built resolver, preserving its PIN-at-login semantics).
PIN/profile-token verification and disabled-library loading are
extracted into shared exported helpers used by both implementations, so
the legacy resolver stays compiled as the parity reference with
identical behavior. The adapter lives in internal/policy (which already
depends on internal/access transitively) — direct typed PDP calls, no
new import cycle. Sites without a policy system (proxy modes, bare test
routers) keep the legacy resolver until the cleanup phase.

Verified: full test suite green (jellycompat TestBeginWebOperation* and
one playback GPU test are pre-existing failures, confirmed identical on
main), 1368-case parity suite, dedicated ViewerResolver parity/PIN/
nil-vs-empty/fail-closed tests, and a full server boot smoke.

Implementation drafted by Codex (GPT-5.5) via codex exec; a first-pass
reflection-based adapter was rejected and reworked into the typed
in-policy adapter; reviewed line-by-line and verified here.

Part of #272

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

* feat(policy): make OPA authoritative for acting-admin and permission gates

vendor/permission.rego reproduces the acting-admin rule (admin role +
primary-profile-or-none), HasEffectivePermission semantics for
marker_edit, and the metadata-curation rule including the subtle
admin-past-refused-bypass case that requires the explicitly ASSIGNED
permission. Policy-backed middleware in policy_gates.go keeps all Go-side
lookups (declared-profile primary check, item->library resolution, the
404-on-unknown-item path) and preserves the legacy status/body taxonomy
exactly — proven by dual-execution middleware tests that run every
scenario through both implementations and assert byte-equal responses.
Permission decisions always log (allowed flag populated); simulate and
the capability endpoint gain the permission domain automatically via the
domain registry. Router swaps behind single constructor choice points
with the legacy gates retained for policy-less wiring.

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed and
verified here.

Part of #272

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

* feat(policy): make OPA authoritative for download and playback admission decisions

vendor/action.rego decides download eligibility (downloads enabled +
user allowed), download-transcode eligibility (transcode enabled + user
allowed + artifacts available), and playback admission (stream/transcode
counts vs limits, zero = unlimited), with a tightening-only
silo_custom.action override that can also clamp a quality ceiling (never
widen — merged via quality.min). Go keeps everything stateful: config
loading, preset-ladder enumeration, and live session counting.

Downloads consult an optional ActionDecider (nil = legacy logic) mapped
back to the existing sentinel errors and capability response. Playback
gains a minimal AdmissionDecider hook at the exact point of the legacy
limit comparison: counts snapshot under the session mutex, PDP evaluated
OUTSIDE the lock, then revalidated under lock before insert (retry on
count drift) — no admission ever decided on stale counts and no eval
under the mutex. Deny reasons map to the legacy ErrTooManyStreams /
ErrTooManyTranscodes sentinels, pinned by tests.

Parity: combination tables driven against the real PresetsFor /
ensureTranscodeAllowed / SessionLimits math; full suite green (known
pre-existing jellycompat flakes only).

Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed
(locking design verified line-by-line) and verified here.

Part of #272

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

* fix(web): satisfy tsc -b strict return typing in the Rego stream tokenizer

The production build (tsc -b) rejects assigning CodeMirror's
string | void next() result to string | undefined; tsc --noEmit did not
catch it. Restructured the string-literal loop.

Part of #272

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

* fix(policy): clearer error when a decision is undefined for partial input

Vendor policies index required input fields directly, so a hand-written
simulate payload missing fields yields an undefined decision. Surface
that as 'decision X is undefined for this input (missing required input
fields?)' instead of 'empty result' — found while exercising the
simulate API against a live server.

Part of #272

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

* chore(web): set changeOrigin automatically when the API proxy target is remote

Remote dev backends sit behind vhost-routing proxies that reject a
localhost Host header; local targets keep the existing pass-through
behavior. Enables pointing the Vite dev server at a hosted backend via
VITE_API_PROXY_TARGET in web/.env.local.

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

* refactor(web): redesign the policy workspace around the decision pipeline

The first-pass UI was structurally generic: a five-column document table
squeezed beside the editor, three equal-weight action buttons with
hidden preconditions, raw version IDs, and jargon copy — nothing taught
the model. The page now teaches it:

- A pipeline strip states the mental model up front: Silo decides the
  baseline -> your overrides narrow it -> every decision is logged. Tabs
  renamed to Overrides / Baseline / Decision Log (ids stay stable for
  bookmarked URLs).
- The document table becomes one card per domain (Library visibility /
  Admin & permissions / Downloads & playback) with plain-language
  descriptions, example rules, status pills (Live vN / Draft / Disabled),
  inline creation, and the enable kill-switch in place.
- Selecting an override drills into a full-width editor with a visible
  lifecycle rail (Draft -> Validated -> Saved -> Live) and one contextual
  primary action per step; the unedited live source shows no actions
  until edited. Version comments appear only at the save step.
- Simulate is reframed as 'Test before going live' with a human verdict
  chip (Allowed / Denied — reason / ceiling summary) above the raw JSON;
  internal generation counters no longer surface.
- History uses 'Make live' with plain go-live copy; authors read
  'User N'; the baseline tab explains that upgrades never touch
  overrides.

Hand-written redesign (no Codex); verified via vitest, tsc, eslint,
prettier, and a production build.

Part of #272

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

* refactor(web): present the policy baseline as readable rules, not raw Rego

The Baseline tab dumped five Rego modules into read-only editors. It now
leads with what the rules actually do: one card per domain with
plain-language statements of the shipped behavior and a note on what an
override may change, plus content-rating and playback-quality tier
ladders parsed live from the lib module sources (so the tiers shown are
the ones the server enforces, not a hardcoded copy). The Rego source
stays one click away behind a per-module accordion and remains the
stated source of truth; unrecognized modules fall back to source-only.

Part of #272

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

* docs(policy): add access-groups design addendum

Groups with permission toggles become the everyday admin surface; the
Rego editor is demoted behind policy.editor_enabled (default off).
Restriction-only composition: group grants are an upper bound, per-user
settings tighten further — same rule as the existing account/profile
merge, one layer up.

Part of #272

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

* feat(access): add access groups — group defaults with restriction-only composition

New access_groups table + users.access_group_id (one group per user, NULL
= today's behavior). Group grants are an upper bound composed with the
user's own settings by strictest-wins rules — library intersection,
MinQuality, AND'd booleans, strictest positive stream/transcode limits,
permission-mask intersection, and a requests toggle gating CreateRequest.
The merge happens in Go (access.ApplyGroupPolicy /
EffectivePolicyForUser) before policy inputs are built, so vendor Rego,
the parity suites, and the decision log are untouched; every enforcement
surface (viewer scope in both resolvers, permission gates, downloads,
playback admission, requests) consumes the effective policy and fails
closed on provider errors. Changing a group's quality ceiling bumps its
members' access_policy_revision, mirroring the per-user rule.

Additive admin API: /admin/access-groups CRUD with member counts;
PUT /admin/users/{id} + user DTOs gain access_group_id.

Also demotes the Rego editor: policy.editor_enabled (default off,
hot-reloaded) drives the capability endpoint's editor_available and
403-gates editor endpoints while the engine and decision logging keep
running.

Design: docs/superpowers/specs/2026-07-02-access-groups-design.md.
Implementation drafted by Codex (GPT-5.5) via codex exec; reviewed
(composition core + fail-closed call-site audit) and verified here.
DB-backed group-store tests pending local Postgres recovery.

Part of #272

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

* feat(web): add Access Groups admin page and gate the policy editor

New /admin/access-groups: a card grid summarizing each group (member
count + key restrictions), drilling into an editor that reuses the same
LibraryAccessSelector and quality presets as the user editor, with
toggles for downloads/transcoded-downloads/requests, concurrent-stream
and transcode limits, and a permissions mask (all-assignable by default,
narrowable to specific permissions). Delete warns how many members fall
back to the built-in defaults. Copy states the composition rule up front:
a group grants the most a member can do; their own restrictions still
apply on top.

The user editor gains a Group picker and read-only row; the Policy nav
entry is now hidden unless the capability reports the editor enabled.
Plumbing (types, hooks, user-editor picker, nav gating) drafted by Codex
(GPT-5.5); the Groups page hand-built. Verified: 25 tests across the
touched suites, tsc, eslint, prettier, and a production build.

Part of #272

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

* feat(access): seed a Default Group and auto-assign newly created users

Adds access_groups.is_default with a partial unique index (one default
at most — the profiles is_primary pattern) and seeds a permissive
'Default Group' whose ceiling is a no-op, so assignment never changes
anyone's effective access until an admin edits it. The seed is guarded
against pre-existing defaults and name collisions; the Down migration
only removes the row if it is still untouched.

Assignment happens at the single INSERT INTO users choke point
(UserRepository.Create): when no explicit group is given, access_group_id
is filled by a scalar subquery on the default flag — NULL when no default
exists. Every creation path (setup, signup, invites, OAuth, admin create)
is covered by construction. Setting a new default via the API atomically
clears the previous one in the same transaction.

Deleting or unsetting the default is legal: new users then start with no
group, which is pre-feature behavior.

Implementation drafted by Codex (GPT-5.5); migration guards and the
choke-point subquery reviewed line-by-line here.

Part of #272

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

* feat(web): surface the default access group

Cards show a Default badge; the group editor gains a 'Default for new
users' toggle (with copy noting existing users are never moved); the
delete dialog warns when removing the default that new accounts will
start with no group.

Part of #272

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

* feat(access): ship the Default Group with house-rule ceilings

Seed values per product decision: 5 concurrent streams, 5 transcodes,
transcoded downloads off, and a permission mask of marker_edit only
(metadata curation excluded). Plain downloads and requests stay on. The
Down guard matches the new values so it still only removes an untouched
seed row. Only newly created users are affected; existing users are
never assigned.

Part of #272

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

* feat(access): retire per-user defaults — the Default Group is the sole default policy

Removes both legacy 'user defaults' mechanisms now that the seeded
Default Group owns new-user policy:

- users.max_streams / max_transcodes column defaults drop from 6/2 to 0
  (= unrestricted at the user layer), so group ceilings apply to new
  signups/invites/OAuth users instead of fighting stale per-user
  numbers. Existing rows keep their stored values — nobody is silently
  uncapped on upgrade.
- The dead defaults.max_playback_quality / defaults.max_profiles
  settings validation goes away with its only writer (the User Defaults
  dialog, removed on the web side).

Part of #272

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

* feat(web): replace the User Defaults dialog with group-governed creation

The Users page's 'User Defaults' dialog (defaults.* server settings)
duplicated what access groups now do properly, and its values were only
ever form prefill — no backend path applied them. The button now links
to Access Groups, and the create-user form seeds unrestricted user-layer
values (0 streams/transcodes, any quality, downloads allowed) so the
member's group governs; per-user fields remain for tightening individual
users.

Part of #272

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

* feat(access): migrate existing non-admin users into the Default Group

Existing users join the seeded Default Group on upgrade so one policy
source governs the whole instance. Their per-user limits still holding
the retired 6/2 column defaults are normalized to 0 in the same
statement so the group's ceilings actually apply; deliberately
customized values are preserved. Admin accounts stay ungrouped —
scope/action decisions are role-blind, so grouping an admin would cap
the server owner on upgrade.

Part of #272

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

* fix(access): keep admins out of the Default Group and treat group moves as policy changes

New-user creation now mirrors the migration's admin exclusion: the
default access group is only auto-assigned to non-admin roles, so a
fresh server owner no longer inherits the starter group's transcode
denial and stream caps.

Changing a user's access group now bumps access_policy_revision (the
group carries permissions, quality, and limits, exactly like the
per-user fields that already bump it) and triggers admin session
revocation when the group actually changes.

Part of #272

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

* fix(policy): enforce marker_edit through the PDP on marker write routes

The Rego permission policy owned marker_edit but no Go caller ever
consulted it: PUT/DELETE /markers went through a handler-local check
that short-circuited admins and read only the user's own permissions,
so group permission masks and custom policy overrides were ignored.

Marker writes are now gated by router middleware like the other
permission surfaces: a PDP-backed RequireMarkerEdit that evaluates the
group-merged effective permissions (plus the legacy variant for
proxy/test wiring without a policy system). The handler-local check and
its user loader are gone.

Part of #272

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

* fix(downloads): assert device/quality policy facts and honor the quality ceiling

The download_transcode action check hard-coded an empty device ID and
never asserted the requested quality, and no caller consumed
ActionDecision.QualityCeiling — custom download policies keyed on those
inputs were silently ineffective.

Resolve now threads the request's device ID and requested quality into
the action input, and a returned quality ceiling downscales the
prepared transcode target (the ceiling applies to what is served,
matching the serve-time rule in serveDownloadBytes). FileQuality and
the content-rating pair stay intentionally empty for downloads —
documented on downloadActionInput: those ceilings are enforced against
the served artifact by the scope-derived access filter, and asserting
the source's quality would wrongly deny capped transcodes.

Part of #272

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

* test(access): align the default-group seed assertions with the migration

The DB test still asserted the earlier no-op seed (transcode allowed,
unlimited streams/transcodes, null permissions); the shipped migration
seeds transcode denied, 5/5 limits, and marker_edit-only permissions,
so the test failed on any database with the migration applied.

Part of #272

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

* fix(policy): lock the Rego sandbox by builtin purity and bound compile work

Exclude every nondeterministic builtin from the admin sandbox instead of
denylisting names, so OPA upgrades cannot silently expose impure builtins
while pure helpers like net.cidr_contains stay usable. Apply the same
capabilities to the runtime engine, cap concurrent compile checks, and
reject oversized sources before they reach the uncancelable compiler.

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

* fix(policy): require literal booleans in vendor override and input checks

Bare object.get truthiness treated any non-false value as satisfied, so a
malformed override 'allowed' value could fail to tighten a base grant and
hand-crafted simulate input could flip flag predicates. Compare against
literal true so anything else denies.

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

* fix(policy): surface decision log cleanup failures to the task manager

CleanupDecisionLogsOnce now returns the first error alongside the deleted
count so a broken partition manager or DB outage marks the scheduled task
failed instead of reporting 100% success while policy_decisions grows.

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

* fix(playback): log admission decider errors before failing closed

A policy-evaluation failure was silently mapped to the too-many-streams
denial, making an engine outage indistinguishable from a real limit hit.

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

* fix(access): nil-guard the downloads user and restore the ABS legacy resolver

effectiveDownloadUser dereferenced policy state before its nil-user check,
and the ABS handler lost viewer-scoped filtering entirely when the policy
system was unavailable because no legacy access.NewResolver fallback was
wired like the other resolver paths.

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

* fix(web): address admin policy review feedback

- invalidate the version query by version_number, the key usePolicyVersion
  actually caches under
- keep the goPrevious cursor-stack updater pure (Strict Mode double-invoke)
- make version history rows keyboard-selectable like the document list
- clamp download_transcode_allowed when downloads are disabled so groups
  cannot save a contradictory record

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

* fix(api): cap policy endpoint request bodies at 1 MiB

The policy write endpoints (create document/version, set enabled,
validate, simulate) decoded JSON bodies without a size limit, so an
oversized payload buffered fully in memory before CompileCheck's
256 KiB source cap could reject it. Route all five through a shared
decodePolicyRequest helper that wraps the body in http.MaxBytesReader
and returns 413 with the repo's standard too_large error shape.

Part of #272

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN

* fix(access): forbid deleting or demoting the default access group

Deleting the default group (or unsetting its is_default flag) left the
server with no default: new non-admin users were then created ungrouped
with max_streams/max_transcodes of 0 — unlimited — because the legacy
per-user column defaults were retired in favor of the group's ceilings.

The store now rejects both operations with ErrDefaultGroupRequired
(mapped to 409); promoting another group remains the supported way to
move the default, and atomically clears the previous one. The admin UI
disables the delete button and the default toggle on the default group
and explains the promote-another-group flow.

Part of #272

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN

* fix(web): keep unsaved policy drafts when a newer version activates elsewhere

The editor state was keyed on the active version's id/sha, so a
background refetch after another admin (or another tab) activated a
version remounted the editor and silently discarded the dirty draft.

PolicyEditorPanel now pins the seed it is editing against and only
adopts an incoming seed when nothing can be lost: the editor is clean,
the draft already equals the incoming source (the same-admin activate
flow), or the selection moved to a different document. Otherwise the
pinned editor stays mounted and an inline notice offers an explicit
"Load live version" action.

Part of #272

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UtnZ2Uewzo959hpneLrtRN

* fix(policy): fail reloads on invalid custom sources and surface degraded/apply state

A stored custom source that stops compiling used to be silently skipped on
reload: the bundle widened to vendor-only for that domain while the generation
reported fully applied. Reload is now strict — a bad enabled source fails the
reload and the last known-good engine keeps serving. Boot keeps its vendor
fallback for availability, but skips are recorded on the engine and exposed
(with store-outage reasons) through System.DegradedState and additive
degraded fields on GET /policy/capability. Activate/SetEnabled re-run
CompileCheck instead of trusting the stored compiled_ok flag.

Mutation endpoints also no longer conflate persistence with live apply:
activation/enable responses carry additive applied/failed_step/
loaded_generation fields and return 202 when the store change persisted but
the local reload failed.

Addresses review findings C1, C2, and the degraded-signal gap (6.1).

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

* fix(policy): type deny reasons across the contract and enforce profile_verified

Deny handling used to branch on exact free-text reason strings in three Go
consumers, and playback reported ANY unrecognized reason — including custom
override free text and engine failures — as a stream-limit error. Decisions
now carry a stable reason_code (custom overrides always get custom_denial);
downloads, the metadata-curation gate, and playback admission switch on codes,
with a new ErrPlaybackNotAllowed -> 403 playback_not_allowed mapping for
non-limit denials. Rego tests pin every vendor code.

The scope contract's tighten-only profile_verified output was also emitted but
never consumed; a policy revocation now surfaces as ErrProfileUnverified (403
profile_unverified) instead of silently proceeding.

Addresses review findings 6.2 and C4.

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

* fix(catalog): close the dual-library disabled-scope bypass in direct item authorization

EnsureAccessible, EnsureAccessibleIDs, and FilterAccessibleContentIDs gated
library access with allow/deny predicates over a single joined
media_item_libraries row, so an item linked to BOTH a passing library and a
disabled one satisfied the disabled check via the passing row — a direct-ID
bypass of disabled-library scope on the detail, media-file, playback, and
download paths. All library access predicates now share one helper
(libraryAccessConditions) emitting independent EXISTS / NOT EXISTS subqueries,
the semantics GetByIDsWithAccess already used, including the orphan-item
membership guard for disabled-only scopes. SQL-shape tests pin every builder
and a DB-gated regression test covers the dual-library item end to end.

Addresses review finding C3 (plus the same shape in
buildFilterAccessibleContentIDsSQL, which the review did not flag).

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

* fix(downloads): serialize quota check and row creation under a per-user advisory lock

The concurrent-download quota was check-then-insert with nothing serializing
the pair: parallel creates could all observe free quota before any row
existed, bypassing the cap and stacking artifact encode jobs. All four
check->insert spans (ephemeral original, artifact-backed, series batch,
managed batch) now run inside Repository.WithUserQuotaLock — a
pg_advisory_xact_lock keyed by user, so the serialization holds across nodes.
The artifact path keeps the limiter-before-Ensure ordering (a rejected request
must not leave an encode job behind) by holding the lock across Ensure.
Managed-entry replacement stays quota-exempt and lock-free. A DB-gated
barrier test races 8 creates against a cap of 1.

Addresses review finding C5.

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

* fix(downloads): assert served quality at create time for original and remux downloads

Direct-original and remux downloads serve the source resolution unchanged, but
create-time policy checks left file_quality empty — an over-ceiling source
registered a row serveDownloadBytes could never satisfy. Resolve now runs a
final download action check with FileQuality populated on those two paths
(capped transcodes keep the ceiling-on-artifact behavior), a custom override
ceiling below the served resolution denies, and quality_ceiling_exceeded maps
to ErrQualityUnavailable. The ActionInput contract now documents exactly when
file_quality and the rating facts are supplied so custom policy authors are
not misled.

Addresses review finding C6.

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

* fix(policy): guard activation against slow overrides and make eval timeouts observable

A custom scope override that exceeds the 25ms eval budget compiled fine,
activated fine, and then converted to 500s on every authenticated request —
server-wide lockout authored in the admin editor. Activation and enable now
run GuardEvalCost: the candidate source is evaluated on a throwaway engine
against a canned representative input under the live budget, and a source
that cannot complete is rejected 422 with ErrPolicySlowEval before it goes
live. Runtime timeouts keep failing closed but now carry a distinct
ErrPolicyEvalTimeout sentinel, an Error log, and a per-engine counter exposed
as eval_timeouts on GET /policy/capability so intermittent near-budget
policies are attributable.

Addresses review finding C7.

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

* style: gofmt remediation files

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:38:19 -04:00
a5fb16a5c5 feat(historyimport): import the Plex account watchlist alongside watch history (#285)
* feat(historyimport): import the Plex account watchlist alongside watch history

The Plex import migrated only watch history; the user's saved watchlist
had to be rebuilt by hand (#245).

- PlexClient gains FetchWatchlist: pages the account-level watchlist on
  the Plex discover API (discover.provider.plex.tv). It authenticates
  with the plex.tv ACCOUNT token — the PIN/OAuth session token, which
  resolvePlexAuth now threads through plexAuth.AccountToken (manual-token
  imports pass the user token, which doubles as the account token).
- Watchlist entries become import Records flagged Watchlisted, carrying
  identity only (movie/show → KindMovie/KindSeries, guids parsed) and no
  watch state. They ride the existing matcher (series matching already
  exists), and matched entries are added to the importing profile's
  watchlist via the idempotent AddToWatchlistAt — re-imports do not
  duplicate. A watchlist fetch failure downgrades to a run warning so the
  history import still completes.
- Run summaries gain a watchlist_added counter (new column + repo
  plumbing + client/admin UI cards).

Fixes #245

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

* fix(historyimport): count only newly inserted watchlist rows in WatchlistAdded

AddToWatchlistAt now reports whether a row was actually inserted (the
insert is ON CONFLICT DO NOTHING / INSERT OR IGNORE), and the import
summary increments WatchlistAdded only for genuine inserts, so
re-importing the same Plex account no longer inflates the count.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-07-05 00:19:31 -04:00
012006e7a7 test(access): add ListProgressSince to stubStore so tests compile (#277)
PR #258 added ListProgressSince to the userstore.UserStore interface but
did not update the access resolver test stub, breaking test compilation
for internal/access on main.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 14:07:28 -04:00
CoffeeKnyteandGitHub 652b5d1e9f perf(jellycompat): fast-paths for slow Jellyfin-compat queries (#247)
* perf(jellycompat): fast-paths for slow Jellyfin-compat queries

Rebuilds the jellycompat slow-query fast-paths cleanly on main, without
the hub-sections virtual-library feature (which is intentionally absent
on this branch). Each path is bounded so behavior stays predictable
under load.

Fast-paths:
- Episode catalog: add the load-bearing `si.type = 'series'` hydration
  guard plus a matching entry-scan series-parent guard, so the page and
  count scans stay aligned with hydration (no under-filled pages, no
  inflated TotalRecordCount from podcast/audiobook episode rows).
- /Shows/{id}/Episodes AdjacentTo: serve a bounded prev/self/next
  neighbor window with series-membership validation and a cross-series
  id guard, falling back to the full listing.
- Resume: serve Continue Watching through the capped native
  continue-watching fetcher (next-up suppressed, in-progress only)
  instead of loadProgressPage's unbounded scan. Wires only the
  sections read-time fetcher (StoreProvider) into ItemsHandler; no
  hub-section/virtual-library exposure.
- Played-history: push the type + library filter down into SQL.
- recently_added: fan a no-parentId browse out across libraries with an
  app-side dedup merge, clamped to BrowseFilters.MaxLimit, and exclude
  compat-hidden (audiobook/podcast) libraries from the fan-out.
- Detail fields: batch per-item GetItemDetail in the Latest/browse
  detail-field paths via GetItemDetailsByIDs, with per-item fallback.
- Search: term-length and result-count guards for aux search.

* perf(jellycompat): clamp Resume fast-path fetch to maxResumeItems

A large client-supplied StartIndex on /UserItems/Resume inflated
loadResumeViaSections' fetchLimit (pageSize + StartIndex) and fed it
straight into ResolvedSection.ItemLimit, letting the continue-watching
fetcher scan up to its continueProgressMaxScanned hard cap (1000 rows)
instead of stopping near maxResumeItems. Clamp fetchLimit to
maxResumeItems and short-circuit to an empty page when StartIndex is at
or beyond the cap (no visible row can land there), keeping the capped
fetcher's scan bounded and predictable under load.

Addresses CodeRabbit review finding on PR #247.
2026-07-01 09:06:24 -04:00
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
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
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
Silo Server Migration c085b12fd1 Initial Silo migration 2026-05-22 23:26:56 -04:00