mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
165 lines
14 KiB
Markdown
165 lines
14 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.
|
|
|
|
## 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`, 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`, 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`. Headers (user agent, referer, origin) are persisted in `request_headers` and re-applied through the same allowlist when read back on retry/resume. 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) and sends `Range: bytes=<offset>-` plus `If-Range` with the stored entity validator. The first response's strong `ETag` (or `Last-Modified`) is persisted in `resume_validator` for exactly this purpose. 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 ordinary transfer failures remove
|
|
the `.part`, while finalization failures and completed-partial failures
|
|
deliberately retain it (the row keeps `filePath` so a later retry can finish
|
|
without re-downloading); pause and restart recovery keep it for a later
|
|
resume. 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. Overlapping loads are request-ordered so a
|
|
late response cannot replace a newer 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.
|
|
- **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 uses compact progress rows with status text and icons; completed movies
|
|
and grouped series reuse the portal's canonical content grid. 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 completed movie and grouped-series cards open the provider detail
|
|
page; the explicit Play action starts the local file. A legacy standalone
|
|
episode without a usable series id stays directly playable because there is
|
|
no reliable detail route to open. Missing completed rows show `File missing`
|
|
with `Download again`; Play and Show in folder are withheld. A file-action
|
|
race that returns `File not found` refreshes the authoritative list before
|
|
pending state clears.
|
|
|
|
## Offline detail playback
|
|
|
|
- A completed VOD with an available finalized file renders an Offline tag on
|
|
both rich and fallback detail shells. Its primary action plays the downloaded
|
|
file, while a neutral
|
|
“Play from this source” action, when a usable provider source is available,
|
|
preserves the existing provider, pinned-source, resume, and restart path.
|
|
- A completed row whose file is missing does not advertise Offline or local
|
|
playback on details; the provider playback path remains primary.
|
|
- Managed MPV/VLC state remains authoritative: Opening disables conflicting
|
|
playback actions, and Stop closes the matched external session before any
|
|
local or provider choice can run.
|
|
- Detail metadata still comes from the owning Xtream or Stalker provider.
|
|
Download rows do not cache a standalone offline detail payload in this MVP,
|
|
so a provider that is unavailable may prevent the detail shell from loading;
|
|
the explicit Play action on the download card remains available for the
|
|
local file.
|
|
|
|
## 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.
|
|
- 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`, and Xtream identifiers. Existing SQLite tables are rebuilt on startup when their status CHECK still lacks `paused`; the `resume_validator` column is added through the idempotent column migrations.
|
|
- 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.
|
|
- 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.
|
|
- 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.
|
|
- 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 an offline metadata snapshot for provider-independent details,
|
|
recordings, queue reordering, bulk pause/cancel actions, disk-free space
|
|
telemetry, or playback analytics.
|