mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
* fix(downloads): reconnect interrupted transfers and resume without validators Xtream panels commonly kill each VOD connection after a byte/time burst (~130-260 MB) and send no ETag/Last-Modified. The validator-only resume path then deleted the partial and surfaced a raw "aborted" failure, so every Retry restarted from zero and large files could never finish. - Resume without a validator through overlap verification: the Range request rewinds by 256 KiB and the replayed window must match the partial's tail byte-for-byte before anything is appended; a mismatch truncates the partial and restarts from scratch (download-overlap.ts). - Reconnect automatically on recoverable interruptions and clean short responses (download-reconnect.ts): progress >=64 KiB past the best attempt resets a 3-stall budget; request-phase failures during reconnects are converted into retained interruptions so automation can never delete a partial. - Extract pure response-header helpers into download-resume-validation.ts to keep download-transfer.ts within the file-size guideline. Verified against a real throttling portal: a 1.6 GB and a 3 GB movie completed through 15 and 23 connection resets respectively. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): address review findings on reconnect baseline and small partials - Judge reconnect progress against the previous attempt instead of a high-water mark, so a transfer that legitimately restarted from byte zero mid-loop (overlap mismatch, ignored Range) is measured by its rebuilt file; at most two such regressions are tolerated per transfer to keep the loop structurally bounded (Greptile P1). - Floor reported progress at the partial's retained size while appending, so a response that ends inside the overlap window can never move persisted progress backwards (Greptile P1). - Verify partials smaller than the overlap window in full from byte zero and append, instead of rewriting the .part in place — an early-dying reconnect can now only grow the file (Codex P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep tolerated regressions off the reconnect stall budget A tolerated restart regression consumed a regression credit AND counted as a stalled attempt (its negative delta is below the progress threshold), so a legitimately rebuilt file that grows in sub-64 KiB steps was failed one reconnect early. Regressions are now charged to their own bounded budget only; the stall budget stays reserved for attempts that genuinely fail to grow the file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): gate success and validator promotion on complete overlap verification Round-3 review findings: - Success is rejected while the overlap verifier has not consumed its entire window: a complete 206 that ends inside the window proves the remote entity shrank, so the transfer truncates and restarts from scratch instead of finalizing the old suffix as a completed file; an early-dying stream stays an ordinary retained interruption (Codex P1). - A verify-append attempt promotes the response's ETag/Last-Modified only after the complete overlap matched — an unverified partial is never blessed with a validator the next resume would If-Range-append onto (Codex P1). - A tolerated restart regression resets stalls accumulated against the discarded representation, so the rebuilt file starts with the full stall budget (Greptile P1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): carry the known total through total-less reconnect responses A resumed response without a usable total (chunked, or an unsatisfiable Content-Range) erased task.totalBytes, so a reset over that response could no longer classify as a retained interruption and generic cleanup deleted the verified partial. The previously learned total is now carried forward for appending attempts; fresh and restarted transfers still drop it, since it described a discarded file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): never fabricate a total for an unverified retained partial A retained failure with an unknown total persisted totalBytes = bytesDownloaded, so after stalled reconnects over a chunked, validator-less response that kept ending inside the overlap window, Retry's completed-partial shortcut saw the .part size equal the fabricated total and finalized the unverified partial without a request. The fallback is now explicit per call site: only a finalization failure after a complete transfer records its byte count as the total; retained interruptions keep an unknown total unknown, forcing Retry to re-verify. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): treat an unsatisfiable resume range as a representation change A range-capable server whose entity shrank below the rewound overlap offset answers 416 before any response body exists, which rejected the request into the generic partial-deleting failure path. The 416 is now recognized as a representation change: the partial is truncated and the transfer restarts against the current entity from byte zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): treat an indeterminate Content-Range total as unknown `Content-Range: bytes 200-299/*` fell through to the Content-Length fallback, deriving a "total" equal to the end of the selected range — a resumed response ending there was declared complete and the truncated partial finalized. An indeterminate total now yields null, letting the previously known total carry forward and classify the short response as a retained truncation instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): signal restarts explicitly and keep carried totals informational Round-8 review findings, both fixed at the design level instead of another byte-comparison patch: - Restart epochs (Greptile P1): the transfer layer now reports every rewrite-from-zero via task.transferRestarts (overlap mismatch, shrunk entity, 416, ignored Range), and the reconnect loop opens a fresh progress epoch on that signal — clean stall budget, no baseline. Byte inference could not recognize a rebuild landing near the previous attempt's count; the explicit signal can. Two restarts are tolerated per transfer; an unsignalled regression is an ordinary stall. - Authoritative vs informational totals (Codex P1): completion and truncation decisions now use only the response's own total or its advertised indeterminate range end (`bytes X-Y/*` -> Y+1); a carried total is informational, is dropped once the bytes on disk falsify it, and can flag a short transfer but never authorize finalization. A mid-reset 206 retains the partial even with a falsified total — the response proved range capability — persisting the total as unknown. Also raises the resume spec's jest timeout and tightens its polling: the previous 5 s default flaked on starved CI runners. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): keep unproven state fully uncommitted across the resume model Round-9 review findings, closing the remaining commit-before-proof gaps: - The response's total now stays uncommitted (task and row) until the complete overlap matched, exactly like the validator: a persisted total equal to the unverified partial's size let the completed-partial shortcut finalize unproven bytes after a pause, crash, or retained failure (Codex P1). - Retained-interruption persistence syncs the live task with the row: a stale falsified total left in memory made the next reconnect's resume-offset guard reject the retained partial into generic cleanup (Codex P1). - An observed 206 is remembered as task.serverAcceptsRanges, so a request-phase failure (no response at all) can retain an unknown-length partial on that evidence instead of deleting it (Codex P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): treat a reset after the final ranged byte as completion A 206 that delivers every advertised byte but ends in ECONNRESET instead of a clean close was classified as an interruption with a falsified total; the reconnect then resumed at EOF, collected a 416, and truncated the complete file — an endpoint that always resets after its last byte could never finish. A retainable failure whose on-disk bytes exactly equal the response's own completion evidence (and the proven total, with the overlap verified) now completes the transfer. Strict equality keeps oversized partials on the generic-failure path. Also splits the transfer error classes and retention classification into download-transfer-errors.ts to stay under the max-lines rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): never let an indeterminate range end authorize completion Reaching Y of `Content-Range: bytes X-Y/*` proves the selected range was delivered, not that the entity ends there — a range-capping server resetting at its cap would have finalized a truncated movie as complete. The reset-after-final-byte completion now requires the response's authoritative total; indeterminate range ends keep flagging short delivery but such resets stay retained interruptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): retain any nonempty partial and keep indeterminate ranges incomplete Round-12 findings, resolved by removing the last evidence requirements: - Retention no longer needs a total, validator, or range proof: since overlap verification owns resume correctness, the next attempt can safely prove, resume, or restart over ANY retained partial — deleting bytes is the only unrecoverable outcome. This closes the whole family (refused reconnects, chunked responses, falsified totals, and reconstructed retry tasks losing the in-memory range flag) and removes task.serverAcceptsRanges entirely (Codex P2). - A clean EOF at the advertised end of an indeterminate range (`bytes X-Y/*`) stays incomplete, matching the reset path: reaching Y proves the range was delivered, not that the entity ends there, so a range-capping server can no longer finalize a truncated movie; the shrunk-entity restart likewise requires an authoritative total (Codex P1). Responses with no range and no total keep the clean-EOF completion contract of unknown-length HTTP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): honor 416-confirmed completion and keep proven validators Round-13 findings plus a CI limit: - A 416 whose `Content-Range: bytes */N` equals the partial's size confirms the file IS the complete entity (under If-Range a validator mismatch yields 200, so the 416 also confirms identity): finalize it instead of truncating and redownloading forever (Codex P1). - A validator proven by a complete overlap match is now promoted on the error path too, and retained-failure/pause persistence write resume_validator from the task — later attempts resume via If-Range instead of replaying the 256 KiB window, which stalled out servers whose per-connection cap barely exceeds the window (Codex P1). - download-resume.spec.ts crossed the 1200-line test limit: the shared harness moves to download-resume.test-harness.ts and the overlap-family cases into download-overlap-resume.spec.ts; the runtime spec gets the same CI-load timeout headroom as the resume specs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): settle falsified totals on clean exits and probe EOF after zero-growth replays Round-14 review findings plus the CI build break: - download-resume.test-harness.ts was outside the tsconfig test-helpers exclude glob and broke every app typecheck/build; renamed to download-resume.test-helpers.ts (the excluded pattern). - A clean indeterminate delivery that outgrows a stale carried total now settles that total to unknown on the row AND the live task before persisting or throwing — the reconnect's resume-offset guard would otherwise reject the retained partial into generic cleanup (Codex P1). - A verified overlap replay of an indeterminate range that appends nothing arms a one-shot EOF probe: the next attempt requests the byte after the partial so a compliant 416 (bytes */N) can confirm the file is the complete entity, instead of repeating the rewind until the stall budget fails a finished download; a probe answered with more data is retired unappended and rewound verification resumes (Codex P1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): retain the partial on an inconclusive EOF-probe 416 A probe at the partial's exact end always collects a 416 when the entity ends there, and the confirming Content-Range length is optional — so a length-less 416 is equally consistent with a complete file, and the unconditional restart redownloaded a likely finished movie every cycle. The probe's 416 now restarts only when a stated total BELOW the partial proves the entity shrank; otherwise the partial is retained. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): treat any EOF-offset 416 as inconclusive, not just the probe A validator-backed resume at the partial's exact end IS an EOF request: when its 416 arrived without the optional Content-Range length, the probingEof gate saw false and restartFromScratch truncated the complete partial. The inconclusive-416 retention now keys on the request having started at the partial's end (covering the probe and every If-Range resume alike); a restart still requires a stated total below the partial. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): contradiction-proof carried totals and identity-gated 416 completion Round-17 review findings: - A carried total now falls the moment the response's advertised range end contradicts it, before any byte lands — waiting for the bytes to reach it left a pause/exit window where an N/N row let the completed-partial shortcut finalize a truncated file (Codex P1). - The 416 completion shortcut now requires identity proof: an exact-EOF request backed by If-Range, or the EOF probe that follows a fully verified overlap replay. A bare length match on a rewound request proves nothing about whose bytes are on disk; a contradictory 416 (stated total says the rewound range was satisfiable) retains instead of restarting (Codex P2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(downloads): extract 416 classification to stay under max-lines The previous commit pushed download-transfer.ts to 404 effective lines; the 416 decision moves into classifyRangeNotSatisfiable() in download-transfer-errors.ts with identical semantics, and the unused re-export-only imports are dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): promote the proven total on the error path and fix the 416 contract doc - The error-path promotion after a complete overlap match now updates task.totalBytes alongside the validator: a pause landing while the partial sits at a stale carried total otherwise persisted an N/N row that Resume's completed-partial shortcut would finalize (Codex P1). - The download-manager contract doc's rewound-416 paragraph now matches classifyRangeNotSatisfiable(): completion requires identity proof at exact EOF, restart requires a proven shrink, everything ambiguous retains (Codex P1 on the doc). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): correct both 416 classification boundaries Round-19 review findings, one in each direction: - A rewound 416 WITHOUT a stated length now retains: unsatisfiability alone never proves the entity shrank relative to the retained bytes, and the canonical contract reserves restart for stated proof (Codex P1). - A stated total EQUAL to a rewound request's first byte now restarts: the entity ending exactly at the rewound offset makes the 416 valid and proves the partial extends past the entity — equality was being misread as a contradiction, stranding the download in retain forever (Codex P1). classifyRangeNotSatisfiable() gains an exhaustive pure table spec covering every branch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): restart on reset-ended responses that completed a shorter entity in-window The clean-EOF path already restarted when a response delivered its complete authoritative total inside the verification window, but a reset arriving right after that final byte took the retention path and stranded the oversized partial in a stall loop. The catch path now mirrors the shrink detection: authoritative total reached inside an unproven overlap restarts from scratch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): request identity encoding for byte-exact transfers Axios's Node adapter transparently decodes gzip/brotli responses, which would put decoded bytes on disk while Content-Length, Content-Range, and every Range offset speak the encoded representation — desyncing resume offsets and overlap verification on origins that compress. Downloads now always send Accept-Encoding: identity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): drop carried totals an indeterminate range can exactly reach `bytes 200-249/*` can deliver the partial exactly TO a carried total of 250; the <= guard kept that total, and a pause or exit anywhere in that window persisted a 250/250 row the completed-partial shortcut would finalize without EOF proof. The guard is now strict: a carried total survives only when the advertised indeterminate range cannot reach it, which also closes the mid-stream pause window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(downloads): arm the EOF probe after a reset-ended verified zero-growth replay A verified overlap replay that reset right at the partial's end (zero growth, indeterminate range, no validator) retained without arming the EOF probe, so every retry replayed the same tail until the stall budget expired — the clean-EOF path's probe arming now has its reset-path mirror. Also splits to stay under max-lines: the classifyRangeNotSatisfiable table spec moves to download-transfer-errors.spec.ts and the DB persist helpers to download-transfer-persistence.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
32 KiB
32 KiB
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.
Backend responsibilities
- Queue control (
apps/electron-backend/src/app/events/database/download-runtime.ts)DownloadTaskmirrors a row of the shareddownloadstable (typeDownloadinlibs/shared/database/src/lib/schema.ts) plus transient cancel/pause/progress helpers (shared task types live indownload-task.ts). Request validation and row creation live indownload-requests.ts, whiledownloads.events.tsstays focused on IPC registration.enqueueDownload()pushes the task ontodownloadQueueand triggersprocessQueue().processQueue()keeps one active download, updates the row todownloading, and callsstartDownload(). The byte transfer itself lives indownload-transfer.ts, finalization and retained-partial persistence indownload-finalize.ts, and the renderer update broadcast indownload-broadcast.ts. - Range-aware transfer (
download-transfer.ts) The transfer streams the response through the backend's validated Axios redirect helper instead ofelectron-dl, and always requestsAccept-Encoding: identity: Range offsets, totals, and the persisted.partmust 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 inrequest_headersand 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-compatibleXTREAM_CLIENT_USER_AGENTused 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 withAbortController; pause keeps the partial file and cancel removes it. Resume checks the existing.partsize (rejecting anything that is not a regular file, so a symlink planted while paused is never followed). The first response's strongETag(orLast-Modified) is persisted inresume_validator; a partial carrying that validator resumes withRange: bytes=<offset>-plusIf-Range, so the server itself proves the entity is unchanged. A retained partial without a validator resumes through overlap verification instead (download-overlap.ts): theRangerequest 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.partand 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 byclassifyRangeNotSatisfiable(): 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 statedbytes */Nequals 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 writeresume_validatorfrom the task, so later attempts resume viaIf-Rangeinstead 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'sETag/Last-Modifiedonto 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 resumeIf-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. A206 Partial Contentanswer must start at the requested offset (Content-Rangeis verified) before bytes are appended; any other 2xx answer — the server ignoringRange, orIf-Rangedetecting that the remote file changed — restarts the transfer from byte zero over the same.partinstead 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
.partpath while leaving the final destination path absent. The selected finalfilePathandfileNameare 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.partis renamed aside and finalized to the next free numbered destination (Movie (1).mp4) instead of resolving the collision by size orunlink(). Completion creates the finalfilePathfrom the.partwithout 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 keepsfilePathso 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.partbefore the row is reset. - Derived file readiness and recovery
DOWNLOADS_GET_LISTandDOWNLOADS_GETinspect 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 persistedcompletedtransfer status.DOWNLOADS_REDOWNLOAD_MISSINGaccepts 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 exposesDOWNLOADS_*handlers for list retrieval, start/pause/resume/cancel/retry/missing-file recovery/remove operations, folder selection/reveal, and theDOWNLOADS_UPDATE_EVENTemitter that the renderer listens to in order to refresh its signal store.
Renderer architecture
- Downloads service (
libs/services/src/lib/downloads.service.ts)DownloadsService.downloadsis 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.hasLoadedDownloadsrecords that the latest attempt completed, including an error, whilehasAuthoritativeDownloadListbecomes 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. TheonDownloadsUpdatebroadcast triggers a new global load. - Series season queueing
SeasonDownloadCoordinatorowns synchronous, per-identity pending reservations and submits an individual episode or selected-season snapshot throughDownloadsService.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 returnsadded,skipped, andfailedcounts. 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 normalizedepisode.idas the canonical episodextreamId; StalkeroriginalCmdandoriginalIdparticipate only in URL resolution. Provider adapters preserve numeric season zero, including a fallback season key of"0", so Specials keep distinctS00coordinates. The exact(playlistId, contentType, xtreamId)identity is authoritative. Complete(playlistId, seriesXtreamId, seasonNumber, episodeNumber)coordinates are a legacy episode-compatibility fallback. Stalker also stores anepisode_identity_scopefor regular/series, embedded VODseries[], and lazy Ministra VODis_seriesorigins. 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. SQLitenulland optionalundefinedcoordinates 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_STARTasynchronously rechecks its retained path in the main process. A restored file returns stablereason: 'already-downloaded'without mutation; active matches returnreason: '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. OnlyENOENTandENOTDIRare authoritative absence; permission, I/O, and other probe errors remain unknown, soDOWNLOADS_STARTleaves 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, whileENOENTandENOTDIRsafely 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.tsanddownload-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, andpausedrows form the active surface;failedandcanceledrows form the attention surface. Acompletedrow 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 validseriesXtreamIdare 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 inDownloadsComponent, async mutations in the component-scopedDownloadManagerActionsService, source-route resolution inDownloadLibraryNavigationService, and rendering in the presentationalDownloadQueueComponent,DownloadLibraryComponent, andDownloadedSeriesDialogComponent. Presentational children emit typedDownloadItemActionvalues 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-badgeitem 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-gaptokens, 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 showFile missingunder Needs attention withDownload again; Play and Show in folder are withheld. A file-action race that returnsFile not foundrefreshes 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 portalresolves 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 virtualvod/seriescategory. 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-shotprovider-onlypresentation: 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.tswires every download IPC command plus theonDownloadsUpdatelistener towindow.electron. The sharedElectronBridgeApicontract inlibs/shared/interfaces/src/lib/electron-api.interface.tsowns the download and playback-position method types;global.d.tsandapps/web/src/typings.d.tsreference 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/downloadsand/workspace/stalker/:id/downloads. All three routes render the same global store; the:idroutes 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 isnone, 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.tsemits adownloadssection 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
downloadstable with statuses (queued,downloading,paused,completed,failed,canceled) plus metadata such asbytesDownloaded,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
lstatprobes 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.tsconverts staledownloadingrows with a non-empty.partfile topaused, converts stalequeuedrows topausedwhile keeping any retained.part(a resumed download waiting behind an active one persists asqueuedwith its partial), and marks staledownloadingrows without recoverable partial bytes asfailed. - 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
pausedand keeps the.part. Retries reuse the same database entry: a failed row with a retainedfilePathresumes its.partthrough HTTP Range, otherwise the retry starts from zero. Resume appends to the existing.partthrough HTTP Range withIf-Rangevalidation when a validator is stored, and through 256 KiB overlap verification when none is. - A
.partthat cannot be deleted (locked, permission denied) never loses its database path: cancel persistscanceledwhile retainingfilePathfor later cleanup, andDOWNLOADS_REMOVEkeeps the row and answerssuccess: false(surfaced as a snackbar) so retrying the remove re-attempts the deletion once the lock is released. - Resume claims the row atomically (
paused→queuedas 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 sizewhile retaining the.partandfilePath, 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 stableDOWNLOAD_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 instartDownload): 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 incrementstask.transferRestartswhenever it rewrites the.partfrom byte zero (overlap mismatch, shrunk entity, HTTP 416, or a server that ignoredRange), 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 exampleECONNREFUSEDagainst 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 compliant416withbytes */Ncan 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 persiststotalBytesas 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
filePaths 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 (
downloadingrow, no partial, final file present with the recorded size) and marks itcompletedinstead 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.parton disk, theRange/If-Rangeresume request, and byte-exact assembly of the final file. Automatic reconnects are covered byapps/electron-backend-e2e/src/download-reliability.e2e.ts: a validator-carrying interrupting server must complete without a manual Retry viaRange/If-Range, and a validator-less interrupting server must complete through the rewound overlap-verificationRangerequest. - 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.
Keeping the backend queue, IPC handlers, shared schema, and renderer signals synchronized minimizes drift between platform rules and the UI. Future work might cover recordings, queue reordering, bulk pause/cancel actions, disk-free space telemetry, or playback analytics.