Skip to main content

mimick/library/
download.rs

1//! Download flow, video handoff, and transfer-tracking helpers.
2//!
3//! Streams the original-quality asset from the server into a local file,
4//! updating the progress bar and transfer rate display. Video files can
5//! optionally be handed off to the system default player after download.
6
7use std::path::{Path, PathBuf};
8use std::rc::Rc;
9use std::sync::Arc;
10
11use glib::clone;
12use gtk::prelude::*;
13use libadwaita::prelude::*;
14
15use crate::api_client::TransferProgressCallback;
16use crate::app_context::AppContext;
17use crate::library::state::LibrarySource;
18use crate::state_manager::TransferDirection;
19
20use super::LibraryWindowUi;
21
22pub(super) fn begin_download_session(ctx: &Arc<AppContext>, item_label: String) {
23    let state_ref = ctx.state.clone();
24    let mut state = state_ref.lock();
25    let route = state.active_server_route.clone();
26    state
27        .transfer
28        .begin_group(TransferDirection::Download, Some(item_label), route);
29}
30
31pub(super) fn track_download_item(
32    ctx: &Arc<AppContext>,
33    item_id: String,
34    item_label: Option<String>,
35    total_bytes: Option<u64>,
36) -> TransferProgressCallback {
37    let state_ref = ctx.state.clone();
38    {
39        let mut state = state_ref.lock();
40        let route = state.active_server_route.clone();
41        state.transfer.register_item(
42            TransferDirection::Download,
43            item_id.clone(),
44            total_bytes,
45            item_label,
46            route,
47        );
48    }
49    Arc::new(move |bytes_done, total_bytes| {
50        let mut state = state_ref.lock();
51        if let Some(total_bytes) = total_bytes {
52            let current = state
53                .transfer
54                .active_item_totals
55                .get(&item_id)
56                .copied()
57                .unwrap_or(0);
58            if current == 0 {
59                state.transfer.update_item_total(&item_id, total_bytes);
60            }
61        }
62        let route = state.active_server_route.clone();
63        state
64            .transfer
65            .update_item_bytes(TransferDirection::Download, &item_id, bytes_done, route);
66    })
67}
68
69pub(super) fn finish_download_item(ctx: &Arc<AppContext>, item_id: &str) {
70    let mut state = ctx.state.lock();
71    let route = state.active_server_route.clone();
72    state
73        .transfer
74        .finish_item(TransferDirection::Download, item_id, route);
75}
76
77/// Hand a local file off to the user's default app via `xdg-open`/equivalent.
78/// Used for local videos per the spec — no in-app playback in v1.
79pub(super) fn open_local_with_default_app(path: &str) {
80    let uri = format!("file://{}", path);
81    if let Err(err) =
82        gtk::gio::AppInfo::launch_default_for_uri(&uri, None::<&gtk::gio::AppLaunchContext>)
83    {
84        log::warn!("Failed to open {}: {}", uri, err);
85    }
86}
87
88pub(super) fn spawn_video_handoff(ui: Rc<LibraryWindowUi>, asset_id: String, filename: String) {
89    glib::MainContext::default().spawn_local(async move {
90        let Some(cache_dir) = crate::profile::cache_dir().map(|p| p.join("video")) else {
91            return;
92        };
93        let _ = std::fs::create_dir_all(&cache_dir);
94        let safe_name =
95            crate::sanitize::safe_filename(&filename).unwrap_or_else(|| asset_id.clone());
96        let path = cache_dir.join(&safe_name);
97        if !path.exists()
98            && let Err(err) = {
99                begin_download_session(&ui.ctx, filename.clone());
100                let progress =
101                    track_download_item(&ui.ctx, asset_id.clone(), Some(filename.clone()), None);
102                let result = ui
103                    .ctx
104                    .api_client
105                    .download_original_to_file(&asset_id, &path, Some(progress))
106                    .await;
107                finish_download_item(&ui.ctx, &asset_id);
108                result
109            }
110        {
111            log::warn!("Video handoff failed for {}: {}", asset_id, err);
112            return;
113        }
114        open_local_with_default_app(&path.display().to_string());
115    });
116}
117
118pub(super) fn start_download(ui: Rc<LibraryWindowUi>, asset_id: String, filename: String) {
119    begin_download_session(&ui.ctx, filename.clone());
120    glib::MainContext::default().spawn_local(clone!(
121        #[strong]
122        ui,
123        async move {
124            let Some(target_dir) = ensure_download_target(&ui).await else {
125                return;
126            };
127            let safe_name =
128                crate::sanitize::safe_filename(&filename).unwrap_or_else(|| asset_id.clone());
129            let output_path = target_dir.join(&safe_name);
130            if output_path.exists() && !show_overwrite_dialog(&ui, &safe_name).await {
131                return;
132            }
133            match do_download(&ui, &asset_id, &output_path).await {
134                Ok(()) => {
135                    let alert = libadwaita::AlertDialog::builder()
136                        .heading("Download Complete")
137                        .body(format!("Saved {}", safe_name))
138                        .build();
139                    alert.add_response("ok", "OK");
140                    alert.present(Some(&ui.window));
141                }
142                Err(err) => {
143                    let alert = libadwaita::AlertDialog::builder()
144                        .heading("Download Failed")
145                        .body(&err)
146                        .build();
147                    alert.add_response("ok", "OK");
148                    alert.present(Some(&ui.window));
149                }
150            }
151        }
152    ));
153}
154
155#[derive(Clone, Copy)]
156enum ConflictAction {
157    Skip,
158    Overwrite,
159    Rename,
160}
161
162pub(super) fn start_download_group(ui: Rc<LibraryWindowUi>, downloads: Vec<(String, String)>) {
163    begin_download_session(&ui.ctx, format!("{} items", downloads.len()));
164    glib::MainContext::default().spawn_local(clone!(
165        #[strong]
166        ui,
167        async move {
168            let Some(target_dir) = ensure_download_target(&ui).await else {
169                return;
170            };
171
172            let mut succeeded: u32 = 0;
173            let mut failed: u32 = 0;
174            let mut skipped: u32 = 0;
175            let mut conflict_policy: Option<ConflictAction> = None;
176
177            for (asset_id, filename) in &downloads {
178                let safe_name =
179                    crate::sanitize::safe_filename(filename).unwrap_or_else(|| asset_id.clone());
180                let mut output_path = target_dir.join(&safe_name);
181
182                if output_path.exists() {
183                    match resolve_conflict(&ui, &safe_name, &mut conflict_policy).await {
184                        ConflictAction::Skip => {
185                            skipped += 1;
186                            continue;
187                        }
188                        ConflictAction::Overwrite => {}
189                        ConflictAction::Rename => {
190                            output_path = unique_path(&target_dir, &safe_name);
191                        }
192                    }
193                }
194
195                match do_download(&ui, asset_id, &output_path).await {
196                    Ok(()) => succeeded += 1,
197                    Err(_) => failed += 1,
198                }
199            }
200
201            ui.grid.selection.unselect_all();
202            ui.select_toggle.set_active(false);
203            show_batch_summary(&ui, succeeded, failed, skipped, &target_dir);
204        }
205    ));
206}
207
208async fn resolve_conflict(
209    ui: &LibraryWindowUi,
210    filename: &str,
211    policy: &mut Option<ConflictAction>,
212) -> ConflictAction {
213    if let Some(a) = *policy {
214        return a;
215    }
216    let (action, apply_all) = show_batch_conflict_dialog(ui, filename).await;
217    if apply_all {
218        *policy = Some(action);
219    }
220    action
221}
222
223fn show_batch_summary(
224    ui: &LibraryWindowUi,
225    succeeded: u32,
226    failed: u32,
227    skipped: u32,
228    target_dir: &Path,
229) {
230    let folder_name = folder_display_name(target_dir);
231    let (heading, body) = if succeeded == 0 && failed == 0 {
232        (
233            "No Assets Downloaded",
234            format!("All {} asset(s) were skipped", skipped),
235        )
236    } else if failed == 0 && skipped == 0 {
237        (
238            "Download Complete",
239            format!("Downloaded {} asset(s) to {}", succeeded, folder_name),
240        )
241    } else {
242        let mut parts = vec![format!(
243            "Downloaded {} asset(s) to {}",
244            succeeded, folder_name
245        )];
246        if skipped > 0 {
247            parts.push(format!("{} skipped", skipped));
248        }
249        if failed > 0 {
250            parts.push(format!("{} failed", failed));
251        }
252        (
253            if succeeded > 0 {
254                "Download Complete"
255            } else {
256                "Download Failed"
257            },
258            parts.join("\n"),
259        )
260    };
261    let alert = libadwaita::AlertDialog::builder()
262        .heading(heading)
263        .body(body)
264        .build();
265    alert.add_response("ok", "OK");
266    alert.present(Some(&ui.window));
267}
268
269async fn do_download(
270    ui: &Rc<LibraryWindowUi>,
271    asset_id: &str,
272    output_path: &Path,
273) -> Result<(), String> {
274    let item_label = output_path
275        .file_name()
276        .map(|name| name.to_string_lossy().to_string())
277        .unwrap_or_else(|| output_path.display().to_string());
278    let progress = track_download_item(&ui.ctx, asset_id.to_string(), Some(item_label), None);
279    let result = ui
280        .ctx
281        .api_client
282        .download_original_to_file(asset_id, output_path, Some(progress))
283        .await;
284    finish_download_item(&ui.ctx, asset_id);
285    if result.is_ok() {
286        let session_finished = !ui.ctx.state.lock().transfer.active;
287        if should_refresh_after_download(ui) && session_finished {
288            super::refresh_library_after_mutation(ui.clone(), true);
289        }
290    }
291    result
292}
293
294async fn show_overwrite_dialog(ui: &LibraryWindowUi, filename: &str) -> bool {
295    let (tx, rx) = tokio::sync::oneshot::channel();
296    let tx = std::cell::Cell::new(Some(tx));
297    let dialog = libadwaita::AlertDialog::builder()
298        .heading("File already exists")
299        .body(format!("\"{}\" already exists. Overwrite?", filename))
300        .build();
301    dialog.add_response("skip", "Skip");
302    dialog.add_response("overwrite", "Overwrite");
303    dialog.set_response_appearance("overwrite", libadwaita::ResponseAppearance::Destructive);
304    dialog.connect_response(None, move |_, response| {
305        if let Some(tx) = tx.take() {
306            let _ = tx.send(response == "overwrite");
307        }
308    });
309    dialog.present(Some(&ui.window));
310    rx.await.unwrap_or(false)
311}
312
313async fn show_batch_conflict_dialog(
314    ui: &LibraryWindowUi,
315    filename: &str,
316) -> (ConflictAction, bool) {
317    let (tx, rx) = tokio::sync::oneshot::channel::<(ConflictAction, bool)>();
318    let tx = std::cell::Cell::new(Some(tx));
319    let dialog = libadwaita::AlertDialog::builder()
320        .heading("File already exists")
321        .body(format!(
322            "\"{}\" already exists in the download folder.",
323            filename
324        ))
325        .build();
326    let apply_all = gtk::CheckButton::builder()
327        .label("Apply to all remaining conflicts")
328        .build();
329    dialog.set_extra_child(Some(&apply_all));
330    dialog.add_response("skip", "Skip");
331    dialog.add_response("rename", "Rename");
332    dialog.add_response("overwrite", "Overwrite");
333    dialog.set_response_appearance("overwrite", libadwaita::ResponseAppearance::Destructive);
334    dialog.set_default_response(Some("rename"));
335    dialog.connect_response(
336        None,
337        clone!(
338            #[weak]
339            apply_all,
340            move |_, response| {
341                let all = apply_all.is_active();
342                let action = match response {
343                    "rename" => ConflictAction::Rename,
344                    "overwrite" => ConflictAction::Overwrite,
345                    _ => ConflictAction::Skip,
346                };
347                if let Some(tx) = tx.take() {
348                    let _ = tx.send((action, all));
349                }
350            }
351        ),
352    );
353    dialog.present(Some(&ui.window));
354    rx.await.unwrap_or((ConflictAction::Skip, false))
355}
356
357fn unique_path(dir: &Path, filename: &str) -> PathBuf {
358    let name = Path::new(filename);
359    let stem = name
360        .file_stem()
361        .and_then(|s| s.to_str())
362        .unwrap_or(filename);
363    let ext = name.extension().and_then(|e| e.to_str());
364    for i in 1..1000 {
365        let candidate = match ext {
366            Some(e) => dir.join(format!("{} ({}).{}", stem, i, e)),
367            None => dir.join(format!("{} ({})", stem, i)),
368        };
369        if !candidate.exists() {
370            return candidate;
371        }
372    }
373    dir.join(format!("{}_copy", filename))
374}
375
376fn folder_display_name(path: &Path) -> String {
377    path.file_name()
378        .and_then(|n| n.to_str())
379        .unwrap_or("selected folder")
380        .to_string()
381}
382
383pub(super) async fn ensure_download_target(ui: &LibraryWindowUi) -> Option<PathBuf> {
384    if let Some(path) = ui.ctx.config.read().data.download_target_path.clone() {
385        return Some(PathBuf::from(path));
386    }
387
388    let (tx, rx) = tokio::sync::oneshot::channel();
389    let dialog = gtk::FileDialog::builder()
390        .title("Choose Download Folder")
391        .build();
392    dialog.select_folder(Some(&ui.window), gtk::gio::Cancellable::NONE, move |res| {
393        let _ = tx.send(
394            res.ok()
395                .and_then(|folder| folder.path())
396                .map(|path| path.to_path_buf()),
397        );
398    });
399    let path = rx.await.ok().flatten()?;
400
401    let (save_tx, save_rx) = tokio::sync::oneshot::channel::<bool>();
402    let save_tx = std::cell::Cell::new(Some(save_tx));
403    let confirm = libadwaita::AlertDialog::builder()
404        .heading("Save as default?")
405        .body(format!("Always download to {}?", path.display()))
406        .build();
407    confirm.add_response("once", "Just Once");
408    confirm.add_response("always", "Always");
409    confirm.set_default_response(Some("always"));
410    confirm.connect_response(None, move |dlg, response| {
411        if let Some(tx) = save_tx.take() {
412            let _ = tx.send(response == "always");
413        }
414        dlg.close();
415    });
416    confirm.present(Some(&ui.window));
417
418    if save_rx.await.unwrap_or(false) {
419        let mut config = ui.ctx.config.write();
420        config.data.download_target_path = Some(path.to_string_lossy().to_string());
421        let _ = config.save();
422    }
423    Some(path)
424}
425
426pub(super) fn should_refresh_after_download(ui: &LibraryWindowUi) -> bool {
427    matches!(
428        ui.ctx.library_state.lock().source,
429        LibrarySource::LocalAll
430            | LibrarySource::LocalSearch { .. }
431            | LibrarySource::Unified
432            | LibrarySource::UnifiedSearch { .. }
433            | LibrarySource::AlbumLocal { .. }
434            | LibrarySource::AlbumUnified { .. }
435    )
436}
437
438pub(super) fn format_rate(bytes_per_sec: f64) -> String {
439    if bytes_per_sec >= 1024.0 * 1024.0 {
440        format!("{:.1} MB/s", bytes_per_sec / (1024.0 * 1024.0))
441    } else if bytes_per_sec >= 1024.0 {
442        format!("{:.1} KB/s", bytes_per_sec / 1024.0)
443    } else {
444        format!("{:.0} B/s", bytes_per_sec.max(0.0))
445    }
446}