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 mut sanitized = PathBuf::new();
189 for component in Path::new(file_path).components() {
190 match component {
191 Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
192 other => sanitized.push(other),
195 }
196 }
197
198 confine_to_root(root, &root.join(&sanitized))?;
199 Ok(sanitized.to_string_lossy().to_string())
200}
201
202#[derive(Debug, specta::Type, serde::Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct DownloadItemAndStartRequest {
207 pub item_id: String,
208 pub user_id: String,
209 pub stream_url: String,
210 pub target_dir: String,
211 pub item_name: Option<String>,
212 pub artist_name: Option<String>,
213 pub album_name: Option<String>,
214}
215
216#[derive(Debug, specta::Type, serde::Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct DownloadItemRequest {
220 pub item_id: String,
221 pub user_id: String,
222 pub file_path: String,
223 pub mime_type: Option<String>,
224 pub priority: Option<i32>,
225 pub item_name: Option<String>,
226 pub artist_name: Option<String>,
227 pub album_name: Option<String>,
228 pub expected_size: Option<i64>,
229}
230
231#[derive(Debug, specta::Type, serde::Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct DownloadVideoRequest {
235 pub item_id: String,
236 pub user_id: String,
237 pub file_path: String,
238 pub mime_type: Option<String>,
239 pub priority: Option<i32>,
240 pub item_name: Option<String>,
241 pub quality_preset: Option<String>,
242 pub series_name: Option<String>,
243 pub season_name: Option<String>,
244 pub episode_number: Option<i32>,
245 pub season_number: Option<i32>,
246}
247
248#[tauri::command]
251#[specta::specta]
252pub async fn download_item_and_start(
253 db: State<'_, DatabaseWrapper>,
254 smart_cache: State<'_, SmartCacheWrapper>,
255 download_manager: State<'_, DownloadManagerWrapper>,
256 app: tauri::AppHandle,
257 request: DownloadItemAndStartRequest,
258) -> Result<i64, String> {
259 let DownloadItemAndStartRequest {
260 item_id,
261 user_id,
262 stream_url,
263 target_dir,
264 item_name,
265 artist_name,
266 album_name,
267 } = request;
268 let safe_name = sanitize_filename(item_name.as_deref().unwrap_or(&item_id));
270 let file_path = format!("downloads/{}.mp3", safe_name);
271
272 let download_id = download_item(
274 db.clone(),
275 smart_cache.clone(),
276 DownloadItemRequest {
277 item_id,
278 user_id,
279 file_path,
280 mime_type: None,
281 priority: None,
282 item_name,
283 artist_name,
284 album_name,
285 expected_size: None,
286 },
287 )
288 .await?;
289
290 start_download(
292 db,
293 download_manager,
294 app,
295 download_id,
296 stream_url,
297 target_dir,
298 )
299 .await?;
300
301 Ok(download_id)
302}
303
304#[tauri::command]
306#[specta::specta]
307pub async fn download_item(
308 db: State<'_, DatabaseWrapper>,
309 smart_cache: State<'_, SmartCacheWrapper>,
310 request: DownloadItemRequest,
311) -> Result<i64, String> {
312 let DownloadItemRequest {
313 item_id,
314 user_id,
315 file_path,
316 mime_type,
317 priority,
318 item_name,
319 artist_name,
320 album_name,
321 expected_size,
322 } = request;
323
324 let file_path = {
331 let root = download_root(&db)?;
332 confine_queued_path(&root, &file_path)?
333 };
334
335 let db_service = {
336 let database = db.0.lock().map_err(|e| e.to_string())?;
337 Arc::new(database.service())
338 };
339
340 if let Some(size) = expected_size {
342 let cache_arc = {
344 let guard = smart_cache.0.lock().map_err(|e| e.to_string())?;
345 guard.clone()
346 };
347
348 let can_download = cache_arc
350 .can_download_async(&db_service, &user_id, size as u64)
351 .await;
352
353 if !can_download {
354 warn!("Storage limit reached. Attempting to free space...");
355
356 match cache_arc
361 .reclaim_expired_async(&db_service, &user_id, &chrono::Utc::now().to_rfc3339())
362 .await
363 {
364 Ok(n) if n > 0 => info!("Reclaimed {} expired cache entries", n),
365 Ok(_) => {}
366 Err(e) => warn!("Expired-entry reclaim failed: {}", e),
367 }
368
369 match cache_arc
371 .evict_lru_async(&db_service, &user_id, size as u64)
372 .await
373 {
374 Ok(freed) if freed > 0 => {
375 info!("Freed {} bytes, proceeding with download", freed);
376 }
377 Ok(_) => {
378 let storage_limit =
379 cache_arc.get_config().map(|c| c.storage_limit).unwrap_or(0);
380 return Err(format!(
381 "Storage limit reached ({} bytes). Unable to free enough space.",
382 storage_limit
383 ));
384 }
385 Err(e) => {
386 error!("Failed to evict items: {}", e);
387 return Err(format!("Storage limit reached. Eviction failed: {}", e));
388 }
389 }
390 }
391 }
392
393 let insert_query = Query::with_params(
395 "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at, item_name, artist_name, album_name)
396 VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, ?)
397 ON CONFLICT(item_id, user_id) DO UPDATE SET
398 priority = excluded.priority,
399 status = 'pending',
400 queued_at = CURRENT_TIMESTAMP,
401 item_name = COALESCE(excluded.item_name, downloads.item_name),
402 artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
403 album_name = COALESCE(excluded.album_name, downloads.album_name)",
404 vec![
405 QueryParam::String(item_id.clone()),
406 QueryParam::String(user_id.clone()),
407 QueryParam::String(file_path),
408 mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
409 QueryParam::Int(priority.unwrap_or(0)),
410 item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
411 artist_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
412 album_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
413 ],
414 );
415
416 db_service
417 .execute(insert_query)
418 .await
419 .map_err(|e| e.to_string())?;
420
421 let id_query = Query::with_params(
424 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
425 vec![QueryParam::String(item_id), QueryParam::String(user_id)],
426 );
427
428 let download_id: i64 = db_service
429 .query_one(id_query, |row| row.get(0))
430 .await
431 .map_err(|e| e.to_string())?;
432
433 Ok(download_id)
434}
435
436#[derive(Debug, Clone, PartialEq)]
444pub(crate) struct AlbumTrack {
445 pub id: String,
446 pub name: String,
447 pub artist_name: Option<String>,
448 pub album_name: Option<String>,
449 pub index_number: Option<i32>,
450}
451
452impl From<&crate::repository::types::MediaItem> for AlbumTrack {
453 fn from(item: &crate::repository::types::MediaItem) -> Self {
454 Self {
455 id: item.id.clone(),
456 name: item.name.clone(),
457 artist_name: item
458 .artists
459 .as_ref()
460 .and_then(|a| serde_json::to_string(a).ok()),
461 album_name: item.album_name.clone(),
462 index_number: item.index_number,
463 }
464 }
465}
466
467pub(crate) async fn cached_album_tracks(
475 db_service: &Arc<crate::storage::db_service::RusqliteService>,
476 album_id: &str,
477) -> Result<Vec<AlbumTrack>, String> {
478 let tracks_query = Query::with_params(
479 "SELECT id, name, artists, album_name, index_number FROM items
480 WHERE (album_id = ? OR parent_id = ?) AND item_type = 'Audio'
481 ORDER BY index_number",
482 vec![
483 QueryParam::String(album_id.to_string()),
484 QueryParam::String(album_id.to_string()),
485 ],
486 );
487
488 db_service
489 .query_many(tracks_query, |row| {
490 Ok(AlbumTrack {
491 id: row.get(0)?,
492 name: row.get(1)?,
493 artist_name: row.get(2)?,
494 album_name: row.get(3)?,
495 index_number: row.get(4)?,
496 })
497 })
498 .await
499 .map_err(|e| e.to_string())
500}
501
502pub(crate) fn album_file_names(tracks: &[AlbumTrack]) -> Vec<String> {
526 let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
527 for track in tracks {
528 *counts.entry(track.name.to_lowercase()).or_default() += 1;
529 }
530
531 tracks
532 .iter()
533 .map(|track| {
534 let title = sanitize_filename(&track.name);
535 if counts.get(&track.name.to_lowercase()).copied().unwrap_or(0) <= 1 {
536 return format!("{}.mp3", title);
537 }
538 match track.index_number {
539 Some(n) => format!("{:02} - {} [{}].mp3", n, title, track.id),
540 None => format!("{} [{}].mp3", title, track.id),
541 }
542 })
543 .collect()
544}
545
546pub(crate) async fn queue_album_tracks(
548 db_service: &Arc<crate::storage::db_service::RusqliteService>,
549 album_id: &str,
550 tracks: &[AlbumTrack],
551 user_id: &str,
552 base_path: &str,
553) -> Result<Vec<i64>, String> {
554 let mut download_ids = Vec::with_capacity(tracks.len());
555 let file_names = album_file_names(tracks);
556
557 for (track, file_name) in tracks.iter().zip(file_names) {
558 let cache_query = Query::with_params(
562 "INSERT OR IGNORE INTO items
563 (id, server_id, parent_id, name, item_type, album_id, album_name, artists, index_number)
564 SELECT ?, a.server_id, a.id, ?, 'Audio', a.id, ?, ?, ?
565 FROM items a WHERE a.id = ?",
566 vec![
567 QueryParam::String(track.id.clone()),
568 QueryParam::String(track.name.clone()),
569 track
570 .album_name
571 .clone()
572 .map(QueryParam::String)
573 .unwrap_or(QueryParam::Null),
574 track
575 .artist_name
576 .clone()
577 .map(QueryParam::String)
578 .unwrap_or(QueryParam::Null),
579 track
580 .index_number
581 .map(QueryParam::Int)
582 .unwrap_or(QueryParam::Null),
583 QueryParam::String(album_id.to_string()),
584 ],
585 );
586 db_service
587 .execute(cache_query)
588 .await
589 .map_err(|e| e.to_string())?;
590
591 let link_query = Query::with_params(
595 "UPDATE items
596 SET album_id = ?,
597 parent_id = COALESCE(parent_id, (SELECT id FROM items WHERE id = ?))
598 WHERE id = ?",
599 vec![
600 QueryParam::String(album_id.to_string()),
601 QueryParam::String(album_id.to_string()),
602 QueryParam::String(track.id.clone()),
603 ],
604 );
605 db_service
606 .execute(link_query)
607 .await
608 .map_err(|e| e.to_string())?;
609
610 let file_path = format!("{}/{}", base_path, file_name);
611
612 let insert_query = Query::with_params(
615 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at, item_name, artist_name, album_name, media_type)
616 VALUES (?, ?, ?, 'pending', 100, CURRENT_TIMESTAMP, ?, ?, ?, 'audio')
617 ON CONFLICT(item_id, user_id) DO UPDATE SET
618 priority = 100,
619 status = CASE WHEN downloads.status = 'completed' THEN 'completed' ELSE 'pending' END,
620 media_type = 'audio',
621 item_name = COALESCE(excluded.item_name, downloads.item_name),
622 artist_name = COALESCE(excluded.artist_name, downloads.artist_name),
623 album_name = COALESCE(excluded.album_name, downloads.album_name)",
624 vec![
625 QueryParam::String(track.id.clone()),
626 QueryParam::String(user_id.to_string()),
627 QueryParam::String(file_path),
628 QueryParam::String(track.name.clone()),
629 track
630 .artist_name
631 .clone()
632 .map(QueryParam::String)
633 .unwrap_or(QueryParam::Null),
634 track
635 .album_name
636 .clone()
637 .map(QueryParam::String)
638 .unwrap_or(QueryParam::Null),
639 ],
640 );
641
642 db_service
643 .execute(insert_query)
644 .await
645 .map_err(|e| e.to_string())?;
646
647 let id_query = Query::with_params(
649 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
650 vec![
651 QueryParam::String(track.id.clone()),
652 QueryParam::String(user_id.to_string()),
653 ],
654 );
655
656 let download_id: i64 = db_service
657 .query_one(id_query, |row| row.get(0))
658 .await
659 .map_err(|e| e.to_string())?;
660 download_ids.push(download_id);
661 }
662
663 Ok(download_ids)
664}
665
666#[tauri::command]
681#[specta::specta]
682#[allow(clippy::too_many_arguments)]
686pub async fn download_album(
687 db: State<'_, DatabaseWrapper>,
688 repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
689 download_manager: State<'_, DownloadManagerWrapper>,
690 app: tauri::AppHandle,
691 handle: String,
692 album_id: String,
693 user_id: String,
694 base_path: String,
695) -> Result<Vec<i64>, String> {
696 let db_service = {
697 let database = db.0.lock().map_err(|e| e.to_string())?;
698 Arc::new(database.service())
699 };
700
701 let repo = repository.0.get(&handle);
702
703 let tracks: Vec<AlbumTrack> = match &repo {
706 Some(repo) => match repo.get_album_tracks(&album_id).await {
707 Ok(items) if !items.is_empty() => items.iter().map(AlbumTrack::from).collect(),
708 Ok(_) => cached_album_tracks(&db_service, &album_id).await?,
709 Err(e) => {
710 warn!(
711 "[download_album] Could not list album {} from the repository ({:?}); \
712 falling back to the cached track list",
713 album_id, e
714 );
715 cached_album_tracks(&db_service, &album_id).await?
716 }
717 },
718 None => cached_album_tracks(&db_service, &album_id).await?,
719 };
720
721 if tracks.is_empty() {
722 warn!("[download_album] No tracks found for album {}", album_id);
723 return Ok(Vec::new());
724 }
725
726 let download_ids =
727 queue_album_tracks(&db_service, &album_id, &tracks, &user_id, &base_path).await?;
728
729 info!(
730 "[download_album] Queued {} track(s) for album {}",
731 download_ids.len(),
732 album_id
733 );
734
735 let Some(repo) = repo else {
739 return Ok(download_ids);
740 };
741
742 let target_dir = {
743 let database = db.0.lock().map_err(|e| e.to_string())?;
744 database
745 .path()
746 .parent()
747 .ok_or_else(|| "Database path has no parent directory".to_string())?
748 .to_string_lossy()
749 .to_string()
750 };
751
752 let repo_for_resolve = Arc::clone(&repo);
753 let outcome = crate::commands::catalog::resolve_pending_download_urls(
754 &db_service,
755 &target_dir,
756 Some(&download_ids),
757 move |item_id: String, _media_type: String, _quality: String| {
758 let repo = Arc::clone(&repo_for_resolve);
759 async move {
760 use crate::repository::MediaRepository;
761 match repo.get_audio_stream_url(&item_id).await {
762 Ok(url) => Some(url.into()),
763 Err(e) => {
764 warn!(
765 "[download_album] Failed to resolve stream URL for {}: {:?}",
766 item_id, e
767 );
768 None
769 }
770 }
771 }
772 },
773 )
774 .await?;
775
776 if outcome.failed > 0 {
777 warn!(
778 "[download_album] {} track(s) could not be resolved and stay queued for the next \
779 reconnect",
780 outcome.failed
781 );
782 }
783
784 let active_downloads = {
785 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
786 manager.get_active_downloads()
787 };
788 pump_download_queue(app, db_service, active_downloads).await;
789
790 Ok(download_ids)
791}
792
793#[tauri::command]
795#[specta::specta]
796pub async fn download_video(
797 db: State<'_, DatabaseWrapper>,
798 request: DownloadVideoRequest,
799) -> Result<i64, String> {
800 let DownloadVideoRequest {
801 item_id,
802 user_id,
803 file_path,
804 mime_type,
805 priority,
806 item_name,
807 quality_preset,
808 series_name,
809 season_name,
810 episode_number,
811 season_number,
812 } = request;
813 let db_service = {
814 let database = db.0.lock().map_err(|e| e.to_string())?;
815 Arc::new(database.service())
816 };
817
818 let quality = quality_preset.unwrap_or_else(|| "original".to_string());
819
820 let insert_query = Query::with_params(
822 "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at,
823 item_name, quality_preset, media_type, series_name, season_name,
824 episode_number, season_number)
825 VALUES (?, ?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
826 ON CONFLICT(item_id, user_id) DO UPDATE SET
827 priority = excluded.priority,
828 status = 'pending',
829 queued_at = CURRENT_TIMESTAMP,
830 quality_preset = excluded.quality_preset,
831 item_name = COALESCE(excluded.item_name, downloads.item_name),
832 series_name = COALESCE(excluded.series_name, downloads.series_name),
833 season_name = COALESCE(excluded.season_name, downloads.season_name),
834 episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
835 season_number = COALESCE(excluded.season_number, downloads.season_number)",
836 vec![
837 QueryParam::String(item_id.clone()),
838 QueryParam::String(user_id.clone()),
839 QueryParam::String(file_path),
840 mime_type.map(QueryParam::String).unwrap_or(QueryParam::Null),
841 QueryParam::Int(priority.unwrap_or(0)),
842 item_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
843 QueryParam::String(quality),
844 series_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
845 season_name.map(QueryParam::String).unwrap_or(QueryParam::Null),
846 episode_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
847 season_number.map(QueryParam::Int).unwrap_or(QueryParam::Null),
848 ],
849 );
850
851 db_service
852 .execute(insert_query)
853 .await
854 .map_err(|e| e.to_string())?;
855
856 let id_query = Query::with_params(
858 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
859 vec![QueryParam::String(item_id), QueryParam::String(user_id)],
860 );
861
862 let download_id: i64 = db_service
863 .query_one(id_query, |row| row.get(0))
864 .await
865 .map_err(|e| e.to_string())?;
866
867 Ok(download_id)
868}
869
870#[tauri::command]
872#[specta::specta]
873pub async fn download_series(
874 db: State<'_, DatabaseWrapper>,
875 series_id: String,
876 series_name: String,
877 user_id: String,
878 base_path: String,
879 quality_preset: Option<String>,
880) -> Result<Vec<i64>, String> {
881 let db_service = {
882 let database = db.0.lock().map_err(|e| e.to_string())?;
883 Arc::new(database.service())
884 };
885
886 let quality = quality_preset.unwrap_or_else(|| "original".to_string());
887
888 let episodes_query = Query::with_params(
890 "SELECT id, name, season_name, index_number, parent_index_number
891 FROM items
892 WHERE series_id = ? AND item_type = 'Episode'
893 ORDER BY parent_index_number, index_number",
894 vec![QueryParam::String(series_id)],
895 );
896
897 let episodes: Vec<EpisodeRow> = db_service
898 .query_many(episodes_query, |row| {
899 Ok((
900 row.get(0)?,
901 row.get(1)?,
902 row.get(2)?,
903 row.get(3)?,
904 row.get(4)?,
905 ))
906 })
907 .await
908 .map_err(|e| e.to_string())?;
909
910 let mut download_ids = Vec::new();
911
912 let total_episodes = episodes.len() as i32;
915 for (idx, (episode_id, episode_name, season_name, episode_number, season_number)) in
916 episodes.into_iter().enumerate()
917 {
918 let priority = 1000 - idx as i32; let season_num = season_number.unwrap_or(1);
922 let episode_num = episode_number.unwrap_or(1);
923 let file_name = format!(
924 "S{:02}E{:02}_{}.mp4",
925 season_num,
926 episode_num,
927 sanitize_filename(&episode_name)
928 );
929 let file_path = format!(
930 "{}/{}/{}",
931 base_path,
932 sanitize_filename(&series_name),
933 file_name
934 );
935
936 let insert_query = Query::with_params(
937 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
938 item_name, quality_preset, media_type, series_name, season_name,
939 episode_number, season_number)
940 VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
941 ON CONFLICT(item_id, user_id) DO UPDATE SET
942 priority = excluded.priority,
943 status = 'pending',
944 queued_at = CURRENT_TIMESTAMP,
945 quality_preset = excluded.quality_preset,
946 item_name = COALESCE(excluded.item_name, downloads.item_name),
947 series_name = COALESCE(excluded.series_name, downloads.series_name),
948 season_name = COALESCE(excluded.season_name, downloads.season_name),
949 episode_number = COALESCE(excluded.episode_number, downloads.episode_number),
950 season_number = COALESCE(excluded.season_number, downloads.season_number)",
951 vec![
952 QueryParam::String(episode_id.clone()),
953 QueryParam::String(user_id.clone()),
954 QueryParam::String(file_path),
955 QueryParam::Int(priority),
956 QueryParam::String(episode_name),
957 QueryParam::String(quality.clone()),
958 QueryParam::String(series_name.clone()),
959 season_name
960 .map(QueryParam::String)
961 .unwrap_or(QueryParam::Null),
962 episode_number
963 .map(QueryParam::Int)
964 .unwrap_or(QueryParam::Null),
965 season_number
966 .map(QueryParam::Int)
967 .unwrap_or(QueryParam::Null),
968 ],
969 );
970
971 db_service
972 .execute(insert_query)
973 .await
974 .map_err(|e| e.to_string())?;
975
976 let id_query = Query::with_params(
977 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
978 vec![
979 QueryParam::String(episode_id),
980 QueryParam::String(user_id.clone()),
981 ],
982 );
983
984 let download_id: i64 = db_service
985 .query_one(id_query, |row| row.get(0))
986 .await
987 .map_err(|e| e.to_string())?;
988
989 download_ids.push(download_id);
990 }
991
992 info!(
993 "[download_series] Queued {} episodes for series '{}'",
994 total_episodes, series_name
995 );
996 Ok(download_ids)
997}
998
999#[tauri::command]
1001#[specta::specta]
1002#[allow(clippy::too_many_arguments)]
1006pub async fn download_season(
1007 db: State<'_, DatabaseWrapper>,
1008 season_id: String,
1009 series_name: String,
1010 season_name: String,
1011 season_number: i32,
1012 user_id: String,
1013 base_path: String,
1014 quality_preset: Option<String>,
1015) -> Result<Vec<i64>, String> {
1016 let db_service = {
1017 let database = db.0.lock().map_err(|e| e.to_string())?;
1018 Arc::new(database.service())
1019 };
1020
1021 let quality = quality_preset.unwrap_or_else(|| "original".to_string());
1022
1023 let episodes_query = Query::with_params(
1025 "SELECT id, name, index_number
1026 FROM items
1027 WHERE parent_id = ? AND item_type = 'Episode'
1028 ORDER BY index_number",
1029 vec![QueryParam::String(season_id)],
1030 );
1031
1032 let episodes: Vec<(String, String, Option<i32>)> = db_service
1033 .query_many(episodes_query, |row| {
1034 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1035 })
1036 .await
1037 .map_err(|e| e.to_string())?;
1038
1039 let mut download_ids = Vec::new();
1040
1041 for (idx, (episode_id, episode_name, episode_number)) in episodes.into_iter().enumerate() {
1043 let priority = 1000 - idx as i32;
1044 let episode_num = episode_number.unwrap_or(1);
1045
1046 let file_name = format!(
1047 "S{:02}E{:02}_{}.mp4",
1048 season_number,
1049 episode_num,
1050 sanitize_filename(&episode_name)
1051 );
1052 let file_path = format!(
1053 "{}/{}/{}",
1054 base_path,
1055 sanitize_filename(&series_name),
1056 file_name
1057 );
1058
1059 let insert_query = Query::with_params(
1060 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at,
1061 item_name, quality_preset, media_type, series_name, season_name,
1062 episode_number, season_number)
1063 VALUES (?, ?, ?, 'pending', ?, CURRENT_TIMESTAMP, ?, ?, 'video', ?, ?, ?, ?)
1064 ON CONFLICT(item_id, user_id) DO UPDATE SET
1065 priority = excluded.priority,
1066 status = 'pending',
1067 queued_at = CURRENT_TIMESTAMP,
1068 quality_preset = excluded.quality_preset",
1069 vec![
1070 QueryParam::String(episode_id.clone()),
1071 QueryParam::String(user_id.clone()),
1072 QueryParam::String(file_path),
1073 QueryParam::Int(priority),
1074 QueryParam::String(episode_name),
1075 QueryParam::String(quality.clone()),
1076 QueryParam::String(series_name.clone()),
1077 QueryParam::String(season_name.clone()),
1078 QueryParam::Int(episode_num),
1079 QueryParam::Int(season_number),
1080 ],
1081 );
1082
1083 db_service
1084 .execute(insert_query)
1085 .await
1086 .map_err(|e| e.to_string())?;
1087
1088 let id_query = Query::with_params(
1089 "SELECT id FROM downloads WHERE item_id = ? AND user_id = ?",
1090 vec![
1091 QueryParam::String(episode_id),
1092 QueryParam::String(user_id.clone()),
1093 ],
1094 );
1095
1096 let download_id: i64 = db_service
1097 .query_one(id_query, |row| row.get(0))
1098 .await
1099 .map_err(|e| e.to_string())?;
1100
1101 download_ids.push(download_id);
1102 }
1103
1104 info!(
1105 "[download_season] Queued {} episodes for {} - {}",
1106 download_ids.len(),
1107 series_name,
1108 season_name
1109 );
1110 Ok(download_ids)
1111}
1112
1113#[allow(dead_code)]
1115fn compute_download_stats(downloads: &[DownloadInfo]) -> DownloadStats {
1116 let mut stats = DownloadStats {
1117 total: downloads.len(),
1118 active_count: 0,
1119 queued_count: 0,
1120 completed_count: 0,
1121 failed_count: 0,
1122 paused_count: 0,
1123 };
1124
1125 for download in downloads {
1126 match download.status.as_str() {
1127 "downloading" => stats.active_count += 1,
1128 "pending" => stats.queued_count += 1,
1129 "completed" => stats.completed_count += 1,
1130 "failed" => stats.failed_count += 1,
1131 "paused" => stats.paused_count += 1,
1132 _ => {}
1133 }
1134 }
1135
1136 stats
1137}
1138
1139#[tauri::command]
1141#[specta::specta]
1142pub async fn get_downloads(
1143 db: State<'_, DatabaseWrapper>,
1144 user_id: String,
1145 status_filter: Option<Vec<String>>,
1146) -> Result<DownloadsResponse, String> {
1147 let db_service = {
1148 let database = db.0.lock().map_err(|e| e.to_string())?;
1149 Arc::new(database.service())
1150 };
1151
1152 let (query_str, params) = if let Some(ref statuses) = status_filter {
1155 let placeholders = statuses.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1156 let sql = format!(
1157 "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
1158 d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
1159 d.retry_count, d.priority,
1160 COALESCE(d.item_name, i.name) as item_name,
1161 COALESCE(d.artist_name, i.artists) as artist_name,
1162 COALESCE(d.album_name, i.album_name) as album_name,
1163 COALESCE(d.series_name, i.series_name) as series_name,
1164 COALESCE(d.season_name, i.season_name) as season_name,
1165 COALESCE(d.episode_number, i.index_number) as episode_number,
1166 COALESCE(d.season_number, i.parent_index_number) as season_number,
1167 d.quality_preset,
1168 COALESCE(d.media_type, 'audio') as media_type,
1169 COALESCE(d.download_source, 'user') as download_source
1170 FROM downloads d
1171 LEFT JOIN items i ON d.item_id = i.id
1172 WHERE d.user_id = ? AND d.status IN ({})
1173 ORDER BY d.priority DESC, d.queued_at ASC",
1174 placeholders
1175 );
1176 let mut p = vec![QueryParam::String(user_id.clone())];
1177 p.extend(statuses.iter().map(|s| QueryParam::String(s.clone())));
1178 (sql, p)
1179 } else {
1180 let sql = "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
1181 d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
1182 d.retry_count, d.priority,
1183 COALESCE(d.item_name, i.name) as item_name,
1184 COALESCE(d.artist_name, i.artists) as artist_name,
1185 COALESCE(d.album_name, i.album_name) as album_name,
1186 COALESCE(d.series_name, i.series_name) as series_name,
1187 COALESCE(d.season_name, i.season_name) as season_name,
1188 COALESCE(d.episode_number, i.index_number) as episode_number,
1189 COALESCE(d.season_number, i.parent_index_number) as season_number,
1190 d.quality_preset,
1191 COALESCE(d.media_type, 'audio') as media_type,
1192 COALESCE(d.download_source, 'user') as download_source
1193 FROM downloads d
1194 LEFT JOIN items i ON d.item_id = i.id
1195 WHERE d.user_id = ?
1196 ORDER BY d.priority DESC, d.queued_at ASC"
1197 .to_string();
1198 (sql, vec![QueryParam::String(user_id)])
1199 };
1200
1201 let query = Query::with_params(query_str, params);
1202
1203 let downloads = db_service
1204 .query_many(query, map_download_row)
1205 .await
1206 .map_err(|e| e.to_string())?;
1207
1208 let stats = compute_download_stats(&downloads);
1210
1211 Ok(DownloadsResponse { downloads, stats })
1212}
1213
1214#[tauri::command]
1228#[specta::specta]
1229pub async fn pause_download(
1230 db: State<'_, DatabaseWrapper>,
1231 download_id: i64,
1232) -> Result<(), String> {
1233 let db_service = {
1234 let database = db.0.lock().map_err(|e| e.to_string())?;
1235 Arc::new(database.service())
1236 };
1237
1238 let query = Query::with_params(
1239 "UPDATE downloads SET status = 'paused' WHERE id = ? AND status IN ('downloading', 'pending')",
1240 vec![QueryParam::Int64(download_id)],
1241 );
1242
1243 db_service.execute(query).await.map_err(|e| e.to_string())?;
1244
1245 let was_running = crate::download::stop::signal(download_id);
1246 info!(
1247 "[pause] Download {} paused (in flight: {})",
1248 download_id, was_running
1249 );
1250 Ok(())
1251}
1252
1253#[tauri::command]
1262#[specta::specta]
1263pub async fn resume_download(
1264 app: tauri::AppHandle,
1265 db: State<'_, DatabaseWrapper>,
1266 download_manager: State<'_, DownloadManagerWrapper>,
1267 download_id: i64,
1268) -> Result<(), String> {
1269 let db_service = {
1270 let database = db.0.lock().map_err(|e| e.to_string())?;
1271 Arc::new(database.service())
1272 };
1273
1274 let query = Query::with_params(
1275 "UPDATE downloads SET status = 'pending', error_message = NULL WHERE id = ? AND status IN ('paused', 'failed')",
1276 vec![QueryParam::Int64(download_id)],
1277 );
1278
1279 db_service.execute(query).await.map_err(|e| e.to_string())?;
1280
1281 crate::download::stop::clear(download_id);
1284
1285 let active_downloads = {
1286 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1287 manager.get_active_downloads()
1288 };
1289 pump_download_queue(app, db_service, active_downloads).await;
1290
1291 Ok(())
1292}
1293
1294#[tauri::command]
1296#[specta::specta]
1297pub async fn cancel_download(
1298 db: State<'_, DatabaseWrapper>,
1299 download_manager: State<'_, DownloadManagerWrapper>,
1300 download_id: i64,
1301) -> Result<(), String> {
1302 let db_service = {
1303 let database = db.0.lock().map_err(|e| e.to_string())?;
1304 Arc::new(database.service())
1305 };
1306
1307 let file_query = Query::with_params(
1309 "SELECT file_path FROM downloads WHERE id = ?",
1310 vec![QueryParam::Int64(download_id)],
1311 );
1312
1313 let file_path: Option<String> = db_service
1314 .query_optional(file_query, |row| row.get(0))
1315 .await
1316 .ok()
1317 .flatten();
1318
1319 let delete_query = Query::with_params(
1321 "DELETE FROM downloads WHERE id = ?",
1322 vec![QueryParam::Int64(download_id)],
1323 );
1324
1325 db_service
1326 .execute(delete_query)
1327 .await
1328 .map_err(|e| e.to_string())?;
1329
1330 crate::download::stop::signal(download_id);
1335 crate::download::stop::clear(download_id);
1336
1337 {
1339 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1340 manager.unregister_download(download_id);
1341 info!(
1342 "Cancelled download {}. Active downloads: {}",
1343 download_id,
1344 manager.active_count()
1345 );
1346 }
1347
1348 if let Some(path) = file_path {
1352 let target = std::path::PathBuf::from(&path);
1353 let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
1354 }
1355
1356 Ok(())
1357}
1358
1359#[tauri::command]
1361#[specta::specta]
1362pub async fn mark_download_completed(
1363 db: State<'_, DatabaseWrapper>,
1364 download_id: i64,
1365 bytes_downloaded: i64,
1366 file_path: String,
1367) -> Result<(), String> {
1368 let file_path = {
1376 let root = download_root(&db)?;
1377 confine_to_root(&root, &root.join(&file_path))?
1378 .to_string_lossy()
1379 .to_string()
1380 };
1381
1382 let db_service = {
1383 let database = db.0.lock().map_err(|e| e.to_string())?;
1384 Arc::new(database.service())
1385 };
1386
1387 let query = Query::with_params(
1388 "UPDATE downloads SET status = 'completed', progress = 1.0, bytes_downloaded = ?,
1389 file_size = ?, file_path = ?, completed_at = CURRENT_TIMESTAMP WHERE id = ?",
1390 vec![
1391 QueryParam::Int64(bytes_downloaded),
1392 QueryParam::Int64(bytes_downloaded),
1393 QueryParam::String(file_path),
1394 QueryParam::Int64(download_id),
1395 ],
1396 );
1397
1398 db_service.execute(query).await.map_err(|e| e.to_string())?;
1399 Ok(())
1400}
1401
1402#[tauri::command]
1404#[specta::specta]
1405pub async fn mark_download_failed(
1406 db: State<'_, DatabaseWrapper>,
1407 download_id: i64,
1408 error_message: String,
1409) -> Result<(), String> {
1410 let db_service = {
1411 let database = db.0.lock().map_err(|e| e.to_string())?;
1412 Arc::new(database.service())
1413 };
1414
1415 let query = Query::with_params(
1416 "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
1417 vec![
1418 QueryParam::String(error_message),
1419 QueryParam::Int64(download_id),
1420 ],
1421 );
1422
1423 db_service.execute(query).await.map_err(|e| e.to_string())?;
1424 Ok(())
1425}
1426
1427#[tauri::command]
1430#[specta::specta]
1431pub async fn start_download(
1432 db: State<'_, DatabaseWrapper>,
1433 download_manager: State<'_, DownloadManagerWrapper>,
1434 app: tauri::AppHandle,
1435 download_id: i64,
1436 stream_url: String,
1437 target_dir: String,
1438) -> Result<(), String> {
1439 use crate::download::events::DownloadEvent;
1440 use tauri::Emitter;
1441
1442 debug!("start_download called for download_id: {}", download_id);
1443 debug!(" stream_url: {}", stream_url);
1444 debug!(" target_dir: {}", target_dir);
1445
1446 {
1448 let manager = download_manager.0.lock().map_err(|e| {
1449 error!("Failed to lock download manager: {}", e);
1450 format!("Failed to lock download manager: {}", e)
1451 })?;
1452
1453 if !manager.can_start_download() {
1454 warn!(
1455 "Cannot start download: maximum concurrent downloads ({}) reached",
1456 manager.max_concurrent()
1457 );
1458 debug!(" Active downloads: {}", manager.active_count());
1459 return Err(format!(
1460 "Maximum concurrent downloads ({}) reached. Please wait for existing downloads to complete.",
1461 manager.max_concurrent()
1462 ));
1463 }
1464
1465 let registered = manager.register_download(download_id);
1467 if !registered {
1468 warn!(
1469 "Failed to register download {}: already registered or limit reached",
1470 download_id
1471 );
1472 return Err("Download already in progress or limit reached".to_string());
1473 }
1474
1475 info!(
1476 "Download {} registered. Active downloads: {}/{}",
1477 download_id,
1478 manager.active_count(),
1479 manager.max_concurrent()
1480 );
1481 }
1482
1483 let db_service = {
1485 let database = db.0.lock().map_err(|e| {
1486 error!("Failed to lock database: {}", e);
1487 e.to_string()
1488 })?;
1489 Arc::new(database.service())
1490 };
1491
1492 let info_query = Query::with_params(
1493 "SELECT item_id, file_path, file_size FROM downloads WHERE id = ?",
1494 vec![QueryParam::Int64(download_id)],
1495 );
1496
1497 let (item_id, file_path, file_size): (String, String, Option<i64>) = db_service
1498 .query_one(info_query, |row| {
1499 Ok((row.get(0)?, row.get(1)?, row.get(2)?))
1500 })
1501 .await
1502 .map_err(|e| {
1503 error!("Failed to query download info: {}", e);
1504 e.to_string()
1505 })?;
1506
1507 debug!(
1508 " Retrieved: item_id={}, file_path={}, file_size={:?}",
1509 item_id, file_path, file_size
1510 );
1511
1512 let target_path = {
1516 let root = download_root(&db)?;
1517 confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))?
1518 };
1519
1520 debug!("Making HEAD request to get file size...");
1522 let head_response = reqwest::Client::new().head(&stream_url).send().await;
1523
1524 let file_size_from_server = match head_response {
1525 Ok(response) => {
1526 let size = response
1527 .headers()
1528 .get(reqwest::header::CONTENT_LENGTH)
1529 .and_then(|v| v.to_str().ok())
1530 .and_then(|v| v.parse::<i64>().ok());
1531
1532 if let Some(size) = size {
1533 debug!(
1534 " Got file size from server: {} bytes ({} MB)",
1535 size,
1536 size / 1024 / 1024
1537 );
1538 } else {
1539 warn!(" Server didn't provide Content-Length header");
1540 }
1541 size
1542 }
1543 Err(e) => {
1544 warn!(" HEAD request failed: {}, continuing anyway...", e);
1545 None
1546 }
1547 };
1548
1549 let update_query = if let Some(size) = file_size_from_server {
1553 Query::with_params(
1554 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, file_size = ?, stream_url = ?, target_dir = ? WHERE id = ?",
1555 vec![
1556 QueryParam::Int64(size),
1557 QueryParam::String(stream_url.clone()),
1558 QueryParam::String(target_dir.clone()),
1559 QueryParam::Int64(download_id),
1560 ],
1561 )
1562 } else {
1563 Query::with_params(
1564 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP, stream_url = ?, target_dir = ? WHERE id = ?",
1565 vec![
1566 QueryParam::String(stream_url.clone()),
1567 QueryParam::String(target_dir.clone()),
1568 QueryParam::Int64(download_id),
1569 ],
1570 )
1571 };
1572
1573 db_service
1574 .execute(update_query)
1575 .await
1576 .map_err(|e| e.to_string())?;
1577
1578 let started_event = DownloadEvent::Started {
1580 download_id,
1581 item_id: item_id.clone(),
1582 };
1583 debug!("Emitting download-event: {:?}", started_event);
1584 debug!(
1585 " Serialized: {}",
1586 serde_json::to_string(&started_event).unwrap_or_default()
1587 );
1588 match app.emit("download-event", started_event) {
1589 Ok(_) => debug!(" Event emitted successfully"),
1590 Err(e) => error!(" Event emit failed: {:?}", e),
1591 }
1592
1593 let active_downloads = {
1595 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1596 manager.get_active_downloads()
1597 };
1598
1599 spawn_download_worker(
1602 app.clone(),
1603 download_id,
1604 item_id,
1605 stream_url,
1606 target_path,
1607 file_size_from_server
1608 .or(file_size)
1609 .and_then(|n| u64::try_from(n).ok()),
1610 active_downloads,
1611 );
1612
1613 Ok(())
1614}
1615
1616#[tauri::command]
1624#[specta::specta]
1625pub async fn enqueue_download(
1626 db: State<'_, DatabaseWrapper>,
1627 download_manager: State<'_, DownloadManagerWrapper>,
1628 app: tauri::AppHandle,
1629 download_id: i64,
1630 stream_url: String,
1631 target_dir: String,
1632) -> Result<(), String> {
1633 let db_service = {
1634 let database = db.0.lock().map_err(|e| e.to_string())?;
1635 Arc::new(database.service())
1636 };
1637
1638 let update_query = Query::with_params(
1641 "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
1642 vec![
1643 QueryParam::String(stream_url),
1644 QueryParam::String(target_dir),
1645 QueryParam::Int64(download_id),
1646 ],
1647 );
1648 db_service
1649 .execute(update_query)
1650 .await
1651 .map_err(|e| e.to_string())?;
1652
1653 let active_downloads = {
1655 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1656 manager.get_active_downloads()
1657 };
1658 pump_download_queue(app, db_service, active_downloads).await;
1659
1660 Ok(())
1661}
1662
1663#[tauri::command]
1672#[specta::specta]
1673pub async fn enqueue_video_downloads(
1674 db: State<'_, DatabaseWrapper>,
1675 download_manager: State<'_, DownloadManagerWrapper>,
1676 repository: State<'_, crate::commands::repository::RepositoryManagerWrapper>,
1677 app: tauri::AppHandle,
1678 handle: String,
1679 download_ids: Vec<i64>,
1680 target_dir: String,
1681) -> Result<(), String> {
1682 let repo = repository.0.get(&handle).ok_or("Repository not found")?;
1683
1684 let db_service = {
1685 let database = db.0.lock().map_err(|e| e.to_string())?;
1686 Arc::new(database.service())
1687 };
1688
1689 for download_id in download_ids {
1690 let info_query = Query::with_params(
1692 "SELECT item_id, COALESCE(quality_preset, 'original') FROM downloads WHERE id = ?",
1693 vec![QueryParam::Int64(download_id)],
1694 );
1695 let (item_id, quality): (String, String) = match db_service
1696 .query_one(info_query, |row| Ok((row.get(0)?, row.get(1)?)))
1697 .await
1698 {
1699 Ok(row) => row,
1700 Err(e) => {
1701 warn!("[enqueue_video] Skipping download {}: {}", download_id, e);
1702 continue;
1703 }
1704 };
1705
1706 let resolved =
1710 crate::repository::resolve_video_download(repo.as_ref(), &item_id, &quality, None)
1711 .await;
1712
1713 let update_query = Query::with_params(
1717 "UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ?, \
1718 file_size = COALESCE(?, file_size) WHERE id = ?",
1719 vec![
1720 QueryParam::String(resolved.url),
1721 QueryParam::String(target_dir.clone()),
1722 expected_bytes_param(resolved.expected_bytes),
1723 QueryParam::Int64(download_id),
1724 ],
1725 );
1726 if let Err(e) = db_service.execute(update_query).await {
1727 warn!(
1728 "[enqueue_video] Failed to persist URL for download {}: {}",
1729 download_id, e
1730 );
1731 }
1732 }
1733
1734 let active_downloads = {
1736 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
1737 manager.get_active_downloads()
1738 };
1739 pump_download_queue(app, db_service, active_downloads).await;
1740
1741 Ok(())
1742}
1743
1744pub(crate) async fn downloads_allowed_on_current_network(app: &tauri::AppHandle) -> bool {
1754 let wifi_only = {
1755 let smart_cache = app.state::<SmartCacheWrapper>();
1756 let cache = match smart_cache.0.lock() {
1757 Ok(c) => c,
1758 Err(e) => {
1759 error!("[pump] Failed to lock smart cache: {}", e);
1760 return true;
1762 }
1763 };
1764 cache.get_config().map(|c| c.wifi_only).unwrap_or(false)
1765 };
1766
1767 if !wifi_only {
1768 return true;
1769 }
1770
1771 let network = app.state::<NetworkStateWrapper>();
1772 network.0.allows_download(true).await
1773}
1774
1775pub(crate) async fn pump_download_queue(
1782 app: tauri::AppHandle,
1783 db_service: Arc<crate::storage::db_service::RusqliteService>,
1784 active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
1785) {
1786 use crate::download::events::DownloadEvent;
1787 use tauri::Emitter;
1788
1789 if !downloads_allowed_on_current_network(&app).await {
1794 info!("[pump] Downloads paused: waiting for an unmetered network (WiFi-only enabled)");
1795 let _ = app.emit("download-event", DownloadEvent::WaitingForNetwork);
1796 return;
1797 }
1798
1799 let max_concurrent = {
1800 let manager = app.state::<DownloadManagerWrapper>();
1801 let manager = match manager.0.lock() {
1802 Ok(m) => m,
1803 Err(e) => {
1804 error!("[pump] Failed to lock download manager: {}", e);
1805 return;
1806 }
1807 };
1808 manager.max_concurrent()
1809 };
1810
1811 loop {
1812 let free_slots = {
1814 let active = match active_downloads.lock() {
1815 Ok(a) => a,
1816 Err(e) => {
1817 error!("[pump] Failed to lock active downloads: {}", e);
1818 return;
1819 }
1820 };
1821 max_concurrent.saturating_sub(active.len())
1822 };
1823 if free_slots == 0 {
1824 return;
1825 }
1826
1827 let next_query = Query::with_params(
1830 "SELECT id, item_id, file_path, stream_url, target_dir, file_size
1831 FROM downloads
1832 WHERE status = 'pending'
1833 AND stream_url IS NOT NULL
1834 AND target_dir IS NOT NULL
1835 ORDER BY priority DESC, queued_at ASC",
1836 vec![],
1837 );
1838
1839 let candidates: Vec<(i64, String, String, String, String, Option<i64>)> = match db_service
1840 .query_many(next_query, |row| {
1841 Ok((
1842 row.get(0)?,
1843 row.get(1)?,
1844 row.get(2)?,
1845 row.get(3)?,
1846 row.get(4)?,
1847 row.get(5)?,
1848 ))
1849 })
1850 .await
1851 {
1852 Ok(rows) => rows,
1853 Err(e) => {
1854 error!("[pump] Failed to query pending downloads: {}", e);
1855 return;
1856 }
1857 };
1858
1859 let next = candidates.into_iter().find(|(id, _, _, _, _, _)| {
1861 active_downloads
1862 .lock()
1863 .map(|active| !active.contains(id))
1864 .unwrap_or(false)
1865 });
1866
1867 let (download_id, item_id, file_path, stream_url, target_dir, file_size) = match next {
1868 Some(n) => n,
1869 None => return, };
1871
1872 let confined = {
1878 let db_state = app.state::<DatabaseWrapper>();
1879 download_root(&db_state).and_then(|root| {
1880 confine_to_root(&root, &PathBuf::from(&target_dir).join(&file_path))
1881 })
1882 };
1883 let target_path = match confined {
1884 Ok(path) => path,
1885 Err(e) => {
1886 error!("[pump] Refusing download {}: {}", download_id, e);
1887 let fail_query = Query::with_params(
1888 "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
1889 vec![QueryParam::String(e), QueryParam::Int64(download_id)],
1890 );
1891 if let Err(db_err) = db_service.execute(fail_query).await {
1892 error!(
1893 "[pump] Failed to mark download {} failed: {}",
1894 download_id, db_err
1895 );
1896 return;
1897 }
1898 continue;
1899 }
1900 };
1901
1902 {
1905 let manager = app.state::<DownloadManagerWrapper>();
1906 let manager = match manager.0.lock() {
1907 Ok(m) => m,
1908 Err(e) => {
1909 error!("[pump] Failed to lock download manager: {}", e);
1910 return;
1911 }
1912 };
1913 if !manager.register_download(download_id) {
1914 return;
1915 }
1916 info!(
1917 "[pump] Download {} started. Active downloads: {}/{}",
1918 download_id,
1919 manager.active_count(),
1920 manager.max_concurrent()
1921 );
1922 }
1923
1924 let update_query = Query::with_params(
1926 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?",
1927 vec![QueryParam::Int64(download_id)],
1928 );
1929 if let Err(e) = db_service.execute(update_query).await {
1930 error!(
1931 "[pump] Failed to mark download {} downloading: {}",
1932 download_id, e
1933 );
1934 if let Ok(mut a) = active_downloads.lock() {
1935 a.remove(&download_id);
1936 }
1937 continue;
1938 }
1939
1940 let _ = app.emit(
1942 "download-event",
1943 DownloadEvent::Started {
1944 download_id,
1945 item_id: item_id.clone(),
1946 },
1947 );
1948
1949 spawn_download_worker(
1950 app.clone(),
1951 download_id,
1952 item_id,
1953 stream_url,
1954 target_path,
1955 file_size.and_then(|n| u64::try_from(n).ok()),
1956 active_downloads.clone(),
1957 );
1958 }
1959}
1960
1961fn expected_bytes_param(expected: Option<u64>) -> QueryParam {
1963 match expected.and_then(|n| i64::try_from(n).ok()) {
1964 Some(n) => QueryParam::Int64(n),
1965 None => QueryParam::Null,
1966 }
1967}
1968
1969fn spawn_download_worker(
1973 app: tauri::AppHandle,
1974 download_id: i64,
1975 item_id: String,
1976 stream_url: String,
1977 target_path: std::path::PathBuf,
1978 expected_bytes: Option<u64>,
1979 active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
1980) {
1981 use crate::download::events::DownloadEvent;
1982 use crate::download::{DownloadTask, DownloadWorker};
1983 use tauri::Emitter;
1984
1985 let task = DownloadTask {
1986 url: stream_url,
1987 target_path: target_path.clone(),
1988 };
1989
1990 tauri::async_runtime::spawn(async move {
1991 debug!("Download task started for download_id: {}", download_id);
1992 let worker = DownloadWorker::new();
1993
1994 let progress_app = app.clone();
1996 let progress_item_id = item_id.clone();
1997 let on_progress = move |bytes_downloaded: u64, content_length: Option<u64>| {
1998 let total = crate::download::estimate::progress_total(content_length, expected_bytes);
2001 let progress = crate::download::estimate::progress_fraction(bytes_downloaded, total);
2002
2003 let event = DownloadEvent::Progress {
2004 download_id,
2005 item_id: progress_item_id.clone(),
2006 bytes_downloaded: bytes_downloaded as i64,
2007 total_bytes: total.map(|t| t.bytes as i64),
2008 progress,
2009 estimated: total.is_some_and(|t| t.estimated),
2010 };
2011 let _ = progress_app.emit("download-event", event);
2012 };
2013
2014 let stop_flag = crate::download::stop::register(download_id);
2017 let result = worker.download(&task, &stop_flag, on_progress).await;
2018 crate::download::stop::clear(download_id);
2019
2020 if let Ok(mut active) = active_downloads.lock() {
2022 active.remove(&download_id);
2023 debug!(
2024 " Unregistered download {}. Active downloads: {}",
2025 download_id,
2026 active.len()
2027 );
2028 }
2029
2030 let db_service = {
2039 let db = app.state::<DatabaseWrapper>();
2040 let database = match db.0.lock() {
2041 Ok(d) => d,
2042 Err(e) => {
2043 error!(
2044 "[pump] Failed to lock database after download {}: {}",
2045 download_id, e
2046 );
2047 return;
2048 }
2049 };
2050 Arc::new(database.service())
2051 };
2052
2053 match result {
2054 Ok(res) => {
2055 info!(
2056 "Download completed successfully: {} bytes",
2057 res.bytes_downloaded
2058 );
2059 let file_path = target_path.to_string_lossy().to_string();
2060
2061 let update = Query::with_params(
2062 "UPDATE downloads SET status = 'completed', progress = 1.0, \
2063 bytes_downloaded = ?, file_size = ?, file_path = ?, \
2064 completed_at = CURRENT_TIMESTAMP WHERE id = ?",
2065 vec![
2066 QueryParam::Int64(res.bytes_downloaded as i64),
2067 QueryParam::Int64(res.bytes_downloaded as i64),
2068 QueryParam::String(file_path.clone()),
2069 QueryParam::Int64(download_id),
2070 ],
2071 );
2072 if let Err(e) = db_service.execute(update).await {
2073 error!(
2074 "[pump] Failed to persist completed status for download {}: {}",
2075 download_id, e
2076 );
2077 }
2078
2079 let completed_event = DownloadEvent::Completed {
2080 download_id,
2081 item_id,
2082 file_path,
2083 bytes_downloaded: res.bytes_downloaded as i64,
2084 };
2085 match app.emit("download-event", completed_event) {
2086 Ok(_) => debug!(" Completed event emitted successfully"),
2087 Err(e) => error!(" Completed event emit failed: {:?}", e),
2088 }
2089 }
2090 Err(e) if e.is_stopped() => {
2096 info!(
2097 "[pump] Download {} stopped by request; partial file kept for resume",
2098 download_id
2099 );
2100 }
2101 Err(e) => {
2102 error!("Download failed: {:?}", e);
2103
2104 let update = Query::with_params(
2105 "UPDATE downloads SET status = 'failed', error_message = ? WHERE id = ?",
2106 vec![
2107 QueryParam::String(e.to_string()),
2108 QueryParam::Int64(download_id),
2109 ],
2110 );
2111 if let Err(db_err) = db_service.execute(update).await {
2112 error!(
2113 "[pump] Failed to persist failed status for download {}: {}",
2114 download_id, db_err
2115 );
2116 }
2117
2118 let failed_event = DownloadEvent::Failed {
2119 download_id,
2120 item_id,
2121 error: e.to_string(),
2122 };
2123 match app.emit("download-event", failed_event) {
2124 Ok(_) => debug!(" Failed event emitted successfully"),
2125 Err(e) => error!(" Failed event emit failed: {:?}", e),
2126 }
2127 }
2128 }
2129
2130 pump_download_queue(app.clone(), db_service, active_downloads).await;
2132 });
2133}
2134
2135#[tauri::command]
2137#[specta::specta]
2138pub async fn delete_download(
2139 db: State<'_, DatabaseWrapper>,
2140 download_id: i64,
2141) -> Result<(), String> {
2142 let db_service = {
2143 let database = db.0.lock().map_err(|e| e.to_string())?;
2144 Arc::new(database.service())
2145 };
2146
2147 let file_query = Query::with_params(
2149 "SELECT file_path FROM downloads WHERE id = ?",
2150 vec![QueryParam::Int64(download_id)],
2151 );
2152
2153 let file_path: Option<String> = db_service
2154 .query_optional(file_query, |row| row.get(0))
2155 .await
2156 .ok()
2157 .flatten();
2158
2159 let delete_query = Query::with_params(
2161 "DELETE FROM downloads WHERE id = ?",
2162 vec![QueryParam::Int64(download_id)],
2163 );
2164
2165 db_service
2166 .execute(delete_query)
2167 .await
2168 .map_err(|e| e.to_string())?;
2169
2170 if let Some(path) = file_path {
2172 let _ = std::fs::remove_file(&path); }
2174
2175 Ok(())
2176}
2177
2178fn map_download_row(row: &rusqlite::Row) -> rusqlite::Result<DownloadInfo> {
2180 Ok(DownloadInfo {
2181 id: row.get(0)?,
2182 item_id: row.get(1)?,
2183 user_id: row.get(2)?,
2184 file_path: row.get(3)?,
2185 file_size: row.get(4)?,
2186 mime_type: row.get(5)?,
2187 status: row.get(6)?,
2188 progress: row.get(7)?,
2189 bytes_downloaded: row.get(8)?,
2190 queued_at: row.get(9)?,
2191 started_at: row.get(10)?,
2192 completed_at: row.get(11)?,
2193 error_message: row.get(12)?,
2194 retry_count: row.get(13)?,
2195 priority: row.get(14)?,
2196 item_name: row.get(15)?,
2197 artist_name: row.get(16)?,
2198 album_name: row.get(17)?,
2199 series_name: row.get(18)?,
2201 season_name: row.get(19)?,
2202 episode_number: row.get(20)?,
2203 season_number: row.get(21)?,
2204 quality_preset: row.get(22)?,
2205 media_type: row
2206 .get::<_, Option<String>>(23)?
2207 .unwrap_or_else(|| "audio".to_string()),
2208 download_source: row
2209 .get::<_, Option<String>>(24)?
2210 .unwrap_or_else(|| "user".to_string()),
2211 })
2212}
2213
2214#[derive(specta::Type, Debug, Clone, serde::Serialize)]
2216pub struct StorageStats {
2217 pub total_bytes: i64,
2218 pub total_items: i64,
2219 pub albums: Vec<AlbumStorageInfo>,
2220}
2221
2222#[derive(specta::Type, Debug, Clone, serde::Serialize)]
2224pub struct AlbumStorageInfo {
2225 pub album_id: String,
2226 pub album_name: String,
2227 pub artist_name: Option<String>,
2228 pub bytes_used: i64,
2229 pub track_count: i64,
2230}
2231
2232#[tauri::command]
2234#[specta::specta]
2235pub async fn get_download_storage_stats(
2236 db: State<'_, DatabaseWrapper>,
2237 user_id: String,
2238) -> Result<StorageStats, String> {
2239 let db_service = {
2240 let database = db.0.lock().map_err(|e| e.to_string())?;
2241 Arc::new(database.service())
2242 };
2243
2244 let total_query = Query::with_params(
2246 "SELECT COALESCE(SUM(file_size), 0), COUNT(*)
2247 FROM downloads
2248 WHERE user_id = ? AND status = 'completed'",
2249 vec![QueryParam::String(user_id.clone())],
2250 );
2251
2252 let (total_bytes, total_items): (i64, i64) = db_service
2253 .query_one(total_query, |row| Ok((row.get(0)?, row.get(1)?)))
2254 .await
2255 .map_err(|e| e.to_string())?;
2256
2257 let albums_query = Query::with_params(
2259 "SELECT
2260 COALESCE(i.album_id, 'unknown') as album_id,
2261 COALESCE(i.album_name, 'Unknown Album') as album_name,
2262 i.artists as artist_name,
2263 COALESCE(SUM(d.file_size), 0) as bytes_used,
2264 COUNT(*) as track_count
2265 FROM downloads d
2266 LEFT JOIN items i ON d.item_id = i.id
2267 WHERE d.user_id = ? AND d.status = 'completed'
2268 GROUP BY COALESCE(i.album_id, 'unknown')
2269 ORDER BY bytes_used DESC",
2270 vec![QueryParam::String(user_id)],
2271 );
2272
2273 let albums: Vec<AlbumStorageInfo> = db_service
2274 .query_many(albums_query, |row| {
2275 Ok(AlbumStorageInfo {
2276 album_id: row.get(0)?,
2277 album_name: row.get(1)?,
2278 artist_name: row.get(2)?,
2279 bytes_used: row.get(3)?,
2280 track_count: row.get(4)?,
2281 })
2282 })
2283 .await
2284 .map_err(|e| e.to_string())?;
2285
2286 Ok(StorageStats {
2287 total_bytes,
2288 total_items,
2289 albums,
2290 })
2291}
2292
2293#[tauri::command]
2295#[specta::specta]
2296pub async fn delete_all_downloads(
2297 db: State<'_, DatabaseWrapper>,
2298 user_id: String,
2299) -> Result<i64, String> {
2300 let db_service = {
2301 let database = db.0.lock().map_err(|e| e.to_string())?;
2302 Arc::new(database.service())
2303 };
2304
2305 let file_query = Query::with_params(
2307 "SELECT file_path FROM downloads WHERE user_id = ? AND status = 'completed'",
2308 vec![QueryParam::String(user_id.clone())],
2309 );
2310
2311 let file_paths: Vec<String> = db_service
2312 .query_many(file_query, |row| row.get(0))
2313 .await
2314 .map_err(|e| e.to_string())?;
2315
2316 let delete_query = Query::with_params(
2318 "DELETE FROM downloads WHERE user_id = ?",
2319 vec![QueryParam::String(user_id)],
2320 );
2321
2322 let deleted_count = db_service
2323 .execute(delete_query)
2324 .await
2325 .map_err(|e| e.to_string())?;
2326
2327 for path in file_paths {
2329 let _ = std::fs::remove_file(&path); let _ = std::fs::remove_file(format!("{}.part", path)); }
2332
2333 Ok(deleted_count as i64)
2334}
2335
2336#[tauri::command]
2338#[specta::specta]
2339pub async fn clear_stale_downloads(
2340 db: State<'_, DatabaseWrapper>,
2341 user_id: String,
2342) -> Result<i64, String> {
2343 let db_service = {
2344 let database = db.0.lock().map_err(|e| e.to_string())?;
2345 Arc::new(database.service())
2346 };
2347
2348 let file_query = Query::with_params(
2353 "SELECT id, file_path FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
2354 vec![QueryParam::String(user_id.clone())],
2355 );
2356
2357 let stale: Vec<(i64, String)> = db_service
2358 .query_many(file_query, |row| Ok((row.get(0)?, row.get(1)?)))
2359 .await
2360 .map_err(|e| e.to_string())?;
2361
2362 for (id, _) in &stale {
2363 crate::download::stop::signal(*id);
2364 crate::download::stop::clear(*id);
2365 }
2366 let file_paths: Vec<String> = stale.into_iter().map(|(_, path)| path).collect();
2367
2368 let delete_query = Query::with_params(
2370 "DELETE FROM downloads WHERE user_id = ? AND status IN ('pending', 'paused', 'failed')",
2371 vec![QueryParam::String(user_id)],
2372 );
2373
2374 let deleted_count = db_service
2375 .execute(delete_query)
2376 .await
2377 .map_err(|e| e.to_string())?;
2378
2379 for path in file_paths {
2382 let target = std::path::PathBuf::from(&path);
2383 let _ = std::fs::remove_file(&target);
2384 let _ = std::fs::remove_file(crate::download::worker::partial_path(&target));
2385 }
2386
2387 Ok(deleted_count as i64)
2388}
2389
2390#[tauri::command]
2392#[specta::specta]
2393pub async fn delete_album_downloads(
2394 db: State<'_, DatabaseWrapper>,
2395 album_id: String,
2396 user_id: String,
2397) -> Result<i64, String> {
2398 let db_service = {
2399 let database = db.0.lock().map_err(|e| e.to_string())?;
2400 Arc::new(database.service())
2401 };
2402
2403 let file_query = Query::with_params(
2405 "SELECT d.file_path FROM downloads d
2406 JOIN items i ON d.item_id = i.id
2407 WHERE d.user_id = ? AND i.album_id = ? AND d.status = 'completed'",
2408 vec![
2409 QueryParam::String(user_id.clone()),
2410 QueryParam::String(album_id.clone()),
2411 ],
2412 );
2413
2414 let file_paths: Vec<String> = db_service
2415 .query_many(file_query, |row| row.get(0))
2416 .await
2417 .map_err(|e| e.to_string())?;
2418
2419 let delete_query = Query::with_params(
2421 "DELETE FROM downloads WHERE user_id = ? AND item_id IN (SELECT id FROM items WHERE album_id = ?)",
2422 vec![QueryParam::String(user_id), QueryParam::String(album_id)],
2423 );
2424
2425 let deleted_count = db_service
2426 .execute(delete_query)
2427 .await
2428 .map_err(|e| e.to_string())?;
2429
2430 for path in file_paths {
2432 let _ = std::fs::remove_file(&path);
2433 let _ = std::fs::remove_file(format!("{}.part", path));
2434 }
2435
2436 Ok(deleted_count as i64)
2437}
2438
2439#[tauri::command]
2448#[specta::specta]
2449pub async fn delete_downloads_under(
2450 db: State<'_, DatabaseWrapper>,
2451 item_id: String,
2452 user_id: String,
2453) -> Result<i64, String> {
2454 let db_service = {
2455 let database = db.0.lock().map_err(|e| e.to_string())?;
2456 Arc::new(database.service())
2457 };
2458
2459 const SCOPE: &str = "d.user_id = ? AND d.status = 'completed'
2461 AND (
2462 d.item_id = ?
2463 OR d.item_id IN (
2464 SELECT c.id FROM items c
2465 WHERE c.album_id = ? OR c.season_id = ? OR c.series_id = ? OR c.parent_id = ?
2466 )
2467 )";
2468
2469 let file_query = Query::with_params(
2470 format!("SELECT d.file_path FROM downloads d WHERE {SCOPE}"),
2471 vec![
2472 QueryParam::String(user_id.clone()),
2473 QueryParam::String(item_id.clone()),
2474 QueryParam::String(item_id.clone()),
2475 QueryParam::String(item_id.clone()),
2476 QueryParam::String(item_id.clone()),
2477 QueryParam::String(item_id.clone()),
2478 ],
2479 );
2480 let file_paths: Vec<String> = db_service
2481 .query_many(file_query, |row| row.get(0))
2482 .await
2483 .map_err(|e| e.to_string())?;
2484
2485 let delete_query = Query::with_params(
2486 format!("DELETE FROM downloads WHERE id IN (SELECT d.id FROM downloads d WHERE {SCOPE})"),
2487 vec![
2488 QueryParam::String(user_id),
2489 QueryParam::String(item_id.clone()),
2490 QueryParam::String(item_id.clone()),
2491 QueryParam::String(item_id.clone()),
2492 QueryParam::String(item_id.clone()),
2493 QueryParam::String(item_id),
2494 ],
2495 );
2496 let deleted_count = db_service
2497 .execute(delete_query)
2498 .await
2499 .map_err(|e| e.to_string())?;
2500
2501 for path in file_paths {
2502 let _ = std::fs::remove_file(&path);
2503 let _ = std::fs::remove_file(format!("{}.part", path));
2504 }
2505
2506 Ok(deleted_count as i64)
2507}
2508
2509#[derive(specta::Type, Debug, Clone, serde::Serialize)]
2511pub struct DownloadManagerStats {
2512 pub max_concurrent: usize,
2513 pub active_count: usize,
2514 pub available_slots: usize,
2515}
2516
2517#[tauri::command]
2519#[specta::specta]
2520pub async fn get_download_manager_stats(
2521 download_manager: State<'_, DownloadManagerWrapper>,
2522) -> Result<DownloadManagerStats, String> {
2523 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2524
2525 let active_count = manager.active_count();
2526 let max_concurrent = manager.max_concurrent();
2527
2528 Ok(DownloadManagerStats {
2529 max_concurrent,
2530 active_count,
2531 available_slots: max_concurrent.saturating_sub(active_count),
2532 })
2533}
2534
2535#[tauri::command]
2537#[specta::specta]
2538pub async fn set_max_concurrent_downloads(
2539 download_manager: State<'_, DownloadManagerWrapper>,
2540 max: usize,
2541) -> Result<(), String> {
2542 let mut manager = download_manager.0.lock().map_err(|e| e.to_string())?;
2543 manager.set_max_concurrent(max);
2544 info!("Set max concurrent downloads to: {}", max);
2545 Ok(())
2546}
2547
2548#[cfg(test)]
2550mod tests {
2551 use super::*;
2552 use crate::storage::Database;
2553 use rusqlite::params;
2554
2555 #[test]
2556 fn test_sanitize_filename() {
2557 assert_eq!(sanitize_filename("normal.mp3"), "normal.mp3");
2558 assert_eq!(
2559 sanitize_filename("track/with\\invalid:chars"),
2560 "track_with_invalid_chars"
2561 );
2562 assert_eq!(sanitize_filename("song?.mp3"), "song_.mp3");
2563 assert_eq!(sanitize_filename("file<>|?.txt"), "file____.txt");
2564 }
2565
2566 #[test]
2567 fn test_sanitize_filename_preserves_extension() {
2568 assert_eq!(sanitize_filename("my:song.mp3"), "my_song.mp3");
2569 assert_eq!(sanitize_filename("track/1.flac"), "track_1.flac");
2570 }
2571
2572 const TEST_ROOT: &str = "/data/data/com.dtourolle.jellytau/files";
2574
2575 #[test]
2583 fn test_queued_download_paths_cannot_escape_the_download_directory() {
2584 let root = Path::new(TEST_ROOT);
2585
2586 assert!(confine_queued_path(root, "downloads/../../../../etc/cron.d/pwn").is_err());
2587 assert!(confine_queued_path(root, "../.bashrc").is_err());
2588 assert!(confine_queued_path(root, "/etc/cron.d/pwn").is_err());
2589
2590 #[allow(clippy::join_absolute_paths)]
2599 {
2600 assert_eq!(
2601 PathBuf::from(root).join("/etc/cron.d/pwn"),
2602 PathBuf::from("/etc/cron.d/pwn")
2603 );
2604 }
2605 }
2606
2607 #[test]
2612 fn test_queued_download_paths_are_otherwise_unchanged() {
2613 let root = Path::new(TEST_ROOT);
2614
2615 for path in [
2616 "downloads/9f8e7d6c", "videos/movies/Arrival.mp4", "albums/abc123/01 - Opening.mp3", "/data/data/com.dtourolle.jellytau/files/videos/Show/S01E02_Pilot.mp4",
2622 ] {
2623 assert_eq!(confine_queued_path(root, path).unwrap(), path);
2624 }
2625
2626 let already = format!("downloads/{}.mp3", sanitize_filename("AC/DC: Live?"));
2630 assert_eq!(confine_queued_path(root, &already).unwrap(), already);
2631 }
2632
2633 #[test]
2639 fn test_a_completed_download_cannot_register_a_file_outside_the_root() {
2640 let root = Path::new(TEST_ROOT);
2641
2642 let written = "/data/data/com.dtourolle.jellytau/files/downloads/9f8e7d6c";
2645 assert_eq!(
2646 confine_to_root(root, Path::new(written)).unwrap(),
2647 PathBuf::from(written)
2648 );
2649
2650 assert_eq!(
2653 confine_to_root(root, &root.join("downloads/9f8e7d6c")).unwrap(),
2654 PathBuf::from(written)
2655 );
2656
2657 assert!(confine_to_root(root, Path::new("/home/u/.ssh/id_ed25519")).is_err());
2658 assert!(confine_to_root(
2659 root,
2660 Path::new("/data/data/com.dtourolle.jellytau/files/../../../../etc/passwd")
2661 )
2662 .is_err());
2663 }
2664
2665 fn setup_test_db() -> Database {
2667 let db = Database::open_in_memory().unwrap();
2668 let conn = db.connection();
2669 let conn = conn.lock_safe();
2670
2671 conn.execute(
2673 "INSERT INTO servers (id, name, url) VALUES (?1, ?2, ?3)",
2674 params!["server1", "Test Server", "http://localhost:8096"],
2675 )
2676 .unwrap();
2677
2678 conn.execute(
2680 "INSERT INTO users (id, server_id, username) VALUES (?1, ?2, ?3)",
2681 params!["user1", "server1", "testuser"],
2682 )
2683 .unwrap();
2684
2685 conn.execute(
2687 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2688 params!["item1", "server1", "Test Song 1", "Audio", "album1", 1],
2689 )
2690 .unwrap();
2691
2692 conn.execute(
2693 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2694 params!["item2", "server1", "Test Song 2", "Audio", "album1", 2],
2695 )
2696 .unwrap();
2697
2698 conn.execute(
2699 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
2700 params!["item3", "server1", "Test Song 3", "Audio", "album1", 3],
2701 )
2702 .unwrap();
2703
2704 drop(conn);
2705 db
2706 }
2707
2708 #[test]
2709 fn test_download_item_returns_correct_id_on_insert() {
2710 let db = setup_test_db();
2711 let conn = db.connection();
2712 let conn = conn.lock_safe();
2713
2714 conn.execute(
2716 "INSERT INTO downloads (item_id, user_id, file_path, mime_type, status, priority, queued_at)
2717 VALUES (?1, ?2, ?3, ?4, 'pending', ?5, CURRENT_TIMESTAMP)",
2718 params!["item1", "user1", "/path/to/file.mp3", "audio/mpeg", 0],
2719 )
2720 .unwrap();
2721
2722 let download_id: i64 = conn
2724 .query_row(
2725 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2726 params!["item1", "user1"],
2727 |row| row.get(0),
2728 )
2729 .unwrap();
2730
2731 assert!(download_id > 0, "Download ID should be positive");
2732
2733 let (status, file_path): (String, String) = conn
2735 .query_row(
2736 "SELECT status, file_path FROM downloads WHERE id = ?1",
2737 params![download_id],
2738 |row| Ok((row.get(0)?, row.get(1)?)),
2739 )
2740 .unwrap();
2741
2742 assert_eq!(status, "pending");
2743 assert_eq!(file_path, "/path/to/file.mp3");
2744 }
2745
2746 #[test]
2747 fn test_download_item_upsert_returns_correct_id() {
2748 let db = setup_test_db();
2749 let conn = db.connection();
2750 let conn = conn.lock_safe();
2751
2752 conn.execute(
2754 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2755 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
2756 params!["item1", "user1", "/path/to/file.mp3"],
2757 )
2758 .unwrap();
2759
2760 let first_id: i64 = conn
2761 .query_row(
2762 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2763 params!["item1", "user1"],
2764 |row| row.get(0),
2765 )
2766 .unwrap();
2767
2768 conn.execute(
2770 "UPDATE downloads SET status = 'failed', error_message = 'Network error' WHERE id = ?1",
2771 params![first_id],
2772 )
2773 .unwrap();
2774
2775 conn.execute(
2777 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2778 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
2779 ON CONFLICT(item_id, user_id) DO UPDATE SET
2780 priority = excluded.priority,
2781 status = 'pending',
2782 queued_at = CURRENT_TIMESTAMP",
2783 params!["item1", "user1", "/path/to/file.mp3"],
2784 )
2785 .unwrap();
2786
2787 let second_id: i64 = conn
2790 .query_row(
2791 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2792 params!["item1", "user1"],
2793 |row| row.get(0),
2794 )
2795 .unwrap();
2796
2797 assert_eq!(first_id, second_id, "UPSERT should return the same row ID");
2799
2800 let status: String = conn
2802 .query_row(
2803 "SELECT status FROM downloads WHERE id = ?1",
2804 params![second_id],
2805 |row| row.get(0),
2806 )
2807 .unwrap();
2808
2809 assert_eq!(
2810 status, "pending",
2811 "Status should be reset to pending after UPSERT"
2812 );
2813 }
2814
2815 #[test]
2816 fn test_query_by_unique_constraint_is_reliable() {
2817 let db = setup_test_db();
2827 let conn = db.connection();
2828 let conn = conn.lock_safe();
2829
2830 conn.execute(
2832 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2833 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)",
2834 params!["item1", "user1", "/path/to/file.mp3"],
2835 )
2836 .unwrap();
2837
2838 let first_id: i64 = conn
2839 .query_row(
2840 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2841 params!["item1", "user1"],
2842 |row| row.get(0),
2843 )
2844 .unwrap();
2845
2846 conn.execute(
2848 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2849 VALUES (?1, ?2, ?3, 'pending', 0, CURRENT_TIMESTAMP)
2850 ON CONFLICT(item_id, user_id) DO UPDATE SET
2851 status = 'pending'",
2852 params!["item1", "user1", "/path/to/file.mp3"],
2853 )
2854 .unwrap();
2855
2856 let id_after_upsert: i64 = conn
2858 .query_row(
2859 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2860 params!["item1", "user1"],
2861 |row| row.get(0),
2862 )
2863 .unwrap();
2864
2865 assert_eq!(
2867 first_id, id_after_upsert,
2868 "Query by unique constraint should always return correct ID"
2869 );
2870 }
2871
2872 #[test]
2873 fn test_download_album_returns_correct_ids() {
2874 let db = setup_test_db();
2875 let conn = db.connection();
2876 let conn = conn.lock_safe();
2877
2878 let track_ids = vec!["item1", "item2", "item3"];
2880 let mut download_ids = Vec::new();
2881
2882 for track_id in &track_ids {
2883 conn.execute(
2884 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2885 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
2886 ON CONFLICT(item_id, user_id) DO UPDATE SET
2887 priority = 100,
2888 status = 'pending'",
2889 params![track_id, "user1", format!("/path/{}.mp3", track_id)],
2890 )
2891 .unwrap();
2892
2893 let download_id: i64 = conn
2895 .query_row(
2896 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2897 params![track_id, "user1"],
2898 |row| row.get(0),
2899 )
2900 .unwrap();
2901
2902 download_ids.push(download_id);
2903 }
2904
2905 assert_eq!(download_ids.len(), 3);
2907 for id in &download_ids {
2908 assert!(*id > 0, "Download ID should be positive");
2909 }
2910
2911 let mut sorted_ids = download_ids.clone();
2913 sorted_ids.sort();
2914 sorted_ids.dedup();
2915 assert_eq!(sorted_ids.len(), 3, "All download IDs should be unique");
2916 }
2917
2918 #[test]
2919 fn test_download_album_upsert_returns_correct_ids() {
2920 let db = setup_test_db();
2921 let conn = db.connection();
2922 let conn = conn.lock_safe();
2923
2924 let track_ids = vec!["item1", "item2", "item3"];
2926 let mut first_ids = Vec::new();
2927
2928 for track_id in &track_ids {
2929 conn.execute(
2930 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2931 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)",
2932 params![track_id, "user1", format!("/path/{}.mp3", track_id)],
2933 )
2934 .unwrap();
2935
2936 let id: i64 = conn
2937 .query_row(
2938 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2939 params![track_id, "user1"],
2940 |row| row.get(0),
2941 )
2942 .unwrap();
2943 first_ids.push(id);
2944 }
2945
2946 conn.execute("UPDATE downloads SET status = 'failed'", [])
2948 .unwrap();
2949
2950 let mut second_ids = Vec::new();
2952
2953 for track_id in &track_ids {
2954 conn.execute(
2955 "INSERT INTO downloads (item_id, user_id, file_path, status, priority, queued_at)
2956 VALUES (?1, ?2, ?3, 'pending', 100, CURRENT_TIMESTAMP)
2957 ON CONFLICT(item_id, user_id) DO UPDATE SET
2958 priority = 100,
2959 status = 'pending'",
2960 params![track_id, "user1", format!("/path/{}.mp3", track_id)],
2961 )
2962 .unwrap();
2963
2964 let id: i64 = conn
2965 .query_row(
2966 "SELECT id FROM downloads WHERE item_id = ?1 AND user_id = ?2",
2967 params![track_id, "user1"],
2968 |row| row.get(0),
2969 )
2970 .unwrap();
2971 second_ids.push(id);
2972 }
2973
2974 assert_eq!(first_ids, second_ids, "UPSERT should preserve original IDs");
2976 }
2977
2978 #[test]
2979 fn test_get_downloads_returns_correct_metadata() {
2980 let db = setup_test_db();
2981 let conn = db.connection();
2982 let conn = conn.lock_safe();
2983
2984 conn.execute(
2986 "INSERT INTO downloads (item_id, user_id, file_path, status, progress, priority)
2987 VALUES (?1, ?2, ?3, 'downloading', 0.5, 10)",
2988 params!["item1", "user1", "/path/to/song.mp3"],
2989 )
2990 .unwrap();
2991
2992 let download: DownloadInfo = conn
2994 .query_row(
2995 "SELECT d.id, d.item_id, d.user_id, d.file_path, d.file_size, d.mime_type, d.status, d.progress,
2996 d.bytes_downloaded, d.queued_at, d.started_at, d.completed_at, d.error_message,
2997 d.retry_count, d.priority,
2998 COALESCE(d.item_name, i.name) as item_name,
2999 COALESCE(d.artist_name, i.artists) as artist_name,
3000 COALESCE(d.album_name, i.album_name) as album_name,
3001 COALESCE(d.series_name, i.series_name) as series_name,
3002 COALESCE(d.season_name, i.season_name) as season_name,
3003 COALESCE(d.episode_number, i.index_number) as episode_number,
3004 COALESCE(d.season_number, i.parent_index_number) as season_number,
3005 d.quality_preset,
3006 COALESCE(d.media_type, 'audio') as media_type,
3007 COALESCE(d.download_source, 'user') as download_source
3008 FROM downloads d
3009 LEFT JOIN items i ON d.item_id = i.id
3010 WHERE d.user_id = ?1",
3011 params!["user1"],
3012 map_download_row,
3013 )
3014 .unwrap();
3015
3016 assert_eq!(download.item_id, "item1");
3017 assert_eq!(download.status, "downloading");
3018 assert!((download.progress - 0.5).abs() < 0.001);
3019 assert_eq!(download.priority, 10);
3020 assert_eq!(download.item_name, Some("Test Song 1".to_string()));
3021 }
3022
3023 #[test]
3024 fn test_download_status_transitions() {
3025 let db = setup_test_db();
3026 let conn = db.connection();
3027 let conn = conn.lock_safe();
3028
3029 conn.execute(
3031 "INSERT INTO downloads (item_id, user_id, file_path, status)
3032 VALUES (?1, ?2, ?3, 'pending')",
3033 params!["item1", "user1", "/path/to/song.mp3"],
3034 )
3035 .unwrap();
3036
3037 let id: i64 = conn.last_insert_rowid();
3038
3039 conn.execute(
3041 "UPDATE downloads SET status = 'downloading', started_at = CURRENT_TIMESTAMP WHERE id = ?1",
3042 params![id],
3043 )
3044 .unwrap();
3045
3046 let status: String = conn
3047 .query_row(
3048 "SELECT status FROM downloads WHERE id = ?1",
3049 params![id],
3050 |row| row.get(0),
3051 )
3052 .unwrap();
3053 assert_eq!(status, "downloading");
3054
3055 conn.execute(
3057 "UPDATE downloads SET status = 'completed', progress = 1.0, completed_at = CURRENT_TIMESTAMP WHERE id = ?1",
3058 params![id],
3059 )
3060 .unwrap();
3061
3062 let (status, progress): (String, f64) = conn
3063 .query_row(
3064 "SELECT status, progress FROM downloads WHERE id = ?1",
3065 params![id],
3066 |row| Ok((row.get(0)?, row.get(1)?)),
3067 )
3068 .unwrap();
3069 assert_eq!(status, "completed");
3070 assert!((progress - 1.0).abs() < 0.001);
3071 }
3072
3073 #[test]
3074 fn test_download_progress_updates() {
3075 let db = setup_test_db();
3076 let conn = db.connection();
3077 let conn = conn.lock_safe();
3078
3079 conn.execute(
3080 "INSERT INTO downloads (item_id, user_id, file_path, status, progress, bytes_downloaded, file_size)
3081 VALUES (?1, ?2, ?3, 'downloading', 0.0, 0, 10000000)",
3082 params!["item1", "user1", "/path/to/song.mp3"],
3083 )
3084 .unwrap();
3085
3086 let id: i64 = conn.last_insert_rowid();
3087
3088 for i in 1..=10 {
3090 let progress = i as f64 / 10.0;
3091 let bytes = i * 1000000;
3092
3093 conn.execute(
3094 "UPDATE downloads SET progress = ?1, bytes_downloaded = ?2 WHERE id = ?3",
3095 params![progress, bytes, id],
3096 )
3097 .unwrap();
3098
3099 let (actual_progress, actual_bytes): (f64, i64) = conn
3100 .query_row(
3101 "SELECT progress, bytes_downloaded FROM downloads WHERE id = ?1",
3102 params![id],
3103 |row| Ok((row.get(0)?, row.get(1)?)),
3104 )
3105 .unwrap();
3106
3107 assert!((actual_progress - progress).abs() < 0.001);
3108 assert_eq!(actual_bytes, bytes);
3109 }
3110 }
3111
3112 #[test]
3113 fn test_compute_download_stats_empty() {
3114 let downloads = vec![];
3115 let stats = compute_download_stats(&downloads);
3116 assert_eq!(stats.total, 0);
3117 assert_eq!(stats.active_count, 0);
3118 assert_eq!(stats.queued_count, 0);
3119 assert_eq!(stats.completed_count, 0);
3120 assert_eq!(stats.failed_count, 0);
3121 assert_eq!(stats.paused_count, 0);
3122 }
3123
3124 #[test]
3125 fn test_compute_download_stats_mixed() {
3126 let downloads = vec![
3127 create_test_download(1, "downloading"),
3128 create_test_download(2, "pending"),
3129 create_test_download(3, "downloading"),
3130 create_test_download(4, "completed"),
3131 create_test_download(5, "failed"),
3132 create_test_download(6, "paused"),
3133 ];
3134 let stats = compute_download_stats(&downloads);
3135 assert_eq!(stats.total, 6);
3136 assert_eq!(stats.active_count, 2);
3137 assert_eq!(stats.queued_count, 1);
3138 assert_eq!(stats.completed_count, 1);
3139 assert_eq!(stats.failed_count, 1);
3140 assert_eq!(stats.paused_count, 1);
3141 }
3142
3143 #[test]
3144 fn test_compute_download_stats_all_same_status() {
3145 let downloads = vec![
3146 create_test_download(1, "completed"),
3147 create_test_download(2, "completed"),
3148 create_test_download(3, "completed"),
3149 ];
3150 let stats = compute_download_stats(&downloads);
3151 assert_eq!(stats.total, 3);
3152 assert_eq!(stats.active_count, 0);
3153 assert_eq!(stats.queued_count, 0);
3154 assert_eq!(stats.completed_count, 3);
3155 assert_eq!(stats.failed_count, 0);
3156 assert_eq!(stats.paused_count, 0);
3157 }
3158
3159 fn create_test_download(id: i64, status: &str) -> DownloadInfo {
3161 DownloadInfo {
3162 id,
3163 item_id: format!("item{}", id),
3164 user_id: "test_user".to_string(),
3165 file_path: format!("/tmp/download{}", id),
3166 file_size: Some(1000),
3167 mime_type: Some("audio/flac".to_string()),
3168 status: status.to_string(),
3169 progress: 0.0,
3170 bytes_downloaded: 0,
3171 queued_at: "2024-01-01T00:00:00Z".to_string(),
3172 started_at: None,
3173 completed_at: None,
3174 error_message: None,
3175 retry_count: 0,
3176 priority: 0,
3177 item_name: Some(format!("Track {}", id)),
3178 artist_name: None,
3179 album_name: None,
3180 series_name: None,
3181 season_name: None,
3182 episode_number: None,
3183 season_number: None,
3184 quality_preset: None,
3185 media_type: "audio".to_string(),
3186 download_source: "user".to_string(),
3187 }
3188 }
3189
3190 fn album_test_db() -> Arc<crate::storage::db_service::RusqliteService> {
3194 let conn = rusqlite::Connection::open_in_memory().unwrap();
3195 conn.execute_batch(
3196 r#"
3197 CREATE TABLE items (
3198 id TEXT PRIMARY KEY,
3199 server_id TEXT NOT NULL,
3200 parent_id TEXT,
3201 name TEXT NOT NULL,
3202 item_type TEXT NOT NULL,
3203 album_id TEXT,
3204 album_name TEXT,
3205 album_artist TEXT,
3206 artists TEXT,
3207 index_number INTEGER
3208 );
3209 CREATE TABLE downloads (
3210 id INTEGER PRIMARY KEY AUTOINCREMENT,
3211 item_id TEXT NOT NULL,
3212 user_id TEXT NOT NULL,
3213 file_path TEXT NOT NULL,
3214 status TEXT DEFAULT 'pending',
3215 priority INTEGER DEFAULT 0,
3216 progress REAL DEFAULT 0,
3217 queued_at TEXT,
3218 item_name TEXT,
3219 artist_name TEXT,
3220 album_name TEXT,
3221 media_type TEXT,
3222 stream_url TEXT,
3223 target_dir TEXT,
3224 UNIQUE(item_id, user_id)
3225 );
3226 INSERT INTO items (id, server_id, name, item_type)
3227 VALUES ('album1', 'server1', 'The Golden Age', 'MusicAlbum');
3228 "#,
3229 )
3230 .unwrap();
3231 Arc::new(crate::storage::db_service::RusqliteService::new(Arc::new(
3232 Mutex::new(conn),
3233 )))
3234 }
3235
3236 fn album_track(id: &str, name: &str, index: i32) -> AlbumTrack {
3237 AlbumTrack {
3238 id: id.to_string(),
3239 name: name.to_string(),
3240 artist_name: Some("Woodkid".to_string()),
3241 album_name: Some("The Golden Age".to_string()),
3242 index_number: Some(index),
3243 }
3244 }
3245
3246 #[tokio::test]
3261 async fn test_queue_album_tracks_queues_every_track_and_links_it_to_the_album() {
3262 let db = album_test_db();
3263
3264 for sql in [
3267 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3268 VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1')",
3269 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3270 VALUES ('t2', 'server1', 'The Great Escape', 'Audio', NULL)",
3271 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3272 VALUES ('t3', 'server1', 'Boat Song', 'Audio', NULL)",
3273 ] {
3274 db.execute(Query::new(sql)).await.unwrap();
3275 }
3276
3277 let tracks = vec![
3278 album_track("t1", "Run Boy Run", 1),
3279 album_track("t2", "The Great Escape", 2),
3280 album_track("t3", "Boat Song", 3),
3281 ];
3282
3283 let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3284 .await
3285 .unwrap();
3286
3287 assert_eq!(
3288 ids.len(),
3289 3,
3290 "every track of the album must get a download row"
3291 );
3292
3293 let queued: i64 = db
3294 .query_one(
3295 Query::new("SELECT COUNT(*) FROM downloads WHERE status = 'pending'"),
3296 |row| row.get(0),
3297 )
3298 .await
3299 .unwrap();
3300 assert_eq!(queued, 3);
3301
3302 let linked: i64 = db
3305 .query_one(
3306 Query::new("SELECT COUNT(*) FROM items WHERE album_id = 'album1'"),
3307 |row| row.get(0),
3308 )
3309 .await
3310 .unwrap();
3311 assert_eq!(
3312 linked, 3,
3313 "queued tracks must be linked to their album; offline browsing joins on album_id"
3314 );
3315 }
3316
3317 #[tokio::test]
3326 async fn test_queue_album_tracks_returns_ids_in_track_order() {
3327 let db = album_test_db();
3328 let tracks = vec![
3329 album_track("t1", "Run Boy Run", 1),
3330 album_track("t2", "The Great Escape", 2),
3331 ];
3332
3333 let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3334 .await
3335 .unwrap();
3336
3337 for (id, track) in ids.iter().zip(tracks.iter()) {
3338 let item_id: String = db
3339 .query_one(
3340 Query::with_params(
3341 "SELECT item_id FROM downloads WHERE id = ?",
3342 vec![QueryParam::Int64(*id)],
3343 ),
3344 |row| row.get(0),
3345 )
3346 .await
3347 .unwrap();
3348 assert_eq!(&item_id, &track.id, "id {} must be {}'s row", id, track.id);
3349 }
3350 }
3351
3352 #[tokio::test]
3357 async fn test_queue_album_tracks_is_idempotent() {
3358 let db = album_test_db();
3359 let tracks = vec![
3360 album_track("t1", "Run Boy Run", 1),
3361 album_track("t2", "The Great Escape", 2),
3362 ];
3363
3364 let first = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3365 .await
3366 .unwrap();
3367 let second = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3368 .await
3369 .unwrap();
3370
3371 assert_eq!(first, second, "the same tracks must map to the same rows");
3372
3373 let rows: i64 = db
3374 .query_one(Query::new("SELECT COUNT(*) FROM downloads"), |row| {
3375 row.get(0)
3376 })
3377 .await
3378 .unwrap();
3379 assert_eq!(rows, 2, "re-queueing must not duplicate download rows");
3380 }
3381
3382 #[test]
3390 fn test_album_file_names_are_unique_within_the_album() {
3391 let tracks = vec![
3392 album_track("t1", "Crucified Again", 5),
3393 album_track("t2", "Crucified Again", 5),
3394 album_track("t3", "Get Right", 7),
3395 ];
3396
3397 let names = album_file_names(&tracks);
3398
3399 assert_eq!(names.len(), 3);
3400 let unique: std::collections::HashSet<_> = names.iter().collect();
3401 assert_eq!(
3402 unique.len(),
3403 3,
3404 "every track of an album needs its own file: {:?}",
3405 names
3406 );
3407 assert!(names.iter().all(|n| n.ends_with(".mp3")), "{:?}", names);
3408 assert!(
3409 names[2].contains("Get Right"),
3410 "an unambiguous title keeps its name: {}",
3411 names[2]
3412 );
3413 }
3414
3415 #[test]
3419 fn test_album_file_names_sanitize_the_title() {
3420 let names = album_file_names(&[album_track("t1", "AC/DC: Live?", 1)]);
3421 assert!(!names[0].contains('/'), "{}", names[0]);
3422 assert!(!names[0].contains(':'), "{}", names[0]);
3423 }
3424
3425 #[tokio::test]
3433 async fn test_cached_album_tracks_finds_tracks_by_either_album_link() {
3434 let db = album_test_db();
3435 for sql in [
3436 "INSERT INTO items (id, server_id, name, item_type, album_id, index_number) \
3437 VALUES ('t1', 'server1', 'Run Boy Run', 'Audio', 'album1', 1)",
3438 "INSERT INTO items (id, server_id, name, item_type, parent_id, index_number) \
3441 VALUES ('t2', 'server1', 'The Great Escape', 'Audio', 'album1', 2)",
3442 "INSERT INTO items (id, server_id, name, item_type, album_id) \
3444 VALUES ('other', 'server1', 'Iron', 'Audio', 'album2')",
3445 ] {
3446 db.execute(Query::new(sql)).await.unwrap();
3447 }
3448
3449 let tracks = cached_album_tracks(&db, "album1").await.unwrap();
3450 let ids: Vec<_> = tracks.iter().map(|t| t.id.as_str()).collect();
3451 assert_eq!(ids, vec!["t1", "t2"]);
3452 }
3453
3454 #[tokio::test]
3460 async fn test_queue_album_tracks_handles_tracks_absent_from_the_cache() {
3461 let db = album_test_db();
3462 let tracks = vec![album_track("never-cached", "Iron", 1)];
3463
3464 let ids = queue_album_tracks(&db, "album1", &tracks, "user1", "albums/album1")
3465 .await
3466 .unwrap();
3467 assert_eq!(ids.len(), 1);
3468
3469 let (item_type, album_id): (String, Option<String>) = db
3470 .query_one(
3471 Query::new("SELECT item_type, album_id FROM items WHERE id = 'never-cached'"),
3472 |row| Ok((row.get(0)?, row.get(1)?)),
3473 )
3474 .await
3475 .unwrap();
3476 assert_eq!(item_type, "Audio");
3477 assert_eq!(album_id.as_deref(), Some("album1"));
3478 }
3479}