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
520pub(crate) async fn resolve_pending_download_urls<F, Fut>(
530 db_service: &Arc<crate::storage::db_service::RusqliteService>,
531 target_dir: &str,
532 only_ids: Option<&[i64]>,
533 resolve: F,
534) -> Result<ResumeQueuedResult, String>
535where
536 F: Fn(String, String, String) -> Fut,
537 Fut: std::future::Future<Output = Option<String>>,
538{
539 if only_ids.is_some_and(|ids| ids.is_empty()) {
540 return Ok(ResumeQueuedResult {
541 resolved: 0,
542 failed: 0,
543 });
544 }
545 let video_types = VIDEO_ITEM_TYPES
553 .iter()
554 .map(|t| format!("'{t}'"))
555 .collect::<Vec<_>>()
556 .join(", ");
557 let id_filter = match only_ids {
558 Some(ids) => format!(
559 " AND d.id IN ({})",
560 ids.iter()
561 .map(|id| id.to_string())
562 .collect::<Vec<_>>()
563 .join(", ")
564 ),
565 None => String::new(),
566 };
567 let rows_query = Query::new(format!(
568 "SELECT d.id, d.item_id,
569 COALESCE(
570 d.media_type,
571 CASE WHEN i.item_type IN ({video_types}) THEN 'video'
572 WHEN i.item_type IS NOT NULL THEN 'audio'
573 END,
574 'audio'),
575 COALESCE(d.quality_preset, 'original')
576 FROM downloads d
577 LEFT JOIN items i ON i.id = d.item_id
578 WHERE d.status = 'pending' AND d.stream_url IS NULL{id_filter}"
579 ));
580 let rows: Vec<(i64, String, String, String)> = db_service
581 .query_many(rows_query, |row| {
582 Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
583 })
584 .await
585 .map_err(|e| e.to_string())?;
586
587 if rows.is_empty() {
588 return Ok(ResumeQueuedResult {
589 resolved: 0,
590 failed: 0,
591 });
592 }
593
594 info!(
595 "[Catalog] Resolving {} offline-queued downloads on reconnect",
596 rows.len()
597 );
598
599 let mut resolved = 0usize;
600 let mut failed = 0usize;
601
602 for (download_id, item_id, media_type, quality) in rows {
603 let stream_url = match resolve(item_id.clone(), media_type, quality).await {
604 Some(url) => url,
605 None => {
606 failed += 1;
607 continue;
608 }
609 };
610
611 let update = Query::with_params(
614 "UPDATE downloads SET stream_url = ?, target_dir = ?
615 WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
616 vec![
617 QueryParam::String(stream_url),
618 QueryParam::String(target_dir.to_string()),
619 QueryParam::Int64(download_id),
620 ],
621 );
622 match db_service.execute(update).await {
623 Ok(n) if n > 0 => resolved += 1,
624 Ok(_) => {} Err(e) => {
626 warn!(
627 "[Catalog] Failed to persist URL for download {}: {}",
628 download_id, e
629 );
630 failed += 1;
631 }
632 }
633 }
634
635 Ok(ResumeQueuedResult { resolved, failed })
636}
637
638#[tauri::command]
647#[specta::specta]
648pub async fn resume_queued_downloads(
649 repository: State<'_, RepositoryManagerWrapper>,
650 db: State<'_, DatabaseWrapper>,
651 download_manager: State<'_, DownloadManagerWrapper>,
652 app: tauri::AppHandle,
653 handle: String,
654) -> Result<ResumeQueuedResult, String> {
655 use crate::repository::MediaRepository;
656
657 let repo = repository.0.get(&handle).ok_or("Repository not found")?;
658
659 let (db_service, target_dir) = {
662 let database = db.0.lock().map_err(|e| e.to_string())?;
663 let target_dir = database
664 .path()
665 .parent()
666 .ok_or_else(|| "Database path has no parent directory".to_string())?
667 .to_string_lossy()
668 .to_string();
669 (Arc::new(database.service()), target_dir)
670 };
671
672 let recover_query = Query::new(
677 "UPDATE downloads SET status = 'pending', stream_url = NULL, progress = 0, \
678 bytes_downloaded = 0, started_at = NULL \
679 WHERE status = 'downloading'",
680 );
681 match db_service.execute(recover_query).await {
682 Ok(n) if n > 0 => info!("[Catalog] Reset {} stale 'downloading' rows to pending", n),
683 Ok(_) => {}
684 Err(e) => warn!("[Catalog] Failed to reset stale downloads: {}", e),
685 }
686
687 if let Err(e) = requeue_mistyped_video_downloads(&db_service).await {
691 warn!(
692 "[Catalog] Failed to requeue mis-typed video downloads: {}",
693 e
694 );
695 }
696
697 let repo_for_resolve = Arc::clone(&repo);
699 let outcome = resolve_pending_download_urls(
700 &db_service,
701 &target_dir,
702 None,
703 move |item_id: String, media_type: String, quality: String| {
704 let repo = Arc::clone(&repo_for_resolve);
705 async move {
706 if media_type == "video" {
707 Some(
708 crate::repository::resolve_video_download_url(
709 repo.as_ref(),
710 &item_id,
711 &quality,
712 None,
713 )
714 .await,
715 )
716 } else {
717 match repo.get_audio_stream_url(&item_id).await {
718 Ok(url) => Some(url),
719 Err(e) => {
720 warn!(
721 "[Catalog] Failed to resolve audio URL for {}: {:?}",
722 item_id, e
723 );
724 None
725 }
726 }
727 }
728 }
729 },
730 )
731 .await
732 .map_err(|e| e.to_string())?;
733
734 let ResumeQueuedResult { resolved, failed } = outcome;
735
736 if resolved > 0 {
738 let active_downloads = {
739 let manager = download_manager.0.lock().map_err(|e| e.to_string())?;
740 manager.get_active_downloads()
741 };
742 pump_download_queue(app, db_service, active_downloads).await;
743 }
744
745 info!(
746 "[Catalog] Resume complete: {} resolved, {} failed",
747 resolved, failed
748 );
749
750 Ok(ResumeQueuedResult { resolved, failed })
751}
752
753#[cfg(test)]
754mod tests {
755 use super::*;
756 use crate::storage::db_service::RusqliteService;
757 use crate::utils::lock::MutexSafe;
758 use rusqlite::Connection;
759 use std::sync::Mutex;
760
761 #[test]
766 fn test_index_is_due() {
767 let ttl = Duration::from_secs(6 * 60 * 60);
768 let now = chrono::DateTime::parse_from_rfc3339("2026-08-04T12:00:00+00:00")
769 .unwrap()
770 .with_timezone(&chrono::Utc);
771
772 assert!(index_is_due(None, now, ttl));
774
775 assert!(index_is_due(Some("2026-08-04T05:00:00+00:00"), now, ttl));
777
778 assert!(!index_is_due(Some("2026-08-04T11:00:00+00:00"), now, ttl));
781
782 assert!(index_is_due(Some("2026-08-04T06:00:00+00:00"), now, ttl));
784
785 assert!(index_is_due(Some("not-a-timestamp"), now, ttl));
788 assert!(index_is_due(Some(""), now, ttl));
789
790 assert!(!index_is_due(Some("2026-08-05T00:00:00+00:00"), now, ttl));
793
794 assert!(!index_is_due(Some("2026-08-04T13:30:00+02:00"), now, ttl));
796 }
797
798 fn test_db() -> Arc<RusqliteService> {
799 let conn = Connection::open_in_memory().unwrap();
800 conn.execute_batch(
801 r#"
802 CREATE TABLE downloads (
803 id INTEGER PRIMARY KEY AUTOINCREMENT,
804 item_id TEXT NOT NULL,
805 status TEXT NOT NULL,
806 stream_url TEXT,
807 target_dir TEXT,
808 media_type TEXT,
809 quality_preset TEXT,
810 progress REAL DEFAULT 0,
811 bytes_downloaded INTEGER DEFAULT 0,
812 started_at TEXT,
813 completed_at TEXT
814 );
815 CREATE TABLE items (
816 id TEXT PRIMARY KEY,
817 item_type TEXT
818 );
819 "#,
820 )
821 .unwrap();
822 Arc::new(RusqliteService::new(Arc::new(Mutex::new(conn))))
823 }
824
825 async fn insert_item(db: &Arc<RusqliteService>, item_id: &str, item_type: &str) {
826 db.execute(Query::with_params(
827 "INSERT INTO items (id, item_type) VALUES (?, ?)",
828 vec![
829 QueryParam::String(item_id.to_string()),
830 QueryParam::String(item_type.to_string()),
831 ],
832 ))
833 .await
834 .unwrap();
835 }
836
837 async fn insert_download(
838 db: &Arc<RusqliteService>,
839 item_id: &str,
840 status: &str,
841 stream_url: Option<&str>,
842 media_type: Option<&str>,
843 ) {
844 let q = Query::with_params(
845 "INSERT INTO downloads (item_id, status, stream_url, media_type) VALUES (?, ?, ?, ?)",
846 vec![
847 QueryParam::String(item_id.to_string()),
848 QueryParam::String(status.to_string()),
849 stream_url
850 .map(|s| QueryParam::String(s.to_string()))
851 .unwrap_or(QueryParam::Null),
852 media_type
853 .map(|s| QueryParam::String(s.to_string()))
854 .unwrap_or(QueryParam::Null),
855 ],
856 );
857 db.execute(q).await.unwrap();
858 }
859
860 async fn get_row(
861 db: &Arc<RusqliteService>,
862 item_id: &str,
863 ) -> (String, Option<String>, Option<String>) {
864 let q = Query::with_params(
865 "SELECT status, stream_url, target_dir FROM downloads WHERE item_id = ?",
866 vec![QueryParam::String(item_id.to_string())],
867 );
868 db.query_one(q, |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
869 .await
870 .unwrap()
871 }
872
873 #[tokio::test]
880 async fn resolves_offline_queued_row_and_leaves_resolved_rows_untouched() {
881 let db = test_db();
882 insert_download(&db, "queued-1", "pending", None, None).await;
884 insert_download(&db, "already", "pending", Some("http://existing/url"), None).await;
886 insert_download(&db, "done", "completed", Some("http://done/url"), None).await;
888
889 let out = resolve_pending_download_urls(
890 &db,
891 "/data/downloads",
892 None,
893 |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
894 )
895 .await
896 .unwrap();
897
898 assert_eq!(out.resolved, 1);
899 assert_eq!(out.failed, 0);
900
901 let (status, url, target) = get_row(&db, "queued-1").await;
903 assert_eq!(status, "pending");
904 assert_eq!(url.as_deref(), Some("http://resolved/queued-1"));
905 assert_eq!(target.as_deref(), Some("/data/downloads"));
906
907 let (_s, url2, _t) = get_row(&db, "already").await;
909 assert_eq!(url2.as_deref(), Some("http://existing/url"));
910 }
911
912 #[tokio::test]
918 async fn only_ids_restricts_the_sweep_to_the_given_rows() {
919 let db = test_db();
920 insert_download(&db, "mine", "pending", None, Some("audio")).await;
921 insert_download(&db, "someone-elses", "pending", None, Some("audio")).await;
922
923 let mine: i64 = db
924 .query_one(
925 Query::new("SELECT id FROM downloads WHERE item_id = 'mine'"),
926 |row| row.get(0),
927 )
928 .await
929 .unwrap();
930
931 let out = resolve_pending_download_urls(
932 &db,
933 "/data",
934 Some(&[mine]),
935 |item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
936 )
937 .await
938 .unwrap();
939
940 assert_eq!(out.resolved, 1);
941 assert_eq!(out.failed, 0);
942
943 let (_s, url, _t) = get_row(&db, "mine").await;
944 assert_eq!(url.as_deref(), Some("http://resolved/mine"));
945
946 let (status, other_url, _t) = get_row(&db, "someone-elses").await;
947 assert_eq!(status, "pending");
948 assert_eq!(
949 other_url, None,
950 "a scoped resolve must leave unrelated pending rows alone"
951 );
952 }
953
954 #[tokio::test]
959 async fn an_empty_id_list_resolves_nothing() {
960 let db = test_db();
961 insert_download(&db, "untouched", "pending", None, Some("audio")).await;
962
963 let out =
964 resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
965 Some(format!("http://resolved/{item_id}"))
966 })
967 .await
968 .unwrap();
969
970 assert_eq!(out.resolved, 0);
971 let (_s, url, _t) = get_row(&db, "untouched").await;
972 assert_eq!(url, None);
973 }
974
975 #[tokio::test]
976 async fn counts_unresolvable_rows_as_failed_and_leaves_them_pending() {
977 let db = test_db();
978 insert_download(&db, "bad", "pending", None, None).await;
979
980 let out =
982 resolve_pending_download_urls(&db, "/data", None, |_id, _mt, _q| async move { None })
983 .await
984 .unwrap();
985
986 assert_eq!(out.resolved, 0);
987 assert_eq!(out.failed, 1);
988
989 let (status, url, _t) = get_row(&db, "bad").await;
991 assert_eq!(status, "pending");
992 assert_eq!(url, None);
993 }
994
995 #[tokio::test]
1003 async fn null_media_type_resolves_from_the_item_type_not_audio() {
1004 let db = test_db();
1005 insert_item(&db, "movie-1", "Movie").await;
1006 insert_item(&db, "ep-1", "Episode").await;
1007 insert_item(&db, "track-1", "Audio").await;
1008 for id in ["movie-1", "ep-1", "track-1"] {
1009 insert_download(&db, id, "pending", None, None).await;
1010 }
1011
1012 let seen = Arc::new(Mutex::new(Vec::new()));
1013 let seen_c = Arc::clone(&seen);
1014 resolve_pending_download_urls(&db, "/data", None, move |item_id, media_type, _q| {
1015 let seen = Arc::clone(&seen_c);
1016 async move {
1017 seen.lock_safe().push((item_id.clone(), media_type));
1018 Some(format!("http://resolved/{item_id}"))
1019 }
1020 })
1021 .await
1022 .unwrap();
1023
1024 let seen = seen.lock_safe().clone();
1025 let of = |id: &str| {
1026 seen.iter()
1027 .find(|(i, _)| i == id)
1028 .map(|(_, m)| m.clone())
1029 .unwrap()
1030 };
1031 assert_eq!(of("movie-1"), "video", "a Movie must download as video");
1032 assert_eq!(of("ep-1"), "video", "an Episode must download as video");
1033 assert_eq!(of("track-1"), "audio", "a track is still audio");
1034 }
1035
1036 #[tokio::test]
1041 async fn unknown_item_falls_back_to_audio() {
1042 let db = test_db();
1043 insert_download(&db, "ghost", "pending", None, None).await;
1044
1045 let seen = Arc::new(Mutex::new(String::new()));
1046 let seen_c = Arc::clone(&seen);
1047 resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
1048 let seen = Arc::clone(&seen_c);
1049 async move {
1050 *seen.lock_safe() = media_type;
1051 Some("http://x".to_string())
1052 }
1053 })
1054 .await
1055 .unwrap();
1056
1057 assert_eq!(*seen.lock_safe(), "audio");
1058 }
1059
1060 #[tokio::test]
1064 async fn explicit_media_type_beats_the_item_type() {
1065 let db = test_db();
1066 insert_item(&db, "odd", "Audio").await;
1067 insert_download(&db, "odd", "pending", None, Some("video")).await;
1068
1069 let seen = Arc::new(Mutex::new(String::new()));
1070 let seen_c = Arc::clone(&seen);
1071 resolve_pending_download_urls(&db, "/data", None, move |_id, media_type, _q| {
1072 let seen = Arc::clone(&seen_c);
1073 async move {
1074 *seen.lock_safe() = media_type;
1075 Some("http://x".to_string())
1076 }
1077 })
1078 .await
1079 .unwrap();
1080
1081 assert_eq!(*seen.lock_safe(), "video");
1082 }
1083
1084 #[tokio::test]
1092 async fn requeues_video_downloaded_under_the_audio_default() {
1093 let db = test_db();
1094 insert_item(&db, "movie-1", "Movie").await;
1095 insert_item(&db, "track-1", "Audio").await;
1096 insert_item(&db, "movie-ok", "Movie").await;
1097 insert_download(&db, "movie-1", "completed", Some("http://audio/url"), None).await;
1099 insert_download(&db, "track-1", "completed", Some("http://audio/ok"), None).await;
1101 insert_download(
1103 &db,
1104 "movie-ok",
1105 "completed",
1106 Some("http://video/ok"),
1107 Some("video"),
1108 )
1109 .await;
1110
1111 let requeued = requeue_mistyped_video_downloads(&db).await.unwrap();
1112 assert_eq!(requeued, 1);
1113
1114 let (status, url, _t) = get_row(&db, "movie-1").await;
1115 assert_eq!(status, "pending", "the mis-typed row must download again");
1116 assert_eq!(url, None, "its audio URL must be cleared so it re-resolves");
1117
1118 let (status, url, _t) = get_row(&db, "track-1").await;
1119 assert_eq!(status, "completed", "a real audio download is untouched");
1120 assert_eq!(url.as_deref(), Some("http://audio/ok"));
1121
1122 let (status, _u, _t) = get_row(&db, "movie-ok").await;
1123 assert_eq!(status, "completed", "a correct video download is untouched");
1124 }
1125
1126 #[tokio::test]
1127 async fn video_rows_use_media_type_in_resolver() {
1128 let db = test_db();
1129 insert_download(&db, "vid-1", "pending", None, Some("video")).await;
1130
1131 let out = resolve_pending_download_urls(
1132 &db,
1133 "/data",
1134 None,
1135 |item_id, media_type, _q| async move {
1136 assert_eq!(media_type, "video");
1137 Some(format!("http://transcode/{item_id}"))
1138 },
1139 )
1140 .await
1141 .unwrap();
1142
1143 assert_eq!(out.resolved, 1);
1144 let (_s, url, _t) = get_row(&db, "vid-1").await;
1145 assert_eq!(url.as_deref(), Some("http://transcode/vid-1"));
1146 }
1147}