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 1b879c11..08c0cf46 100644 --- a/src/app/download-manager/download-manager.component.css +++ b/src/app/download-manager/download-manager.component.css @@ -25,6 +25,16 @@ button.btn-outline-light:hover { background-color: #2b2d30; } +.download-list { + max-height: 40vh; + overflow-y: auto; +} + +.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..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 }} - {{ 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..e4bfea86 100644 --- a/src/app/download.service.ts +++ b/src/app/download.service.ts @@ -1,16 +1,22 @@ 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"; +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 +24,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 +44,111 @@ 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); } } - async download(id: String, path?: string) { - let download = this.Downloads.get(id)!; + /// Queues a download and resolves once it completed, failed or got cancelled + async download(id: String, path?: string, directory?: string) { + 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; + 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; + } + + /// 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, + directory: download.directory, }); 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..1fcd3345 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,11 @@ export class Download { channel!: Channel; unlisten?: UnlistenFn; progressUpdate!: Subject; + 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; }