Files
silo-server/internal/playback/transcode.go
T
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

1795 lines
54 KiB
Go

package playback
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"log/slog"
"math"
"mime"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
func init() {
// Register .m4s so http.ServeFile sets the correct Content-Type for
// fMP4 HLS segments. Go's default MIME database does not include it.
_ = mime.AddExtensionType(".m4s", "video/mp4")
}
// TranscodeOpts holds configuration for an HLS transcode session.
type TranscodeOpts struct {
InputPath string
OutputDir string // e.g., /tmp/silo-transcode/{session_id}/
SessionID string
SourceVideoCodec string
SeekSeconds float64
TargetResolution string // e.g., 1080p, 720p
TargetCodecVideo string // e.g., h264 (or hevc if allowed)
TargetCodecAudio string // e.g., aac
SegmentDuration int // seconds, default 6
StartSegmentNumber int // -hls_segment_start_number, default 0
FFmpegPath string // optional explicit ffmpeg binary path
HWAccel string // auto, qsv, vaapi, none
HWDevice string // e.g., /dev/dri/renderD128 (default if empty)
SubtitleTrackIndex int // -1 = no subtitles
SubtitleBurnIn bool
AudioTrackIndex int // -1 = default (first track), >= 0 = specific track
TargetBitrateKbps int // max video bitrate in kbps; 0 = CRF-only (no cap)
TotalDuration float64 // total media duration in seconds (for VOD manifest)
FastStart bool // use superfast preset for faster first-segment production
NodeType string
ExecutionMode string
FFmpegLogSink FFmpegLogSink
}
// TranscodeSession manages a running ffmpeg HLS transcode process.
type TranscodeSession struct {
cmd *exec.Cmd
cancel context.CancelFunc
opts TranscodeOpts
outputDir string
running bool
restarting bool
waitErr error
stderr *boundedTailBuffer
mu sync.Mutex
done chan struct{} // closed when the monitor goroutine finishes
stdinPipe io.WriteCloser
lastRequestedSegment int
throttler *TranscodeThrottler
stderrLinesLogged int
stderrBytesLogged int
stderrDroppedLines int
stderrCapLogged bool
restartCount int
stderrLineIndex int
stderrWriter *ffmpegStderrWriter
}
// SegmentProgress describes the media ffmpeg has actually produced on disk.
type SegmentProgress struct {
ProducedHead int
ProducedCount int
LastProducedAt time.Time
ManifestModTime time.Time
HasManifest bool
Running bool
Restarting bool
StartSegmentNumber int
SegmentDuration int
LastRequestedSegment int
}
// SegmentRecoveryDecision tells the segment handler whether to briefly wait
// for ffmpeg or seek-restart immediately.
type SegmentRecoveryDecision struct {
Wait bool
WaitTimeout time.Duration
Reason string
Progress SegmentProgress
}
// defaultSegmentDuration is the segment length when not specified. Short
// segments (2s) allow the player to start quickly while still maintaining
// efficient HTTP delivery. This matches the approach used by Plex.
const defaultSegmentDuration = 2
const maxPersistedFFmpegLines = 2000
const maxPersistedFFmpegBytes = 256 * 1024
const maxPersistedFFmpegChars = 2000
const (
maxSequentialMissingSegments = 2
segmentWaitGrace = 1500 * time.Millisecond
maxSegmentWait = 6 * time.Second
minSegmentWait = 3 * time.Second
minStaleProducedWindow = 5 * time.Second
)
// StartTranscode launches an ffmpeg process that produces HLS segments.
func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession, error) {
if opts.SegmentDuration <= 0 {
opts.SegmentDuration = defaultSegmentDuration
}
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
// Ensure output directory exists.
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
return nil, fmt.Errorf("create output dir: %w", err)
}
ctx, cancel := context.WithCancel(ctx)
s := &TranscodeSession{
cancel: cancel,
opts: opts,
outputDir: opts.OutputDir,
running: true,
done: make(chan struct{}),
stderr: newBoundedTailBuffer(stderrTailMaxBytes),
lastRequestedSegment: opts.StartSegmentNumber,
}
args := buildFFmpegArgs(opts)
bin := opts.FFmpegPath
if bin == "" {
bin = ffmpegBinary()
}
log.Printf("playback: ffmpeg cmd: %s %s", bin, strings.Join(args, " "))
s.logFFmpegEvent(ctx, "ffmpeg process starting", "")
cmd := exec.CommandContext(ctx, bin, args...)
stdinPipe, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("create stdin pipe: %w", err)
}
cmd.Dir = opts.OutputDir
cmd.Stderr = s.newStderrWriter(ctx)
cmd.WaitDelay = 3 * time.Second
if err := cmd.Start(); err != nil {
cancel()
s.logFFmpegEvent(ctx, "ffmpeg process exit error", err.Error())
return nil, fmt.Errorf("start ffmpeg: %w", err)
}
s.cmd = cmd
s.stdinPipe = stdinPipe
s.logFFmpegEvent(ctx, "ffmpeg process started", "")
// Monitor ffmpeg in background.
go func() {
waitErr := cmd.Wait()
s.flushStderr(ctx)
s.mu.Lock()
s.running = false
s.waitErr = waitErr
s.mu.Unlock()
s.logWaitResult(ctx, waitErr)
close(s.done)
}()
return s, nil
}
// IsMPEG2VideoCodec reports whether a probed video codec name identifies
// MPEG-2 video. It accepts common FFmpeg aliases because codec strings can
// come from scan metadata, direct probes, or client capability lists.
func IsMPEG2VideoCodec(codec string) bool {
normalized := strings.NewReplacer(
" ", "",
"-", "",
"_", "",
".", "",
).Replace(strings.ToLower(strings.TrimSpace(codec)))
switch normalized {
case "mpeg2video", "mpeg2", "mp2v":
return true
default:
return false
}
}
// IsMPEG4Part2VideoCodec reports whether a codec name identifies MPEG-4 Part 2
// video, commonly found in older XviD/DivX AVI files.
func IsMPEG4Part2VideoCodec(codec string) bool {
normalized := strings.NewReplacer(
" ", "",
"-", "",
"_", "",
".", "",
).Replace(strings.ToLower(strings.TrimSpace(codec)))
switch normalized {
case "mpeg4", "mp4v", "xvid", "divx", "dx50":
return true
default:
return false
}
}
// buildFFmpegArgs constructs the full ffmpeg argument list from TranscodeOpts.
func buildFFmpegArgs(opts TranscodeOpts) []string {
// Resolve "auto" into a concrete accel method once so all downstream
// helpers (appendHWAccelArgs, appendVideoArgs, etc.) see the real value.
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
isVideoCopy := opts.TargetCodecVideo == "copy"
isAudioCopy := opts.TargetCodecAudio == "copy"
args := []string{
"-hide_banner",
"-loglevel", "error",
}
// Hardware acceleration — skip when copying video (no encoding needed).
if !isVideoCopy {
args = appendHWAccelArgs(args, opts)
}
// Limit input probing to speed up startup, especially on network storage.
// -fflags +genpts generates PTS for files with missing timestamps;
// +fastseek enables faster input seeking (matches Jellyfin).
args = append(args,
"-fflags", "+genpts+fastseek",
"-analyzeduration", "3000000", // 3 seconds (default 5s)
"-probesize", "5000000", // 5 MB (default 5MB, explicit for clarity)
)
// Seek before input for fast seeking.
if opts.SeekSeconds > 0 {
args = append(args, "-ss", fmt.Sprintf("%.3f", opts.SeekSeconds))
// When video is copied but audio is transcoded, accurate_seek causes
// A/V desync: video must start at a keyframe but audio is trimmed to
// the exact seek point. Disabling it keeps both streams aligned.
if isVideoCopy && !isAudioCopy {
args = append(args, "-noaccurate_seek")
}
}
// Input file.
args = append(args, "-i", opts.InputPath)
args = append(args, "-map_metadata", "-1")
args = append(args, "-map_chapters", "-1")
args = appendStreamSelectionArgs(args, opts.AudioTrackIndex)
args = appendTimestampNormalizationArgs(args, opts)
// Video codec and encoding settings.
if isVideoCopy {
args = append(args, "-c:v", "copy")
} else {
args = appendVideoArgs(args, opts)
}
// Copy-video sessions only do audio work on the filter/encode side.
// ffmpeg's default thread selection spawns one filter thread per CPU
// (observed 14 idle `af#0:1` threads for a 5.1→2.0 downmix), so pin
// audio filter + encode to a single thread.
if isVideoCopy && !isAudioCopy {
args = append(args, "-threads", "1", "-filter_threads", "1", "-filter_complex_threads", "1")
}
// Audio codec.
args = appendAudioArgs(args, opts)
// Subtitle burn-in and resolution scaling — only when encoding video.
// When burn-in is active, the subtitle filter chain includes scaling
// (and hw download/upload for QSV/VAAPI). Otherwise, standalone scaling.
if !isVideoCopy {
if opts.SubtitleBurnIn && opts.SubtitleTrackIndex >= 0 {
args = appendSubtitleBurnInArgs(args, opts)
} else if opts.HWAccel == "qsv" {
scale := qsvScaleFilter(opts.TargetResolution)
args = append(args, "-vf", scale)
} else if opts.HWAccel == "vaapi" {
scale := vaapiScaleFilter(opts.TargetResolution)
args = append(args, "-vf", scale)
} else if opts.TargetResolution != "" {
scale := resolutionToScale(opts.TargetResolution)
if scale != "" {
args = append(args, "-vf", scale)
}
}
args = appendSegmentBoundaryArgs(args, opts)
}
// HLS output options.
// Codec-copy sessions usually use fMP4 segments — no transmuxing needed in
// hls.js, which avoids Safari MSE compatibility issues with certain codecs
// in TS. MPEG-2 video is the exception: Apple consumes it as compatibility
// HLS, so package it in MPEG-TS while still copying the video stream.
// Actual transcoding uses MPEG-TS segments to avoid the hls.js endOfStream()
// race with fMP4 (hls.js #6337).
var segmentPattern string
segmentType := "mpegts"
copyVideoUsesFMP4 := isVideoCopy && !IsMPEG2VideoCodec(opts.SourceVideoCodec)
if copyVideoUsesFMP4 {
segmentType = "fmp4"
segmentPattern = filepath.Join(opts.OutputDir, "seg_%05d.m4s")
} else {
segmentPattern = filepath.Join(opts.OutputDir, "seg_%05d.ts")
}
manifestPath := filepath.Join(opts.OutputDir, "stream.m3u8")
args = append(args,
"-max_muxing_queue_size", "2048",
"-max_delay", "5000000",
"-f", "hls",
"-hls_time", fmt.Sprintf("%d", opts.SegmentDuration),
"-hls_list_size", "0",
"-hls_segment_type", segmentType,
// Write segments to temp files first so the player never fetches a
// partially-written segment during a quality switch.
"-hls_flags", "independent_segments+temp_file",
"-hls_segment_filename", segmentPattern,
)
// fMP4 segments need movflags=+frag_discont so each fragment writes
// audio DTS/PTS including the initial delay into MOOF→TRAF→TFDT.
// Without this, some browsers (notably Chromium on macOS) can experience
// A/V sync issues during copy-mode HLS playback. Matches Jellyfin's
// proven fMP4 HLS pipeline.
if copyVideoUsesFMP4 {
args = append(args, "-hls_segment_options", "movflags=+frag_discont")
}
if opts.StartSegmentNumber > 0 {
args = append(args, "-start_number", fmt.Sprintf("%d", opts.StartSegmentNumber))
}
args = append(args, manifestPath)
return args
}
func resolveEffectiveTranscodeHWAccel(opts TranscodeOpts) string {
hwAccel := ResolveHWAccel(opts.HWAccel)
if hwAccel == "" {
return ""
}
if strings.EqualFold(opts.TargetCodecVideo, "copy") {
return "none"
}
if IsMPEG4Part2VideoCodec(opts.SourceVideoCodec) {
return "none"
}
return hwAccel
}
// appendStreamSelectionArgs limits output to primary video/audio streams.
func appendStreamSelectionArgs(args []string, audioTrackIndex int) []string {
args = append(args, "-map", "0:v:0")
if audioTrackIndex >= 0 {
args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioTrackIndex))
} else {
args = append(args, "-map", "0:a:0?")
}
args = append(args, "-sn")
args = append(args, "-dn")
return args
}
// appendTimestampNormalizationArgs selects timestamp handling based on the
// playback mode. Copy-video full-file starts use zero-based timestamps so
// fMP4 fragments always have sane local durations. Copy-video resumes
// preserve source timestamps so each fragment's TFDT matches its playlist
// position (segment K sits at playlist-time K*segDur); zero-basing here
// makes seg_K carry TFDT=0, and strict players (Jellyfin Android TV /
// ExoPlayer) read EXT-X-START, jump to seg_K expecting media at K*segDur,
// see TFDT=0, treat the gap as a discontinuity, reload init.mp4, and
// eventually abort — the symptom that crashes ATV on a second resume.
// Encoded transcodes keep the source-timestamp policy unconditionally.
func appendTimestampNormalizationArgs(args []string, opts TranscodeOpts) []string {
if strings.EqualFold(opts.TargetCodecVideo, "copy") {
if opts.SeekSeconds > 0 {
return append(args,
"-copyts",
"-avoid_negative_ts", "disabled",
)
}
return append(args,
"-avoid_negative_ts", "make_zero",
)
}
return append(args,
"-copyts",
"-avoid_negative_ts", "disabled",
)
}
// appendSegmentBoundaryArgs forces keyframes on segment boundaries so each HLS
// fragment starts cleanly and can be appended independently by the player.
//
// With -copyts, the output timestamp t starts at the seek position rather than
// 0. Subtracting SeekSeconds prevents a "catch-up storm" where n_forced races
// from 0 to seek_position/segment_duration, making every frame an I-frame and
// grinding encoding to a halt for large seeks.
func appendSegmentBoundaryArgs(args []string, opts TranscodeOpts) []string {
args = append(args, "-sc_threshold", "0")
if opts.SeekSeconds > 0 {
args = append(args, "-force_key_frames",
fmt.Sprintf("expr:gte(t-%.3f,n_forced*%d)", opts.SeekSeconds, opts.SegmentDuration))
} else {
args = append(args, "-force_key_frames",
fmt.Sprintf("expr:gte(t,n_forced*%d)", opts.SegmentDuration))
}
// Hardware encoders (QSV, VAAPI) may not reliably honor
// force_key_frames expressions. Set explicit GOP size so segment
// boundaries always start with an IDR frame. We assume 30 fps as a
// safe ceiling — the GOP will be at most segmentDuration * 30 frames.
// Matches Jellyfin's approach for hardware encoders.
if opts.HWAccel == "qsv" || opts.HWAccel == "vaapi" {
gopSize := fmt.Sprintf("%d", opts.SegmentDuration*30)
args = append(args, "-g", gopSize, "-keyint_min", gopSize)
}
return args
}
// appendHWAccelArgs adds hardware acceleration flags based on the HWAccel setting.
// The caller must resolve "auto" via ResolveHWAccel before calling this.
func appendHWAccelArgs(args []string, opts TranscodeOpts) []string {
switch opts.HWAccel {
case "qsv":
hwDevice := PickRenderDevice(opts.HWDevice)
if hwDevice == "" {
slog.Warn("no GPU render device found, QSV transcode may fail")
hwDevice = "/dev/dri/renderD128" // last-resort fallback
}
// VAAPI→QSV hardware pipeline: derive QSV from VAAPI device.
args = append(args,
"-init_hw_device", fmt.Sprintf("vaapi=va:%s,driver=iHD,kernel_driver=i915,vendor_id=0x8086", hwDevice),
"-init_hw_device", "qsv=qs@va",
"-filter_hw_device", "va",
"-hwaccel", "vaapi",
"-hwaccel_output_format", "vaapi",
"-noautorotate",
)
case "vaapi":
vaapiDevice := PickRenderDevice(opts.HWDevice)
if vaapiDevice == "" {
vaapiDevice = "/dev/dri/renderD128" // last-resort fallback
}
args = append(args,
"-init_hw_device", fmt.Sprintf("vaapi=hw:%s", vaapiDevice),
"-filter_hw_device", "hw",
"-hwaccel", "vaapi",
"-hwaccel_output_format", "vaapi",
)
}
return args
}
// videoPreset returns an encoder-compatible preset. CPU encoders use a faster
// fast-start preset for initial playback, while QSV stays on the fastest
// preset family it supports.
func videoPreset(opts TranscodeOpts, hwAccel string) string {
if hwAccel == "qsv" {
return "veryfast"
}
if opts.FastStart {
return "superfast"
}
return "veryfast"
}
// appendVideoArgs adds video codec arguments.
func appendVideoArgs(args []string, opts TranscodeOpts) []string {
codec := opts.TargetCodecVideo
if codec == "" {
codec = "h264"
}
if codec == "copy" {
return append(args, "-c:v", "copy")
}
preset := videoPreset(opts, opts.HWAccel)
hasBitrateCap := opts.TargetBitrateKbps > 0
switch {
case opts.HWAccel == "qsv" && codec == "h264":
if hasBitrateCap {
// VBR mode with bitrate cap instead of global_quality.
args = append(args, "-c:v", "h264_qsv", "-preset", preset,
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
} else {
args = append(args, "-c:v", "h264_qsv", "-preset", preset, "-global_quality", "23")
}
case opts.HWAccel == "qsv" && codec == "hevc":
if hasBitrateCap {
args = append(args, "-c:v", "hevc_qsv", "-preset", preset,
"-b:v", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
} else {
args = append(args, "-c:v", "hevc_qsv", "-preset", preset, "-global_quality", "28")
}
case opts.HWAccel == "vaapi" && codec == "h264":
args = append(args, "-c:v", "h264_vaapi", "-qp", "23")
if hasBitrateCap {
args = append(args,
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
}
case opts.HWAccel == "vaapi" && codec == "hevc":
args = append(args, "-c:v", "hevc_vaapi", "-qp", "28")
if hasBitrateCap {
args = append(args,
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
}
default:
// CPU fallback — match Jellyfin's proven browser-compatible settings.
// Force yuv420p to ensure 8-bit output (10-bit sources produce High 10
// Profile which browsers cannot decode via MSE).
if codec == "hevc" {
args = append(args, "-c:v", "libx265", "-preset", preset, "-crf", "28", "-pix_fmt", "yuv420p")
} else {
args = append(args, "-c:v", "libx264", "-preset", preset, "-crf", "23",
"-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.1")
}
if hasBitrateCap {
args = append(args,
"-maxrate", fmt.Sprintf("%dk", opts.TargetBitrateKbps),
"-bufsize", fmt.Sprintf("%dk", opts.TargetBitrateKbps*2))
}
}
return args
}
// appendAudioArgs adds audio codec arguments. Supports "copy" for passthrough,
// plus opus / aac / eac3 / ac3 as re-encode targets. EAC3 and AC3 are useful
// when we must transcode video but want to preserve surround channels for an
// HDMI receiver — both are legal in HLS fMP4 (not MPEG-TS; ensure the HLS
// packager is fMP4 when emitting these).
func appendAudioArgs(args []string, opts TranscodeOpts) []string {
codec := opts.TargetCodecAudio
if codec == "" {
codec = "aac"
}
switch codec {
case "copy":
args = append(args, "-c:a", "copy")
case "opus":
args = append(args, "-c:a", "libopus", "-b:a", "192k", "-ac", "2")
case "eac3":
// Typical Dolby Digital Plus 5.1 bitrate; let the source dictate channel
// count so we preserve surround when possible.
args = append(args, "-c:a", "eac3", "-b:a", "384k")
case "ac3":
// Legacy Dolby Digital; universal AVR support.
args = append(args, "-c:a", "ac3", "-b:a", "448k")
default:
args = append(args, "-c:a", "aac", "-b:a", "192k", "-ac", "2")
}
return args
}
// appendSubtitleBurnInArgs adds subtitle burn-in filter arguments.
// For CPU encoding, the filter chain is: [scale,]subtitles.
// For QSV/VAAPI, frames must be downloaded from hardware, processed on CPU,
// then re-uploaded: hwdownload → format=yuv420p → [scale,] subtitles → hwupload → hwmap.
func appendSubtitleBurnInArgs(args []string, opts TranscodeOpts) []string {
scale := resolutionToScale(opts.TargetResolution)
subFilter := fmt.Sprintf("subtitles='%s':si=%d",
escapeFilterPath(opts.InputPath), opts.SubtitleTrackIndex)
// Build the CPU filter portion: scale (if any) then subtitle overlay.
// Scale must come before subtitles so text is rendered at target resolution.
var cpuFilters string
if scale != "" {
cpuFilters = scale + "," + subFilter
} else {
cpuFilters = subFilter
}
switch opts.HWAccel {
case "qsv":
// VAAPI→QSV pipeline: download from VAAPI surface to CPU, apply subtitle
// and scale filters, convert to nv12 (required by hwupload for VAAPI
// surfaces), upload back to VAAPI, then map to QSV for the encoder.
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload,hwmap=derive_device=qsv,format=qsv"
args = append(args, "-vf", vf)
case "vaapi":
// VAAPI-only: download, apply CPU filters, convert to nv12, upload back.
vf := "hwdownload,format=yuv420p," + cpuFilters + ",format=nv12,hwupload"
args = append(args, "-vf", vf)
default:
// CPU encoding: filters run directly on decoded frames.
args = append(args, "-vf", cpuFilters)
}
return args
}
// resolutionToScale returns an ffmpeg scale filter string for the target resolution.
func resolutionToScale(res string) string {
switch res {
case "2160p":
return "scale=-2:2160"
case "1080p":
return "scale=-2:1080"
case "720p":
return "scale=-2:720"
case "480p":
return "scale=-2:480"
case "420p":
return "scale=-2:420"
case "328p":
return "scale=-2:328"
default:
return ""
}
}
// qsvScaleFilter returns the VAAPI→QSV filter chain with optional resolution scaling.
func qsvScaleFilter(res string) string {
switch res {
case "2160p":
return "scale_vaapi=w=-2:h=2160:format=nv12,hwmap=derive_device=qsv,format=qsv"
case "1080p":
return "scale_vaapi=w=-2:h=1080:format=nv12,hwmap=derive_device=qsv,format=qsv"
case "720p":
return "scale_vaapi=w=-2:h=720:format=nv12,hwmap=derive_device=qsv,format=qsv"
case "480p":
return "scale_vaapi=w=-2:h=480:format=nv12,hwmap=derive_device=qsv,format=qsv"
case "420p":
return "scale_vaapi=w=-2:h=420:format=nv12,hwmap=derive_device=qsv,format=qsv"
case "328p":
return "scale_vaapi=w=-2:h=328:format=nv12,hwmap=derive_device=qsv,format=qsv"
default:
return "scale_vaapi=format=nv12,hwmap=derive_device=qsv,format=qsv"
}
}
// vaapiScaleFilter keeps VAAPI frames in hardware and converts them to a
// browser-compatible encoder format. Using the CPU scale filter on VAAPI frames
// causes FFmpeg auto_scale format-negotiation failures.
func vaapiScaleFilter(res string) string {
switch res {
case "2160p":
return "scale_vaapi=w=-2:h=2160:format=nv12"
case "1080p":
return "scale_vaapi=w=-2:h=1080:format=nv12"
case "720p":
return "scale_vaapi=w=-2:h=720:format=nv12"
case "480p":
return "scale_vaapi=w=-2:h=480:format=nv12"
case "420p":
return "scale_vaapi=w=-2:h=420:format=nv12"
case "328p":
return "scale_vaapi=w=-2:h=328:format=nv12"
default:
return "scale_vaapi=format=nv12"
}
}
// filterPathReplacer escapes special characters in file paths for ffmpeg filter syntax.
var filterPathReplacer = strings.NewReplacer(
"'", "'\\''",
"[", "\\[",
"]", "\\]",
";", "\\;",
",", "\\,",
)
// escapeFilterPath escapes special characters in file paths for ffmpeg filter syntax.
func escapeFilterPath(path string) string {
return filterPathReplacer.Replace(path)
}
// minManifestSegments is the standard startup lead for actively encoded HLS.
// True video transcodes benefit from a larger cushion so playback does not
// outrun the encoder immediately after the first frame appears.
const minManifestSegments = 3
// minCopyManifestSegments is the startup lead for codec-copy sessions.
// Copying video while transcoding only audio can produce startup files far
// faster than real-time encoding, so waiting for 3 full segments adds
// unnecessary latency at playback start.
const minCopyManifestSegments = 2
func startupSegmentRequirement(opts TranscodeOpts) int {
if strings.EqualFold(opts.TargetCodecVideo, "copy") {
return minCopyManifestSegments
}
return minManifestSegments
}
// GetManifest returns the HLS m3u8 manifest content.
// It returns ErrManifestNotReady if the manifest does not yet contain enough
// segments for reliable HLS playback (see minManifestSegments).
func (s *TranscodeSession) GetManifest() ([]byte, error) {
s.mu.Lock()
defer s.mu.Unlock()
manifestPath := filepath.Join(s.outputDir, "stream.m3u8")
data, err := os.ReadFile(manifestPath)
if err != nil {
if os.IsNotExist(err) {
if !s.running {
if s.restarting {
return nil, ErrManifestNotReady
}
if s.waitErr != nil {
stderr := truncateStderr(s.stderr.String())
if stderr != "" {
return nil, fmt.Errorf("%w: %v (stderr: %s)", ErrTranscodeFailed, s.waitErr, stderr)
}
return nil, fmt.Errorf("%w: %v", ErrTranscodeFailed, s.waitErr)
}
return nil, ErrTranscodeFailed
}
return nil, ErrManifestNotReady
}
return nil, fmt.Errorf("read manifest: %w", err)
}
requiredSegments := startupSegmentRequirement(s.opts)
// Wait until enough startup media exists before serving. Counting #EXTINF
// lines alone is not enough for FFmpeg's live-written manifest because the
// playlist can reference copy-mode segments before the files are fully
// flushed to disk, especially on resumed sessions with a non-zero media
// sequence. Requiring the referenced startup files prevents the browser from
// stalling on its very first segment fetch.
if s.running && !startupFilesReady(data, s.outputDir, requiredSegments) {
return nil, ErrManifestNotReady
}
if strings.EqualFold(s.opts.TargetCodecVideo, "copy") {
if err := validateCopyPlaybackManifest(data); err != nil {
return nil, fmt.Errorf("invalid copy playback manifest: %w", err)
}
}
return data, nil
}
// WaitForManifest polls until the manifest is ready for playback or the timeout
// expires. It keeps the initial request open long enough for FFmpeg to write
// the first safe playback window instead of forcing the client to race a 503.
func (s *TranscodeSession) WaitForManifest(timeout time.Duration) ([]byte, error) {
deadline := time.After(timeout)
for {
manifest, err := s.GetManifest()
if err == nil {
return manifest, nil
}
if err != nil && err != ErrManifestNotReady {
return nil, err
}
select {
case <-deadline:
return nil, s.manifestTimeoutError(timeout)
case <-time.After(100 * time.Millisecond):
}
}
}
// BuildPlaybackManifest returns the manifest we should expose to clients.
//
// Copy-video sessions always expose FFmpeg's real manifest so the playlist
// timing matches the variable-length fragments FFmpeg actually writes and the
// seekable window reflects what FFmpeg has produced so far. Encoded transcodes
// still use the synthetic full VOD manifest when duration is known because
// forced keyframes make that timeline stable and seek-anywhere friendly.
func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) {
opts := s.Opts()
if strings.EqualFold(opts.TargetCodecVideo, "copy") || opts.TotalDuration <= 0 {
// Copy-video or unknown-duration sessions must use FFmpeg's real manifest.
manifest, err := s.WaitForManifest(30 * time.Second)
if err != nil {
return nil, err
}
return RewriteManifestPaths(manifest, segPrefix, rawQuery)
}
return s.GenerateFullManifest(segPrefix, rawQuery), nil
}
func firstNonEmptyManifestLine(manifest []byte) []byte {
for line := range bytes.SplitSeq(manifest, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) > 0 {
return trimmed
}
}
return nil
}
func validateManifestHeader(manifest []byte) error {
if len(bytes.TrimSpace(manifest)) == 0 {
return fmt.Errorf("manifest is empty")
}
if line := firstNonEmptyManifestLine(manifest); !bytes.Equal(line, []byte("#EXTM3U")) {
return fmt.Errorf("manifest missing #EXTM3U header")
}
return nil
}
func parseTargetDuration(manifest []byte) (int, error) {
for line := range bytes.SplitSeq(manifest, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if bytes.HasPrefix(trimmed, []byte("#EXT-X-TARGETDURATION:")) {
value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-TARGETDURATION:"))
targetDuration, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("parse target duration %q: %w", value, err)
}
return targetDuration, nil
}
}
return 0, fmt.Errorf("manifest missing #EXT-X-TARGETDURATION")
}
func validateCopyPlaybackManifest(manifest []byte) error {
if err := validateManifestHeader(manifest); err != nil {
return err
}
targetDuration, err := parseTargetDuration(manifest)
if err != nil {
return err
}
if targetDuration <= 0 {
return fmt.Errorf("manifest target duration must be positive, got %d", targetDuration)
}
timeline, err := parseManifestTimeline(manifest)
if err != nil {
return err
}
if len(timeline.entries) == 0 {
return fmt.Errorf("manifest contains no playable media segments")
}
for _, entry := range timeline.entries {
if entry.duration <= 0 {
return fmt.Errorf("segment %d has non-positive duration %.6f", entry.number, entry.duration)
}
}
return nil
}
func extractMapURI(line []byte) string {
const marker = `URI="`
text := string(line)
start := strings.Index(text, marker)
if start < 0 {
return ""
}
start += len(marker)
end := strings.Index(text[start:], `"`)
if end < 0 {
return ""
}
return text[start : start+end]
}
func manifestURIToFilename(uri string) string {
base, _, _ := strings.Cut(uri, "?")
return filepath.Base(base)
}
func manifestStartupFiles(manifest []byte, maxSegments int) ([]string, int) {
files := make([]string, 0, maxSegments+1)
segmentCount := 0
for line := range bytes.SplitSeq(manifest, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
continue
}
if bytes.HasPrefix(trimmed, []byte("#EXT-X-MAP:")) {
if uri := extractMapURI(trimmed); uri != "" {
files = append(files, manifestURIToFilename(uri))
}
continue
}
if trimmed[0] == '#' {
continue
}
files = append(files, manifestURIToFilename(string(trimmed)))
segmentCount++
if segmentCount >= maxSegments {
break
}
}
return files, segmentCount
}
func startupFilesReady(manifest []byte, outputDir string, requiredSegments int) bool {
files, segmentCount := manifestStartupFiles(manifest, requiredSegments)
if segmentCount < requiredSegments {
return false
}
for _, name := range files {
info, err := os.Stat(filepath.Join(outputDir, name))
if err != nil || info.Size() <= 0 {
return false
}
}
return true
}
type manifestSegmentEntry struct {
number int
duration float64
}
type manifestTimeline struct {
mediaSequence int
entries []manifestSegmentEntry
}
func parseManifestTimeline(manifest []byte) (manifestTimeline, error) {
if err := validateManifestHeader(manifest); err != nil {
return manifestTimeline{}, err
}
timeline := manifestTimeline{}
currentNumber := 0
var pendingDuration float64
var haveDuration bool
for line := range bytes.SplitSeq(manifest, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
continue
}
if bytes.HasPrefix(trimmed, []byte("#EXT-X-MEDIA-SEQUENCE:")) {
value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-MEDIA-SEQUENCE:"))
sequence, err := strconv.Atoi(value)
if err != nil {
return manifestTimeline{}, fmt.Errorf("parse media sequence %q: %w", value, err)
}
timeline.mediaSequence = sequence
currentNumber = sequence
continue
}
if bytes.HasPrefix(trimmed, []byte("#EXTINF:")) {
value := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(string(trimmed), "#EXTINF:"), ","))
duration, err := strconv.ParseFloat(value, 64)
if err != nil {
return manifestTimeline{}, fmt.Errorf("parse segment duration %q: %w", value, err)
}
pendingDuration = duration
haveDuration = true
continue
}
if trimmed[0] == '#' {
continue
}
if !haveDuration {
continue
}
segmentNumber := currentNumber
if parsed, err := ParseSegmentNumber(filepath.Base(string(trimmed))); err == nil {
segmentNumber = parsed
}
timeline.entries = append(timeline.entries, manifestSegmentEntry{
number: segmentNumber,
duration: pendingDuration,
})
currentNumber = segmentNumber + 1
haveDuration = false
}
return timeline, nil
}
func hlsSegmentExtension(opts TranscodeOpts) string {
if strings.EqualFold(opts.TargetCodecVideo, "copy") && !IsMPEG2VideoCodec(opts.SourceVideoCodec) {
return ".m4s"
}
return ".ts"
}
func segmentFilename(segNum int, opts TranscodeOpts) string {
return fmt.Sprintf("seg_%05d%s", segNum, hlsSegmentExtension(opts))
}
func segmentWaitTimeout(segmentDuration int) time.Duration {
if segmentDuration <= 0 {
segmentDuration = defaultSegmentDuration
}
timeout := time.Duration(segmentDuration)*time.Second + segmentWaitGrace
if timeout < minSegmentWait {
timeout = minSegmentWait
}
if timeout > maxSegmentWait {
timeout = maxSegmentWait
}
return timeout
}
func staleProducedWindow(segmentDuration int) time.Duration {
if segmentDuration <= 0 {
segmentDuration = defaultSegmentDuration
}
window := 2*time.Duration(segmentDuration)*time.Second + segmentWaitGrace
if window < minStaleProducedWindow {
window = minStaleProducedWindow
}
return window
}
// SegmentProgress reports the highest manifest-referenced segment that exists
// on disk with data. This is the produced media source of truth.
func (s *TranscodeSession) SegmentProgress(time.Time) SegmentProgress {
s.mu.Lock()
opts := s.opts
progress := SegmentProgress{
ProducedHead: opts.StartSegmentNumber - 1,
Running: s.running,
Restarting: s.restarting,
StartSegmentNumber: opts.StartSegmentNumber,
SegmentDuration: opts.SegmentDuration,
LastRequestedSegment: s.lastRequestedSegment,
}
s.mu.Unlock()
if progress.SegmentDuration <= 0 {
progress.SegmentDuration = defaultSegmentDuration
}
manifestPath := filepath.Join(s.outputDir, "stream.m3u8")
manifestInfo, statErr := os.Stat(manifestPath)
if statErr != nil {
return progress
}
progress.HasManifest = true
progress.ManifestModTime = manifestInfo.ModTime()
manifest, err := os.ReadFile(manifestPath)
if err != nil {
return progress
}
timeline, err := parseManifestTimeline(manifest)
if err != nil {
return progress
}
for _, entry := range timeline.entries {
segmentPath := filepath.Join(s.outputDir, segmentFilename(entry.number, opts))
info, err := os.Stat(segmentPath)
if err != nil || info.Size() <= 0 {
continue
}
progress.ProducedCount++
if entry.number > progress.ProducedHead {
progress.ProducedHead = entry.number
}
if info.ModTime().After(progress.LastProducedAt) {
progress.LastProducedAt = info.ModTime()
}
}
return progress
}
// SegmentRecoveryDecision determines whether a missing segment should briefly
// wait for ffmpeg or immediately use the seek-restart path.
func (s *TranscodeSession) SegmentRecoveryDecision(segNum int, now time.Time) SegmentRecoveryDecision {
progress := s.SegmentProgress(now)
decision := SegmentRecoveryDecision{
WaitTimeout: segmentWaitTimeout(progress.SegmentDuration),
Progress: progress,
}
switch {
case !progress.Running:
decision.Reason = "transcode_not_running"
case progress.Restarting:
decision.Reason = "transcode_restarting"
case segNum < progress.StartSegmentNumber:
decision.Reason = "before_start_segment"
case segNum <= progress.ProducedHead:
decision.Reason = "segment_missing_behind_produced_head"
case !progress.HasManifest:
if segNum <= progress.StartSegmentNumber+1 {
decision.Wait = true
decision.Reason = "startup_manifest_not_ready"
} else {
decision.Reason = "startup_request_beyond_window"
}
case segNum > progress.ProducedHead+maxSequentialMissingSegments:
decision.Reason = "request_beyond_produced_window"
case progress.ProducedHead >= progress.StartSegmentNumber && now.Sub(progress.LastProducedAt) > staleProducedWindow(progress.SegmentDuration):
decision.Reason = "produced_output_stale"
default:
decision.Wait = true
decision.Reason = "near_produced_head"
}
return decision
}
// GenerateFullManifest builds a complete VOD-style HLS manifest that lists
// every segment for the full media duration, matching Jellyfin's approach.
// The player can seek to any position immediately; the backend produces
// segments on demand when they are requested via HandleGetTranscodeSegment.
//
// segPrefix is prepended to each segment filename (e.g. "segment/") and
// rawQuery is appended as a query string (e.g. auth tokens).
func (s *TranscodeSession) GenerateFullManifest(segPrefix, rawQuery string) []byte {
opts := s.Opts()
totalDur := opts.TotalDuration
segDur := opts.SegmentDuration
if segDur <= 0 {
segDur = defaultSegmentDuration
}
if totalDur <= 0 {
totalDur = float64(segDur) // fallback: single segment
}
segCount := int(math.Ceil(totalDur / float64(segDur)))
if segCount < 1 {
segCount = 1
}
var suffix string
if rawQuery != "" {
suffix = "?" + rawQuery
}
segExt := hlsSegmentExtension(opts)
hlsVersion := 3
if segExt == ".m4s" {
hlsVersion = 7
}
var buf bytes.Buffer
buf.WriteString("#EXTM3U\n")
buf.WriteString(fmt.Sprintf("#EXT-X-VERSION:%d\n", hlsVersion))
buf.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", segDur))
buf.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n")
buf.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
if segExt == ".m4s" {
buf.WriteString(fmt.Sprintf("#EXT-X-MAP:URI=\"%sinit.mp4%s\"\n", segPrefix, suffix))
}
for i := range segCount {
dur := float64(segDur)
if i == segCount-1 {
// Last segment covers the remainder.
dur = totalDur - float64(i)*float64(segDur)
if dur <= 0 {
dur = float64(segDur)
}
}
buf.WriteString(fmt.Sprintf("#EXTINF:%.6f,\n", dur))
buf.WriteString(fmt.Sprintf("%sseg_%05d%s%s\n", segPrefix, i, segExt, suffix))
}
buf.WriteString("#EXT-X-ENDLIST\n")
return buf.Bytes()
}
// GetSegment returns the file path of a named segment if it exists.
func (s *TranscodeSession) GetSegment(name string) (string, error) {
// Sanitize the name to prevent directory traversal.
clean := filepath.Base(name)
segPath := filepath.Join(s.outputDir, clean)
info, err := os.Stat(segPath)
if err != nil {
if os.IsNotExist(err) {
return "", ErrSegmentNotFound
}
return "", fmt.Errorf("stat segment: %w", err)
}
if info.Size() <= 0 {
// ffmpeg can create init.mp4 before it has written any bytes. Treat
// zero-byte files as not-ready so callers fall back to WaitForSegment.
return "", ErrSegmentNotFound
}
return segPath, nil
}
// Close terminates the ffmpeg process and removes the temporary output directory.
func (s *TranscodeSession) Close() error {
s.StopThrottler()
// Cancel the context to kill the process (no mutex needed for cancel).
if s.cancel != nil {
s.cancel()
}
// Wait for the monitor goroutine to finish reaping the process.
// This avoids a deadlock: the goroutine needs s.mu to mark running=false,
// so we must not hold s.mu while waiting.
// done is nil when no process was started (e.g. test-only sessions).
if s.done != nil {
<-s.done
}
s.mu.Lock()
defer s.mu.Unlock()
s.running = false
// Clean up temporary directory.
if s.outputDir != "" {
if err := os.RemoveAll(s.outputDir); err != nil {
return fmt.Errorf("remove output dir: %w", err)
}
}
return nil
}
// Done returns a channel that closes when the current ffmpeg process exits.
func (s *TranscodeSession) Done() <-chan struct{} {
s.mu.Lock()
defer s.mu.Unlock()
return s.done
}
// IsRunning reports whether the ffmpeg process is still running.
func (s *TranscodeSession) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// WaitError returns the error from the last ffmpeg process exit, or nil if
// the process exited cleanly. A nil return means all output was written
// successfully and segments should remain servable.
func (s *TranscodeSession) WaitError() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.waitErr
}
// Opts returns the TranscodeOpts used to create this session (for testing).
func (s *TranscodeSession) Opts() TranscodeOpts {
s.mu.Lock()
defer s.mu.Unlock()
return s.opts
}
// SetAudioTrackIndex updates the audio track index in the session's opts.
// Must be called before Restart() to take effect on the new ffmpeg process.
func (s *TranscodeSession) SetAudioTrackIndex(index int) {
s.mu.Lock()
defer s.mu.Unlock()
s.opts.AudioTrackIndex = index
}
// cleanStaleSegments removes segment files at or after startSegment and the
// old manifest so a restarted copy-mode FFmpeg process writes clean output.
// The init.mp4 is preserved — its codec configuration is derived from the
// source file and is identical across restarts.
func (s *TranscodeSession) cleanStaleSegments(startSegment int) {
entries, err := os.ReadDir(s.outputDir)
if err != nil {
return
}
for _, entry := range entries {
name := entry.Name()
if name == "stream.m3u8" {
os.Remove(filepath.Join(s.outputDir, name))
continue
}
if name == "init.mp4" {
continue
}
segNum, parseErr := ParseSegmentNumber(name)
if parseErr != nil {
continue
}
if segNum >= startSegment {
os.Remove(filepath.Join(s.outputDir, name))
}
}
}
// Restart kills the current ffmpeg process and starts a new one seeking to
// the given position. startSegment sets -hls_segment_start_number so that
// output filenames align with the expected segment numbering. Existing
// segment files are preserved so backward seeks can reuse them; for
// copy-mode sessions, stale segments at or after the restart point are
// cleaned to prevent serving data from the wrong timeline position.
func (s *TranscodeSession) Restart(ctx context.Context, seekSeconds float64, startSegment int) error {
s.StopThrottler()
s.mu.Lock()
s.restarting = true
cancelCurrent := s.cancel
done := s.done
s.mu.Unlock()
// Kill current process without removing output directory.
if cancelCurrent != nil {
cancelCurrent()
}
if done != nil {
<-done
}
s.mu.Lock()
s.running = false
s.waitErr = nil
if s.stderr != nil {
s.stderr.Reset()
}
s.restartCount++
opts := s.opts
s.mu.Unlock()
// Copy-mode restarts must clean stale segments so ffmpeg writes fresh
// output. Encoded transcodes keep old segments for backward seek reuse.
if strings.EqualFold(opts.TargetCodecVideo, "copy") {
s.cleanStaleSegments(startSegment)
}
opts.SeekSeconds = seekSeconds
opts.StartSegmentNumber = startSegment
opts.FastStart = false // seek-restarts use veryfast for better quality
args := buildFFmpegArgs(opts)
bin := opts.FFmpegPath
if bin == "" {
bin = ffmpegBinary()
}
log.Printf("playback: ffmpeg restart cmd: %s %s", bin, strings.Join(args, " "))
s.logFFmpegEvent(ctx, "ffmpeg process restart", "")
ctx, cancel := context.WithCancel(ctx)
cmd := exec.CommandContext(ctx, bin, args...)
stdinPipe, err := cmd.StdinPipe()
if err != nil {
cancel()
s.mu.Lock()
s.restarting = false
s.waitErr = err
s.mu.Unlock()
return fmt.Errorf("create stdin pipe: %w", err)
}
cmd.Dir = opts.OutputDir
cmd.Stderr = s.newStderrWriter(ctx)
cmd.WaitDelay = 3 * time.Second
if err := cmd.Start(); err != nil {
cancel()
s.mu.Lock()
s.restarting = false
s.waitErr = err
s.mu.Unlock()
s.logFFmpegEvent(ctx, "ffmpeg process exit error", err.Error())
return fmt.Errorf("restart ffmpeg: %w", err)
}
s.mu.Lock()
if s.stdinPipe != nil {
s.stdinPipe.Close()
}
s.cmd = cmd
s.cancel = cancel
s.opts = opts
s.running = true
s.restarting = false
s.stdinPipe = stdinPipe
s.lastRequestedSegment = startSegment
s.done = make(chan struct{})
s.mu.Unlock()
go func() {
waitErr := cmd.Wait()
s.flushStderr(ctx)
s.mu.Lock()
s.running = false
s.waitErr = waitErr
s.mu.Unlock()
s.logWaitResult(ctx, waitErr)
close(s.done)
}()
return nil
}
// WaitForSegment polls until the named segment file exists on disk or the
// timeout expires. Returns the segment file path on success.
//
// Segments are served as soon as they appear on disk. The -hls_flags temp_file
// flag ensures ffmpeg writes to a .tmp file and atomically renames on completion,
// so a successful stat means the segment is fully written.
func (s *TranscodeSession) WaitForSegment(name string, timeout time.Duration) (string, error) {
clean := filepath.Base(name)
segPath := filepath.Join(s.outputDir, clean)
deadline := time.After(timeout)
for {
info, statErr := os.Stat(segPath)
segReady := statErr == nil && info.Size() > 0
if segReady {
return segPath, nil
}
s.mu.Lock()
running := s.running
restarting := s.restarting
waitErr := s.waitErr
s.mu.Unlock()
if restarting {
select {
case <-deadline:
return "", ErrSegmentNotFound
case <-time.After(100 * time.Millisecond):
continue
}
}
if !running && waitErr != nil {
return "", fmt.Errorf("%w: %v", ErrTranscodeFailed, waitErr)
}
// If ffmpeg finished cleanly but the segment doesn't exist,
// it won't appear later — fail fast.
if !running {
return "", ErrSegmentNotFound
}
select {
case <-deadline:
return "", ErrSegmentNotFound
case <-time.After(100 * time.Millisecond):
}
}
}
// RewriteManifestPaths prefixes relative segment references in an HLS manifest
// with segPrefix (e.g. "segment/") and optionally appends rawQuery as a query
// string. This ensures the HLS player's segment requests match server routes
// and preserve any auth or cache-busting parameters from the manifest URL.
func RewriteManifestPaths(manifest []byte, segPrefix, rawQuery string) ([]byte, error) {
if err := validateManifestHeader(manifest); err != nil {
return nil, fmt.Errorf("invalid manifest: %w", err)
}
var suffix string
if rawQuery != "" {
suffix = "?" + rawQuery
}
lines := bytes.Split(manifest, []byte("\n"))
for i, line := range lines {
trimmed := bytes.TrimSpace(line)
if len(trimmed) == 0 {
continue
}
// Rewrite #EXT-X-MAP:URI="filename"
if bytes.HasPrefix(trimmed, []byte("#EXT-X-MAP:")) {
rewritten, err := rewriteMapURI(trimmed, segPrefix, suffix)
if err != nil {
return nil, err
}
lines[i] = rewritten
continue
}
// Skip other tags/comments.
if trimmed[0] == '#' {
continue
}
// Segment filename line.
lines[i] = []byte(segPrefix + string(trimmed) + suffix)
}
return bytes.Join(lines, []byte("\n")), nil
}
// rewriteMapURI rewrites the URI value inside an #EXT-X-MAP tag.
func rewriteMapURI(line []byte, segPrefix, suffix string) ([]byte, error) {
uriStart := bytes.Index(line, []byte(`URI="`))
if uriStart < 0 {
return nil, fmt.Errorf("invalid #EXT-X-MAP line: missing URI attribute")
}
uriStart += 5 // skip past URI="
uriEnd := bytes.IndexByte(line[uriStart:], '"')
if uriEnd < 0 {
return nil, fmt.Errorf("invalid #EXT-X-MAP line: unterminated URI attribute")
}
uriEnd += uriStart
oldURI := string(line[uriStart:uriEnd])
newURI := segPrefix + oldURI + suffix
result := make([]byte, 0, len(line)+len(newURI)-len(oldURI))
result = append(result, line[:uriStart]...)
result = append(result, []byte(newURI)...)
result = append(result, line[uriEnd:]...)
return result, nil
}
func (s *TranscodeSession) manifestTimeoutError(timeout time.Duration) error {
s.mu.Lock()
running := s.running
waitErr := s.waitErr
stderr := ""
if s.stderr != nil {
stderr = truncateStderr(s.stderr.String())
}
s.mu.Unlock()
switch {
case waitErr != nil && stderr != "":
return fmt.Errorf("%w after %s: ffmpeg exited: %v (stderr: %s)", ErrManifestNotReady, timeout, waitErr, stderr)
case waitErr != nil:
return fmt.Errorf("%w after %s: ffmpeg exited: %v", ErrManifestNotReady, timeout, waitErr)
case running:
return fmt.Errorf("%w after %s: ffmpeg still running", ErrManifestNotReady, timeout)
default:
return fmt.Errorf("%w after %s: ffmpeg is no longer running", ErrManifestNotReady, timeout)
}
}
// IsCopyVideo reports whether this session is repackaging video without
// re-encoding. Copy-mode manifests must reflect FFmpeg's real fragment timing.
func (s *TranscodeSession) IsCopyVideo() bool {
s.mu.Lock()
defer s.mu.Unlock()
return strings.EqualFold(s.opts.TargetCodecVideo, "copy")
}
// SegmentStartTime reports the source-timeline start time of the requested
// segment using the current on-disk manifest. The bool return is false when
// the segment is not present in the manifest yet.
func (s *TranscodeSession) SegmentStartTime(segNum int) (float64, bool, error) {
s.mu.Lock()
manifestPath := filepath.Join(s.outputDir, "stream.m3u8")
baseSeekSeconds := s.opts.SeekSeconds
s.mu.Unlock()
manifest, err := os.ReadFile(manifestPath)
if err != nil {
if os.IsNotExist(err) {
return 0, false, ErrManifestNotReady
}
return 0, false, fmt.Errorf("read manifest: %w", err)
}
timeline, err := parseManifestTimeline(manifest)
if err != nil {
return 0, false, fmt.Errorf("parse manifest timeline: %w", err)
}
if len(timeline.entries) == 0 {
return 0, false, ErrManifestNotReady
}
currentTime := baseSeekSeconds
for _, entry := range timeline.entries {
if entry.number == segNum {
return currentTime, true, nil
}
currentTime += entry.duration
}
return 0, false, nil
}
// RestartSeekTarget resolves the source-timeline time to restart FFmpeg for
// the requested segment. Copy-mode sessions prefer the current manifest's real
// timing when available; encoded sessions use fixed-duration seek math
// matching the synthetic VOD manifest.
func (s *TranscodeSession) RestartSeekTarget(segNum int) (float64, bool, error) {
if strings.EqualFold(s.Opts().TargetCodecVideo, "copy") {
seekSeconds, ok, err := s.SegmentStartTime(segNum)
switch {
case err == nil && ok:
return seekSeconds, true, nil
case err != nil && !errors.Is(err, ErrManifestNotReady):
return 0, false, err
}
}
segDuration := defaultSegmentDuration
if opts := s.Opts(); opts.SegmentDuration > 0 {
segDuration = opts.SegmentDuration
}
return float64(segNum * segDuration), true, nil
}
// ReportSegmentDownloaded records that the client has downloaded the given
// segment number. Only updates if segNum exceeds the current high-water mark.
func (s *TranscodeSession) ReportSegmentDownloaded(segNum int) {
s.mu.Lock()
defer s.mu.Unlock()
if segNum > s.lastRequestedSegment {
s.lastRequestedSegment = segNum
}
}
// LastRequestedSegment returns the highest segment number downloaded by the client.
func (s *TranscodeSession) LastRequestedSegment() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.lastRequestedSegment
}
// StartThrottler creates and starts a throttler for this session.
// No-op if thresholdSeconds <= 0 or stdinPipe is nil.
func (s *TranscodeSession) StartThrottler(thresholdSeconds int) {
s.mu.Lock()
if s.stdinPipe == nil || thresholdSeconds <= 0 {
s.mu.Unlock()
return
}
t := NewTranscodeThrottler(s, s.stdinPipe, thresholdSeconds, s.opts.SegmentDuration)
s.throttler = t
s.mu.Unlock()
t.Start()
}
// StopThrottler stops the throttler if one is running.
func (s *TranscodeSession) StopThrottler() {
s.mu.Lock()
t := s.throttler
s.throttler = nil
s.mu.Unlock()
if t != nil {
t.Stop()
}
}
type ffmpegStderrWriter struct {
session *TranscodeSession
ctx context.Context
partial []byte
}
func (w *ffmpegStderrWriter) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
w.partial = append(w.partial, p...)
for {
idx := bytes.IndexByte(w.partial, '\n')
if idx < 0 {
break
}
line := strings.TrimRight(string(w.partial[:idx]), "\r")
w.session.logFFmpegLine(w.ctx, line)
w.partial = append([]byte(nil), w.partial[idx+1:]...)
}
return len(p), nil
}
func (w *ffmpegStderrWriter) Flush() {
if len(w.partial) == 0 {
return
}
w.session.logFFmpegLine(w.ctx, strings.TrimRight(string(w.partial), "\r"))
w.partial = nil
}
func (s *TranscodeSession) newStderrWriter(ctx context.Context) io.Writer {
lineWriter := &ffmpegStderrWriter{session: s, ctx: ctx}
s.mu.Lock()
defer s.mu.Unlock()
if s.stderr == nil {
s.stderr = newBoundedTailBuffer(stderrTailMaxBytes)
}
s.stderrWriter = lineWriter
return io.MultiWriter(s.stderr, lineWriter)
}
func (s *TranscodeSession) flushStderr(ctx context.Context) {
s.mu.Lock()
writer := s.stderrWriter
s.stderrWriter = nil
s.mu.Unlock()
if writer != nil {
writer.Flush()
}
}
func (s *TranscodeSession) logFFmpegLine(ctx context.Context, line string) {
if s == nil || s.opts.FFmpegLogSink == nil {
return
}
line = strings.ToValidUTF8(line, "\uFFFD")
if strings.TrimSpace(line) == "" {
return
}
trimmed, truncated := truncateUTF8String(line, maxPersistedFFmpegChars)
if truncated {
trimmed += "...[truncated]"
}
s.mu.Lock()
defer s.mu.Unlock()
if s.stderrLinesLogged >= maxPersistedFFmpegLines || s.stderrBytesLogged+len(trimmed) > maxPersistedFFmpegBytes {
s.stderrDroppedLines++
if !s.stderrCapLogged {
s.stderrCapLogged = true
attrs := s.ffmpegAttrsLocked()
attrs.DroppedLines = s.stderrDroppedLines
s.opts.FFmpegLogSink.WriteEvent(ctx, s.opts.SessionID, attrs, "ffmpeg stderr logging capped")
}
return
}
s.stderrLinesLogged++
s.stderrBytesLogged += len(trimmed)
s.stderrLineIndex++
attrs := s.ffmpegAttrsLocked()
attrs.LineIndex = s.stderrLineIndex
s.opts.FFmpegLogSink.WriteLine(ctx, s.opts.SessionID, attrs, trimmed)
}
func (s *TranscodeSession) logFFmpegEvent(ctx context.Context, message, exitError string) {
if s == nil || s.opts.FFmpegLogSink == nil {
return
}
s.mu.Lock()
attrs := s.ffmpegAttrsLocked()
attrs.ExitError = exitError
s.mu.Unlock()
s.opts.FFmpegLogSink.WriteEvent(ctx, s.opts.SessionID, attrs, message)
}
func (s *TranscodeSession) logWaitResult(ctx context.Context, waitErr error) {
if waitErr == nil {
s.logFFmpegEvent(ctx, "ffmpeg process exited", "")
return
}
s.logFFmpegEvent(ctx, "ffmpeg process exit error", formatWaitError(waitErr))
}
func (s *TranscodeSession) ffmpegAttrsLocked() FFmpegLogAttrs {
return FFmpegLogAttrs{
NodeType: s.opts.NodeType,
ExecutionMode: s.opts.ExecutionMode,
InputPath: s.opts.InputPath,
OutputDir: s.opts.OutputDir,
TargetResolution: s.opts.TargetResolution,
TargetVideoCodec: s.opts.TargetCodecVideo,
TargetAudioCodec: s.opts.TargetCodecAudio,
HWAccel: s.opts.HWAccel,
SeekSeconds: s.opts.SeekSeconds,
StartSegmentNumber: s.opts.StartSegmentNumber,
RestartCount: s.restartCount,
DroppedLines: s.stderrDroppedLines,
}
}
func formatWaitError(err error) string {
if err == nil {
return ""
}
if exitErr, ok := err.(*exec.ExitError); ok {
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
return fmt.Sprintf("exit_code=%d: %v", status.ExitStatus(), err)
}
}
return err.Error()
}