mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* feat(epg): copy catch-up programme URLs without changing playback * feat(downloads): save completed Xtream archive programmes as TS * fix(epg): let newer archive copy requests supersede pending work * fix(downloads): protect archive partials and independent submissions * fix(downloads): verify archive identity through finalization * fix(downloads): bound archive storage and capture cleanup entries * fix(downloads): preserve archive ownership across failure paths * fix(downloads): recover explicitly verified archive completions * fix(downloads): journal archive promotion before publishing files * fix(downloads): reset archive proof before an explicit restart * fix(downloads): preserve archive recovery ownership and interruption * fix(downloads): verify durable archive identity at resume open * fix(downloads): fence archive commands during completion commit * fix(downloads): persist archive ownership throughout its lifecycle * fix(downloads): protect archive removal and missing-file recovery * fix(downloads): journal private cleanup captures for recovery * fix(downloads): journal active archive cleanup before removal * fix(downloads): clean settled archives before deleting stale rows * fix(downloads): preserve archive ownership on removal and resubmission * fix(downloads): recover proven archive completions before retry * fix(downloads): recover local archives before remote transfer checks * test(downloads): resolve archive fixture from workspace root * fix(downloads): distinguish reused archive inodes by creation time * fix(downloads): bind fresh archive reservations to owned files * fix(downloads): clean reservations when ownership writes fail * fix(downloads): commit archive reservation and ownership atomically * fix(downloads): retain captures until replacement restoration succeeds * fix(downloads): require durable ownership before cleanup relocation * fix(downloads): preserve 64-bit archive file identities on Windows * refactor(release): keep capture fixture constants in their shared module * fix(downloads): preserve the last link of captured foreign files * fix(downloads): expose retained archive recovery files * fix(downloads): keep recovery instructions open while copying
593 lines
54 KiB
Markdown
593 lines
54 KiB
Markdown
# Download Manager Architecture
|
|
|
|
The download manager is a desktop-only feature that layers a curated queue,
|
|
progress tracking, storage configuration, and playback controls on top of the
|
|
existing Xtream (`libs/portal/xtream`) + Stalker (`libs/portal/stalker`) portal
|
|
views. Backend work is handled in the Electron process while the Angular
|
|
renderer exposes the global `/workspace/downloads` page, source-scoped route
|
|
variants, contextual buttons, and theme-aware styling.
|
|
|
|
## Xtream archive downloads
|
|
|
|
The desktop Xtream Live TV programme dialog offers **Download programme (TS)**
|
|
for completed, available catch-up programmes in both timeline and list views.
|
|
The host captures the playlist, channel and original programme timestamps before
|
|
resolving the existing timeshift URL (including the panel timezone). A channel
|
|
change while the dialog is open invalidates its action. Downloading does not
|
|
select the programme or change playback. Collection views retain archive URL
|
|
copying; archive downloads are initially exposed through Xtream Live TV only.
|
|
M3U, Stalker and PWA archive downloads, HLS assembly and recording future broadcasts
|
|
are outside this feature.
|
|
|
|
`EpgArchiveDownloadService` submits the resolved URL and the playlist's playback
|
|
header allowlist to the existing desktop queue. Pending submission suppression
|
|
is per programme identity, so slow resolution does not discard a different
|
|
programme's request. HLS URLs are refused explicitly;
|
|
the backend also checks the response type and every 188-byte MPEG-TS packet.
|
|
No transcoder or media helper is required. The file always uses `.ts`.
|
|
|
|
The `catchup` download type stores `programme_start` and JSON metadata containing
|
|
channel name, start/stop Unix seconds and the known archive expiry. Its unique
|
|
identity is `(playlist_id, xtream_id, programme_start)`; movies and episodes keep
|
|
their existing identity. `download-schema.ts` widens the SQLite CHECK in a
|
|
transaction after older additive migrations, preserving row IDs, files, headers,
|
|
resume state and metadata. It also replaces the former global unique index with
|
|
separate catch-up and non-catch-up indexes.
|
|
|
|
Only ended programmes can be enqueued. Known expiry is checked on enqueue,
|
|
retry, resume, missing-file recovery and when the queued transfer starts.
|
|
Pause keeps its owned partial file, but **resume/retry restarts from byte zero**,
|
|
without Range, If-Range, or automatic reconnect append. The queue explains this.
|
|
Before truncating, `download-catchup-output.ts` rejects symlinks and hardlinks,
|
|
opens without truncation (with O_NOFOLLOW where available), checks descriptor
|
|
identity against lstat, then truncates and writes through that same descriptor.
|
|
An absent partial is created exclusively, so a replaced path cannot redirect writes.
|
|
The task retains that descriptor's device/inode identity through promotion:
|
|
`download-catchup-finalize.ts` verifies the source and published file, rejects a
|
|
replaced partial, and copies from the verified descriptor on filesystems without
|
|
hardlinks. Existing destination files are never overwritten. Cleanup atomically
|
|
moves the public pathname into a private temporary directory before checking its
|
|
identity and removing it. A captured replacement is restored with no-clobber
|
|
linking; if restoration is unavailable or the original pathname is occupied,
|
|
the file is retained in `.iptvnator-cleanup-*/entry` and its recovery location
|
|
is logged. Cleanup never recursively deletes a nonempty quarantine. This closes
|
|
the predictable-path check/unlink window; it does not isolate files from
|
|
same-user processes that deliberately enter the private temporary directory.
|
|
Active failure and cancellation use the same captured transfer identity; a
|
|
partial that was never safely opened is preserved instead of being deleted.
|
|
`download_archive_finalizations` is a write-ahead SQLite journal keyed by download
|
|
ID (cascade-deleted with the download). It records the path, size, source identity
|
|
and expected final identity **before hardlink promotion**, or before the first
|
|
byte is written to an exclusively created copy destination. Thus even a completed
|
|
unknown-length file has durable proof before the completion-status write. Startup
|
|
requires that proof and a matching regular file, identity and size to recover an
|
|
archive; termination before promotion leaves the verified partial paused. An
|
|
owned incomplete copy is removed by journal identity before the source resumes. The
|
|
journal also makes startup partial cleanup identity-aware. A journaled partial
|
|
whose identity changed is preserved and detached from the failed row; Retry
|
|
reserves a fresh path instead of adopting or truncating it. The journal remains with
|
|
completed archives until they are removed. An explicitly restarted transfer
|
|
carries the journaled source identity to output opening and checks
|
|
it against the opened descriptor. Only then does it replace the previous proof,
|
|
before truncation or writes, replacing the old finalization proof with a
|
|
transfer-phase journal containing the newly opened identity. That identity
|
|
survives pause, failure and restart without claiming the file is complete. A
|
|
rejected replacement is detached from the failed row without deletion, so Retry
|
|
reserves a new path. Retained catch-up paths without any durable proof are also
|
|
preserved and redirected to a fresh reservation. On a final-path collision,
|
|
archives never relocate a retained partial: only a verified owned partial is
|
|
discarded before reserving a new filename, because archive retries restart at zero. The transfer proof is upgraded to finalization proof only
|
|
after validated EOF. Process-local proof allows immediate
|
|
recovery after a transient completion DB error without waiting for a restart.
|
|
Fallback copying observes pause/cancel between bounded 64 KiB reads/writes and
|
|
before publication completes. Interruption removes only the owned copy and
|
|
leaves the source for the runtime pause/cancel handler. Once publication identity
|
|
and size pass the final check, the task synchronously enters completion commit
|
|
before awaited partial cleanup and the SQLite completion write. Pause/cancel then
|
|
return false without setting flags; a command is never accepted and subsequently
|
|
overwritten by completion. Remove rejects this committing row before partial
|
|
cleanup or deletion, and Clear completed skips it, preserving the cascading
|
|
journal until completion finishes. Remove waits for an accepted active archive
|
|
cancellation to settle before reading/deleting its row; queued archives are
|
|
canceled first, and a concurrent new runtime attempt blocks removal. A settled
|
|
archive still marked downloading after a failed status write also retries
|
|
journal cleanup before row deletion. Remove/Clear preserve a final file whose
|
|
journaled device/inode/creation-time identity and full size prove completed promotion, even when completion
|
|
status writes failed and its stored status is stale. Repeat submissions, Retry
|
|
and Resume restore such a journal-proven completion in place before any new
|
|
transfer or ownership reset, before expiry, provider DNS and new-folder checks
|
|
that apply only to another remote transfer; retained cleanup failures keep their
|
|
journal. Remove, Clear completed and missing-file
|
|
re-download and repeated programme submissions use journal-backed private
|
|
capture for archive partial cleanup;
|
|
unknown or replaced entries are preserved. Ownership reads device/inode as BigInt and journals decimal strings without
|
|
losing 64-bit Windows file references, alongside a positive file creation timestamp, so inode reuse after unlink cannot
|
|
bless a new entry; proofs lacking creation time remain untrusted. Fresh
|
|
reservations capture this identity from their exclusive creation descriptor and
|
|
commit it together with the downloads row path/name in one SQLite transaction
|
|
before the HTTP wait. A committed reservation is therefore recoverable even if
|
|
the process exits before transfer setup. Existing partials without matching expected
|
|
ownership are never truncated, including before the first response. Before capture, a synchronous SQLite
|
|
write records `partialCleanupPath` (or `finalCleanupPath` for failed promotion)
|
|
in the existing ownership proof. Active cancellation/failure, promotion and
|
|
startup recovery use the same journal-backed cleanup as manual actions. Cancel,
|
|
failure and removal of an unfinished attempt also clean its journaled final target;
|
|
removing a completed row preserves its media. Retry cleans an incomplete owned
|
|
final before reserving a destination. A failed
|
|
unlink or replacement restoration keeps that durable pointer and blocks journal
|
|
deletion/replacement. Later cleanup retries no-clobber restoration of captured
|
|
foreign entries; an occupied public path or unsupported hardlinks preserves both
|
|
the capture and its journal for recovery. Even after a foreign entry is restored,
|
|
its private recovery copy is never automatically unlinked: another process could
|
|
remove the public link first. Remove/Clear return a structured recovery path and open a persistent, localized
|
|
dialog with the full path, Copy recovery path and manual recovery instructions.
|
|
Copy keeps the dialog open, including on clipboard failure; Close dismisses it.
|
|
After the user recovers it and explicitly removes that private copy, cleanup may release the
|
|
journal. Ordinary owned-file cleanup remains automatic. Remove/Clear, Retry/Resume and fresh
|
|
reservations retry identity-verified cleanup, including after restart and on
|
|
filesystems without hardlinks. Cleanup remains synchronous after the
|
|
runtime guard, so a completion transition cannot interleave with unlink.
|
|
Missing archive re-downloads claim a fresh reservation instead of inheriting the
|
|
completed file's identity.
|
|
If the initial reservation journal write fails, persistence is retried once
|
|
before journal-backed cleanup. If SQLite remains unavailable, the empty
|
|
reservation stays at its public path; no unjournaled entry is relocated. An
|
|
empty fallback-copy target whose final proof cannot commit is likewise preserved.
|
|
A kill between exclusive reservation/copy-file creation and its identity journal
|
|
commit, or persistent SQLite failure before that commit, can leave an unowned
|
|
**empty** destination: no bytes are written before the commit.
|
|
Recovery preserves that file rather than guessing ownership; Retry uses a
|
|
numbered free destination. SQLite and filesystem creation cannot commit
|
|
atomically, and portable rename cannot guarantee no-clobber publication on the
|
|
filesystems that need this fallback. This bounded orphan is preferred to deleting
|
|
or overwriting an unrelated file.
|
|
Cancellation of queued/paused archives uses the same journal-backed cleanup as
|
|
Remove; unproven or replaced entries, including symlinks, are preserved.
|
|
A retained archive cannot use the VOD byte-count completion shortcut. Transfers
|
|
have a 30-second idle timeout and a total deadline of twice programme duration
|
|
plus ten minutes, capped at 24 hours. Transfers also stop at the smallest of a
|
|
100 Mbit/s budget for the programme duration plus one minute, 64 GiB, and
|
|
half of the initial available space after subtracting a 1 GiB reserve (reserving a second
|
|
copy for filesystems without hardlinks). The retained partial is safely truncated
|
|
through its verified descriptor before computing this budget, so Resume/Retry
|
|
can reuse its released space. Known Content-Length values above
|
|
that budget are rejected before writing; unknown-length responses are counted
|
|
before forwarding chunks. Free space is rechecked every 16 MiB to account for
|
|
other disk activity, and copy headroom is checked again against the final byte
|
|
count after the output stream closes, including short tails below that interval. These safety limits apply to TS archives only. Failure never promotes the partial to the
|
|
library, and errors omit credential-bearing URLs.
|
|
|
|
Completion means clean HTTP EOF, matching Content-Length when supplied, and
|
|
valid nonempty TS packet framing. **It does not verify media duration or the
|
|
provider's programme boundaries.** A provider can return a shorter valid clip
|
|
with clean EOF; this version cannot identify that without demuxing duration.
|
|
Unknown-length responses show indeterminate progress until EOF.
|
|
|
|
Completed archive cards appear under All with programme title, channel and
|
|
broadcast date. Clicking a card plays the local file through the download action;
|
|
it does not navigate to a movie or series detail route. Files and metadata remain
|
|
available after restart and after the source archive expires.
|
|
|
|
## Backend responsibilities
|
|
|
|
- **Queue control (`apps/electron-backend/src/app/events/database/download-runtime.ts`)**
|
|
`DownloadTask` mirrors a row of the shared `downloads` table (type `Download` in `libs/shared/database/src/lib/schema.ts`) plus transient cancel/pause/progress helpers (shared task types live in `download-task.ts`). Request validation and row creation live in `download-requests.ts`, retry/resume flows in `download-resume-requests.ts`, removal/terminal cleanup in
|
|
`download-removal-requests.ts`, while `downloads.events.ts` stays focused on IPC registration. `enqueueDownload()` pushes the task onto `downloadQueue` and triggers `processQueue()`. `processQueue()` keeps one active download, updates the row to `downloading`, and calls `startDownload()`. The byte transfer itself lives in `download-transfer.ts`, finalization and retained-partial persistence in `download-finalize.ts` (ordinary file promotion in `download-file-finalize.ts`, archive completion in
|
|
`download-catchup-completion.ts`, proven completion recovery in `download-catchup-recover-completion.ts`, target reservation in `download-runtime-reservation.ts`), cancellation/pause persistence in `download-runtime-persistence.ts`, and the renderer update broadcast in `download-broadcast.ts`.
|
|
- **Range-aware transfer (`download-transfer.ts`)**
|
|
The transfer streams the response through the backend's validated Axios redirect helper instead of `electron-dl`, and always requests `Accept-Encoding: identity`: Range offsets, totals, and the persisted `.part` must describe the same representation, and Axios's transparent gzip/brotli decoding would put decoded bytes on disk while every counter speaks encoded bytes. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. Fresh Xtream movie and series-episode downloads propagate the playlist's configured headers, using its User-Agent when present and otherwise sharing the provider-compatible `XTREAM_CLIENT_USER_AGENT` used by Xtream API requests and stream probes. Retry, resume, and missing-file recovery resolve the owning playlist type and add that fallback to legacy Xtream rows without a stored User-Agent; known Stalker rows are left unchanged. Download rows deliberately outlive individually deleted playlists, so a headerless legacy row whose source no longer exists receives the same IPTV-player fallback because its original provider type cannot be recovered. Active pause/cancel operations abort the current request with `AbortController`; pause keeps the partial file and cancel removes it. Resume checks the existing `.part` size (rejecting anything that is not a regular file, so a symlink planted while paused is never followed). The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator`; a partial carrying that validator resumes with `Range: bytes=<offset>-` plus `If-Range`, so the server itself proves the entity is unchanged. A retained partial **without** a validator resumes through overlap verification instead (`download-overlap.ts`): the `Range` request rewinds by up to 256 KiB (`OVERLAP_VERIFICATION_BYTES`) and a transform stream compares that replayed window byte-for-byte against the partial's tail before anything is appended — a self-made validator for the many Xtream panels that send neither header. A mismatching overlap truncates the `.part` and restarts the transfer from byte zero (`OverlapMismatchError`); a partial smaller than the overlap window is verified in full from byte zero over a plain request and appended to — never rewritten in place, so a reconnect that dies early can only grow the file. Success requires the verifier to have consumed its ENTIRE window: a response that ends inside the overlap is an ordinary retained interruption when the stream died early, but a response that delivered its complete AUTHORITATIVE total inside the window — whether it then closed cleanly or reset — proves the remote entity shrank and restarts from scratch; the old suffix is never finalized as a completed file. An HTTP 416 answer to a resume request is classified by `classifyRangeNotSatisfiable()`: it COMPLETES only an exact-EOF request with identity proof (`If-Range`-backed, or the EOF probe that follows a fully verified overlap replay) whose stated `bytes */N` equals the partial — a bare length match on a rewound request proves nothing about whose bytes are on disk; it RESTARTS only when a STATED total proves the entity shrank — the total sits below a rewound request's first byte, or at it (the rewound range beginning exactly at the new EOF), or below the partial at an exact-EOF request; every length-less, ambiguous, or contradictory 416 RETAINS the partial, and none of these paths ever reaches generic cleanup. A validator promoted by a complete overlap match survives mid-append failures too: the promotion also runs on the error path, and retained-failure and pause persistence write `resume_validator` from the task, so later attempts resume via `If-Range` instead of replaying the window — without this, a server whose per-connection cap barely exceeds the window would stall out on sub-threshold progress. A verify-append attempt promotes the response's `ETag`/`Last-Modified` onto the row only after the complete overlap matched; until then the retained bytes are unproven and blessing them with a validator would let the next resume `If-Range`-append onto a foreign prefix. The response's TOTAL stays equally uncommitted (task and row) until the overlap matched — a persisted total equal to the unverified partial's size would let the completed-partial shortcut finalize unproven bytes after a pause, crash, or retained failure. Retained-interruption persistence keeps the live task in sync with the row (a stale falsified total would make the next reconnect's resume-offset guard reject the partial). Overlap replay re-counts bytes from the rewound offset, so reported progress is floored at the partial's retained size whenever the transfer appends — a response that ends inside the overlap can never move displayed progress backwards. A `206 Partial Content` answer must start at the requested offset (`Content-Range` is verified) before bytes are appended; any other 2xx answer — the server ignoring `Range`, or `If-Range` detecting that the remote file changed — restarts the transfer from byte zero over the same `.part` instead of failing the download.
|
|
- **Destination collision policy**
|
|
Existing destination files are never overwritten, inspected, or deleted.
|
|
Before starting a new transfer, the backend atomically reserves a free
|
|
numbered `.part` path while leaving the final destination path absent. The
|
|
selected final `filePath` and `fileName` are persisted before transfer
|
|
begins. When a retained download's recorded destination got occupied while
|
|
it was paused or failed (for example by a file the user created), the
|
|
retained `.part` is renamed aside and finalized to the next free numbered
|
|
destination (`Movie (1).mp4`) instead of resolving the collision by size or
|
|
`unlink()`. Completion creates the final `filePath` from the `.part` without
|
|
overwriting an existing file; cancel and non-recoverable transfer failures
|
|
remove the `.part`, while finalization failures, completed-partial failures,
|
|
and allowlisted network interruptions after bytes reached disk deliberately
|
|
retain it (the row keeps `filePath` so a later retry can finish without
|
|
re-downloading); pause and restart recovery keep partials. A retry resumes
|
|
through If-Range when a validator was stored and through overlap
|
|
verification otherwise.
|
|
Re-downloading such a failed row from a detail page
|
|
(`DOWNLOADS_START`) deletes the retained `.part` before the row is reset.
|
|
- **Derived file readiness and recovery**
|
|
`DOWNLOADS_GET_LIST` and `DOWNLOADS_GET` inspect completed destinations on
|
|
every read. Only regular, non-symbolic-link files are reported as available;
|
|
missing paths, directories, symlinks, and inspection errors are reported as
|
|
missing without changing the persisted `completed` transfer status.
|
|
`DOWNLOADS_REDOWNLOAD_MISSING` accepts only the managed row id, rechecks the
|
|
file, and returns a recovered result without network access when it has
|
|
reappeared. Otherwise it conditionally claims the completed row, preserves
|
|
its owned destination, re-applies the stored header allowlist and URL safety
|
|
policy, and enqueues a fresh transfer without overwriting an existing file.
|
|
- **IPC surface**
|
|
The backend exposes `DOWNLOADS_*` handlers for list retrieval,
|
|
start/pause/resume/cancel/retry/missing-file recovery/remove operations,
|
|
folder selection/reveal, and the `DOWNLOADS_UPDATE_EVENT` emitter that the
|
|
renderer listens to in order to refresh its signal store.
|
|
|
|
## Renderer architecture
|
|
|
|
- **Downloads service** (`libs/services/src/lib/downloads.service.ts`)
|
|
`DownloadsService.downloads` is the renderer's authoritative **global** list.
|
|
`loadDownloads()` therefore always invokes the Electron list IPC without its
|
|
legacy optional playlist scope. Route scope, category, and search must never
|
|
replace or narrow that signal. Loads are serialized: at most one list IPC is
|
|
active, and callers arriving during it coalesce behind one trailing refresh.
|
|
Each response therefore commits in request order. A caller assigned to the
|
|
trailing refresh resolves after that refresh settles even when later
|
|
broadcasts queue the following refresh, preventing both stale continuation
|
|
and starvation during frequent progress updates.
|
|
`hasLoadedDownloads` records that the latest attempt completed, including an
|
|
error, while
|
|
`hasAuthoritativeDownloadList` becomes true after a successful request,
|
|
remains true while a later refresh is in flight, and clears only if the
|
|
latest request fails. Series download actions require both loaded and
|
|
authoritative state so a failed refresh cannot restart rows missing from a
|
|
stale or empty renderer snapshot. Before each fresh download or resume the
|
|
service asks the main process for an authorized folder and calls the
|
|
corresponding IPC command. The `onDownloadsUpdate` broadcast triggers a new
|
|
global load.
|
|
- **Series season queueing**
|
|
`SeasonDownloadCoordinator` owns synchronous, per-identity pending
|
|
reservations and submits an individual episode or selected-season snapshot
|
|
through `DownloadsService.startDownload()`. Season batches are sequential
|
|
and best-effort: one candidate failing does not stop later candidates. After
|
|
added or stable duplicate submissions, one authoritative list refresh closes
|
|
the pending-to-queued/downloaded handoff, and the coordinator returns
|
|
`added`, `skipped`, and `failed` counts. Xtream and Stalker adapters own
|
|
provider URL, request header, and metadata preparation; the coordinator owns
|
|
only provider-neutral orchestration. When a reserved candidate matches a
|
|
completed-missing row, the coordinator performs one authoritative preflight
|
|
refresh before any provider preparation. If another list request is active,
|
|
the preflight joins the single trailing refresh; later download-update
|
|
broadcasts cannot delay that assigned refresh. Restored files therefore
|
|
become stable skips without requiring a Stalker URL/network request. Both
|
|
providers use normalized `episode.id` as the canonical
|
|
episode `xtreamId`; Stalker `originalCmd` and `originalId` participate only
|
|
in URL resolution. Provider adapters preserve numeric season zero, including
|
|
a fallback season key of `"0"`, so Specials keep distinct `S00` coordinates.
|
|
The exact `(playlistId, contentType, xtreamId)` identity is authoritative.
|
|
Complete `(playlistId, seriesXtreamId, seasonNumber, episodeNumber)`
|
|
coordinates are a legacy episode-compatibility fallback. Stalker also stores
|
|
an `episode_identity_scope` for regular `/series`, embedded VOD `series[]`,
|
|
and lazy Ministra VOD `is_series` origins. A known different scope is a
|
|
different episode owner; an older coordinate row without a provable scope
|
|
fails closed instead of being migrated across modes. Exact canonical legacy
|
|
rows remain authoritative. Other ambiguous or conflicting matches resolve to
|
|
the same explicit renderer conflict state rather than masquerading as a
|
|
missing row, so both the episode action and season count fail closed.
|
|
SQLite `null` and optional `undefined` coordinates both mean that a canonical
|
|
legacy row is incomplete, matching the backend resolver. Renderer-pending,
|
|
queued, downloading, and paused episodes are skipped, as are completed rows
|
|
whose file is available or whose availability is still unknown. Failed,
|
|
canceled, completed-missing, and unambiguous row-less episodes are eligible;
|
|
a completed-missing row is restarted as a fresh download. Before resetting
|
|
such a completed row, `DOWNLOADS_START` asynchronously rechecks its retained
|
|
path in the main process. A restored file returns stable
|
|
`reason: 'already-downloaded'` without mutation; active matches return
|
|
`reason: 'already-in-progress'`. The recheck has a one-second caller deadline
|
|
that starts before shared-slot acquisition; timeout or probe failure leaves
|
|
the row untouched and returns a failed submission, allowing the sequential
|
|
season loop to continue. Completed-file list callers have the same deadline
|
|
and report a timeout as missing for that snapshot. The underlying filesystem
|
|
operation remains coalesced and charged against the four-probe cap until it
|
|
actually settles, so later callers get independent bounded waits without
|
|
duplicating stalled native work. Only `ENOENT` and `ENOTDIR` are authoritative
|
|
absence; permission, I/O, and other probe errors remain unknown, so
|
|
`DOWNLOADS_START` leaves the completed row and file path untouched. Before a
|
|
completed-missing, failed, or canceled row clears its path, the same start IPC
|
|
asynchronously removes any retained `.part`. Cleanup coalesces same-path work
|
|
and allows at most four underlying unlinks. A one-second admission deadline
|
|
rejects queued work before it can mutate the filesystem; once an unlink
|
|
starts, the request awaits its authoritative result so no late side effect can
|
|
race a retry. Permission and I/O failures keep the row's ownership intact,
|
|
while `ENOENT` and `ENOTDIR` safely proceed. The coordinator counts both stable
|
|
duplicate reasons as skipped. There is no batch IPC,
|
|
parallel transfer, or queue reordering: destination authorization, persisted
|
|
header handling, and the backend's one-active-transfer FIFO semantics remain
|
|
unchanged.
|
|
- **Pure manager model**
|
|
(`download-manager.viewmodel.ts` and `download-library.viewmodel.ts`)
|
|
derives the current route scope, search/category filtering, queue partitions,
|
|
stable ordering, counts, and tracked byte total without mutating service
|
|
state. `queued`, `downloading`, and `paused` rows form the active surface;
|
|
`failed` and `canceled` rows form the attention surface. A `completed` row
|
|
whose derived file availability is missing also enters attention with a
|
|
dedicated recovery reason; only available completed rows enter the offline
|
|
library. Completed episodes with a valid
|
|
`seriesXtreamId` are grouped by `(playlistId, seriesXtreamId)`, ordered by
|
|
season and episode, and represented by one poster card. Episodes without a
|
|
usable series id remain standalone cards so they never disappear.
|
|
- **Downloads workspace** (`libs/portal/downloads/feature`)
|
|
keeps orchestration in `DownloadsComponent`, async mutations in the
|
|
component-scoped `DownloadManagerActionsService`, source-route resolution in
|
|
`DownloadLibraryNavigationService`, and rendering in the presentational
|
|
`DownloadQueueComponent`, `DownloadLibraryComponent`, and
|
|
`DownloadedSeriesDialogComponent`. Presentational children emit typed
|
|
`DownloadItemAction` values and do not inject the download service, router,
|
|
dialogs, or snackbars.
|
|
The fixed header and filter row sit above one vertical scroll owner. The
|
|
queue and library sections share one heading treatment (the dashboard-rail
|
|
title style with an `.app-count-badge` item count) so same-level sections
|
|
read alike; the queue's bordered panel wraps only the row list. Queue rows
|
|
expose exactly one visible primary action — pause, resume, or retry — while
|
|
cancel, copy-URL, and remove live in the row's overflow menu, so two
|
|
adjacent destructive icons never compete. Completed movies
|
|
and grouped series reuse the portal's canonical content grid as compact
|
|
poster cards borrowed from the dashboard-rail card language: a type badge
|
|
and an always-visible ⋮ trigger sit on the artwork, the title and series
|
|
facts sit below it, and Play / Show in folder / Copy URL / Remove live in
|
|
that overflow menu. File size is not repeated on the card; it belongs to the
|
|
focused offline detail. Both completed
|
|
cards and their loading skeleton consume the global
|
|
`--cover-grid-min-width` / `--cover-gap` tokens, so the Small, Medium, and
|
|
Large cover preference behaves like it does elsewhere in the app. All
|
|
surfaces use the existing `--app-*` and Material system tokens. Ready cards
|
|
do not repeat an Offline badge; source provenance is available at the top of
|
|
their overflow menus. The global
|
|
workspace download shortcut displays the service's active count, while the
|
|
page badge and filter counts reflect the current route scope.
|
|
- **Honest interactions**
|
|
Every asynchronous item command owns a pending id until the IPC result
|
|
settles, preventing duplicate dispatch without optimistically changing a
|
|
status. Service failures surface through the established snackbar path.
|
|
Removing a completed entry explicitly says the finalized media file remains
|
|
on disk. Removing a paused, failed, or canceled entry says retained partial
|
|
data is deleted. “Clear finished” communicates both outcomes and preserves
|
|
playlist scope when the page is opened under a source route. VOD and episode
|
|
detail views continue to render a paused download as an active Resume button
|
|
(`DownloadsService.isPaused()` / `resumeDownloadByContent()`). Artwork and
|
|
titles on every completed card — movie, grouped series, and standalone
|
|
episode alike — open the focused offline detail for that download; the
|
|
explicit Play command in the poster's overflow menu still starts the local
|
|
file directly. A legacy standalone episode without a usable series id
|
|
resolves to a single-episode offline detail built from its own row.
|
|
Missing completed rows show `File missing` under Needs attention with
|
|
`Download again`; Play and Show in folder are withheld. A file-action race
|
|
that returns `File not found` refreshes the authoritative list and returns
|
|
the user from focused details to the manager.
|
|
|
|
## Focused offline details
|
|
|
|
- Ready movies and grouped series open a local-focused detail view rather than
|
|
a provider catalog page. A movie exposes local Play and Show in folder. A
|
|
series projects only completed episode rows whose finalized files are still
|
|
available, grouped into seasons; every episode Play and Show in folder action
|
|
targets that row's local file. The provider's other seasons and episodes are
|
|
deliberately absent from this view.
|
|
- `View in portal` resolves an exact category/item route for Xtream. For
|
|
Stalker it accepts a matching recently-viewed snapshot only when its raw
|
|
movie/regular-series/VOD-series markers agree with the download type, so
|
|
overlapping movie and series ids cannot select the wrong item. An exact
|
|
numeric category stored in the download snapshot wins over the recent
|
|
collection's virtual `vod`/`series` category. Without a matching recent
|
|
shape, only a movie with that exact numeric category can form a
|
|
metadata-only target; episodes and legacy movies without one leave the
|
|
handoff unavailable. The normal provider detail opens in one-shot
|
|
`provider-only` presentation: provider content and playback remain available
|
|
when that host resolves them, while local, Offline, and download actions are
|
|
hidden.
|
|
- A completed row that is no longer locally available is never rendered as a
|
|
ready offline detail. Direct or stale detail URLs return to the manager; if
|
|
navigation fails, the detail shell shows the missing-file error with Back and
|
|
Retry. Invalid or removed download ids render a focused not-found state.
|
|
- Movie and episode downloads capture a versioned, display-only metadata
|
|
snapshot at start time from the already-rendered Xtream or Stalker detail,
|
|
including any TMDB fields already present. The snapshot keeps provider
|
|
identity/category separately from presentation metadata and lets the offline
|
|
view render even when the source portal is unavailable.
|
|
- A grouped series selects its newest valid parent snapshot, then fills only
|
|
missing parent metadata from older valid member snapshots. Newer values,
|
|
per-episode metadata, language, and freshness identity remain authoritative.
|
|
Each episode row still uses its own stored episode metadata.
|
|
- Legacy rows and sparse, stale, or wrong-language snapshots are backfilled
|
|
when focused details open: row metadata supplies a safe local fallback,
|
|
provider data is merged when it can be resolved, and opt-in TMDB enrichment
|
|
uses the same app language and merge rules as provider details. Successful
|
|
refreshes persist to the representative row; transient failures keep safe
|
|
improvements without falsely marking the snapshot fresh, and concurrent
|
|
refreshes are request-ordered so only the latest generation may write.
|
|
- Snapshot artwork accepts only safe HTTP(S) image URLs. Renderer normalization
|
|
and main-process persistence independently reject credential-shaped path and
|
|
query keys, including `username`, so provider credentials cannot be cached in
|
|
offline metadata.
|
|
|
|
## Global API surface
|
|
|
|
- **Preload + types**
|
|
`apps/electron-backend/src/app/api/main.preload.ts` wires every download IPC command plus the `onDownloadsUpdate` listener to `window.electron`. The shared `ElectronBridgeApi` contract in `libs/shared/interfaces/src/lib/electron-api.interface.ts` owns the download and playback-position method types; `global.d.ts` and `apps/web/src/typings.d.ts` reference that contract instead of redeclaring the bridge.
|
|
|
|
## Routing and navigation
|
|
|
|
- The global workspace is `/workspace/downloads`. Source-scoped variants are
|
|
available under both portal flavors:
|
|
`/workspace/xtreams/:id/downloads` and
|
|
`/workspace/stalker/:id/downloads`. All three routes render the same global
|
|
store; the `:id` routes derive a view-only playlist scope.
|
|
- Each scope exposes the same focused child route:
|
|
`/workspace/downloads/:downloadId`,
|
|
`/workspace/xtreams/:id/downloads/:downloadId`, and
|
|
`/workspace/stalker/:id/downloads/:downloadId`. The workspace shell treats
|
|
all three as focused content: route search is disabled and the context panel
|
|
is `none`, so provider categories are not shown beside a local-only item.
|
|
- The manager persists its selected All/Movies/Series/In progress filter in
|
|
the current route query with `replaceUrl`. Opening a focused item then keeps
|
|
that exact scoped URL as its validated return target, so Back restores the
|
|
manager's scope, search query, filter, and browser-history position.
|
|
- Downloads navigation is data-driven: `libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts` emits a `downloads` section link (`path: [...root, 'downloads']`) for both portals, so they reuse the same download page.
|
|
|
|
## Queuing, persistence, and UX notes
|
|
|
|
- Every download row writes to the shared `downloads` table with statuses (`queued`, `downloading`, `paused`, `completed`, `failed`, `canceled`) plus metadata such as `bytesDownloaded`, `totalBytes`, `errorMessage`, `requestHeaders`, `resumeValidator`, the offline-detail metadata snapshot, and Xtream identifiers. Downloads are locally owned records rather than playlist children: deleting a source retains its rows and local files, keeps them visible in the global library, and disables provider handoff until that source exists again. Startup rebuilds older tables that still carry the playlist foreign key, while additive columns use the idempotent column migrations.
|
|
- Download-list and focused-detail reads verify completed files with asynchronous `lstat` probes in the main process. One shared probe queue permits at most four filesystem calls at once and coalesces only in-flight checks for the same path; it does not cache completed results, so external file deletion is visible on the next refresh without letting frequent progress broadcasts block Electron's main thread.
|
|
- On startup, `download-recovery.ts` converts stale `downloading` rows with a non-empty `.part` file to `paused`, converts stale `queued` rows to `paused` while keeping any retained `.part` (a resumed download waiting behind an active one persists as `queued` with its partial), and marks stale `downloading` rows without recoverable partial bytes as `failed`.
|
|
- Queue cancellation removes a queued task or records an active cancellation request and aborts the request when available. Pausing follows the same abort path but persists `paused` and keeps the `.part`. Retries reuse the same database entry: a failed row with a retained `filePath` resumes its `.part` through HTTP Range, otherwise the retry starts from zero. Resume appends to the existing `.part` through HTTP Range with `If-Range` validation when a validator is stored, and through 256 KiB overlap verification when none is.
|
|
- A `.part` that cannot be deleted (locked, permission denied) never loses its database path: cancel persists `canceled` while retaining `filePath` for later cleanup, and `DOWNLOADS_REMOVE` keeps the row and answers `success: false` (surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released.
|
|
- Resume claims the row atomically (`paused` → `queued` as a conditional update) and the runtime queue rejects duplicate ids, so two rapid Resume clicks racing the status refresh can never produce two transfers for the same download.
|
|
- A response that ends cleanly before the advertised representation size (for example a proxy that caps each response) is never committed as completed: the transfer fails with `Transfer ended before the advertised size` while retaining the `.part` and `filePath`, so a retry continues via Range from where it stopped.
|
|
- An allowlisted network failure retains ANY nonempty `.part`: since overlap verification owns resume correctness, the next attempt can safely prove, resume, or restart over whatever was retained — deleting bytes is the only unrecoverable outcome, so retention needs no total, validator, or range evidence. A total the bytes on disk have falsified is persisted as unknown, never as the falsified value. The failed row exposes a stable `DOWNLOAD_NETWORK_INTERRUPTED (<code>)` message without a URL; Retry continues through the same resume validation. Only non-network errors, empty fresh failures, and a partial that shrank mid-attempt keep the generic failure path.
|
|
- The runtime reconnects interrupted transfers on its own (`download-reconnect.ts`, wrapping the transfer in `startDownload`): a recoverable interruption or clean short response triggers an automatic reconnect after a 1 s delay, because servers that cap each connection at N bytes or seconds — common Xtream anti-download throttling — would otherwise demand a manual Retry click per ~130 MB slice. The loop is structurally bounded: an attempt must end at least 64 KiB past the previous attempt to reset the stall budget, and three consecutive attempts without that progress surface the last interruption as the ordinary retained failure. Restarts are an EXPLICIT signal, never byte inference: the transfer layer increments `task.transferRestarts` whenever it rewrites the `.part` from byte zero (overlap mismatch, shrunk entity, HTTP 416, or a server that ignored `Range`), and the loop opens a fresh progress epoch on that signal — clean stall budget, no baseline — because a rebuilt file that happens to land near the previous attempt's byte count is indistinguishable from a stall by byte comparison alone. Only two restarts are tolerated per transfer, or an always-restarting server would reset the budget forever; an unsignalled byte regression is an ordinary stall. Cancel and pause are re-checked around every reconnect. A reconnect attempt that fails before any response (for example `ECONNREFUSED` against a rebooting panel) is converted into the same retained interruption instead of falling into the generic partial-deleting failure path, so an automatic reconnect can never destroy a multi-gigabyte partial the user did not touch. Total handling separates authority from information: the response's own total is the ONLY thing that authorizes completion. An indeterminate range's advertised end (`bytes X-Y/*` → Y+1) flags short delivery, and an indeterminate range that ends cleanly at Y stays incomplete too — reaching Y proves only that the selected range was delivered, so the transfer reconnects from the new offset instead of finalizing; only a response with no range and no total keeps the clean-EOF completion contract of unknown-length HTTP. A reset after the final byte of an AUTHORITATIVE total (overlap proven, totals in agreement) completes the transfer instead of resuming at EOF into a 416-truncate loop; a total carried forward from an earlier response is informational only — it can flag a short transfer, is dropped once the bytes on disk falsify it OR once an indeterminate range can even REACH it (strictly-below guard — settled to unknown on every exit path, clean deliveries and mid-stream pauses included, keeping row and live task consistent), and never authorizes finalization. A verified replay of an indeterminate range that appends nothing arms a one-shot EOF probe: the next attempt requests the byte after the partial outright, so a compliant `416` with `bytes */N` can confirm completion — the ordinary rewound request can never observe it; a probe answered with more data is retired unappended (no overlap proof at that offset) and ordinary rewound verification resumes; a 416 WITHOUT a confirming length answered to ANY request starting at the partial's exact end — the EOF probe and every validator-backed resume alike — is inconclusive, since a request at the entity's true end always collects one and the length is optional: the partial is retained rather than restarted, and only a stated total BELOW the partial proves the entity shrank. Only a fresh or restarted transfer drops the carried total entirely, since it described a discarded file. When no total was ever known, a retained failure persists `totalBytes` as null rather than fabricating one from the byte count — a fabricated total equal to the partial's size would let Retry's completed-partial shortcut finalize an unverified partial without a request; only a finalization failure after a COMPLETE transfer records its byte count as the total, which is what lets its Retry finalize the proven partial directly.
|
|
- Retained `filePath`s recorded in the database stay usable after the user switches download folders — resume/retry of a retained row does not re-require the folder to be the current selection. Fresh downloads still authorize against the currently selected folder.
|
|
- Startup recovery recognizes a finalization that crashed between creating the final file and committing the row (`downloading` row, no partial, final file present with the recorded size) and marks it `completed` instead of failing it and orphaning the file.
|
|
- Pause/resume is covered end to end by `apps/electron-backend-e2e/src/downloads.e2e.ts`: a throttled Range-capable mock server verifies the paused `.part` on disk, the `Range`/`If-Range` resume request, and byte-exact assembly of the final file. Automatic reconnects are covered by `apps/electron-backend-e2e/src/download-reliability.e2e.ts`: a validator-carrying interrupting server must complete without a manual Retry via `Range`/`If-Range`, and a validator-less interrupting server must complete through the rewound overlap-verification `Range` request.
|
|
- The OS downloads path is always authorized. A custom folder becomes
|
|
authorized only after native folder selection, and the main process persists
|
|
that selection under Electron `userData`. Renderer settings may display the
|
|
path, but they are not trusted as authorization.
|
|
- The manager's search and All/Movies/Series/In progress filters affect visible
|
|
queue and library entities only. The tracked-byte summary deliberately
|
|
ignores search/category filtering while honoring route scope, so hiding a
|
|
card never makes its disk footprint appear to vanish.
|
|
|
|
## Live-TV recordings
|
|
|
|
Recordings made with the embedded MPV player live beside downloads, not inside
|
|
them: a recording has no source URL to re-fetch, no byte totals, and no
|
|
retry/resume semantics, so it gets its own `recordings` SQLite table (no
|
|
unique index — re-recording a channel is normal; `playlist_id` carries no FK
|
|
so recordings survive source deletion, with `playlist_name` stored as a
|
|
display snapshot via `playlistDisplayLabel`).
|
|
|
|
- **Lifecycle tracking** is owned by `EmbeddedMpvRecordingTracker`
|
|
(`apps/electron-backend/src/app/services/embedded-mpv-recording-tracker.ts`):
|
|
explicit start/stop hooks in `EmbeddedMpvNativeService` plus a
|
|
session-snapshot observer. The stop hook is a _request_, not an outcome —
|
|
`addon.stopRecording()` only dispatches (async mpv property set, or a
|
|
command written to the frame-copy helper), so finalization always waits for
|
|
the snapshot reporting the recording inactive; statting or unlinking
|
|
earlier would report a short recording as failed and could delete bytes mpv
|
|
is still flushing. macOS native-view clears `recordingActive` _before_
|
|
dispatching the async property set and restores it if that request is
|
|
rejected, so an inactive snapshot must additionally survive a 1.5 s settle
|
|
window (three poll cycles) before it counts as an acknowledgement; a revived
|
|
recording cancels the pending finalization. A 10 s bound finalizes anyway if
|
|
the acknowledgement never arrives. None of this timing is load-bearing for
|
|
stop enrichment — that path does not wait for finalization at all. The same observer covers the stop paths that never call
|
|
`stopRecording()` at all (stream-replacement auto-stop, frame-copy helper
|
|
crash, session error/close). A synthetic error/closed snapshot arrives
|
|
while teardown may still be flushing (the frame-copy helper exits up to
|
|
~2 s after `disposeSession()`), so it defers finalization behind a 2.5 s
|
|
flush window instead of statting immediately — the row stays `recording`
|
|
(startup-repairable) through the window, and an already-armed stop settle
|
|
timer keeps its `completed` verdict. Statuses: `recording` → `completed` (acknowledged
|
|
stop, `fs.stat` size) / `interrupted` (implicit stop with a playable partial
|
|
— MPEG-TS is streamable) / `failed` (start error or absent/empty file). Only
|
|
a recording that never went active has its empty pre-reserved file unlinked;
|
|
once mpv owned the file, the bytes are left alone. Rows carry `owner_pid`,
|
|
so startup repair (`recording-recovery.ts`, `reconcileStaleRecordings`)
|
|
resolves what a hard kill left in `recording` while skipping rows another
|
|
live instance still owns (`IPTVNATOR_ALLOW_MULTIPLE_INSTANCES`) and rows
|
|
the tracker itself is still tracking (`activeRowIds()`) — the renderer is
|
|
interactive before this pass runs, so a recording started during bootstrap
|
|
carries this process's own pid and only the tracker's ledger proves it
|
|
live. Bare pid liveness is not ownership: the holder must also look like
|
|
an IPTVnator/Electron process (`ps`/`tasklist` name, best-effort) and must
|
|
not provably have started after the recording did (`ps -o etime=` /
|
|
PowerShell `StartTime`) — a pid frees only when its previous owner dies,
|
|
so a recycled pid's holder is always younger than the recording, even
|
|
when it landed on another Electron app; unreadable evidence stays
|
|
conservative and keeps the skip. Repair changes broadcast one
|
|
`RECORDINGS_UPDATE_EVENT` because the renderer may already hold the
|
|
pre-repair list. Both repair and tracker finalization stat through a
|
|
bounded async probe (3 s deadline; only `ENOENT`/`ENOTDIR` proves
|
|
absence): repair leaves an unjudgeable row in `recording` for a later
|
|
startup, finalization keeps the requested status with an unknown size —
|
|
a recording directory on a dead network mount must neither block the
|
|
main thread/queue nor brand a likely-good file `failed`. Repair probes
|
|
its whole batch concurrently, so `main.ts` awaits roughly one deadline,
|
|
not one per row. List decoration likewise preserves an inconclusive
|
|
probe: `ElectronRecordingItem.fileAvailability` adds `'unknown'`, and
|
|
consumers gate on `=== 'missing'` so only proven absence reaches Needs
|
|
attention or hides file actions. The focused
|
|
recording detail route is guarded by the `supportsRecordings` capability
|
|
(redirect to the manager) since `RecordingsService` never becomes
|
|
authoritative in the PWA. Tracker finalization is bound to the exact open entry, never the
|
|
reusable session id: a stop → immediate restart on the same session leaves
|
|
the old entry to its own settle timer, and replacing the map entry arms
|
|
that timer if no stop was ever observed, so neither row can be finalized
|
|
by the other's lifecycle.
|
|
- **Metadata is captured at recording start** (EPG is time-sensitive and
|
|
Xtream/Stalker EPG never reaches SQLite): each live host — M3U player,
|
|
Xtream live layout, Stalker ITV layout, unified live tab — assembles a
|
|
`RecordingStartMetadata` (channel name/logo, playlist id + display-label
|
|
snapshot, source type, EPG key, current program) that flows
|
|
`WebPlayerViewComponent → EmbeddedMpvPlayerComponent →
|
|
EmbeddedMpvControlsAdapter → EmbeddedMpvRecordingStartOptions.metadata`.
|
|
`EmbeddedMpvPlayerComponent` watches the session snapshot for the
|
|
active→inactive recording edge and emits `recordingStopped` — one owner for
|
|
every trigger, including a Stop clicked in the download manager, which
|
|
talks to the main process directly and never reaches the player's own
|
|
toggle. The event carries the EPG key captured while the recording was
|
|
active, because a channel switch auto-stops the recording and the host's own
|
|
state already describes the new channel by the time the stop is handled;
|
|
each host enriches only when that key still matches its current channel.
|
|
The EPG key is not unique for M3U items (a `tvgId`, or the display-name
|
|
fallback, can be shared by several list entries), so hosts with M3U
|
|
selections additionally set `RecordingStartMetadata.sourceItemKey` (the
|
|
unified tab's `item.uid`, the M3U player's `channel.id`) — captured and
|
|
carried through the stop event the same way, and compared before
|
|
enriching, so switching between two same-keyed items cannot attach the
|
|
second item's schedule to the first item's recording. The host answers with **stop enrichment**: it filters its in-memory
|
|
program list to the programs overlapping `[startedAt, endedAt]`
|
|
(`filterRecordingProgramsOverlap` in `@iptvnator/shared/interfaces`) and
|
|
sends them through `RECORDINGS_UPDATE_PROGRAMS`, keyed by the unique
|
|
target path — that is how a recording spanning a program boundary lists
|
|
every covered show. The handler is deliberately independent of finalization: it
|
|
looks the row up in **any** status (the newest for that path — `openSync
|
|
('wx')` keeps a reserved path exclusive while its recording owns it) and
|
|
`finalize()` never touches `programs_json`, so the two writes commit in
|
|
either order. The only wait is the tracker's queue drain, which guarantees
|
|
the row's INSERT exists — no deadline, and therefore no way for a one-shot
|
|
enrichment to be dropped by a clock. A recording stopped while no player is mounted on that
|
|
channel keeps its start snapshot.
|
|
- **IPC surface** (`recordings.events.ts`): `RECORDINGS_GET_LIST/GET/STOP/
|
|
REMOVE/UPDATE_PROGRAMS/REVEAL_FILE/PLAY_FILE` plus the dedicated
|
|
`RECORDINGS_UPDATE_EVENT` bare ping (not shared with downloads, so
|
|
recording transitions do not force availability-probed download refetches).
|
|
Active rows are decorated with a live `fs.stat` size — `file_size_bytes` is
|
|
only persisted at finalization, so the manager's growing size comes from
|
|
there. Recording totals also feed the manager-wide All chip and the header's
|
|
active badge, so a page listing only recordings never reads "All 0".
|
|
Reveal/play are gated by `isManagedRecordingFile` — the path must exist in
|
|
the recordings table, mirroring `isManagedDownloadFile`, so the
|
|
renderer-supplied recording directory stays a write-location preference
|
|
rather than a shell-access grant. `RECORDINGS_STOP` resolves the row's
|
|
`session_id` and stops through `EmbeddedMpvNativeService`, so the manager
|
|
can stop a recording without knowing about MPV sessions — but only for rows
|
|
this process owns: session ids restart per process, so dispatching a
|
|
foreign row's id would stop an unrelated local recording. Remove keeps
|
|
finished files on disk (same contract as downloads) and cleans up a failed
|
|
row's leftover reservation only while no other row claims that path — a
|
|
retry within the same timestamp second reuses the freed name. Renderer gate: a separate
|
|
`supportsRecordings` capability allowlist — deliberately NOT folded into
|
|
`supportsDownloads`, which would strip older builds of the whole manager.
|
|
- **UI**: `RecordingsService` mirrors `DownloadsService` (one global list,
|
|
coalesced refreshes via `DownloadListLoadState`, refetch on ping). The
|
|
manager adds a `recording` filter chip; `recording-manager.viewmodel.ts`
|
|
partitions rows into a "Recording now" queue section (pulsing REC chip with
|
|
elapsed time and live file size — never a percentage, the length is
|
|
unknown; the size comes from a bounded, in-flight-coalesced `stat` with a
|
|
1 s deadline so a recording directory on a dead network filesystem
|
|
degrades to "no size" instead of wedging every list load), a recordings-only Needs attention list (Remove only: a broadcast
|
|
cannot be re-recorded), and a "Recordings" library section of 16:9
|
|
channel-logo cards (`recording-queue.component.*`,
|
|
`recording-library.component.*`). Card titles use the captured program
|
|
title, falling back to channel + start time. The focused detail
|
|
(`recording-detail/`, route `/workspace/downloads/recording/:recordingId`,
|
|
context panel and route search hidden via `workspace-shell-route.utils.ts`)
|
|
shows the recorded time range, covered programs when a recording spans ≥2
|
|
shows, file path, and Play/Reveal/Stop/Remove; a missing file degrades to
|
|
Back + Remove.
|
|
|
|
Keeping the backend queue, IPC handlers, shared schema, and renderer signals
|
|
synchronized minimizes drift between platform rules and the UI. Future work
|
|
might cover queue reordering, bulk pause/cancel actions, disk-free space
|
|
telemetry, playback analytics, or frame-copy screenshot posters for
|
|
recordings.
|