1use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::Arc;
19use std::time::Duration;
20
21use log::{info, warn};
22use tauri::{Emitter, Manager, State};
23
24use crate::commands::download::{pump_download_queue, DownloadManagerWrapper};
25use crate::commands::repository::RepositoryManagerWrapper;
26use crate::commands::storage::DatabaseWrapper;
27use crate::repository::types::GetItemsOptions;
28use crate::storage::db_service::{DatabaseService, Query, QueryParam};
29
30const LAST_CATALOG_SYNC_KEY: &str = "last_catalog_sync";
33
34const CATALOG_INDEX_TTL: Duration = Duration::from_secs(6 * 60 * 60);
42
43const CATALOG_INDEX_TICK: Duration = Duration::from_secs(5 * 60);
49
50const CATALOG_INDEX_FIRST_CHECK: Duration = Duration::from_secs(15);
55
56pub const CATALOG_INDEX_EVENT: &str = "catalog-index-event";
58
59static INDEX_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
63
64struct IndexPassGuard;
68
69impl Drop for IndexPassGuard {
70 fn drop(&mut self) {
71 INDEX_IN_PROGRESS.store(false, Ordering::SeqCst);
72 }
73}
74
75#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct CatalogIndexEvent {
79 pub state: String,
81 pub items_cached: usize,
82 pub items_pruned: usize,
83 pub libraries_failed: usize,
84 pub error: Option<String>,
86}
87
88const CATALOG_ITEM_TYPES: &[&str] = &[
98 "MusicAlbum",
99 "MusicArtist",
100 "Movie",
101 "Series",
102 "Season",
103 "Episode",
104 "Audio",
105 "BoxSet",
106 "Playlist",
107];
108
109const VIDEO_ITEM_TYPES: &[&str] = &["Movie", "Episode", "Video", "MusicVideo"];
116
117#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct CatalogSyncResult {
120 pub items_cached: usize,
122 pub libraries_failed: usize,
124 pub items_pruned: usize,
127}
128
129#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub struct CatalogSyncStatus {
132 pub last_synced_at: Option<String>,
134}
135
136#[tauri::command]
144#[specta::specta]
145pub async fn sync_full_catalog(
146 repository: State<'_, RepositoryManagerWrapper>,
147 db: State<'_, DatabaseWrapper>,
148 handle: String,
149) -> Result<CatalogSyncResult, String> {
150 let repo = repository.0.get(&handle).ok_or("Repository not found")?;
151
152 let db_service = {
153 let database = db.0.lock().map_err(|e| e.to_string())?;
154 Arc::new(database.service())
155 };
156
157 run_index_pass(repo, db_service).await
158}
159
160pub(crate) async fn run_index_pass(
166 repo: Arc<crate::repository::HybridRepository>,
167 db_service: Arc<crate::storage::db_service::RusqliteService>,
168) -> Result<CatalogSyncResult, String> {
169 use crate::repository::MediaRepository;
170
171 if INDEX_IN_PROGRESS.swap(true, Ordering::SeqCst) {
174 return Err("A catalog index pass is already running".to_string());
175 }
176 let _guard = IndexPassGuard;
177
178 let libraries = repo.get_libraries().await.map_err(|e| e.to_string())?;
179 info!(
180 "[Catalog] Full sync starting across {} libraries",
181 libraries.len()
182 );
183
184 let include_types: Vec<String> = CATALOG_ITEM_TYPES.iter().map(|s| s.to_string()).collect();
185
186 let pass_started_at = chrono::Utc::now().to_rfc3339();
189
190 let mut items_cached = 0usize;
191 let mut libraries_failed = 0usize;
192
193 for library in &libraries {
194 let opts = GetItemsOptions {
195 recursive: Some(true),
196 include_item_types: Some(include_types.clone()),
197 limit: Some(100_000),
198 ..Default::default()
199 };
200
201 match repo.cache_items_from_server(&library.id, Some(opts)).await {
202 Ok(items) => {
203 info!(
204 "[Catalog] Cached {} items from library '{}'",
205 items.len(),
206 library.name
207 );
208 items_cached += items.len();
209 }
210 Err(e) => {
211 warn!(
212 "[Catalog] Failed to sync library '{}': {:?}",
213 library.name, e
214 );
215 libraries_failed += 1;
216 }
217 }
218 }
219
220 let mut items_pruned = 0usize;
225 if libraries_failed == 0 && !libraries.is_empty() {
226 match repo
227 .prune_stale_catalog(&pass_started_at, &include_types)
228 .await
229 {
230 Ok(removed) => {
231 items_pruned = removed;
232 if removed > 0 {
233 info!(
234 "[Catalog] Pruned {} entries no longer on the server",
235 removed
236 );
237 }
238 }
239 Err(e) => warn!("[Catalog] Prune of stale catalog entries failed: {:?}", e),
240 }
241 } else if libraries_failed > 0 {
242 info!(
243 "[Catalog] Skipping stale-entry prune: {} librar{} failed to sync, so the crawl is not authoritative",
244 libraries_failed,
245 if libraries_failed == 1 { "y" } else { "ies" }
246 );
247 }
248
249 let now = chrono::Utc::now().to_rfc3339();
251 let upsert = Query::with_params(
252 "INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)
253 ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP",
254 vec![
255 QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string()),
256 QueryParam::String(now),
257 ],
258 );
259 if let Err(e) = db_service.execute(upsert).await {
260 warn!("[Catalog] Failed to persist last-sync timestamp: {}", e);
261 }
262
263 info!(
264 "[Catalog] Full sync complete: {} items cached, {} pruned, {} libraries failed",
265 items_cached, items_pruned, libraries_failed
266 );
267
268 Ok(CatalogSyncResult {
269 items_cached,
270 libraries_failed,
271 items_pruned,
272 })
273}
274
275pub(crate) fn index_is_due(
283 last_synced_at: Option<&str>,
284 now: chrono::DateTime<chrono::Utc>,
285 ttl: Duration,
286) -> bool {
287 let Some(raw) = last_synced_at else {
288 return true;
289 };
290 let Ok(last) = chrono::DateTime::parse_from_rfc3339(raw) else {
291 return true;
292 };
293 now.signed_duration_since(last.with_timezone(&chrono::Utc))
294 .to_std()
295 .map(|elapsed| elapsed >= ttl)
296 .unwrap_or(false)
299}
300
301async fn read_last_sync(
303 db_service: &Arc<crate::storage::db_service::RusqliteService>,
304) -> Option<String> {
305 db_service
306 .query_optional(
307 Query::with_params(
308 "SELECT value FROM app_settings WHERE key = ?",
309 vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
310 ),
311 |row| row.get(0),
312 )
313 .await
314 .ok()
315 .flatten()
316}
317
318pub fn spawn_catalog_indexer(app: tauri::AppHandle) {
329 tauri::async_runtime::spawn(async move {
330 tokio::time::sleep(CATALOG_INDEX_FIRST_CHECK).await;
333
334 loop {
335 if let Err(e) = maybe_run_scheduled_pass(&app).await {
336 warn!("[Catalog] Scheduled index pass skipped: {}", e);
339 }
340
341 tokio::time::sleep(CATALOG_INDEX_TICK).await;
342 }
343 });
344}
345
346async fn maybe_run_scheduled_pass(app: &tauri::AppHandle) -> Result<(), String> {
348 if INDEX_IN_PROGRESS.load(Ordering::SeqCst) {
349 return Ok(());
350 }
351
352 let db_service = {
353 let db = app.state::<DatabaseWrapper>();
354 let database = db.0.lock().map_err(|e| e.to_string())?;
355 Arc::new(database.service())
356 };
357
358 if !index_is_due(
359 read_last_sync(&db_service).await.as_deref(),
360 chrono::Utc::now(),
361 CATALOG_INDEX_TTL,
362 ) {
363 return Ok(());
364 }
365
366 {
369 let monitor = app.state::<crate::commands::connectivity::ConnectivityMonitorWrapper>();
370 let monitor = monitor.0.lock().await;
371 if !monitor.get_status().await.is_server_reachable {
372 return Ok(());
373 }
374 }
375
376 let repo = {
377 let manager = app.state::<RepositoryManagerWrapper>();
378 let handles = manager.0.handles();
379 let Some(handle) = handles.first() else {
380 return Ok(());
382 };
383 manager.0.get(handle).ok_or("Repository not found")?
384 };
385
386 info!("[Catalog] Index is stale; starting a scheduled pass");
387 let _ = app.emit(
388 CATALOG_INDEX_EVENT,
389 CatalogIndexEvent {
390 state: "started".to_string(),
391 items_cached: 0,
392 items_pruned: 0,
393 libraries_failed: 0,
394 error: None,
395 },
396 );
397
398 match run_index_pass(repo, db_service).await {
399 Ok(result) => {
400 let _ = app.emit(
401 CATALOG_INDEX_EVENT,
402 CatalogIndexEvent {
403 state: "finished".to_string(),
404 items_cached: result.items_cached,
405 items_pruned: result.items_pruned,
406 libraries_failed: result.libraries_failed,
407 error: None,
408 },
409 );
410 Ok(())
411 }
412 Err(e) => {
413 let _ = app.emit(
414 CATALOG_INDEX_EVENT,
415 CatalogIndexEvent {
416 state: "failed".to_string(),
417 items_cached: 0,
418 items_pruned: 0,
419 libraries_failed: 0,
420 error: Some(e.clone()),
421 },
422 );
423 Err(e)
424 }
425 }
426}
427
428#[tauri::command]
431#[specta::specta]
432pub async fn catalog_sync_status(
433 db: State<'_, DatabaseWrapper>,
434) -> Result<CatalogSyncStatus, String> {
435 let db_service = {
436 let database = db.0.lock().map_err(|e| e.to_string())?;
437 Arc::new(database.service())
438 };
439
440 let query = Query::with_params(
441 "SELECT value FROM app_settings WHERE key = ?",
442 vec![QueryParam::String(LAST_CATALOG_SYNC_KEY.to_string())],
443 );
444 let last_synced_at: Option<String> = db_service
445 .query_optional(query, |row| row.get(0))
446 .await
447 .map_err(|e| e.to_string())?;
448
449 Ok(CatalogSyncStatus { last_synced_at })
450}
451
452#[tauri::command]
461#[specta::specta]
462pub fn set_show_server_catalog(show: bool) {
463 crate::repository::offline::set_include_catalog_browse(show);
464}
465
466#[derive(specta::Type, Debug, Clone, serde::Serialize, serde::Deserialize)]
467#[serde(rename_all = "camelCase")]
468pub struct ResumeQueuedResult {
469 pub resolved: usize,
471 pub failed: usize,
473}
474
475pub(crate) async fn requeue_mistyped_video_downloads(
492 db_service: &Arc<crate::storage::db_service::RusqliteService>,
493) -> Result<usize, String> {
494 let video_types = VIDEO_ITEM_TYPES
495 .iter()
496 .map(|t| format!("'{t}'"))
497 .collect::<Vec<_>>()
498 .join(", ");
499
500 let query = Query::new(format!(
501 "UPDATE downloads
502 SET status = 'pending', stream_url = NULL, progress = 0,
503 bytes_downloaded = 0, started_at = NULL, completed_at = NULL
504 WHERE media_type IS NULL
505 AND status = 'completed'
506 AND item_id IN (SELECT id FROM items WHERE item_type IN ({video_types}))"
507 ));
508
509 let n = db_service.execute(query).await.map_err(|e| e.to_string())? as usize;
510
511 if n > 0 {
512 info!(
513 "[Catalog] Requeued {} video download(s) that were fetched as audio",
514 n
515 );
516 }
517 Ok(n)
518}
519
520#[derive(Debug, Clone, PartialEq, Eq)]
523pub(crate) struct ResolvedDownloadUrl {
524 pub url: String,
525 pub expected_bytes: Option<u64>,
526}
527
528impl From<String> for ResolvedDownloadUrl {
529 fn from(url: String) -> Self {
532 Self {
533 url,
534 expected_bytes: None,
535 }
536 }
537}
538
539impl From<crate::repository::ResolvedVideoDownload> for ResolvedDownloadUrl {
540 fn from(r: crate::repository::ResolvedVideoDownload) -> Self {
541 Self {
542 url: r.url,
543 expected_bytes: r.expected_bytes,
544 }
545 }
546}
547
548pub(crate) async fn resolve_pending_download_urls<F, Fut>(
558 db_service: &Arc<crate::storage::db_service::RusqliteService>,
559 target_dir: &str,
560 only_ids: Option<&[i64]>,
561 resolve: F,
562) -> Result<ResumeQueuedResult, String>
563where
564 F: Fn(String, String, String) -> Fut,
565 Fut: std::future::Future<Output = Option<ResolvedDownloadUrl>>,
566{
567 if only_ids.is_some_and(|ids| ids.is_empty()) {
568 return Ok(ResumeQueuedResult {
569 resolved: 0,
570 failed: 0,
571 });
572 }
573 let video_types = VIDEO_ITEM_TYPES
581 .iter()
582 .map(|t| format!("'{t}'"))
583 .collect::<Vec<_>>()
584 .join(", ");
585 let id_filter = match only_ids {
586 Some(ids) => format!(
587 " AND d.id IN ({})",
588 ids.iter()
589 .map(|id| id.to_string())
590 .collect::<Vec<_>>()
591 .join(", ")
592 ),
593 None => String::new(),
594 };
595 let rows_query = Query::new(format!(
596 "SELECT d.id, d.item_id,
597 COALESCE(
598 d.media_type,
599 CASE WHEN i.item_type IN ({video_types}) THEN 'video'
600 WHEN i.item_type IS NOT NULL THEN 'audio'
601 END,
602 'audio'),
603 COALESCE(d.quality_preset, 'original')
604 FROM downloads d
605 LEFT JOIN items i ON i.id = d.item_id
606 WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
607 ));
608 let rows: Vec<(i64, String, String, String)> = db_service
609 .query_many(rows_query, |row| {
610 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
611 })
612 .await
613 .map_err(|e| e.to_string())?;
614
615 if rows.is_empty() {
616 return Ok(ResumeQueuedResult {
617 resolved: 0,
618 failed: 0,
619 });
620 }
621
622 info!(
623 "[Catalog] Resolving {} offline-queued downloads on reconnect",
624 rows.len()
625 );
626
627 let mut resolved = 0usize;
628 let mut failed = 0usize;
629
630 for (download_id, item_id, media_type, quality) in rows {
631 let target = match resolve(item_id.clone(), media_type, quality).await {
632 Some(target) => target,
633 None => {
634 failed += 1;
635 continue;
636 }
637 };
638
639 let expected = target
644 .expected_bytes
645 .and_then(|n| i64::try_from(n).ok())
646 .map_or(QueryParam::Null, QueryParam::Int64);
647 let update = Query::with_params(
648 "UPDATE downloads SET stream_url = ?, target_dir = ?,
649 file_size = COALESCE(?, file_size)
650 WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
651 vec![
652 QueryParam::String(target.url),
653 QueryParam::String(target_dir.to_string()),
654 expected,
655 QueryParam::Int64(download_id),
656 ],
657 );
658 match db_service.execute(update).await {
659 Ok(n) if n > 0 => resolved += 1,
660 Ok(_) => {} Err(e) => {
662 warn!(
663 "[Catalog] Failed to persist URL for download {}: {}",
664 download_id, e
665 );
666 failed += 1;
667 }
668 }
669 }
670
671 Ok(ResumeQueuedResult { resolved, failed })
672}
673
674#[tauri::command]
683#[specta::specta]
684pub async fn resume_queued_downloads(
685 repository: State<'_, RepositoryManagerWrapper>,
686 db: State<'_, DatabaseWrapper>,
687 download_manager: State<'_, DownloadManagerWrapper>,
688 app: tauri::AppHandle,
689 handle: String,
690) -> Result<ResumeQueuedResult, String> {
691 use crate::repository::MediaRepository;
692
693 let repo = repository.0.get(&handle).ok_or("Repository not found")?;
694
695 let (db_service, target_dir) = {
698 let database = db.0.lock().map_err(|e| e.to_string())?;
699 let target_dir = database
700 .path()
701 .parent()
702 .ok_or_else(|| "Database path has no parent directory".to_string())?
703 .to_string_lossy()
704 .to_string();
705 (Arc::new(database.service()), target_dir)
706 };
707
708 let recover_query = Query::new(
713 "UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
714 bytes_downloaded = 0, started_at = NULL \
715 WHERE status = 'downloading'",
716 );
717 match db_service.execute(recover_query).await {
718 Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
719 Ok(_) => {}
720 Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
721 }
722
723 if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
727 warn!(
728 "[Catalog] Failed to requeue mis-typed video downloads: {}",
729 e
730 );
731 }
732
733 let repo_for_resolve = Arc::clone(&repo);
735 let outcome = resolve_pending_download_urls(
736 &db_service,
737 &target_dir,
738 None,
739 move |item_id: String, media_type: String, quality: String| {
740 let repo = Arc::clone(&repo_for_resolve);
741 async move {
742 if media_type == "video" {
743 Some(
744 crate::repository::resolve_video_download(
745 repo.as_ref(),
746 &item_id,
747 &quality,
748 None,
749 )
750 .await
751 .into(),
752 )
753 } else {
754 match repo.get_audio_stream_url(&item_id).await {
755 Ok(url) => Some(url.into()),
756 Err(e) => {
757 warn!(
758 "[Catalog] Failed to resolve audio URL for {}: {:?}",
759 item_id, e
760 );
761 None
762 }
763 }
764 }
765 }
766 },
767 )
768 .await
769 .map_err(|e| e.to_string())?;
770
771 let ResumeQueuedResult { resolved, failed } = outcome;
772
773 if resolved > 0 {
775 let active_downloads = {
776 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
777 manager.get_active_downloads()
778 };
779 pump_download_queue(app, db_service, active_downloads).await;
780 }
781
782 info!(
783 "[Catalog] Resume complete: {} resolved, {} failed",
784 resolved, failed
785 );
786
787 Ok(ResumeQueuedResult { resolved, failed })
788}
789
790#[cfg(test)]
791mod tests {
792 use super::*;
793 use crate::storage::db_service::RusqliteService;
794 use crate::utils::lock::MutexSafe;
795 use rusqlite::Connection;
796 use std::sync::Mutex;
797
798 #[test]
803 fn test_index_is_due() {
804 let ttl = Duration::from_secs(6 * 60 * 60);
805 let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
806 .unwrap()
807 .with_timezone(&chrono::Utc);
808
809 assert!(index_is_due(None, now, ttl));
811
812 assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
814
815 assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
818
819 assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
821
822 assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
825 assert!(index_is_due(Some(""), now, ttl));
826
827 assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
830
831 assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
833 }
834
835 fn test_db() -> Arc<RusqliteService> {
836 let conn = Connection::open_in_memory().unwrap();
837 conn.execute_batch(
838 r#"
839 CREATE TABLE downloads (
840 id INTEGER PRIMARY KEY AUTOINCREMENT,
841 item_id TEXT NOT NULL,
842 status TEXT NOT NULL,
843 stream_url TEXT,
844 target_dir TEXT,
845 media_type TEXT,
846 quality_preset TEXT,
847 file_size INTEGER,
848 progress REAL DEFAULT 0,
849 bytes_downloaded INTEGER DEFAULT 0,
850 started_at TEXT,
851 completed_at TEXT
852 );
853 CREATE TABLE items (
854 id TEXT PRIMARY KEY,
855 item_type TEXT
856 );
857 "#,
858 )
859 .unwrap();
860 Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
861 }
862
863 async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
864 db.execute(Query::with_params(
865 "INSERT INTO items (id, item_type) VALUES (?, ?)",
866 vec![
867 QueryParam::String(item_id.to_string()),
868 QueryParam::String(item_type.to_string()),
869 ],
870 ))
871 .await
872 .unwrap();
873 }
874
875 async fn insert_download(
876 db: &Arc<RusqliteService>,
877 item_id: &str,
878 status: &str,
879 stream_url: Option<&str>,
880 media_type: Option<&str>,
881 ) {
882 let q = Query::with_params(
883 "INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
884 vec![
885 QueryParam::String(item_id.to_string()),
886 QueryParam::String(status.to_string()),
887 stream_url
888 .map(|s| QueryParam::String(s.to_string()))
889 .unwrap_or(QueryParam::Null),
890 media_type
891 .map(|s| QueryParam::String(s.to_string()))
892 .unwrap_or(QueryParam::Null),
893 ],
894 );
895 db.execute(q).await.unwrap();
896 }
897
898 async fn get_row(
899 db: &Arc<RusqliteService>,
900 item_id: &str,
901 ) -> (String, Option<String>, Option<String>) {
902 let q = Query::with_params(
903 "SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
904 vec![QueryParam::String(item_id.to_string())],
905 );
906 db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
907 .await
908 .unwrap()
909 }
910
911 #[tokio::test]
918 async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
919 let db = test_db();
920 insert_download(&db, "queued-1", "pending", None, None).await;
922 insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
924 insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
926
927 let out = resolve_pending_download_urls(
928 &db,
929 "/data/downloads",
930 None,
931 |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
932 )
933 .await
934 .unwrap();
935
936 assert_eq!(out.resolved, 1);
937 assert_eq!(out.failed, 0);
938
939 let (status, url, target) = get_row(&db, "queued-1").await;
941 assert_eq!(status, "pending");
942 assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
943 assert_eq!(target.as_deref(), Some("/data/downloads"));
944
945 let (_s, url2, _t) = get_row(&db, "already").await;
947 assert_eq!(url2.as_deref(), Some("http://existing/url"));
948 }
949
950 #[tokio::test]
956 async fn only_ids_restricts_the_sweep_to_the_given_rows() {
957 let db = test_db();
958 insert_download(&db, "mine", "pending", None, Some("audio")).await;
959 insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
960
961 let mine: i64 = db
962 .query_one(
963 Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
964 |row| row.get(0),
965 )
966 .await
967 .unwrap();
968
969 let out = resolve_pending_download_urls(
970 &db,
971 "/data",
972 Some(&[mine]),
973 |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
974 )
975 .await
976 .unwrap();
977
978 assert_eq!(out.resolved, 1);
979 assert_eq!(out.failed, 0);
980
981 let (_s, url, _t) = get_row(&db, "mine").await;
982 assert_eq!(url.as_deref(), Some("http://resolved/mine"));
983
984 let (status, other_url, _t) = get_row(&db, "someone-elses").await;
985 assert_eq!(status, "pending");
986 assert_eq!(
987 other_url, None,
988 "a scoped resolve must leave unrelated pending rows alone"
989 );
990 }
991
992 #[tokio::test]
997 async fn an_empty_id_list_resolves_nothing() {
998 let db = test_db();
999 insert_download(&db, "untouched", "pending", None, Some("audio")).await;
1000
1001 let out =
1002 resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
1003 Some(format!("http://resolved/{item_id}").into())
1004 })
1005 .await
1006 .unwrap();
1007
1008 assert_eq!(out.resolved, 0);
1009 let (_s, url, _t) = get_row(&db, "untouched").await;
1010 assert_eq!(url, None);
1011 }
1012
1013 #[tokio::test]
1014 async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
1015 let db = test_db();
1016 insert_download(&db, "bad", "pending", None, None).await;
1017
1018 let out =
1020 resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
1021 .await
1022 .unwrap();
1023
1024 assert_eq!(out.resolved, 0);
1025 assert_eq!(out.failed, 1);
1026
1027 let (status, url, _t) = get_row(&db, "bad").await;
1029 assert_eq!(status, "pending");
1030 assert_eq!(url, None);
1031 }
1032
1033 #[tokio::test]
1041 async fn null_media_type_resolves_from_the_item_type_not_audio() {
1042 let db = test_db();
1043 insert_item(&db, "movie-1", "Movie").await;
1044 insert_item(&db, "ep-1", "Episode").await;
1045 insert_item(&db, "track-1", "Audio").await;
1046 for id in ["movie-1", "ep-1", "track-1"] {
1047 insert_download(&db, id, "pending", None, None).await;
1048 }
1049
1050 let seen = Arc::new(Mutex::new(Vec::new()));
1051 let seen_c = Arc::clone(&seen);
1052 resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
1053 let seen = Arc::clone(&seen_c);
1054 async move {
1055 seen.lock_safe().push((item_id.clone(), media_type));
1056 Some(format!("http://resolved/{item_id}").into())
1057 }
1058 })
1059 .await
1060 .unwrap();
1061
1062 let seen = seen.lock_safe().clone();
1063 let of = |id: &str| {
1064 seen.iter()
1065 .find(|(i, _)| i == id)
1066 .map(|(_, m)| m.clone())
1067 .unwrap()
1068 };
1069 assert_eq!(of("movie-1"), "video", "a Movie must download as video");
1070 assert_eq!(of("ep-1"), "video", "an Episode must download as video");
1071 assert_eq!(of("track-1"), "audio", "a track is still audio");
1072 }
1073
1074 #[tokio::test]
1079 async fn unknown_item_falls_back_to_audio() {
1080 let db = test_db();
1081 insert_download(&db, "ghost", "pending", None, None).await;
1082
1083 let seen = Arc::new(Mutex::new(String::new()));
1084 let seen_c = Arc::clone(&seen);
1085 resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
1086 let seen = Arc::clone(&seen_c);
1087 async move {
1088 *seen.lock_safe() = media_type;
1089 Some("http://x".to_string().into())
1090 }
1091 })
1092 .await
1093 .unwrap();
1094
1095 assert_eq!(*seen.lock_safe(), "audio");
1096 }
1097
1098 #[tokio::test]
1102 async fn explicit_media_type_beats_the_item_type() {
1103 let db = test_db();
1104 insert_item(&db, "odd", "Audio").await;
1105 insert_download(&db, "odd", "pending", None, Some("video")).await;
1106
1107 let seen = Arc::new(Mutex::new(String::new()));
1108 let seen_c = Arc::clone(&seen);
1109 resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
1110 let seen = Arc::clone(&seen_c);
1111 async move {
1112 *seen.lock_safe() = media_type;
1113 Some("http://x".to_string().into())
1114 }
1115 })
1116 .await
1117 .unwrap();
1118
1119 assert_eq!(*seen.lock_safe(), "video");
1120 }
1121
1122 #[tokio::test]
1130 async fn requeues_video_downloaded_under_the_audio_default() {
1131 let db = test_db();
1132 insert_item(&db, "movie-1", "Movie").await;
1133 insert_item(&db, "track-1", "Audio").await;
1134 insert_item(&db, "movie-ok", "Movie").await;
1135 insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
1137 insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
1139 insert_download(
1141 &db,
1142 "movie-ok",
1143 "completed",
1144 Some("http://video/ok"),
1145 Some("video"),
1146 )
1147 .await;
1148
1149 let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
1150 assert_eq!(requeued, 1);
1151
1152 let (status, url, _t) = get_row(&db, "movie-1").await;
1153 assert_eq!(status, "pending", "the mis-typed row must download again");
1154 assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
1155
1156 let (status, url, _t) = get_row(&db, "track-1").await;
1157 assert_eq!(status, "completed", "a real audio download is untouched");
1158 assert_eq!(url.as_deref(), Some("http://audio/ok"));
1159
1160 let (status, _u, _t) = get_row(&db, "movie-ok").await;
1161 assert_eq!(status, "completed", "a correct video download is untouched");
1162 }
1163
1164 #[tokio::test]
1171 async fn resolving_persists_the_predicted_size_without_erasing_a_known_one() {
1172 let db = test_db();
1173 insert_download(&db, "film", "pending", None, Some("video")).await;
1174 insert_download(&db, "track", "pending", None, Some("audio")).await;
1175 db.execute(Query::with_params(
1176 "UPDATE downloads SET file_size = 777 WHERE item_id = ?",
1177 vec![QueryParam::String("track".to_string())],
1178 ))
1179 .await
1180 .unwrap();
1181
1182 resolve_pending_download_urls(&db, "/data", None, |item_id, media_type, _q| async move {
1183 Some(ResolvedDownloadUrl {
1184 url: format!("http://resolved/{item_id}"),
1185 expected_bytes: (media_type == "video").then_some(1_500_000_000),
1186 })
1187 })
1188 .await
1189 .unwrap();
1190
1191 let size = |item: &'static str| {
1192 let db = Arc::clone(&db);
1193 async move {
1194 db.query_one(
1195 Query::with_params(
1196 "SELECT file_size FROM downloads WHERE item_id = ?",
1197 vec![QueryParam::String(item.to_string())],
1198 ),
1199 |row| row.get::<_, Option<i64>>(0),
1200 )
1201 .await
1202 .unwrap()
1203 }
1204 };
1205 assert_eq!(size("film").await, Some(1_500_000_000));
1206 assert_eq!(size("track").await, Some(777));
1207 }
1208
1209 #[tokio::test]
1210 async fn video_rows_use_media_type_in_resolver() {
1211 let db = test_db();
1212 insert_download(&db, "vid-1", "pending", None, Some("video")).await;
1213
1214 let out = resolve_pending_download_urls(
1215 &db,
1216 "/data",
1217 None,
1218 |item_id, media_type, _q| async move {
1219 assert_eq!(media_type, "video");
1220 Some(format!("http://transcode/{item_id}").into())
1221 },
1222 )
1223 .await
1224 .unwrap();
1225
1226 assert_eq!(out.resolved, 1);
1227 let (_s, url, _t) = get_row(&db, "vid-1").await;
1228 assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
1229 }
1230}