1use 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
77pub(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::<>k::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}