1#[cfg(test)]
4use crate::utils::lock::MutexSafe;
5use log::{debug, error, info, warn};
6use std::path::{Component, Path, PathBuf};
7use std::sync::{Arc, Mutex};
8use tauri::{Manager, State};
9
10use super::{DatabaseWrapper, SmartCacheWrapper};
11use crate::download::network::{NetworkState, NetworkStateHandle, NetworkType};
12use crate::download::{DownloadInfo, DownloadManager};
13use crate::storage::db_service::{DatabaseService, Query, QueryParam};
14
15mod pinning;
18mod smart_cache;
19pub use pinning::*;
20pub use smart_cache::*;
21
22type EpisodeRow = (String, String, Option<String>, Option<i32>, Option<i32>);
25
26pub struct DownloadManagerWrapper(pub Mutex<DownloadManager>);
28
29pub struct NetworkStateWrapper(pub NetworkStateHandle);
33
34#[tauri::command]
42#[specta::specta]
43pub async fn set_network_state(
44 app: tauri::AppHandle,
45 network: NetworkStateWrapperArg,
46 db: State<'_, DatabaseWrapper>,
47 download_manager: State<'_, DownloadManagerWrapper>,
48) -> Result<(), String> {
49 let new_state = NetworkState {
50 network_type: network.network_type,
51 unmetered: network.unmetered,
52 };
53
54 let handle = app.state::<NetworkStateWrapper>().0.clone();
55 let previous = handle.get().await;
56 handle.set(new_state).await;
57
58 if previous != new_state {
59 info!(
60 "[network] Transport changed: {:?} (unmetered={}) -> {:?} (unmetered={})",
61 previous.network_type, previous.unmetered, new_state.network_type, new_state.unmetered
62 );
63 }
64
65 if downloads_allowed_on_current_network(&app).await {
67 let db_service = {
68 let database = db.0.lock().map_err(|e| e.to_string())?;
69 Arc::new(database.service())
70 };
71 let active = {
72 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
73 manager.get_active_downloads()
74 };
75 pump_download_queue(app.clone(), db_service, active).await;
76 }
77
78 Ok(())
79}
80
81#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct NetworkStateWrapperArg {
87 pub network_type: NetworkType,
88 pub unmetered: bool,
89}
90
91#[tauri::command]
98#[specta::specta]
99pub async fn get_downloads_allowed(app: tauri::AppHandle) -> Result<bool, String> {
100 Ok(downloads_allowed_on_current_network(&app).await)
101}
102
103#[allow(dead_code)]
105#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct DownloadStats {
108 pub total: usize,
109 pub active_count: usize,
110 pub queued_count: usize,
111 pub completed_count: usize,
112 pub failed_count: usize,
113 pub paused_count: usize,
114}
115
116#[allow(dead_code)]
118#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct DownloadsResponse {
121 pub downloads: Vec<DownloadInfo>,
122 pub stats: DownloadStats,
123}
124
125fn sanitize_filename(name: &str) -> String {
127 name.chars()
128 .map(|c| match c {
129 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
130 _ => c,
131 })
132 .collect()
133}
134
135fn download_root(db: &DatabaseWrapper) -> Result<PathBuf, String> {
140 let database = db.0.lock().map_err(|e| e.to_string())?;
141 database
142 .path()
143 .parent()
144 .map(|p| p.to_path_buf())
145 .ok_or_else(|| "Database path has no parent directory".to_string())
146}
147
148fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
158 let mut resolved = PathBuf::new();
159 for component in candidate.components() {
160 match component {
161 Component::ParentDir => {
162 resolved.pop();
163 }
164 Component::CurDir => {}
165 other => resolved.push(other),
166 }
167 }
168
169 if resolved.starts_with(root) {
170 Ok(resolved)
171 } else {
172 Err(format!(
173 "Refusing a download path outside the download directory: {}",
174 candidate.display()
175 ))
176 }
177}
178
179fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
188 let sep = file_path
194 .chars()
195 .find(|c| std::path::is_separator(*c))
196 .unwrap_or('/');
197 let mut sanitized = PathBuf::new();
198 let mut spelled = String::new();
199 let mut need_sep = false;
200 for component in Path::new(file_path).components() {
201 let piece = match component {
202 Component::Normal(part) => sanitize_filename(&part.to_string_lossy()),
203 other => other.as_os_str().to_string_lossy().into_owned(),
206 };
207 sanitized.push(&piece);
208 match component {
209 Component::Prefix(_) => spelled.push_str(&piece),
210 Component::RootDir => {
211 spelled.push(sep);
212 need_sep = false;
213 continue;
214 }
215 _ => {
216 if need_sep {
217 spelled.push(sep);
218 }
219 spelled.push_str(&piece);
220 }
221 }
222 need_sep = !matches!(component, Component::Prefix(_));
223 }
224
225 confine_to_root(root, &root.join(&sanitized))?;
226 Ok(spelled)
227}
228
229#[derive(Debug, specta::Type, serde::Deserialize)]
232#[serde(rename_all = "camelCase")]
233pub struct DownloadItemAndStartRequest {
234 pub item_id: String,
235 pub user_id: String,
236 pub stream_url: String,
237 pub target_dir: String,
238 pub item_name: Option<String>,
239 pub artist_name: Option<String>,
240 pub album_name: Option<String>,
241}
242
243#[derive(Debug, specta::Type, serde::Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct DownloadItemRequest {
247 pub item_id: String,
248 pub user_id: String,
249 pub file_path: String,
250 pub mime_type: Option<String>,
251 pub priority: Option<i32>,
252 pub item_name: Option<String>,
253 pub artist_name: Option<String>,
254 pub album_name: Option<String>,
255 pub expected_size: Option<i64>,
256}
257
258#[derive(Debug, specta::Type, serde::Deserialize)]
260#[serde(rename_all = "camelCase")]
261pub struct DownloadVideoRequest {
262 pub item_id: String,
263 pub user_id: String,
264 pub file_path: String,
265 pub mime_type: Option<String>,
266 pub priority: Option<i32>,
267 pub item_name: Option<String>,
268 pub quality_preset: Option<String>,
269 pub series_name: Option<String>,
270 pub season_name: Option<String>,
271 pub episode_number: Option<i32>,
272 pub season_number: Option<i32>,
273}
274
275#[tauri::command]
278#[specta::specta]
279pub async fn download_item_and_start(
280 db: State<'_, DatabaseWrapper>,
281 smart_cache: State<'_, SmartCacheWrapper>,
282 download_manager: State<'_, DownloadManagerWrapper>,
283 app: tauri::AppHandle,
284 request: DownloadItemAndStartRequest,
285) -> Result<i64, String> {
286 let DownloadItemAndStartRequest {
287 item_id,
288 user_id,
289 stream_url,
290 target_dir,
291 item_name,
292 artist_name,
293 album_name,
294 } = request;
295 let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
297 let file_path = format!("downloads/{}.mp3", safe_name);
298
299 let download_id = download_item(
301 db.clone(),
302 smart_cache.clone(),
303 DownloadItemRequest {
304 item_id,
305 user_id,
306 file_path,
307 mime_type: None,
308 priority: None,
309 item_name,
310 artist_name,
311 album_name,
312 expected_size: None,
313 },
314 )
315 .await?;
316
317 start_download(
319 db,
320 download_manager,
321 app,
322 download_id,
323 stream_url,
324 target_dir,
325 )
326 .await?;
327
328 Ok(download_id)
329}
330
331#[tauri::command]
333#[specta::specta]
334pub async fn download_item(
335 db: State<'_, DatabaseWrapper>,
336 smart_cache: State<'_, SmartCacheWrapper>,
337 request: DownloadItemRequest,
338) -> Result<i64, String> {
339 let DownloadItemRequest {
340 item_id,
341 user_id,
342 file_path,
343 mime_type,
344 priority,
345 item_name,
346 artist_name,
347 album_name,
348 expected_size,
349 } = request;
350
351 let file_path = {
358 let root = download_root(&db)?;
359 confine_queued_path(&root, &file_path)?
360 };
361
362 let db_service = {
363 let database = db.0.lock().map_err(|e| e.to_string())?;
364 Arc::new(database.service())
365 };
366
367 if let Some(size) = expected_size {
369 let cache_arc = {
371 let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
372 guard.clone()
373 };
374
375 let can_download = cache_arc
377 .can_download_async(&db_service, &user_id, size as u64)
378 .await;
379
380 if !can_download {
381 warn!("Storage limit reached. Attempting to free space...");
382
383 match cache_arc
388 .reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
389 .await
390 {
391 Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
392 Ok(_) => {}
393 Err(e) => warn!("Expired-entry reclaim failed: {}", e),
394 }
395
396 match cache_arc
398 .evict_lru_async(&db_service, &user_id, size as u64)
399 .await
400 {
401 Ok(freed) if freed > 0 => {
402 info!("Freed {} bytes, proceeding with download", freed);
403 }
404 Ok(_) => {
405 let storage_limit =
406 cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
407 return Err(format!(
408 "Storage limit reached ({} bytes). Unable to free enough space.",
409 storage_limit
410 ));
411 }
412 Err(e) => {
413 error!("Failed to evict items: {}", e);
414 return Err(format!("Storage limit reached. Eviction failed: {}", e));
415 }
416 }
417 }
418 }
419
420 let insert_query = Query::with_params(
422 "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at, item_name, artist_name, album_name)
423 VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, ?)
424 ON CONFLICT(item_id, user_id) DO UPDATE SET
425 priority = excluded.priority,
426 status = 'pending',
427 queued_at = CURRENT_TIMESTAMP,
428 item_name = COALESCE(excluded.item_name, downloads.item_name),
429 artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
430 album_name = COALESCE(excluded.album_name, downloads.album_name)",
431 vec![
432 QueryParam::String(item_id.clone()),
433 QueryParam::String(user_id.clone()),
434 QueryParam::String(file_path),
435 mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
436 QueryParam::Int(priority.unwrap_or(0)),
437 item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
438 artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
439 album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
440 ],
441 );
442
443 db_service
444 .execute(insert_query)
445 .await
446 .map_err(|e| e.to_string())?;
447
448 let id_query = Query::with_params(
451 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
452 vec![QueryParam::String(item_id), QueryParam::String(user_id)],
453 );
454
455 let download_id: i64 = db_service
456 .query_one(id_query, |row| row.get(0))
457 .await
458 .map_err(|e| e.to_string())?;
459
460 Ok(download_id)
461}
462
463#[derive(Debug, Clone, PartialEq)]
471pub(crate) struct AlbumTrack {
472 pub id: String,
473 pub name: String,
474 pub artist_name: Option<String>,
475 pub album_name: Option<String>,
476 pub index_number: Option<i32>,
477}
478
479impl From<&crate::repository::types::MediaItem> for AlbumTrack {
480 fn from(item: &crate::repository::types::MediaItem) -> Self {
481 Self {
482 id: item.id.clone(),
483 name: item.name.clone(),
484 artist_name: item
485 .artists
486 .as_ref()
487 .and_then(|a| serde_json::to_string(a).ok()),
488 album_name: item.album_name.clone(),
489 index_number: item.index_number,
490 }
491 }
492}
493
494pub(crate) async fn cached_album_tracks(
502 db_service: &Arc<crate::storage::db_service::RusqliteService>,
503 album_id: &str,
504) -> Result<Vec<AlbumTrack>, String> {
505 let tracks_query = Query::with_params(
506 "SELECT id, name, artists, album_name, index_number FROM items
507 WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
508 ORDER BY index_number",
509 vec![
510 QueryParam::String(album_id.to_string()),
511 QueryParam::String(album_id.to_string()),
512 ],
513 );
514
515 db_service
516 .query_many(tracks_query, |row| {
517 Ok(AlbumTrack {
518 id: row.get(0)?,
519 name: row.get(1)?,
520 artist_name: row.get(2)?,
521 album_name: row.get(3)?,
522 index_number: row.get(4)?,
523 })
524 })
525 .await
526 .map_err(|e| e.to_string())
527}
528
529pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
553 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
554 for track in tracks {
555 *counts.entry(track.name.to_lowercase()).or_default() += 1;
556 }
557
558 tracks
559 .iter()
560 .map(|track| {
561 let title = sanitize_filename(&track.name);
562 if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
563 return format!("{}.mp3", title);
564 }
565 match track.index_number {
566 Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
567 None => format!("{} [{}].mp3", title, track.id),
568 }
569 })
570 .collect()
571}
572
573pub(crate) async fn queue_album_tracks(
575 db_service: &Arc<crate::storage::db_service::RusqliteService>,
576 album_id: &str,
577 tracks: &[AlbumTrack],
578 user_id: &str,
579 base_path: &str,
580) -> Result<Vec<i64>, String> {
581 let mut download_ids = Vec::with_capacity(tracks.len());
582 let file_names = album_file_names(tracks);
583
584 for (track, file_name) in tracks.iter().zip(file_names) {
585 let cache_query = Query::with_params(
589 "INSERT OR IGNORE INTO items
590 (id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
591 SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
592 FROM items a WHERE a.id = ?",
593 vec![
594 QueryParam::String(track.id.clone()),
595 QueryParam::String(track.name.clone()),
596 track
597 .album_name
598 .clone()
599 .map(QueryParam::String)
600 .unwrap_or(QueryParam::Null),
601 track
602 .artist_name
603 .clone()
604 .map(QueryParam::String)
605 .unwrap_or(QueryParam::Null),
606 track
607 .index_number
608 .map(QueryParam::Int)
609 .unwrap_or(QueryParam::Null),
610 QueryParam::String(album_id.to_string()),
611 ],
612 );
613 db_service
614 .execute(cache_query)
615 .await
616 .map_err(|e| e.to_string())?;
617
618 let link_query = Query::with_params(
622 "UPDATE items
623 SET album_id = ?,
624 parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
625 WHERE id = ?",
626 vec![
627 QueryParam::String(album_id.to_string()),
628 QueryParam::String(album_id.to_string()),
629 QueryParam::String(track.id.clone()),
630 ],
631 );
632 db_service
633 .execute(link_query)
634 .await
635 .map_err(|e| e.to_string())?;
636
637 let file_path = format!("{}/{}", base_path, file_name);
638
639 let insert_query = Query::with_params(
642 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
643 VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
644 ON CONFLICT(item_id, user_id) DO UPDATE SET
645 priority = 100,
646 status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
647 media_type = 'audio',
648 item_name = COALESCE(excluded.item_name, downloads.item_name),
649 artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
650 album_name = COALESCE(excluded.album_name, downloads.album_name)",
651 vec![
652 QueryParam::String(track.id.clone()),
653 QueryParam::String(user_id.to_string()),
654 QueryParam::String(file_path),
655 QueryParam::String(track.name.clone()),
656 track
657 .artist_name
658 .clone()
659 .map(QueryParam::String)
660 .unwrap_or(QueryParam::Null),
661 track
662 .album_name
663 .clone()
664 .map(QueryParam::String)
665 .unwrap_or(QueryParam::Null),
666 ],
667 );
668
669 db_service
670 .execute(insert_query)
671 .await
672 .map_err(|e| e.to_string())?;
673
674 let id_query = Query::with_params(
676 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
677 vec![
678 QueryParam::String(track.id.clone()),
679 QueryParam::String(user_id.to_string()),
680 ],
681 );
682
683 let download_id: i64 = db_service
684 .query_one(id_query, |row| row.get(0))
685 .await
686 .map_err(|e| e.to_string())?;
687 download_ids.push(download_id);
688 }
689
690 Ok(download_ids)
691}
692
693#[tauri::command]
708#[specta::specta]
709#[allow(clippy::too_many_arguments)]
713pub async fn download_album(
714 db: State<'_, DatabaseWrapper>,
715 repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
716 download_manager: State<'_, DownloadManagerWrapper>,
717 app: tauri::AppHandle,
718 handle: String,
719 album_id: String,
720 user_id: String,
721 base_path: String,
722) -> Result<Vec<i64>, String> {
723 let db_service = {
724 let database = db.0.lock().map_err(|e| e.to_string())?;
725 Arc::new(database.service())
726 };
727
728 let repo = repository.0.get(&handle);
729
730 let tracks: Vec<AlbumTrack> = match &repo {
733 Some(repo) => match repo.get_album_tracks(&album_id).await {
734 Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
735 Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
736 Err(e) => {
737 warn!(
738 "[download_album] Could not list album {} from the repository ({:?}); \
739 falling back to the cached track list",
740 album_id, e
741 );
742 cached_album_tracks(&db_service, &album_id).await?
743 }
744 },
745 None => cached_album_tracks(&db_service, &album_id).await?,
746 };
747
748 if tracks.is_empty() {
749 warn!("[download_album] No tracks found for album {}", album_id);
750 return Ok(Vec::new());
751 }
752
753 let download_ids =
754 queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;
755
756 info!(
757 "[download_album] Queued {} track(s) for album {}",
758 download_ids.len(),
759 album_id
760 );
761
762 let Some(repo) = repo else {
766 return Ok(download_ids);
767 };
768
769 let target_dir = {
770 let database = db.0.lock().map_err(|e| e.to_string())?;
771 database
772 .path()
773 .parent()
774 .ok_or_else(|| "Database path has no parent directory".to_string())?
775 .to_string_lossy()
776 .to_string()
777 };
778
779 let repo_for_resolve = Arc::clone(&repo);
780 let outcome = crate::commands::catalog::resolve_pending_download_urls(
781 &db_service,
782 &target_dir,
783 Some(&download_ids),
784 move |item_id: String, _media_type: String, _quality: String| {
785 let repo = Arc::clone(&repo_for_resolve);
786 async move {
787 use crate::repository::MediaRepository;
788 match repo.get_audio_stream_url(&item_id).await {
789 Ok(url) => Some(url.into()),
790 Err(e) => {
791 warn!(
792 "[download_album] Failed to resolve stream URL for {}: {:?}",
793 item_id, e
794 );
795 None
796 }
797 }
798 }
799 },
800 )
801 .await?;
802
803 if outcome.failed > 0 {
804 warn!(
805 "[download_album] {} track(s) could not be resolved and stay queued for the next \
806 reconnect",
807 outcome.failed
808 );
809 }
810
811 let active_downloads = {
812 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
813 manager.get_active_downloads()
814 };
815 pump_download_queue(app, db_service, active_downloads).await;
816
817 Ok(download_ids)
818}
819
820#[tauri::command]
822#[specta::specta]
823pub async fn download_video(
824 db: State<'_, DatabaseWrapper>,
825 request: DownloadVideoRequest,
826) -> Result<i64, String> {
827 let DownloadVideoRequest {
828 item_id,
829 user_id,
830 file_path,
831 mime_type,
832 priority,
833 item_name,
834 quality_preset,
835 series_name,
836 season_name,
837 episode_number,
838 season_number,
839 } = request;
840 let db_service = {
841 let database = db.0.lock().map_err(|e| e.to_string())?;
842 Arc::new(database.service())
843 };
844
845 let quality = quality_preset.unwrap_or_else(|| "original".to_string());
846
847 let insert_query = Query::with_params(
849 "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at,
850 item_name, quality_preset, media_type, series_name, season_name,
851 episode_number, season_number)
852 VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
853 ON CONFLICT(item_id, user_id) DO UPDATE SET
854 priority = excluded.priority,
855 status = 'pending',
856 queued_at = CURRENT_TIMESTAMP,
857 quality_preset = excluded.quality_preset,
858 item_name = COALESCE(excluded.item_name, downloads.item_name),
859 series_name = COALESCE(excluded.series_name, downloads.series_name),
860 season_name = COALESCE(excluded.season_name, downloads.season_name),
861 episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
862 season_number = COALESCE(excluded.season_number, downloads.season_number)",
863 vec![
864 QueryParam::String(item_id.clone()),
865 QueryParam::String(user_id.clone()),
866 QueryParam::String(file_path),
867 mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
868 QueryParam::Int(priority.unwrap_or(0)),
869 item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
870 QueryParam::String(quality),
871 series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
872 season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
873 episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
874 season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
875 ],
876 );
877
878 db_service
879 .execute(insert_query)
880 .await
881 .map_err(|e| e.to_string())?;
882
883 let id_query = Query::with_params(
885 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
886 vec![QueryParam::String(item_id), QueryParam::String(user_id)],
887 );
888
889 let download_id: i64 = db_service
890 .query_one(id_query, |row| row.get(0))
891 .await
892 .map_err(|e| e.to_string())?;
893
894 Ok(download_id)
895}
896
897#[tauri::command]
899#[specta::specta]
900pub async fn download_series(
901 db: State<'_, DatabaseWrapper>,
902 series_id: String,
903 series_name: String,
904 user_id: String,
905 base_path: String,
906 quality_preset: Option<String>,
907) -> Result<Vec<i64>, String> {
908 let db_service = {
909 let database = db.0.lock().map_err(|e| e.to_string())?;
910 Arc::new(database.service())
911 };
912
913 let quality = quality_preset.unwrap_or_else(|| "original".to_string());
914
915 let episodes_query = Query::with_params(
917 "SELECT id, name, season_name, index_number, parent_index_number
918 FROM items
919 WHERE series_id = ? AND item_type = 'Episode'
920 ORDER BY parent_index_number, index_number",
921 vec![QueryParam::String(series_id)],
922 );
923
924 let episodes: Vec<EpisodeRow> = db_service
925 .query_many(episodes_query, |row| {
926 Ok((
927 row.get(0)?,
928 row.get(1)?,
929 row.get(2)?,
930 row.get(3)?,
931 row.get(4)?,
932 ))
933 })
934 .await
935 .map_err(|e| e.to_string())?;
936
937 let mut download_ids = Vec::new();
938
939 let total_episodes = episodes.len() as i32;
942 for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
943 episodes.into_iter().enumerate()
944 {
945 let priority = 1000 - idx as i32; let season_num = season_number.unwrap_or(1);
949 let episode_num = episode_number.unwrap_or(1);
950 let file_name = format!(
951 "S{:02}E{:02}_{}.mp4",
952 season_num,
953 episode_num,
954 sanitize_filename(&episode_name)
955 );
956 let file_path = format!(
957 "{}/{}/{}",
958 base_path,
959 sanitize_filename(&series_name),
960 file_name
961 );
962
963 let insert_query = Query::with_params(
964 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
965 item_name, quality_preset, media_type, series_name, season_name,
966 episode_number, season_number)
967 VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
968 ON CONFLICT(item_id, user_id) DO UPDATE SET
969 priority = excluded.priority,
970 status = 'pending',
971 queued_at = CURRENT_TIMESTAMP,
972 quality_preset = excluded.quality_preset,
973 item_name = COALESCE(excluded.item_name, downloads.item_name),
974 series_name = COALESCE(excluded.series_name, downloads.series_name),
975 season_name = COALESCE(excluded.season_name, downloads.season_name),
976 episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
977 season_number = COALESCE(excluded.season_number, downloads.season_number)",
978 vec![
979 QueryParam::String(episode_id.clone()),
980 QueryParam::String(user_id.clone()),
981 QueryParam::String(file_path),
982 QueryParam::Int(priority),
983 QueryParam::String(episode_name),
984 QueryParam::String(quality.clone()),
985 QueryParam::String(series_name.clone()),
986 season_name
987 .map(QueryParam::String)
988 .unwrap_or(QueryParam::Null),
989 episode_number
990 .map(QueryParam::Int)
991 .unwrap_or(QueryParam::Null),
992 season_number
993 .map(QueryParam::Int)
994 .unwrap_or(QueryParam::Null),
995 ],
996 );
997
998 db_service
999 .execute(insert_query)
1000 .await
1001 .map_err(|e| e.to_string())?;
1002
1003 let id_query = Query::with_params(
1004 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
1005 vec![
1006 QueryParam::String(episode_id),
1007 QueryParam::String(user_id.clone()),
1008 ],
1009 );
1010
1011 let download_id: i64 = db_service
1012 .query_one(id_query, |row| row.get(0))
1013 .await
1014 .map_err(|e| e.to_string())?;
1015
1016 download_ids.push(download_id);
1017 }
1018
1019 info!(
1020 "[download_series] Queued {} episodes for series '{}'",
1021 total_episodes, series_name
1022 );
1023 Ok(download_ids)
1024}
1025
1026#[tauri::command]
1028#[specta::specta]
1029#[allow(clippy::too_many_arguments)]
1033pub async fn download_season(
1034 db: State<'_, DatabaseWrapper>,
1035 season_id: String,
1036 series_name: String,
1037 season_name: String,
1038 season_number: i32,
1039 user_id: String,
1040 base_path: String,
1041 quality_preset: Option<String>,
1042) -> Result<Vec<i64>, String> {
1043 let db_service = {
1044 let database = db.0.lock().map_err(|e| e.to_string())?;
1045 Arc::new(database.service())
1046 };
1047
1048 let quality = quality_preset.unwrap_or_else(|| "original".to_string());
1049
1050 let episodes_query = Query::with_params(
1052 "SELECT id, name, index_number
1053 FROM items
1054 WHERE parent_id = ? AND item_type = 'Episode'
1055 ORDER BY index_number",
1056 vec![QueryParam::String(season_id)],
1057 );
1058
1059 let episodes: Vec<(String, String, Option<i32>)> = db_service
1060 .query_many(episodes_query, |row| {
1061 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1062 })
1063 .await
1064 .map_err(|e| e.to_string())?;
1065
1066 let mut download_ids = Vec::new();
1067
1068 for (idx, (episode_id, episode_name, episode_number)) in episodes.into_iter().enumerate() {
1070 let priority = 1000 - idx as i32;
1071 let episode_num = episode_number.unwrap_or(1);
1072
1073 let file_name = format!(
1074 "S{:02}E{:02}_{}.mp4",
1075 season_number,
1076 episode_num,
1077 sanitize_filename(&episode_name)
1078 );
1079 let file_path = format!(
1080 "{}/{}/{}",
1081 base_path,
1082 sanitize_filename(&series_name),
1083 file_name
1084 );
1085
1086 let insert_query = Query::with_params(
1087 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
1088 item_name, quality_preset, media_type, series_name, season_name,
1089 episode_number, season_number)
1090 VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
1091 ON CONFLICT(item_id, user_id) DO UPDATE SET
1092 priority = excluded.priority,
1093 status = 'pending',
1094 queued_at = CURRENT_TIMESTAMP,
1095 quality_preset = excluded.quality_preset",
1096 vec![
1097 QueryParam::String(episode_id.clone()),
1098 QueryParam::String(user_id.clone()),
1099 QueryParam::String(file_path),
1100 QueryParam::Int(priority),
1101 QueryParam::String(episode_name),
1102 QueryParam::String(quality.clone()),
1103 QueryParam::String(series_name.clone()),
1104 QueryParam::String(season_name.clone()),
1105 QueryParam::Int(episode_num),
1106 QueryParam::Int(season_number),
1107 ],
1108 );
1109
1110 db_service
1111 .execute(insert_query)
1112 .await
1113 .map_err(|e| e.to_string())?;
1114
1115 let id_query = Query::with_params(
1116 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
1117 vec![
1118 QueryParam::String(episode_id),
1119 QueryParam::String(user_id.clone()),
1120 ],
1121 );
1122
1123 let download_id: i64 = db_service
1124 .query_one(id_query, |row| row.get(0))
1125 .await
1126 .map_err(|e| e.to_string())?;
1127
1128 download_ids.push(download_id);
1129 }
1130
1131 info!(
1132 "[download_season] Queued {} episodes for {} - {}",
1133 download_ids.len(),
1134 series_name,
1135 season_name
1136 );
1137 Ok(download_ids)
1138}
1139
1140#[allow(dead_code)]
1142fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
1143 let mut stats = DownloadStats {
1144 total: downloads.len(),
1145 active_count: 0,
1146 queued_count: 0,
1147 completed_count: 0,
1148 failed_count: 0,
1149 paused_count: 0,
1150 };
1151
1152 for download in downloads {
1153 match download.status.as_str() {
1154 "downloading" => stats.active_count += 1,
1155 "pending" => stats.queued_count += 1,
1156 "completed" => stats.completed_count += 1,
1157 "failed" => stats.failed_count += 1,
1158 "paused" => stats.paused_count += 1,
1159 _ => {}
1160 }
1161 }
1162
1163 stats
1164}
1165
1166#[tauri::command]
1168#[specta::specta]
1169pub async fn get_downloads(
1170 db: State<'_, DatabaseWrapper>,
1171 user_id: String,
1172 status_filter: Option<Vec<String>>,
1173) -> Result<DownloadsResponse, String> {
1174 let db_service = {
1175 let database = db.0.lock().map_err(|e| e.to_string())?;
1176 Arc::new(database.service())
1177 };
1178
1179 let (query_str, params) = if let Some(ref statuses) = status_filter {
1182 let placeholders = statuses.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1183 let sql = format!(
1184 "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
1185 d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
1186 d.retry_count, d.priority,
1187 COALESCE(d.item_name, i.name) as item_name,
1188 COALESCE(d.artist_name, i.artists) as artist_name,
1189 COALESCE(d.album_name, i.album_name) as album_name,
1190 COALESCE(d.series_name, i.series_name) as series_name,
1191 COALESCE(d.season_name, i.season_name) as season_name,
1192 COALESCE(d.episode_number, i.index_number) as episode_number,
1193 COALESCE(d.season_number, i.parent_index_number) as season_number,
1194 d.quality_preset,
1195 COALESCE(d.media_type, 'audio') as media_type,
1196 COALESCE(d.download_source, 'user') as download_source
1197 FROM downloads d
1198 LEFT JOIN items i ON d.item_id = i.id
1199 WHERE d.user_id = ? AND d.status IN ({})
1200 ORDER BY d.priority DESC, d.queued_at ASC",
1201 placeholders
1202 );
1203 let mut p = vec![QueryParam::String(user_id.clone())];
1204 p.extend(statuses.iter().map(|s| QueryParam::String(s.clone())));
1205 (sql, p)
1206 } else {
1207 let sql = "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
1208 d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
1209 d.retry_count, d.priority,
1210 COALESCE(d.item_name, i.name) as item_name,
1211 COALESCE(d.artist_name, i.artists) as artist_name,
1212 COALESCE(d.album_name, i.album_name) as album_name,
1213 COALESCE(d.series_name, i.series_name) as series_name,
1214 COALESCE(d.season_name, i.season_name) as season_name,
1215 COALESCE(d.episode_number, i.index_number) as episode_number,
1216 COALESCE(d.season_number, i.parent_index_number) as season_number,
1217 d.quality_preset,
1218 COALESCE(d.media_type, 'audio') as media_type,
1219 COALESCE(d.download_source, 'user') as download_source
1220 FROM downloads d
1221 LEFT JOIN items i ON d.item_id = i.id
1222 WHERE d.user_id = ?
1223 ORDER BY d.priority DESC, d.queued_at ASC"
1224 .to_string();
1225 (sql, vec![QueryParam::String(user_id)])
1226 };
1227
1228 let query = Query::with_params(query_str, params);
1229
1230 let downloads = db_service
1231 .query_many(query, map_download_row)
1232 .await
1233 .map_err(|e| e.to_string())?;
1234
1235 let stats = compute_download_stats(&downloads);
1237
1238 Ok(DownloadsResponse { downloads, stats })
1239}
1240
1241#[tauri::command]
1255#[specta::specta]
1256pub async fn pause_download(
1257 db: State<'_, DatabaseWrapper>,
1258 download_id: i64,
1259) -> Result<(), String> {
1260 let db_service = {
1261 let database = db.0.lock().map_err(|e| e.to_string())?;
1262 Arc::new(database.service())
1263 };
1264
1265 let query = Query::with_params(
1266 "UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
1267 vec![QueryParam::Int64(download_id)],
1268 );
1269
1270 db_service.execute(query).await.map_err(|e| e.to_string())?;
1271
1272 let was_running = crate::download::stop::signal(download_id);
1273 info!(
1274 "[pause] Download {} paused (in flight: {})",
1275 download_id, was_running
1276 );
1277 Ok(())
1278}
1279
1280#[tauri::command]
1289#[specta::specta]
1290pub async fn resume_download(
1291 app: tauri::AppHandle,
1292 db: State<'_, DatabaseWrapper>,
1293 download_manager: State<'_, DownloadManagerWrapper>,
1294 download_id: i64,
1295) -> Result<(), String> {
1296 let db_service = {
1297 let database = db.0.lock().map_err(|e| e.to_string())?;
1298 Arc::new(database.service())
1299 };
1300
1301 let query = Query::with_params(
1302 "UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
1303 vec![QueryParam::Int64(download_id)],
1304 );
1305
1306 db_service.execute(query).await.map_err(|e| e.to_string())?;
1307
1308 crate::download::stop::clear(download_id);
1311
1312 let active_downloads = {
1313 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1314 manager.get_active_downloads()
1315 };
1316 pump_download_queue(app, db_service, active_downloads).await;
1317
1318 Ok(())
1319}
1320
1321#[tauri::command]
1323#[specta::specta]
1324pub async fn cancel_download(
1325 db: State<'_, DatabaseWrapper>,
1326 download_manager: State<'_, DownloadManagerWrapper>,
1327 download_id: i64,
1328) -> Result<(), String> {
1329 let db_service = {
1330 let database = db.0.lock().map_err(|e| e.to_string())?;
1331 Arc::new(database.service())
1332 };
1333
1334 let file_query = Query::with_params(
1336 "SELECT file_path FROM downloads WHERE id = ?",
1337 vec![QueryParam::Int64(download_id)],
1338 );
1339
1340 let file_path: Option<String> = db_service
1341 .query_optional(file_query, |row| row.get(0))
1342 .await
1343 .ok()
1344 .flatten();
1345
1346 let delete_query = Query::with_params(
1348 "DELETE FROM downloads WHERE id = ?",
1349 vec![QueryParam::Int64(download_id)],
1350 );
1351
1352 db_service
1353 .execute(delete_query)
1354 .await
1355 .map_err(|e| e.to_string())?;
1356
1357 crate::download::stop::signal(download_id);
1362 crate::download::stop::clear(download_id);
1363
1364 {
1366 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1367 manager.unregister_download(download_id);
1368 info!(
1369 "Cancelled download {}. Active downloads: {}",
1370 download_id,
1371 manager.active_count()
1372 );
1373 }
1374
1375 if let Some(path) = file_path {
1379 let target = std::path::PathBuf::from(&path);
1380 let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
1381 }
1382
1383 Ok(())
1384}
1385
1386#[tauri::command]
1388#[specta::specta]
1389pub async fn mark_download_completed(
1390 db: State<'_, DatabaseWrapper>,
1391 download_id: i64,
1392 bytes_downloaded: i64,
1393 file_path: String,
1394) -> Result<(), String> {
1395 let file_path = {
1403 let root = download_root(&db)?;
1404 confine_to_root(&root, &root.join(&file_path))?
1405 .to_string_lossy()
1406 .to_string()
1407 };
1408
1409 let db_service = {
1410 let database = db.0.lock().map_err(|e| e.to_string())?;
1411 Arc::new(database.service())
1412 };
1413
1414 let query = Query::with_params(
1415 "UPDATE downloads SET status = 'completed', progress = 1.0, bytes_downloaded = ?,
1416 file_size = ?, file_path = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?",
1417 vec![
1418 QueryParam::Int64(bytes_downloaded),
1419 QueryParam::Int64(bytes_downloaded),
1420 QueryParam::String(file_path),
1421 QueryParam::Int64(download_id),
1422 ],
1423 );
1424
1425 db_service.execute(query).await.map_err(|e| e.to_string())?;
1426 Ok(())
1427}
1428
1429#[tauri::command]
1431#[specta::specta]
1432pub async fn mark_download_failed(
1433 db: State<'_, DatabaseWrapper>,
1434 download_id: i64,
1435 error_message: String,
1436) -> Result<(), String> {
1437 let db_service = {
1438 let database = db.0.lock().map_err(|e| e.to_string())?;
1439 Arc::new(database.service())
1440 };
1441
1442 let query = Query::with_params(
1443 "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
1444 vec![
1445 QueryParam::String(error_message),
1446 QueryParam::Int64(download_id),
1447 ],
1448 );
1449
1450 db_service.execute(query).await.map_err(|e| e.to_string())?;
1451 Ok(())
1452}
1453
1454#[tauri::command]
1457#[specta::specta]
1458pub async fn start_download(
1459 db: State<'_, DatabaseWrapper>,
1460 download_manager: State<'_, DownloadManagerWrapper>,
1461 app: tauri::AppHandle,
1462 download_id: i64,
1463 stream_url: String,
1464 target_dir: String,
1465) -> Result<(), String> {
1466 use crate::download::events::DownloadEvent;
1467 use tauri::Emitter;
1468
1469 debug!("start_download called for download_id: {}", download_id);
1470 debug!(" stream_url: {}", stream_url);
1471 debug!(" target_dir: {}", target_dir);
1472
1473 {
1475 let manager = download_manager.0.lock().map_err(|e| {
1476 error!("Failed to lock download manager: {}", e);
1477 format!("Failed to lock download manager: {}", e)
1478 })?;
1479
1480 if !manager.can_start_download() {
1481 warn!(
1482 "Cannot start download: maximum concurrent downloads ({}) reached",
1483 manager.max_concurrent()
1484 );
1485 debug!(" Active downloads: {}", manager.active_count());
1486 return Err(format!(
1487 "Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
1488 manager.max_concurrent()
1489 ));
1490 }
1491
1492 let registered = manager.register_download(download_id);
1494 if !registered {
1495 warn!(
1496 "Failed to register download {}: already registered or limit reached",
1497 download_id
1498 );
1499 return Err("Download already in progress or limit reached".to_string());
1500 }
1501
1502 info!(
1503 "Download {} registered. Active downloads: {}/{}",
1504 download_id,
1505 manager.active_count(),
1506 manager.max_concurrent()
1507 );
1508 }
1509
1510 let db_service = {
1512 let database = db.0.lock().map_err(|e| {
1513 error!("Failed to lock database: {}", e);
1514 e.to_string()
1515 })?;
1516 Arc::new(database.service())
1517 };
1518
1519 let info_query = Query::with_params(
1520 "SELECT item_id, file_path, file_size FROM downloads WHERE id = ?",
1521 vec![QueryParam::Int64(download_id)],
1522 );
1523
1524 let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
1525 .query_one(info_query, |row| {
1526 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1527 })
1528 .await
1529 .map_err(|e| {
1530 error!("Failed to query download info: {}", e);
1531 e.to_string()
1532 })?;
1533
1534 debug!(
1535 " Retrieved: item_id={}, file_path={}, file_size={:?}",
1536 item_id, file_path, file_size
1537 );
1538
1539 let target_path = {
1543 let root = download_root(&db)?;
1544 confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))?
1545 };
1546
1547 debug!("Making HEAD request to get file size...");
1549 let head_response = reqwest::Client::new().head(&stream_url).send().await;
1550
1551 let file_size_from_server = match head_response {
1552 Ok(response) => {
1553 let size = response
1554 .headers()
1555 .get(reqwest::header::CONTENT_LENGTH)
1556 .and_then(|v| v.to_str().ok())
1557 .and_then(|v| v.parse::<i64>().ok());
1558
1559 if let Some(size) = size {
1560 debug!(
1561 " Got file size from server: {} bytes ({} MB)",
1562 size,
1563 size / 1024 / 1024
1564 );
1565 } else {
1566 warn!(" Server didn't provide Content-Length header");
1567 }
1568 size
1569 }
1570 Err(e) => {
1571 warn!(" HEAD request failed: {}, continuing anyway...", e);
1572 None
1573 }
1574 };
1575
1576 let update_query = if let Some(size) = file_size_from_server {
1580 Query::with_params(
1581 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
1582 vec![
1583 QueryParam::Int64(size),
1584 QueryParam::String(stream_url.clone()),
1585 QueryParam::String(target_dir.clone()),
1586 QueryParam::Int64(download_id),
1587 ],
1588 )
1589 } else {
1590 Query::with_params(
1591 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
1592 vec![
1593 QueryParam::String(stream_url.clone()),
1594 QueryParam::String(target_dir.clone()),
1595 QueryParam::Int64(download_id),
1596 ],
1597 )
1598 };
1599
1600 db_service
1601 .execute(update_query)
1602 .await
1603 .map_err(|e| e.to_string())?;
1604
1605 let started_event = DownloadEvent::Started {
1607 download_id,
1608 item_id: item_id.clone(),
1609 };
1610 debug!("Emitting download-event: {:?}", started_event);
1611 debug!(
1612 " Serialized: {}",
1613 serde_json::to_string(&started_event).unwrap_or_default()
1614 );
1615 match app.emit("download-event", started_event) {
1616 Ok(_) => debug!(" Event emitted successfully"),
1617 Err(e) => error!(" Event emit failed: {:?}", e),
1618 }
1619
1620 let active_downloads = {
1622 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1623 manager.get_active_downloads()
1624 };
1625
1626 spawn_download_worker(
1629 app.clone(),
1630 download_id,
1631 item_id,
1632 stream_url,
1633 target_path,
1634 file_size_from_server
1635 .or(file_size)
1636 .and_then(|n| u64::try_from(n).ok()),
1637 active_downloads,
1638 );
1639
1640 Ok(())
1641}
1642
1643#[tauri::command]
1651#[specta::specta]
1652pub async fn enqueue_download(
1653 db: State<'_, DatabaseWrapper>,
1654 download_manager: State<'_, DownloadManagerWrapper>,
1655 app: tauri::AppHandle,
1656 download_id: i64,
1657 stream_url: String,
1658 target_dir: String,
1659) -> Result<(), String> {
1660 let db_service = {
1661 let database = db.0.lock().map_err(|e| e.to_string())?;
1662 Arc::new(database.service())
1663 };
1664
1665 let update_query = Query::with_params(
1668 "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
1669 vec![
1670 QueryParam::String(stream_url),
1671 QueryParam::String(target_dir),
1672 QueryParam::Int64(download_id),
1673 ],
1674 );
1675 db_service
1676 .execute(update_query)
1677 .await
1678 .map_err(|e| e.to_string())?;
1679
1680 let active_downloads = {
1682 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1683 manager.get_active_downloads()
1684 };
1685 pump_download_queue(app, db_service, active_downloads).await;
1686
1687 Ok(())
1688}
1689
1690#[tauri::command]
1699#[specta::specta]
1700pub async fn enqueue_video_downloads(
1701 db: State<'_, DatabaseWrapper>,
1702 download_manager: State<'_, DownloadManagerWrapper>,
1703 repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
1704 app: tauri::AppHandle,
1705 handle: String,
1706 download_ids: Vec<i64>,
1707 target_dir: String,
1708) -> Result<(), String> {
1709 let repo = repository.0.get(&handle).ok_or("Repository not found")?;
1710
1711 let db_service = {
1712 let database = db.0.lock().map_err(|e| e.to_string())?;
1713 Arc::new(database.service())
1714 };
1715
1716 for download_id in download_ids {
1717 let info_query = Query::with_params(
1719 "SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
1720 vec![QueryParam::Int64(download_id)],
1721 );
1722 let (item_id, quality): (String, String) = match db_service
1723 .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
1724 .await
1725 {
1726 Ok(row) => row,
1727 Err(e) => {
1728 warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
1729 continue;
1730 }
1731 };
1732
1733 let resolved =
1737 crate::repository::resolve_video_download(repo.as_ref(), &item_id, &quality, None)
1738 .await;
1739
1740 let update_query = Query::with_params(
1744 "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ?, \
1745 file_size = COALESCE(?, file_size) WHERE id = ?",
1746 vec![
1747 QueryParam::String(resolved.url),
1748 QueryParam::String(target_dir.clone()),
1749 expected_bytes_param(resolved.expected_bytes),
1750 QueryParam::Int64(download_id),
1751 ],
1752 );
1753 if let Err(e) = db_service.execute(update_query).await {
1754 warn!(
1755 "[enqueue_video] Failed to persist URL for download {}: {}",
1756 download_id, e
1757 );
1758 }
1759 }
1760
1761 let active_downloads = {
1763 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1764 manager.get_active_downloads()
1765 };
1766 pump_download_queue(app, db_service, active_downloads).await;
1767
1768 Ok(())
1769}
1770
1771pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
1781 let wifi_only = {
1782 let smart_cache = app.state::<SmartCacheWrapper>();
1783 let cache = match smart_cache.0.lock() {
1784 Ok(c) => c,
1785 Err(e) => {
1786 error!("[pump] Failed to lock smart cache: {}", e);
1787 return true;
1789 }
1790 };
1791 cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
1792 };
1793
1794 if !wifi_only {
1795 return true;
1796 }
1797
1798 let network = app.state::<NetworkStateWrapper>();
1799 network.0.allows_download(true).await
1800}
1801
1802pub(crate) async fn pump_download_queue(
1809 app: tauri::AppHandle,
1810 db_service: Arc<crate::storage::db_service::RusqliteService>,
1811 active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
1812) {
1813 use crate::download::events::DownloadEvent;
1814 use tauri::Emitter;
1815
1816 if !downloads_allowed_on_current_network(&app).await {
1821 info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
1822 let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
1823 return;
1824 }
1825
1826 let max_concurrent = {
1827 let manager = app.state::<DownloadManagerWrapper>();
1828 let manager = match manager.0.lock() {
1829 Ok(m) => m,
1830 Err(e) => {
1831 error!("[pump] Failed to lock download manager: {}", e);
1832 return;
1833 }
1834 };
1835 manager.max_concurrent()
1836 };
1837
1838 loop {
1839 let free_slots = {
1841 let active = match active_downloads.lock() {
1842 Ok(a) => a,
1843 Err(e) => {
1844 error!("[pump] Failed to lock active downloads: {}", e);
1845 return;
1846 }
1847 };
1848 max_concurrent.saturating_sub(active.len())
1849 };
1850 if free_slots == 0 {
1851 return;
1852 }
1853
1854 let next_query = Query::with_params(
1857 "SELECT id, item_id, file_path, stream_url, target_dir, file_size
1858 FROM downloads
1859 WHERE status = 'pending'
1860 AND stream_url IS NOT NULL
1861 AND target_dir IS NOT NULL
1862 ORDER BY priority DESC, queued_at ASC",
1863 vec![],
1864 );
1865
1866 let candidates: Vec<(i64, String, String, String, String, Option<i64>)> = match db_service
1867 .query_many(next_query, |row| {
1868 Ok((
1869 row.get(0)?,
1870 row.get(1)?,
1871 row.get(2)?,
1872 row.get(3)?,
1873 row.get(4)?,
1874 row.get(5)?,
1875 ))
1876 })
1877 .await
1878 {
1879 Ok(rows) => rows,
1880 Err(e) => {
1881 error!("[pump] Failed to query pending downloads: {}", e);
1882 return;
1883 }
1884 };
1885
1886 let next = candidates.into_iter().find(|(id, _, _, _, _, _)| {
1888 active_downloads
1889 .lock()
1890 .map(|active| !active.contains(id))
1891 .unwrap_or(false)
1892 });
1893
1894 let (download_id, item_id, file_path, stream_url, target_dir, file_size) = match next {
1895 Some(n) => n,
1896 None => return, };
1898
1899 let confined = {
1905 let db_state = app.state::<DatabaseWrapper>();
1906 download_root(&db_state).and_then(|root| {
1907 confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))
1908 })
1909 };
1910 let target_path = match confined {
1911 Ok(path) => path,
1912 Err(e) => {
1913 error!("[pump] Refusing download {}: {}", download_id, e);
1914 let fail_query = Query::with_params(
1915 "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
1916 vec![QueryParam::String(e), QueryParam::Int64(download_id)],
1917 );
1918 if let Err(db_err) = db_service.execute(fail_query).await {
1919 error!(
1920 "[pump] Failed to mark download {} failed: {}",
1921 download_id, db_err
1922 );
1923 return;
1924 }
1925 continue;
1926 }
1927 };
1928
1929 {
1932 let manager = app.state::<DownloadManagerWrapper>();
1933 let manager = match manager.0.lock() {
1934 Ok(m) => m,
1935 Err(e) => {
1936 error!("[pump] Failed to lock download manager: {}", e);
1937 return;
1938 }
1939 };
1940 if !manager.register_download(download_id) {
1941 return;
1942 }
1943 info!(
1944 "[pump] Download {} started. Active downloads: {}/{}",
1945 download_id,
1946 manager.active_count(),
1947 manager.max_concurrent()
1948 );
1949 }
1950
1951 let update_query = Query::with_params(
1953 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
1954 vec![QueryParam::Int64(download_id)],
1955 );
1956 if let Err(e) = db_service.execute(update_query).await {
1957 error!(
1958 "[pump] Failed to mark download {} downloading: {}",
1959 download_id, e
1960 );
1961 if let Ok(mut a) = active_downloads.lock() {
1962 a.remove(&download_id);
1963 }
1964 continue;
1965 }
1966
1967 let _ = app.emit(
1969 "download-event",
1970 DownloadEvent::Started {
1971 download_id,
1972 item_id: item_id.clone(),
1973 },
1974 );
1975
1976 spawn_download_worker(
1977 app.clone(),
1978 download_id,
1979 item_id,
1980 stream_url,
1981 target_path,
1982 file_size.and_then(|n| u64::try_from(n).ok()),
1983 active_downloads.clone(),
1984 );
1985 }
1986}
1987
1988fn expected_bytes_param(expected: Option<u64>) -> QueryParam {
1990 match expected.and_then(|n| i64::try_from(n).ok()) {
1991 Some(n) => QueryParam::Int64(n),
1992 None => QueryParam::Null,
1993 }
1994}
1995
1996fn spawn_download_worker(
2000 app: tauri::AppHandle,
2001 download_id: i64,
2002 item_id: String,
2003 stream_url: String,
2004 target_path: std::path::PathBuf,
2005 expected_bytes: Option<u64>,
2006 active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
2007) {
2008 use crate::download::events::DownloadEvent;
2009 use crate::download::{DownloadTask, DownloadWorker};
2010 use tauri::Emitter;
2011
2012 let task = DownloadTask {
2013 url: stream_url,
2014 target_path: target_path.clone(),
2015 };
2016
2017 tauri::async_runtime::spawn(async move {
2018 debug!("Download task started for download_id: {}", download_id);
2019 let worker = DownloadWorker::new();
2020
2021 let progress_app = app.clone();
2023 let progress_item_id = item_id.clone();
2024 let on_progress = move |bytes_downloaded: u64, content_length: Option<u64>| {
2025 let total = crate::download::estimate::progress_total(content_length, expected_bytes);
2028 let progress = crate::download::estimate::progress_fraction(bytes_downloaded, total);
2029
2030 let event = DownloadEvent::Progress {
2031 download_id,
2032 item_id: progress_item_id.clone(),
2033 bytes_downloaded: bytes_downloaded as i64,
2034 total_bytes: total.map(|t| t.bytes as i64),
2035 progress,
2036 estimated: total.is_some_and(|t| t.estimated),
2037 };
2038 let _ = progress_app.emit("download-event", event);
2039 };
2040
2041 let stop_flag = crate::download::stop::register(download_id);
2044 let result = worker.download(&task, &stop_flag, on_progress).await;
2045 crate::download::stop::clear(download_id);
2046
2047 if let Ok(mut active) = active_downloads.lock() {
2049 active.remove(&download_id);
2050 debug!(
2051 " Unregistered download {}. Active downloads: {}",
2052 download_id,
2053 active.len()
2054 );
2055 }
2056
2057 let db_service = {
2066 let db = app.state::<DatabaseWrapper>();
2067 let database = match db.0.lock() {
2068 Ok(d) => d,
2069 Err(e) => {
2070 error!(
2071 "[pump] Failed to lock database after download {}: {}",
2072 download_id, e
2073 );
2074 return;
2075 }
2076 };
2077 Arc::new(database.service())
2078 };
2079
2080 match result {
2081 Ok(res) => {
2082 info!(
2083 "Download completed successfully: {} bytes",
2084 res.bytes_downloaded
2085 );
2086 let file_path = target_path.to_string_lossy().to_string();
2087
2088 let update = Query::with_params(
2089 "UPDATE downloads SET status = 'completed', progress = 1.0, \
2090 bytes_downloaded = ?, file_size = ?, file_path = ?, \
2091 completed_at = CURRENT_TIMESTAMP WHERE id = ?",
2092 vec![
2093 QueryParam::Int64(res.bytes_downloaded as i64),
2094 QueryParam::Int64(res.bytes_downloaded as i64),
2095 QueryParam::String(file_path.clone()),
2096 QueryParam::Int64(download_id),
2097 ],
2098 );
2099 if let Err(e) = db_service.execute(update).await {
2100 error!(
2101 "[pump] Failed to persist completed status for download {}: {}",
2102 download_id, e
2103 );
2104 }
2105
2106 let completed_event = DownloadEvent::Completed {
2107 download_id,
2108 item_id,
2109 file_path,
2110 bytes_downloaded: res.bytes_downloaded as i64,
2111 };
2112 match app.emit("download-event", completed_event) {
2113 Ok(_) => debug!(" Completed event emitted successfully"),
2114 Err(e) => error!(" Completed event emit failed: {:?}", e),
2115 }
2116 }
2117 Err(e) if e.is_stopped() => {
2123 info!(
2124 "[pump] Download {} stopped by request; partial file kept for resume",
2125 download_id
2126 );
2127 }
2128 Err(e) => {
2129 error!("Download failed: {:?}", e);
2130
2131 let update = Query::with_params(
2132 "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
2133 vec![
2134 QueryParam::String(e.to_string()),
2135 QueryParam::Int64(download_id),
2136 ],
2137 );
2138 if let Err(db_err) = db_service.execute(update).await {
2139 error!(
2140 "[pump] Failed to persist failed status for download {}: {}",
2141 download_id, db_err
2142 );
2143 }
2144
2145 let failed_event = DownloadEvent::Failed {
2146 download_id,
2147 item_id,
2148 error: e.to_string(),
2149 };
2150 match app.emit("download-event", failed_event) {
2151 Ok(_) => debug!(" Failed event emitted successfully"),
2152 Err(e) => error!(" Failed event emit failed: {:?}", e),
2153 }
2154 }
2155 }
2156
2157 pump_download_queue(app.clone(), db_service, active_downloads).await;
2159 });
2160}
2161
2162#[tauri::command]
2164#[specta::specta]
2165pub async fn delete_download(
2166 db: State<'_, DatabaseWrapper>,
2167 download_id: i64,
2168) -> Result<(), String> {
2169 let db_service = {
2170 let database = db.0.lock().map_err(|e| e.to_string())?;
2171 Arc::new(database.service())
2172 };
2173
2174 let file_query = Query::with_params(
2176 "SELECT file_path FROM downloads WHERE id = ?",
2177 vec![QueryParam::Int64(download_id)],
2178 );
2179
2180 let file_path: Option<String> = db_service
2181 .query_optional(file_query, |row| row.get(0))
2182 .await
2183 .ok()
2184 .flatten();
2185
2186 let delete_query = Query::with_params(
2188 "DELETE FROM downloads WHERE id = ?",
2189 vec![QueryParam::Int64(download_id)],
2190 );
2191
2192 db_service
2193 .execute(delete_query)
2194 .await
2195 .map_err(|e| e.to_string())?;
2196
2197 if let Some(path) = file_path {
2199 let _ = std::fs::remove_file(&path); }
2201
2202 Ok(())
2203}
2204
2205fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
2207 Ok(DownloadInfo {
2208 id: row.get(0)?,
2209 item_id: row.get(1)?,
2210 user_id: row.get(2)?,
2211 file_path: row.get(3)?,
2212 file_size: row.get(4)?,
2213 mime_type: row.get(5)?,
2214 status: row.get(6)?,
2215 progress: row.get(7)?,
2216 bytes_downloaded: row.get(8)?,
2217 queued_at: row.get(9)?,
2218 started_at: row.get(10)?,
2219 completed_at: row.get(11)?,
2220 error_message: row.get(12)?,
2221 retry_count: row.get(13)?,
2222 priority: row.get(14)?,
2223 item_name: row.get(15)?,
2224 artist_name: row.get(16)?,
2225 album_name: row.get(17)?,
2226 series_name: row.get(18)?,
2228 season_name: row.get(19)?,
2229 episode_number: row.get(20)?,
2230 season_number: row.get(21)?,
2231 quality_preset: row.get(22)?,
2232 media_type: row
2233 .get::<_, Option<String>>(23)?
2234 .unwrap_or_else(|| "audio".to_string()),
2235 download_source: row
2236 .get::<_, Option<String>>(24)?
2237 .unwrap_or_else(|| "user".to_string()),
2238 })
2239}
2240
2241#[derive(specta::Type, Debug, Clone, serde::Serialize)]
2243pub struct StorageStats {
2244 pub total_bytes: i64,
2245 pub total_items: i64,
2246 pub albums: Vec<AlbumStorageInfo>,
2247}
2248
2249#[derive(specta::Type, Debug, Clone, serde::Serialize)]
2251pub struct AlbumStorageInfo {
2252 pub album_id: String,
2253 pub album_name: String,
2254 pub artist_name: Option<String>,
2255 pub bytes_used: i64,
2256 pub track_count: i64,
2257}
2258
2259#[tauri::command]
2261#[specta::specta]
2262pub async fn get_download_storage_stats(
2263 db: State<'_, DatabaseWrapper>,
2264 user_id: String,
2265) -> Result<StorageStats, String> {
2266 let db_service = {
2267 let database = db.0.lock().map_err(|e| e.to_string())?;
2268 Arc::new(database.service())
2269 };
2270
2271 let total_query = Query::with_params(
2273 "SELECT COALESCE(SUM(file_size), 0), COUNT(*)
2274 FROM downloads
2275 WHERE user_id = ? AND status = 'completed'",
2276 vec![QueryParam::String(user_id.clone())],
2277 );
2278
2279 let (total_bytes, total_items): (i64, i64) = db_service
2280 .query_one(total_query, |row| Ok((row.get(0)?, row.get(1)?)))
2281 .await
2282 .map_err(|e| e.to_string())?;
2283
2284 let albums_query = Query::with_params(
2286 "SELECT
2287 COALESCE(i.album_id, 'unknown') as album_id,
2288 COALESCE(i.album_name, 'Unknown Album') as album_name,
2289 i.artists as artist_name,
2290 COALESCE(SUM(d.file_size), 0) as bytes_used,
2291 COUNT(*) as track_count
2292 FROM downloads d
2293 LEFT JOIN items i ON d.item_id = i.id
2294 WHERE d.user_id = ? AND d.status = 'completed'
2295 GROUP BY COALESCE(i.album_id, 'unknown')
2296 ORDER BY bytes_used DESC",
2297 vec![QueryParam::String(user_id)],
2298 );
2299
2300 let albums: Vec<AlbumStorageInfo> = db_service
2301 .query_many(albums_query, |row| {
2302 Ok(AlbumStorageInfo {
2303 album_id: row.get(0)?,
2304 album_name: row.get(1)?,
2305 artist_name: row.get(2)?,
2306 bytes_used: row.get(3)?,
2307 track_count: row.get(4)?,
2308 })
2309 })
2310 .await
2311 .map_err(|e| e.to_string())?;
2312
2313 Ok(StorageStats {
2314 total_bytes,
2315 total_items,
2316 albums,
2317 })
2318}
2319
2320#[tauri::command]
2322#[specta::specta]
2323pub async fn delete_all_downloads(
2324 db: State<'_, DatabaseWrapper>,
2325 user_id: String,
2326) -> Result<i64, String> {
2327 let db_service = {
2328 let database = db.0.lock().map_err(|e| e.to_string())?;
2329 Arc::new(database.service())
2330 };
2331
2332 let file_query = Query::with_params(
2334 "SELECT file_path FROM downloads WHERE user_id = ? AND status = 'completed'",
2335 vec![QueryParam::String(user_id.clone())],
2336 );
2337
2338 let file_paths: Vec<String> = db_service
2339 .query_many(file_query, |row| row.get(0))
2340 .await
2341 .map_err(|e| e.to_string())?;
2342
2343 let delete_query = Query::with_params(
2345 "DELETE FROM downloads WHERE user_id = ?",
2346 vec![QueryParam::String(user_id)],
2347 );
2348
2349 let deleted_count = db_service
2350 .execute(delete_query)
2351 .await
2352 .map_err(|e| e.to_string())?;
2353
2354 for path in file_paths {
2356 let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(format!("{}.part", path)); }
2359
2360 Ok(deleted_count as i64)
2361}
2362
2363#[tauri::command]
2365#[specta::specta]
2366pub async fn clear_stale_downloads(
2367 db: State<'_, DatabaseWrapper>,
2368 user_id: String,
2369) -> Result<i64, String> {
2370 let db_service = {
2371 let database = db.0.lock().map_err(|e| e.to_string())?;
2372 Arc::new(database.service())
2373 };
2374
2375 let file_query = Query::with_params(
2380 "SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
2381 vec![QueryParam::String(user_id.clone())],
2382 );
2383
2384 let stale: Vec<(i64, String)> = db_service
2385 .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
2386 .await
2387 .map_err(|e| e.to_string())?;
2388
2389 for (id, _) in &stale {
2390 crate::download::stop::signal(*id);
2391 crate::download::stop::clear(*id);
2392 }
2393 let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
2394
2395 let delete_query = Query::with_params(
2397 "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
2398 vec![QueryParam::String(user_id)],
2399 );
2400
2401 let deleted_count = db_service
2402 .execute(delete_query)
2403 .await
2404 .map_err(|e| e.to_string())?;
2405
2406 for path in file_paths {
2409 let target = std::path::PathBuf::from(&path);
2410 let _ = std::fs::remove_file(&target);
2411 let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
2412 }
2413
2414 Ok(deleted_count as i64)
2415}
2416
2417#[tauri::command]
2419#[specta::specta]
2420pub async fn delete_album_downloads(
2421 db: State<'_, DatabaseWrapper>,
2422 album_id: String,
2423 user_id: String,
2424) -> Result<i64, String> {
2425 let db_service = {
2426 let database = db.0.lock().map_err(|e| e.to_string())?;
2427 Arc::new(database.service())
2428 };
2429
2430 let file_query = Query::with_params(
2432 "SELECT d.file_path FROM downloads d
2433 JOIN items i ON d.item_id = i.id
2434 WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
2435 vec![
2436 QueryParam::String(user_id.clone()),
2437 QueryParam::String(album_id.clone()),
2438 ],
2439 );
2440
2441 let file_paths: Vec<String> = db_service
2442 .query_many(file_query, |row| row.get(0))
2443 .await
2444 .map_err(|e| e.to_string())?;
2445
2446 let delete_query = Query::with_params(
2448 "DELETE FROM downloads WHERE user_id = ? AND item_id IN (SELECT id FROM items WHERE album_id = ?)",
2449 vec![QueryParam::String(user_id), QueryParam::String(album_id)],
2450 );
2451
2452 let deleted_count = db_service
2453 .execute(delete_query)
2454 .await
2455 .map_err(|e| e.to_string())?;
2456
2457 for path in file_paths {
2459 let _ = std::fs::remove_file(&path);
2460 let _ = std::fs::remove_file(format!("{}.part", path));
2461 }
2462
2463 Ok(deleted_count as i64)
2464}
2465
2466#[tauri::command]
2475#[specta::specta]
2476pub async fn delete_downloads_under(
2477 db: State<'_, DatabaseWrapper>,
2478 item_id: String,
2479 user_id: String,
2480) -> Result<i64, String> {
2481 let db_service = {
2482 let database = db.0.lock().map_err(|e| e.to_string())?;
2483 Arc::new(database.service())
2484 };
2485
2486 const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
2488 AND (
2489 d.item_id = ?
2490 OR d.item_id IN (
2491 SELECT c.id FROM items c
2492 WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
2493 )
2494 )";
2495
2496 let file_query = Query::with_params(
2497 format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
2498 vec![
2499 QueryParam::String(user_id.clone()),
2500 QueryParam::String(item_id.clone()),
2501 QueryParam::String(item_id.clone()),
2502 QueryParam::String(item_id.clone()),
2503 QueryParam::String(item_id.clone()),
2504 QueryParam::String(item_id.clone()),
2505 ],
2506 );
2507 let file_paths: Vec<String> = db_service
2508 .query_many(file_query, |row| row.get(0))
2509 .await
2510 .map_err(|e| e.to_string())?;
2511
2512 let delete_query = Query::with_params(
2513 format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
2514 vec![
2515 QueryParam::String(user_id),
2516 QueryParam::String(item_id.clone()),
2517 QueryParam::String(item_id.clone()),
2518 QueryParam::String(item_id.clone()),
2519 QueryParam::String(item_id.clone()),
2520 QueryParam::String(item_id),
2521 ],
2522 );
2523 let deleted_count = db_service
2524 .execute(delete_query)
2525 .await
2526 .map_err(|e| e.to_string())?;
2527
2528 for path in file_paths {
2529 let _ = std::fs::remove_file(&path);
2530 let _ = std::fs::remove_file(format!("{}.part", path));
2531 }
2532
2533 Ok(deleted_count as i64)
2534}
2535
2536#[derive(specta::Type, Debug, Clone, serde::Serialize)]
2538pub struct DownloadManagerStats {
2539 pub max_concurrent: usize,
2540 pub active_count: usize,
2541 pub available_slots: usize,
2542}
2543
2544#[tauri::command]
2546#[specta::specta]
2547pub async fn get_download_manager_stats(
2548 download_manager: State<'_, DownloadManagerWrapper>,
2549) -> Result<DownloadManagerStats, String> {
2550 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2551
2552 let active_count = manager.active_count();
2553 let max_concurrent = manager.max_concurrent();
2554
2555 Ok(DownloadManagerStats {
2556 max_concurrent,
2557 active_count,
2558 available_slots: max_concurrent.saturating_sub(active_count),
2559 })
2560}
2561
2562#[tauri::command]
2564#[specta::specta]
2565pub async fn set_max_concurrent_downloads(
2566 download_manager: State<'_, DownloadManagerWrapper>,
2567 max: usize,
2568) -> Result<(), String> {
2569 let mut manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2570 manager.set_max_concurrent(max);
2571 info!("Set max concurrent downloads to: {}", max);
2572 Ok(())
2573}
2574
2575#[cfg(test)]
2577mod tests {
2578 use super::*;
2579 use crate::storage::Database;
2580 use rusqlite::params;
2581
2582 #[test]
2583 fn test_sanitize_filename() {
2584 assert_eq!(sanitize_filename("normal.mp3"), "normal.mp3");
2585 assert_eq!(
2586 sanitize_filename("track/with\\invalid:chars"),
2587 "track_with_invalid_chars"
2588 );
2589 assert_eq!(sanitize_filename("song?.mp3"), "song_.mp3");
2590 assert_eq!(sanitize_filename("file<>|?.txt"), "file____.txt");
2591 }
2592
2593 #[test]
2594 fn test_sanitize_filename_preserves_extension() {
2595 assert_eq!(sanitize_filename("my:song.mp3"), "my_song.mp3");
2596 assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
2597 }
2598
2599 const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files";
2601
2602 #[test]
2610 fn test_queued_download_paths_cannot_escape_the_download_directory() {
2611 let root = Path::new(TEST_ROOT);
2612
2613 assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err());
2614 assert!(confine_queued_path(root, "../.bashrc").is_err());
2615 assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err());
2616
2617 #[allow(clippy::join_absolute_paths)]
2626 {
2627 assert_eq!(
2628 PathBuf::from(root).join("/etc/cron.d/pwn"),
2629 PathBuf::from("/etc/cron.d/pwn")
2630 );
2631 }
2632 }
2633
2634 #[test]
2639 fn test_queued_download_paths_are_otherwise_unchanged() {
2640 let root = Path::new(TEST_ROOT);
2641
2642 for path in [
2643 "downloads/9f8e7d6c", "videos/movies/Arrival.mp4", "albums/abc123/01 - Opening.mp3", "/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4",
2649 ] {
2650 assert_eq!(confine_queued_path(root, path).unwrap(), path);
2651 }
2652
2653 let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?"));
2657 assert_eq!(confine_queued_path(root, &already).unwrap(), already);
2658 }
2659
2660 #[test]
2666 fn test_a_completed_download_cannot_register_a_file_outside_the_root() {
2667 let root = Path::new(TEST_ROOT);
2668
2669 let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c";
2672 assert_eq!(
2673 confine_to_root(root, Path::new(written)).unwrap(),
2674 PathBuf::from(written)
2675 );
2676
2677 assert_eq!(
2680 confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(),
2681 PathBuf::from(written)
2682 );
2683
2684 assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err());
2685 assert!(confine_to_root(
2686 root,
2687 Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd")
2688 )
2689 .is_err());
2690 }
2691
2692 fn setup_test_db() -> Database {
2694 let db = Database::open_in_memory().unwrap();
2695 let conn = db.connection();
2696 let conn = conn.lock_safe();
2697
2698 conn.execute(
2700 "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
2701 params!["server1", "Test Server", "http://localhost:8096"],
2702 )
2703 .unwrap();
2704
2705 conn.execute(
2707 "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
2708 params!["user1", "server1", "testuser"],
2709 )
2710 .unwrap();
2711
2712 conn.execute(
2714 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2715 params!["item1", "server1", "Test Song 1", "Audio", "album1", 1],
2716 )
2717 .unwrap();
2718
2719 conn.execute(
2720 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2721 params!["item2", "server1", "Test Song 2", "Audio", "album1", 2],
2722 )
2723 .unwrap();
2724
2725 conn.execute(
2726 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2727 params!["item3", "server1", "Test Song 3", "Audio", "album1", 3],
2728 )
2729 .unwrap();
2730
2731 drop(conn);
2732 db
2733 }
2734
2735 #[test]
2736 fn test_download_item_returns_correct_id_on_insert() {
2737 let db = setup_test_db();
2738 let conn = db.connection();
2739 let conn = conn.lock_safe();
2740
2741 conn.execute(
2743 "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at)
2744 VALUES (?1, ?2, ?3, ?4, 'pending', ?5, CURRENT_TIMESTAMP)",
2745 params!["item1", "user1", "/path/to/file.mp3", "audio/mpeg", 0],
2746 )
2747 .unwrap();
2748
2749 let download_id: i64 = conn
2751 .query_row(
2752 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2753 params!["item1", "user1"],
2754 |row| row.get(0),
2755 )
2756 .unwrap();
2757
2758 assert!(download_id > 0, "Download ID should be positive");
2759
2760 let (status, file_path): (String, String) = conn
2762 .query_row(
2763 "SELECT status, file_path FROM downloads WHERE id = ?1",
2764 params![download_id],
2765 |row| Ok((row.get(0)?, row.get(1)?)),
2766 )
2767 .unwrap();
2768
2769 assert_eq!(status, "pending");
2770 assert_eq!(file_path, "/path/to/file.mp3");
2771 }
2772
2773 #[test]
2774 fn test_download_item_upsert_returns_correct_id() {
2775 let db = setup_test_db();
2776 let conn = db.connection();
2777 let conn = conn.lock_safe();
2778
2779 conn.execute(
2781 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2782 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
2783 params!["item1", "user1", "/path/to/file.mp3"],
2784 )
2785 .unwrap();
2786
2787 let first_id: i64 = conn
2788 .query_row(
2789 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2790 params!["item1", "user1"],
2791 |row| row.get(0),
2792 )
2793 .unwrap();
2794
2795 conn.execute(
2797 "UPDATE downloads SET status = 'failed', error_message = 'Network error' WHERE id = ?1",
2798 params![first_id],
2799 )
2800 .unwrap();
2801
2802 conn.execute(
2804 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2805 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
2806 ON CONFLICT(item_id, user_id) DO UPDATE SET
2807 priority = excluded.priority,
2808 status = 'pending',
2809 queued_at = CURRENT_TIMESTAMP",
2810 params!["item1", "user1", "/path/to/file.mp3"],
2811 )
2812 .unwrap();
2813
2814 let second_id: i64 = conn
2817 .query_row(
2818 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2819 params!["item1", "user1"],
2820 |row| row.get(0),
2821 )
2822 .unwrap();
2823
2824 assert_eq!(first_id, second_id, "UPSERT should return the same row ID");
2826
2827 let status: String = conn
2829 .query_row(
2830 "SELECT status FROM downloads WHERE id = ?1",
2831 params![second_id],
2832 |row| row.get(0),
2833 )
2834 .unwrap();
2835
2836 assert_eq!(
2837 status, "pending",
2838 "Status should be reset to pending after UPSERT"
2839 );
2840 }
2841
2842 #[test]
2843 fn test_query_by_unique_constraint_is_reliable() {
2844 let db = setup_test_db();
2854 let conn = db.connection();
2855 let conn = conn.lock_safe();
2856
2857 conn.execute(
2859 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2860 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
2861 params!["item1", "user1", "/path/to/file.mp3"],
2862 )
2863 .unwrap();
2864
2865 let first_id: i64 = conn
2866 .query_row(
2867 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2868 params!["item1", "user1"],
2869 |row| row.get(0),
2870 )
2871 .unwrap();
2872
2873 conn.execute(
2875 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2876 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
2877 ON CONFLICT(item_id, user_id) DO UPDATE SET
2878 status = 'pending'",
2879 params!["item1", "user1", "/path/to/file.mp3"],
2880 )
2881 .unwrap();
2882
2883 let id_after_upsert: i64 = conn
2885 .query_row(
2886 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2887 params!["item1", "user1"],
2888 |row| row.get(0),
2889 )
2890 .unwrap();
2891
2892 assert_eq!(
2894 first_id, id_after_upsert,
2895 "Query by unique constraint should always return correct ID"
2896 );
2897 }
2898
2899 #[test]
2900 fn test_download_album_returns_correct_ids() {
2901 let db = setup_test_db();
2902 let conn = db.connection();
2903 let conn = conn.lock_safe();
2904
2905 let track_ids = vec!["item1", "item2", "item3"];
2907 let mut download_ids = Vec::new();
2908
2909 for track_id in &track_ids {
2910 conn.execute(
2911 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2912 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
2913 ON CONFLICT(item_id, user_id) DO UPDATE SET
2914 priority = 100,
2915 status = 'pending'",
2916 params![track_id, "user1", format!("/path/{}.mp3", track_id)],
2917 )
2918 .unwrap();
2919
2920 let download_id: i64 = conn
2922 .query_row(
2923 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2924 params![track_id, "user1"],
2925 |row| row.get(0),
2926 )
2927 .unwrap();
2928
2929 download_ids.push(download_id);
2930 }
2931
2932 assert_eq!(download_ids.len(), 3);
2934 for id in &download_ids {
2935 assert!(*id > 0, "Download ID should be positive");
2936 }
2937
2938 let mut sorted_ids = download_ids.clone();
2940 sorted_ids.sort();
2941 sorted_ids.dedup();
2942 assert_eq!(sorted_ids.len(), 3, "All download IDs should be unique");
2943 }
2944
2945 #[test]
2946 fn test_download_album_upsert_returns_correct_ids() {
2947 let db = setup_test_db();
2948 let conn = db.connection();
2949 let conn = conn.lock_safe();
2950
2951 let track_ids = vec!["item1", "item2", "item3"];
2953 let mut first_ids = Vec::new();
2954
2955 for track_id in &track_ids {
2956 conn.execute(
2957 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2958 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)",
2959 params![track_id, "user1", format!("/path/{}.mp3", track_id)],
2960 )
2961 .unwrap();
2962
2963 let id: i64 = conn
2964 .query_row(
2965 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2966 params![track_id, "user1"],
2967 |row| row.get(0),
2968 )
2969 .unwrap();
2970 first_ids.push(id);
2971 }
2972
2973 conn.execute("UPDATE downloads SET status = 'failed'", [])
2975 .unwrap();
2976
2977 let mut second_ids = Vec::new();
2979
2980 for track_id in &track_ids {
2981 conn.execute(
2982 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2983 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
2984 ON CONFLICT(item_id, user_id) DO UPDATE SET
2985 priority = 100,
2986 status = 'pending'",
2987 params![track_id, "user1", format!("/path/{}.mp3", track_id)],
2988 )
2989 .unwrap();
2990
2991 let id: i64 = conn
2992 .query_row(
2993 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2994 params![track_id, "user1"],
2995 |row| row.get(0),
2996 )
2997 .unwrap();
2998 second_ids.push(id);
2999 }
3000
3001 assert_eq!(first_ids, second_ids, "UPSERT should preserve original IDs");
3003 }
3004
3005 #[test]
3006 fn test_get_downloads_returns_correct_metadata() {
3007 let db = setup_test_db();
3008 let conn = db.connection();
3009 let conn = conn.lock_safe();
3010
3011 conn.execute(
3013 "INSERT INTO downloads (item_id, user_id, file_path, status, progress, priority)
3014 VALUES (?1, ?2, ?3, 'downloading', 0.5, 10)",
3015 params!["item1", "user1", "/path/to/song.mp3"],
3016 )
3017 .unwrap();
3018
3019 let download: DownloadInfo = conn
3021 .query_row(
3022 "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
3023 d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
3024 d.retry_count, d.priority,
3025 COALESCE(d.item_name, i.name) as item_name,
3026 COALESCE(d.artist_name, i.artists) as artist_name,
3027 COALESCE(d.album_name, i.album_name) as album_name,
3028 COALESCE(d.series_name, i.series_name) as series_name,
3029 COALESCE(d.season_name, i.season_name) as season_name,
3030 COALESCE(d.episode_number, i.index_number) as episode_number,
3031 COALESCE(d.season_number, i.parent_index_number) as season_number,
3032 d.quality_preset,
3033 COALESCE(d.media_type, 'audio') as media_type,
3034 COALESCE(d.download_source, 'user') as download_source
3035 FROM downloads d
3036 LEFT JOIN items i ON d.item_id = i.id
3037 WHERE d.user_id = ?1",
3038 params!["user1"],
3039 map_download_row,
3040 )
3041 .unwrap();
3042
3043 assert_eq!(download.item_id, "item1");
3044 assert_eq!(download.status, "downloading");
3045 assert!((download.progress - 0.5).abs() < 0.001);
3046 assert_eq!(download.priority, 10);
3047 assert_eq!(download.item_name, Some("Test Song 1".to_string()));
3048 }
3049
3050 #[test]
3051 fn test_download_status_transitions() {
3052 let db = setup_test_db();
3053 let conn = db.connection();
3054 let conn = conn.lock_safe();
3055
3056 conn.execute(
3058 "INSERT INTO downloads (item_id, user_id, file_path, status)
3059 VALUES (?1, ?2, ?3, 'pending')",
3060 params!["item1", "user1", "/path/to/song.mp3"],
3061 )
3062 .unwrap();
3063
3064 let id: i64 = conn.last_insert_rowid();
3065
3066 conn.execute(
3068 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?1",
3069 params![id],
3070 )
3071 .unwrap();
3072
3073 let status: String = conn
3074 .query_row(
3075 "SELECT status FROM downloads WHERE id = ?1",
3076 params![id],
3077 |row| row.get(0),
3078 )
3079 .unwrap();
3080 assert_eq!(status, "downloading");
3081
3082 conn.execute(
3084 "UPDATE downloads SET status = 'completed', progress = 1.0, completed_at = CURRENT_TIMESTAMP WHERE id = ?1",
3085 params![id],
3086 )
3087 .unwrap();
3088
3089 let (status, progress): (String, f64) = conn
3090 .query_row(
3091 "SELECT status, progress FROM downloads WHERE id = ?1",
3092 params![id],
3093 |row| Ok((row.get(0)?, row.get(1)?)),
3094 )
3095 .unwrap();
3096 assert_eq!(status, "completed");
3097 assert!((progress - 1.0).abs() < 0.001);
3098 }
3099
3100 #[test]
3101 fn test_download_progress_updates() {
3102 let db = setup_test_db();
3103 let conn = db.connection();
3104 let conn = conn.lock_safe();
3105
3106 conn.execute(
3107 "INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)
3108 VALUES (?1, ?2, ?3, 'downloading', 0.0, 0, 10000000)",
3109 params!["item1", "user1", "/path/to/song.mp3"],
3110 )
3111 .unwrap();
3112
3113 let id: i64 = conn.last_insert_rowid();
3114
3115 for i in 1..=10 {
3117 let progress = i as f64 / 10.0;
3118 let bytes = i * 1000000;
3119
3120 conn.execute(
3121 "UPDATE downloads SET progress = ?1, bytes_downloaded = ?2 WHERE id = ?3",
3122 params![progress, bytes, id],
3123 )
3124 .unwrap();
3125
3126 let (actual_progress, actual_bytes): (f64, i64) = conn
3127 .query_row(
3128 "SELECT progress, bytes_downloaded FROM downloads WHERE id = ?1",
3129 params![id],
3130 |row| Ok((row.get(0)?, row.get(1)?)),
3131 )
3132 .unwrap();
3133
3134 assert!((actual_progress - progress).abs() < 0.001);
3135 assert_eq!(actual_bytes, bytes);
3136 }
3137 }
3138
3139 #[test]
3140 fn test_compute_download_stats_empty() {
3141 let downloads = vec![];
3142 let stats = compute_download_stats(&downloads);
3143 assert_eq!(stats.total, 0);
3144 assert_eq!(stats.active_count, 0);
3145 assert_eq!(stats.queued_count, 0);
3146 assert_eq!(stats.completed_count, 0);
3147 assert_eq!(stats.failed_count, 0);
3148 assert_eq!(stats.paused_count, 0);
3149 }
3150
3151 #[test]
3152 fn test_compute_download_stats_mixed() {
3153 let downloads = vec![
3154 create_test_download(1, "downloading"),
3155 create_test_download(2, "pending"),
3156 create_test_download(3, "downloading"),
3157 create_test_download(4, "completed"),
3158 create_test_download(5, "failed"),
3159 create_test_download(6, "paused"),
3160 ];
3161 let stats = compute_download_stats(&downloads);
3162 assert_eq!(stats.total, 6);
3163 assert_eq!(stats.active_count, 2);
3164 assert_eq!(stats.queued_count, 1);
3165 assert_eq!(stats.completed_count, 1);
3166 assert_eq!(stats.failed_count, 1);
3167 assert_eq!(stats.paused_count, 1);
3168 }
3169
3170 #[test]
3171 fn test_compute_download_stats_all_same_status() {
3172 let downloads = vec![
3173 create_test_download(1, "completed"),
3174 create_test_download(2, "completed"),
3175 create_test_download(3, "completed"),
3176 ];
3177 let stats = compute_download_stats(&downloads);
3178 assert_eq!(stats.total, 3);
3179 assert_eq!(stats.active_count, 0);
3180 assert_eq!(stats.queued_count, 0);
3181 assert_eq!(stats.completed_count, 3);
3182 assert_eq!(stats.failed_count, 0);
3183 assert_eq!(stats.paused_count, 0);
3184 }
3185
3186 fn create_test_download(id: i64, status: &str) -> DownloadInfo {
3188 DownloadInfo {
3189 id,
3190 item_id: format!("item{}", id),
3191 user_id: "test_user".to_string(),
3192 file_path: format!("/tmp/download{}", id),
3193 file_size: Some(1000),
3194 mime_type: Some("audio/flac".to_string()),
3195 status: status.to_string(),
3196 progress: 0.0,
3197 bytes_downloaded: 0,
3198 queued_at: "2024-01-01T00:00:00Z".to_string(),
3199 started_at: None,
3200 completed_at: None,
3201 error_message: None,
3202 retry_count: 0,
3203 priority: 0,
3204 item_name: Some(format!("Track {}", id)),
3205 artist_name: None,
3206 album_name: None,
3207 series_name: None,
3208 season_name: None,
3209 episode_number: None,
3210 season_number: None,
3211 quality_preset: None,
3212 media_type: "audio".to_string(),
3213 download_source: "user".to_string(),
3214 }
3215 }
3216
3217 fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
3221 let conn = rusqlite::Connection::open_in_memory().unwrap();
3222 conn.execute_batch(
3223 r#"
3224 CREATE TABLE items (
3225 id TEXT PRIMARY KEY,
3226 server_id TEXT NOT NULL,
3227 parent_id TEXT,
3228 name TEXT NOT NULL,
3229 item_type TEXT NOT NULL,
3230 album_id TEXT,
3231 album_name TEXT,
3232 album_artist TEXT,
3233 artists TEXT,
3234 index_number INTEGER
3235 );
3236 CREATE TABLE downloads (
3237 id INTEGER PRIMARY KEY AUTOINCREMENT,
3238 item_id TEXT NOT NULL,
3239 user_id TEXT NOT NULL,
3240 file_path TEXT NOT NULL,
3241 status TEXT DEFAULT 'pending',
3242 priority INTEGER DEFAULT 0,
3243 progress REAL DEFAULT 0,
3244 queued_at TEXT,
3245 item_name TEXT,
3246 artist_name TEXT,
3247 album_name TEXT,
3248 media_type TEXT,
3249 stream_url TEXT,
3250 target_dir TEXT,
3251 UNIQUE(item_id, user_id)
3252 );
3253 INSERT INTO items (id, server_id, name, item_type)
3254 VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
3255 "#,
3256 )
3257 .unwrap();
3258 Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
3259 Mutex::new(conn),
3260 )))
3261 }
3262
3263 fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
3264 AlbumTrack {
3265 id: id.to_string(),
3266 name: name.to_string(),
3267 artist_name: Some("Woodkid".to_string()),
3268 album_name: Some("The Golden Age".to_string()),
3269 index_number: Some(index),
3270 }
3271 }
3272
3273 #[tokio::test]
3288 async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
3289 let db = album_test_db();
3290
3291 for sql in [
3294 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3295 VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
3296 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3297 VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
3298 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3299 VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
3300 ] {
3301 db.execute(Query::new(sql)).await.unwrap();
3302 }
3303
3304 let tracks = vec![
3305 album_track("t1", "Run Boy Run", 1),
3306 album_track("t2", "The Great Escape", 2),
3307 album_track("t3", "Boat Song", 3),
3308 ];
3309
3310 let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3311 .await
3312 .unwrap();
3313
3314 assert_eq!(
3315 ids.len(),
3316 3,
3317 "every track of the album must get a download row"
3318 );
3319
3320 let queued: i64 = db
3321 .query_one(
3322 Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
3323 |row| row.get(0),
3324 )
3325 .await
3326 .unwrap();
3327 assert_eq!(queued, 3);
3328
3329 let linked: i64 = db
3332 .query_one(
3333 Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
3334 |row| row.get(0),
3335 )
3336 .await
3337 .unwrap();
3338 assert_eq!(
3339 linked, 3,
3340 "queued tracks must be linked to their album; offline browsing joins on album_id"
3341 );
3342 }
3343
3344 #[tokio::test]
3353 async fn test_queue_album_tracks_returns_ids_in_track_order() {
3354 let db = album_test_db();
3355 let tracks = vec![
3356 album_track("t1", "Run Boy Run", 1),
3357 album_track("t2", "The Great Escape", 2),
3358 ];
3359
3360 let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3361 .await
3362 .unwrap();
3363
3364 for (id, track) in ids.iter().zip(tracks.iter()) {
3365 let item_id: String = db
3366 .query_one(
3367 Query::with_params(
3368 "SELECT item_id FROM downloads WHERE id = ?",
3369 vec![QueryParam::Int64(*id)],
3370 ),
3371 |row| row.get(0),
3372 )
3373 .await
3374 .unwrap();
3375 assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
3376 }
3377 }
3378
3379 #[tokio::test]
3384 async fn test_queue_album_tracks_is_idempotent() {
3385 let db = album_test_db();
3386 let tracks = vec![
3387 album_track("t1", "Run Boy Run", 1),
3388 album_track("t2", "The Great Escape", 2),
3389 ];
3390
3391 let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3392 .await
3393 .unwrap();
3394 let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3395 .await
3396 .unwrap();
3397
3398 assert_eq!(first, second, "the same tracks must map to the same rows");
3399
3400 let rows: i64 = db
3401 .query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
3402 row.get(0)
3403 })
3404 .await
3405 .unwrap();
3406 assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
3407 }
3408
3409 #[test]
3417 fn test_album_file_names_are_unique_within_the_album() {
3418 let tracks = vec![
3419 album_track("t1", "Crucified Again", 5),
3420 album_track("t2", "Crucified Again", 5),
3421 album_track("t3", "Get Right", 7),
3422 ];
3423
3424 let names = album_file_names(&tracks);
3425
3426 assert_eq!(names.len(), 3);
3427 let unique: std::collections::HashSet<_> = names.iter().collect();
3428 assert_eq!(
3429 unique.len(),
3430 3,
3431 "every track of an album needs its own file: {:?}",
3432 names
3433 );
3434 assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
3435 assert!(
3436 names[2].contains("Get Right"),
3437 "an unambiguous title keeps its name: {}",
3438 names[2]
3439 );
3440 }
3441
3442 #[test]
3446 fn test_album_file_names_sanitize_the_title() {
3447 let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
3448 assert!(!names[0].contains('/'), "{}", names[0]);
3449 assert!(!names[0].contains(':'), "{}", names[0]);
3450 }
3451
3452 #[tokio::test]
3460 async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
3461 let db = album_test_db();
3462 for sql in [
3463 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
3464 VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
3465 "INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
3468 VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
3469 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3471 VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
3472 ] {
3473 db.execute(Query::new(sql)).await.unwrap();
3474 }
3475
3476 let tracks = cached_album_tracks(&db, "album1").await.unwrap();
3477 let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
3478 assert_eq!(ids, vec!["t1", "t2"]);
3479 }
3480
3481 #[tokio::test]
3487 async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
3488 let db = album_test_db();
3489 let tracks = vec![album_track("never-cached", "Iron", 1)];
3490
3491 let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3492 .await
3493 .unwrap();
3494 assert_eq!(ids.len(), 1);
3495
3496 let (item_type, album_id): (String, Option<String>) = db
3497 .query_one(
3498 Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
3499 |row| Ok((row.get(0)?, row.get(1)?)),
3500 )
3501 .await
3502 .unwrap();
3503 assert_eq!(item_type, "Audio");
3504 assert_eq!(album_id.as_deref(), Some("album1"));
3505 }
3506}