From cabf79984ce4035059cb20092622e2e7d29a27e4 Mon Sep 17 00:00:00 2001 From: Josh Mulheron Date: Fri, 11 Sep 2026 16:56:35 +0100 Subject: [PATCH 1/2] Queue downloads instead of cancelling the in-progress one Starting a second download used to kill the first. The eviction happens in handle_max_streams: play_stop is a per-source map of cancellation tokens shared by mpv playback and downloads, and with max_streams defaulting to 1 a new download would shift_remove_index(0) and cancel the running one. DownloadService now queues instead. download() pushes onto a queue and returns a promise that settles when the download actually finishes, fails or is cancelled, so existing callers need no changes, and the backend is only invoked once the channel's source has no download in flight. Queueing is per source_id to match how play_stop and max_streams are keyed, so separate sources still run in parallel without exceeding the limit. Also: - abortDownload drops a queued download from the queue rather than calling abort_download, which had no token to cancel and would have failed - cleanup and advancing the queue now happen only in run(), removing the double complete.next(true) and double unlisten on cancel - addDownload returns the existing entry for a duplicate id instead of overwriting it and leaking the first unlisten - the download manager shows queued entries as "Queued" with a striped bar Co-Authored-By: Claude Opus 5 (1M context) --- .../download-manager.component.css | 5 ++ .../download-manager.component.html | 10 ++- .../download-manager.component.ts | 4 + src/app/download.service.ts | 88 ++++++++++++++++--- src/app/models/download.ts | 10 +++ 5 files changed, 103 insertions(+), 14 deletions(-) diff --git a/src/app/download-manager/download-manager.component.css b/src/app/download-manager/download-manager.component.css index 1b879c11..ce654d21 100644 --- a/src/app/download-manager/download-manager.component.css +++ b/src/app/download-manager/download-manager.component.css @@ -25,6 +25,11 @@ button.btn-outline-light:hover { background-color: #2b2d30; } +.queued { + color: #aaa; + font-style: italic; +} + .stop { width: 1.5rem; height: 1.5rem; diff --git a/src/app/download-manager/download-manager.component.html b/src/app/download-manager/download-manager.component.html index 8652a3e8..c6855efd 100644 --- a/src/app/download-manager/download-manager.component.html +++ b/src/app/download-manager/download-manager.component.html @@ -22,12 +22,16 @@
{{ download.channel.name }} - {{ download.progress }}% + Queued + {{ download.progress }}%
-
+
+
diff --git a/src/app/download-manager/download-manager.component.ts b/src/app/download-manager/download-manager.component.ts index a2e18712..79438c36 100644 --- a/src/app/download-manager/download-manager.component.ts +++ b/src/app/download-manager/download-manager.component.ts @@ -17,6 +17,10 @@ export class DownloadManagerComponent implements OnInit { return Array.from(this.downloadService.Downloads.values()); } + isQueued(download: Download) { + return this.downloadService.isQueued(download); + } + toggleMinimize() { this.isMinimized = !this.isMinimized; } diff --git a/src/app/download.service.ts b/src/app/download.service.ts index 28bb81cc..0683a74d 100644 --- a/src/app/download.service.ts +++ b/src/app/download.service.ts @@ -1,16 +1,23 @@ import { Injectable, NgZone } from "@angular/core"; -import { Download } from "./models/download"; +import { Download, DownloadStatus } from "./models/download"; import { Subject } from "rxjs"; import { invoke } from "@tauri-apps/api/core"; import { ErrorService } from "./error.service"; import { listen } from "@tauri-apps/api/event"; import { Channel } from "./models/channel"; +/// Used when a channel has no source id, so those downloads still queue against each other +const NO_SOURCE = -1; + @Injectable({ providedIn: "root", }) export class DownloadService { Downloads: Map = new Map(); + /// Downloads waiting for a free slot, in the order they were requested + private queue: Download[] = []; + /// The download currently running for a given source id + private running: Map = new Map(); constructor( private error: ErrorService, @@ -18,13 +25,19 @@ export class DownloadService { ) { } async addDownload(id: string, channel: Channel): Promise { + let existing = this.Downloads.get(id); + if (existing) { + return existing; + } let download: Download = { channel: channel, progress: 0, complete: new Subject(), id: id, progressUpdate: new Subject(), + status: DownloadStatus.queued, }; + download.unlisten = await listen(`progress-${download.id}`, (event) => { this.ngZone.run(() => { download.progress = event.payload; @@ -32,39 +45,92 @@ export class DownloadService { download.progressUpdate.next(download.progress); }); this.Downloads.set(download.id, download); + return download; } async abortDownload(id: String) { + let download = this.Downloads.get(id); + if (!download) { + return; + } + // Nothing was ever sent to the backend, so there is no token to cancel + if (download.status == DownloadStatus.queued) { + this.queue = this.queue.filter((x) => x.id != download!.id); + this.deleteDownload(download); + download.settle?.(); + this.error.info("Download cancelled"); + return; + } try { - let download = this.Downloads.get(id); - if (download) { - await invoke("abort_download", { - sourceId: download.channel.source_id, - downloadId: download.id, - }); - this.deleteDownload(download); - } + await invoke("abort_download", { + sourceId: download.channel.source_id, + downloadId: download.id, + }); + // Cleanup and starting the next download is left to run(), whose invoke + // is about to reject with "download aborted" } catch (e) { console.error(e); this.error.handleError(e); } } + /// Queues a download and resolves once it completed, failed or got cancelled async download(id: String, path?: string) { - let download = this.Downloads.get(id)!; + let download = this.Downloads.get(id); + if (!download) { + return; + } + // Already waiting or running, don't queue the same channel twice + if (this.queue.includes(download) || this.running.get(this.sourceKey(download)) == download) { + return; + } + download.path = path; + let settled = new Promise((resolve) => (download!.settle = resolve)); + this.queue.push(download); + this.startNext(); + return settled; + } + + isQueued(download: Download) { + return download.status == DownloadStatus.queued; + } + + /// Starts every queued download whose source has no download running + private startNext() { + for (let download of [...this.queue]) { + if (this.running.has(this.sourceKey(download))) { + continue; + } + this.queue.splice(this.queue.indexOf(download), 1); + this.running.set(this.sourceKey(download), download); + this.run(download); + } + } + + private async run(download: Download) { + this.ngZone.run(() => { + download.status = DownloadStatus.downloading; + }); try { await invoke("download", { downloadId: download.id, channel: download.channel, - path: path, + path: download.path, }); this.error.success("Download completed successfully"); } catch (e) { if (e == "download aborted") this.error.info("Download cancelled"); else this.error.handleError(e); } + this.running.delete(this.sourceKey(download)); this.deleteDownload(download); + download.settle?.(); + this.startNext(); + } + + private sourceKey(download: Download) { + return download.channel.source_id ?? NO_SOURCE; } deleteDownload(download: Download) { diff --git a/src/app/models/download.ts b/src/app/models/download.ts index 14dc8044..132e274f 100644 --- a/src/app/models/download.ts +++ b/src/app/models/download.ts @@ -2,6 +2,11 @@ import { UnlistenFn } from "@tauri-apps/api/event"; import { Subject } from "rxjs"; import { Channel } from "./channel"; +export enum DownloadStatus { + queued = 0, + downloading = 1, +} + export class Download { id!: string; progress!: number; @@ -9,4 +14,9 @@ export class Download { channel!: Channel; unlisten?: UnlistenFn; progressUpdate!: Subject; + status!: DownloadStatus; + /// Where to save the file, picked before the download is queued + path?: string; + /// Resolves whatever called download(), once it finished, failed or got cancelled + settle?: () => void; } From 5f08fa05ae2dfe32ad8c82623ca31b0d0b310464 Mon Sep 17 00:00:00 2001 From: Josh Mulheron Date: Fri, 11 Sep 2026 17:40:34 +0100 Subject: [PATCH 2/2] Allow a download queue and add bulk download buttons for shows and invidual seasons --- src-tauri/src/lib.rs | 17 ++++- src-tauri/src/sql.rs | 41 ++++++++++++ src-tauri/src/utils.rs | 14 +++- .../channel-tile/channel-tile.component.html | 9 ++- .../channel-tile/channel-tile.component.ts | 66 ++++++++++++++++++- .../download-manager.component.css | 5 ++ .../download-manager.component.html | 4 +- src/app/download.service.ts | 22 ++++++- src/app/models/download.ts | 2 + 9 files changed, 170 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c959262b..6e6fdc9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,7 +115,9 @@ pub fn run() { hide_channel, hide_group, remove_from_history, - get_all_expiries + get_all_expiries, + get_season_episodes, + get_series_episodes ]) .setup(|app| { app.manage(Mutex::new(AppState { @@ -439,12 +441,23 @@ async fn download( channel: Channel, download_id: String, path: Option, + directory: Option, ) -> Result<(), String> { - utils::download(state.clone(), app, channel, &download_id, path) + utils::download(state.clone(), app, channel, &download_id, path, directory) .await .map_err(map_err_frontend) } +#[tauri::command(async)] +fn get_season_episodes(season_id: i64) -> Result, String> { + sql::get_season_episodes(season_id).map_err(map_err_frontend) +} + +#[tauri::command(async)] +fn get_series_episodes(series_id: i64, source_id: i64) -> Result, String> { + sql::get_series_episodes(series_id, source_id).map_err(map_err_frontend) +} + #[tauri::command] async fn abort_download( state: State<'_, Mutex>, diff --git a/src-tauri/src/sql.rs b/src-tauri/src/sql.rs index 9720622d..c01a4c5e 100644 --- a/src-tauri/src/sql.rs +++ b/src-tauri/src/sql.rs @@ -1827,6 +1827,47 @@ pub fn find_all_episodes_after(channel: &Channel) -> Result> { .collect()) } +pub fn get_season_episodes(season_id: i64) -> Result> { + let sql = get_conn()?; + Ok(sql + .prepare( + r#" + SELECT * FROM channels + WHERE season_id = ? + AND media_type = ? + AND url IS NOT NULL + AND hidden = 0 + ORDER BY episode_num, name + "#, + )? + .query_map(params![season_id, media_type::MOVIE], row_to_channel)? + .filter_map(Result::ok) + .collect()) +} + +pub fn get_series_episodes(series_id: i64, source_id: i64) -> Result> { + let sql = get_conn()?; + Ok(sql + .prepare( + r#" + SELECT channels.* FROM channels + LEFT JOIN seasons ON seasons.id = channels.season_id + WHERE channels.series_id = ? + AND channels.source_id = ? + AND channels.media_type = ? + AND channels.url IS NOT NULL + AND channels.hidden = 0 + ORDER BY seasons.season_number, channels.episode_num, channels.name + "#, + )? + .query_map( + params![series_id, source_id, media_type::MOVIE], + row_to_channel, + )? + .filter_map(Result::ok) + .collect()) +} + pub fn update_source_last_updated(source_id: i64) -> Result<()> { let sql = get_conn()?; sql.execute( diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 97821315..60e2496f 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -74,6 +74,7 @@ pub async fn download( channel: Channel, download_id: &str, path: Option, + directory: Option, ) -> Result<()> { let source_id = channel.source_id.context("no source id provided")?; let source = sql::get_source_from_id(source_id) @@ -119,7 +120,18 @@ pub async fn download( let mut downloaded = 0; let path = match path { Some(p) => p, - None => get_download_path(get_filename(name, url)?)?, + None => { + let filename = get_filename(name, url)?; + // Bulk downloads pick a directory once instead of a path per episode + match directory { + Some(dir) => { + let mut dir = Path::new(&dir).to_path_buf(); + dir.push(filename); + dir.to_string_lossy().to_string() + } + None => get_download_path(filename)?, + } + } }; let mut file = tokio::fs::File::create(&path).await?; let mut send_threshold: f64 = 0.1; diff --git a/src/app/channel-tile/channel-tile.component.html b/src/app/channel-tile/channel-tile.component.html index 0742023b..d626900e 100644 --- a/src/app/channel-tile/channel-tile.component.html +++ b/src/app/channel-tile/channel-tile.component.html @@ -40,7 +40,8 @@ Unfavorite Favorite - @@ -51,6 +52,12 @@ + + diff --git a/src/app/channel-tile/channel-tile.component.ts b/src/app/channel-tile/channel-tile.component.ts index 3614a4e2..a23c0f7d 100644 --- a/src/app/channel-tile/channel-tile.component.ts +++ b/src/app/channel-tile/channel-tile.component.ts @@ -24,7 +24,7 @@ import { RestreamModalComponent } from "../restream-modal/restream-modal.compone import { DownloadService } from "../download.service"; import { Download } from "../models/download"; import { Subscription, take } from "rxjs"; -import { save } from "@tauri-apps/plugin-dialog"; +import { open, save } from "@tauri-apps/plugin-dialog"; import { CHANNEL_EXTENSION, GROUP_EXTENSION, RECORD_EXTENSION } from "../models/extensions"; import { getDateFormatted, getExtension, sanitizeFileName } from "../utils"; import { NodeType, fromMediaType } from "../models/nodeType"; @@ -139,7 +139,6 @@ export class ChannelTileComponent implements OnDestroy, AfterViewInit { } onRightClick(event: MouseEvent) { - if (this.channel?.media_type == MediaType.season) return; this.alreadyExistsInFav = this.channel!.favorite!; this.alreadyHidden = this.channel!.hidden!; this.downloading = this.isDownloading(); @@ -414,6 +413,69 @@ export class ChannelTileComponent implements OnDestroy, AfterViewInit { await this.download.abortDownload(this.channel!.id!.toString()); } + isSeason() { + return this.channel?.media_type == MediaType.season; + } + + isSeries() { + return this.channel?.media_type == MediaType.serie; + } + + async downloadSeason() { + await this.bulkDownload("episodes", () => + invoke("get_season_episodes", { seasonId: this.channel!.id }), + ); + } + + async downloadSeries() { + if (!this.memory.SeriesRefreshed.has(this.channel!.id!)) { + try { + await invoke("get_episodes", { channel: this.channel }); + this.memory.SeriesRefreshed.set(this.channel!.id!, true); + } catch (e) { + this.error.handleError(e, "Failed to fetch series"); + return; + } + } + await this.bulkDownload("episodes", () => + invoke("get_series_episodes", { + seriesId: parseInt(this.channel!.url!), + sourceId: this.channel!.source_id, + }), + ); + } + + private async bulkDownload(entityName: string, getChannels: () => Promise) { + let channels: Channel[]; + try { + channels = await getChannels(); + } catch (e) { + this.error.handleError(e, `Failed to fetch ${entityName}`); + return; + } + if (channels.length == 0) { + this.toastr.info(`No ${entityName} to download`); + return; + } + let directory = undefined; + if (this.memory.IsContainer || this.memory.AlwaysAskSave) { + directory = await open({ + directory: true, + canCreateDirectories: true, + title: `Select where to download ${entityName}`, + }); + if (!directory) { + return; + } + } + let queued = await this.download.addBulkDownloads(channels, directory ?? undefined); + if (queued == 0) { + this.toastr.info(`All ${entityName} are already downloading`); + return; + } + this.toastr.success(`Queued ${queued} ${queued == 1 ? "episode" : entityName}`); + } + getExistingDownload() { let download = this.download.Downloads.get(this.channel!.id!.toString()); if (download) { diff --git a/src/app/download-manager/download-manager.component.css b/src/app/download-manager/download-manager.component.css index ce654d21..08c0cf46 100644 --- a/src/app/download-manager/download-manager.component.css +++ b/src/app/download-manager/download-manager.component.css @@ -25,6 +25,11 @@ button.btn-outline-light:hover { background-color: #2b2d30; } +.download-list { + max-height: 40vh; + overflow-y: auto; +} + .queued { color: #aaa; font-style: italic; diff --git a/src/app/download-manager/download-manager.component.html b/src/app/download-manager/download-manager.component.html index c6855efd..f84cb8c5 100644 --- a/src/app/download-manager/download-manager.component.html +++ b/src/app/download-manager/download-manager.component.html @@ -1,6 +1,6 @@
- Downloads + Downloads ({{ getDownloads().length }})
-
+
{{ download.channel.name }} diff --git a/src/app/download.service.ts b/src/app/download.service.ts index 0683a74d..e4bfea86 100644 --- a/src/app/download.service.ts +++ b/src/app/download.service.ts @@ -6,7 +6,6 @@ import { ErrorService } from "./error.service"; import { listen } from "@tauri-apps/api/event"; import { Channel } from "./models/channel"; -/// Used when a channel has no source id, so those downloads still queue against each other const NO_SOURCE = -1; @Injectable({ @@ -76,7 +75,7 @@ export class DownloadService { } /// Queues a download and resolves once it completed, failed or got cancelled - async download(id: String, path?: string) { + async download(id: String, path?: string, directory?: string) { let download = this.Downloads.get(id); if (!download) { return; @@ -86,12 +85,30 @@ export class DownloadService { return; } download.path = path; + download.directory = directory; let settled = new Promise((resolve) => (download!.settle = resolve)); this.queue.push(download); this.startNext(); return settled; } + async addBulkDownloads(channels: Channel[], directory?: string): Promise { + let queued = 0; + for (let channel of channels) { + if (channel.id == undefined) { + continue; + } + let id = channel.id.toString(); + if (this.Downloads.has(id)) { + continue; + } + let download = await this.addDownload(id, channel); + this.download(download.id, undefined, directory); + queued++; + } + return queued; + } + isQueued(download: Download) { return download.status == DownloadStatus.queued; } @@ -117,6 +134,7 @@ export class DownloadService { downloadId: download.id, channel: download.channel, path: download.path, + directory: download.directory, }); this.error.success("Download completed successfully"); } catch (e) { diff --git a/src/app/models/download.ts b/src/app/models/download.ts index 132e274f..1fcd3345 100644 --- a/src/app/models/download.ts +++ b/src/app/models/download.ts @@ -17,6 +17,8 @@ export class Download { status!: DownloadStatus; /// Where to save the file, picked before the download is queued path?: string; + /// Directory to save into, used by bulk downloads that pick a folder once + directory?: string; /// Resolves whatever called download(), once it finished, failed or got cancelled settle?: () => void; }