1use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, Mutex};
6
7use crate::config::Config;
8use crate::db::connection::Database;
9use crate::db::queries;
10use crate::db::queries::shares::{ShareKind, Slice};
11use crate::player::commands::PlayerCommand;
12use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
13use crate::remote::client::{SubsonicAuth, SubsonicClient, SubsonicError};
14use crate::remote::download::DownloadError;
15
16pub fn get_remote_password(cfg: &Config) -> Option<String> {
23 (!cfg.remote.password.is_empty()).then(|| cfg.remote.password.clone())
24}
25
26pub fn spawn_library_watch(
50 db_path: std::path::PathBuf,
51 on_state: impl Fn(bool) + Send + Sync + 'static,
52) -> Option<std::thread::JoinHandle<()>> {
53 use std::collections::BTreeSet;
54 use std::time::{Duration, Instant};
55
56 use notify::{RecursiveMode, Watcher};
57
58 use crate::index::scanner::{self, ScanOptions};
59 use crate::index::watch::{WatchedRoot, scan_target};
60
61 const SETTLE: Duration = Duration::from_secs(5);
64 const CHECK: Duration = Duration::from_secs(30);
66 const MAX_DIRS: usize = 200;
68 const RESCAN: Duration = Duration::from_secs(15 * 60);
72
73 std::thread::Builder::new()
74 .name("koan-library-watch".into())
75 .spawn(move || {
76 let scan = |reason: &str, folders: &[PathBuf], dirs: Option<&[PathBuf]>| {
77 if folders.is_empty() {
78 return;
79 }
80 let Ok(db) = Database::open_existing(&db_path) else {
81 return;
82 };
83 on_state(true);
84 let result = match dirs {
85 Some(dirs) => {
86 scanner::scan_dirs(&db, folders, dirs, ScanOptions::default(), None)
87 }
88 None => scanner::full_scan(&db, folders, ScanOptions::default(), None),
89 };
90 on_state(false);
91 log::info!(
92 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
93 result.added,
94 result.updated,
95 result.removed,
96 result.skipped
97 );
98 };
99 let folders = || Config::cached().library.folders.clone();
100
101 let (tx, rx) = std::sync::mpsc::channel();
102 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
103 let _ = tx.send(event);
104 }) else {
105 log::warn!("could not watch the library folders");
106 return;
107 };
108
109 let mut roots: Vec<WatchedRoot> = Vec::new();
113 let mut rewatch = |roots: &mut Vec<WatchedRoot>| {
114 let wanted: Vec<WatchedRoot> = folders()
115 .iter()
116 .filter_map(|f| WatchedRoot::resolve(f))
117 .collect();
118 roots.retain(|root| {
119 let keep = wanted.contains(root);
120 if !keep {
121 let _ = watcher.unwatch(&root.path);
122 }
123 keep
124 });
125 let mut fresh = Vec::new();
126 for root in wanted {
127 if roots.contains(&root) {
128 continue;
129 }
130 match watcher.watch(&root.path, RecursiveMode::Recursive) {
131 Ok(()) => {
132 fresh.push(root.path.clone());
133 roots.push(root);
134 }
135 Err(e) => log::warn!("could not watch {}: {e}", root.path.display()),
136 }
137 }
138 fresh
139 };
140
141 std::thread::sleep(Duration::from_secs(3));
143 rewatch(&mut roots);
144 scan("startup", &folders(), None);
145
146 let mut dirs = BTreeSet::new();
147 let mut everything = false;
148 let mut settle_at: Option<Instant> = None;
149 let mut check_at = Instant::now() + CHECK;
150 let mut rescan_at = Instant::now() + RESCAN;
151 loop {
152 let now = Instant::now();
153 let wake = settle_at
154 .map_or(check_at, |at| at.min(check_at))
155 .min(rescan_at);
156 match rx.recv_timeout(wake.saturating_duration_since(now)) {
157 Ok(Ok(event)) if event.need_rescan() => {
158 everything = true;
159 settle_at = Some(Instant::now() + SETTLE);
160 }
161 Ok(Ok(event)) => {
162 let mut heard = false;
163 for dir in event
164 .paths
165 .iter()
166 .filter_map(|p| scan_target(&event.kind, p, &roots))
167 {
168 dirs.insert(dir);
169 heard = true;
170 }
171 if heard {
172 settle_at = Some(Instant::now() + SETTLE);
173 }
174 }
175 Ok(Err(e)) => log::debug!("library watch: {e}"),
176 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
177 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
178 }
179
180 let now = Instant::now();
181 if settle_at.is_some_and(|at| now >= at) {
182 let changed =
183 scanner::minimal_dirs(std::mem::take(&mut dirs).into_iter().collect());
184 if everything || changed.len() > MAX_DIRS {
185 scan("watched change", &folders(), None);
186 rescan_at = Instant::now() + RESCAN;
187 } else {
188 scan("watched change", &folders(), Some(&changed));
189 }
190 everything = false;
191 settle_at = None;
192 }
193 if now >= rescan_at {
194 scan("periodic", &folders(), None);
195 rescan_at = Instant::now() + RESCAN;
196 }
197 if now >= check_at {
198 let fresh = rewatch(&mut roots);
199 if !fresh.is_empty() {
200 scan("newly watched", &fresh, None);
201 }
202 check_at = Instant::now() + CHECK;
203 }
204 }
205 })
206 .ok()
207}
208
209pub fn spawn_auto_sync(
223 db_path: std::path::PathBuf,
224 on_state: impl Fn(bool) + Send + 'static,
225 on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
226) -> Option<std::thread::JoinHandle<()>> {
227 std::thread::Builder::new()
228 .name("koan-auto-sync".into())
229 .spawn(move || {
230 std::thread::sleep(std::time::Duration::from_secs(5));
231 loop {
232 let cfg = Config::load().unwrap_or_default();
233 if !cfg.remote.enabled || !cfg.remote.auto_sync {
234 std::thread::sleep(std::time::Duration::from_secs(60));
237 continue;
238 }
239
240 if let Some(client) = subsonic_client(&cfg)
241 && let Ok(db) = Database::open_existing(&db_path)
242 {
243 on_state(true);
244 match sync_remote(
245 &db,
246 &client,
247 false,
248 &cfg.remote.url,
249 &cfg.remote.username,
250 &on_progress,
251 ) {
252 Ok(s) => log::info!(
253 "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
254 favourites {}↑ {}↓; playlists {}↓ {}↑",
255 s.library.artists_synced,
256 s.library.albums_synced,
257 s.library.tracks_synced,
258 s.library.albums_failed,
259 s.favourites.pushed,
260 s.favourites.imported,
261 s.playlists.pulled,
262 s.playlists.pushed,
263 ),
264 Err(e) => log::warn!("auto sync failed: {e}"),
265 }
266 on_state(false);
267 }
268
269 match cfg.remote.auto_sync_interval_mins {
270 0 => return,
272 mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
273 }
274 }
275 })
276 .ok()
277}
278
279#[derive(Debug, Clone, Copy, Default)]
281pub struct RebuildSummary {
282 pub tracks: u64,
283 pub albums: u64,
284 pub artists: u64,
285}
286
287pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
298 let count = |sql: &str| -> u64 {
299 db.conn
300 .query_row(sql, [], |r| r.get::<_, i64>(0))
301 .unwrap_or(0) as u64
302 };
303 let summary = RebuildSummary {
304 tracks: count("SELECT COUNT(*) FROM tracks"),
305 albums: count("SELECT COUNT(*) FROM albums"),
306 artists: count("SELECT COUNT(*) FROM artists"),
307 };
308
309 db.conn.execute_batch(
312 "BEGIN;
313 DELETE FROM track_vectors;
314 DELETE FROM lyrics_cache;
315 DELETE FROM play_history;
316 DELETE FROM scan_cache;
317 DELETE FROM tracks_fts;
318 DELETE FROM tracks;
319 DELETE FROM similar_artists;
320 DELETE FROM albums;
321 DELETE FROM artists;
322 COMMIT;",
323 )?;
324 let _ = db.conn.execute_batch("VACUUM");
325 Ok(summary)
326}
327
328pub fn evict_cache(
333 db: &Database,
334 cfg: &Config,
335 keep: &std::collections::HashSet<i64>,
336 verbose: bool,
337) -> u64 {
338 let Some(limit) = cfg.cache_limit_bytes().map(|l| l as i64) else {
339 return 0;
340 };
341 let mut current = match queries::total_cache_size(&db.conn) {
342 Ok(s) => s,
343 Err(e) => {
344 log::warn!("cache eviction: failed to query cache size: {e}");
345 return 0;
346 }
347 };
348 if current <= limit {
349 if verbose {
350 log::info!("cache within limit: {current} / {limit} bytes");
351 }
352 return 0;
353 }
354 let albums = match queries::cached_albums_lru(&db.conn) {
355 Ok(a) => a,
356 Err(e) => {
357 log::warn!("cache eviction: failed to query cached albums: {e}");
358 return 0;
359 }
360 };
361 let mut freed: i64 = 0;
362 for album in &albums {
363 if current <= limit {
364 break;
365 }
366 if album.track_ids.iter().any(|id| keep.contains(id)) {
367 continue;
368 }
369 for path in &album.cached_paths {
370 match std::fs::remove_file(path) {
371 Ok(()) => {}
372 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
373 Err(e) => log::warn!("cache eviction: failed to delete {path}: {e}"),
374 }
375 }
376 if let Err(e) = queries::clear_cached_paths_for(&db.conn, &album.track_ids) {
377 log::warn!("cache eviction: failed to clear DB for album: {e}");
378 }
379 log::info!(
380 "evicted: {} — {} ({} bytes)",
381 album.artist_name,
382 album.album_title,
383 album.total_size
384 );
385 current -= album.total_size;
386 freed += album.total_size;
387 }
388 remove_empty_dirs(&cfg.cache_dir());
389 if freed > 0 {
390 log::info!("cache eviction freed {freed} bytes");
391 }
392 freed as u64
393}
394
395fn remove_empty_dirs(dir: &Path) {
397 if !dir.is_dir() {
398 return;
399 }
400 for entry in walkdir::WalkDir::new(dir)
401 .contents_first(true)
402 .into_iter()
403 .filter_map(Result::ok)
404 .filter(|e| e.file_type().is_dir() && e.path() != dir)
405 {
406 let _ = std::fs::remove_dir(entry.path());
407 }
408}
409
410pub fn cache_size_bytes(cfg: &Config) -> u64 {
412 walkdir::WalkDir::new(cfg.cache_dir())
413 .into_iter()
414 .filter_map(Result::ok)
415 .filter(|e| e.file_type().is_file())
416 .filter_map(|e| e.metadata().ok())
417 .map(|m| m.len())
418 .sum()
419}
420
421pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
426 let (lower, upper) = queries::folder_prefix_range(folder);
427 db.conn
428 .query_row(
429 "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
430 [&lower, &upper],
431 |r| r.get::<_, i64>(0),
432 )
433 .unwrap_or(0) as u64
434}
435
436pub fn tracks_from_server(db: &Database) -> u64 {
438 db.conn
439 .query_row(
440 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
441 [],
442 |r| r.get::<_, i64>(0),
443 )
444 .unwrap_or(0) as u64
445}
446
447pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
460 let folder = &crate::index::spelling::on_disk(folder);
462 let (lower, upper) = queries::folder_prefix_range(folder);
463
464 let tx = crate::db::queries::write_transaction(&db.conn)?;
465 tx.execute(
467 "UPDATE tracks SET path = NULL, source = 'remote'
468 WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
469 [&lower, &upper],
470 )?;
471
472 let ids: Vec<i64> = {
473 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
474 let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
475 rows.filter_map(Result::ok).collect()
476 };
477 delete_track_rows(&tx, &ids)?;
478 prune_empty_albums_and_artists(&tx)?;
479 tx.commit()?;
480 Ok(ids.len() as u64)
481}
482
483fn delete_track_rows(conn: &rusqlite::Connection, ids: &[i64]) -> rusqlite::Result<()> {
484 for id in ids {
485 conn.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
486 conn.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
487 conn.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
488 conn.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
489 conn.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
490 conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
491 }
492 Ok(())
493}
494
495pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
501 let tx = crate::db::queries::write_transaction(&db.conn)?;
502
503 let ids: Vec<i64> = {
504 let mut stmt =
505 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
506 let rows = stmt.query_map([], |r| r.get(0))?;
507 rows.filter_map(Result::ok).collect()
508 };
509 delete_track_rows(&tx, &ids)?;
510 tx.execute(
512 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
513 WHERE remote_id IS NOT NULL",
514 [],
515 )?;
516 tx.execute("DELETE FROM similar_artists", [])?;
517 prune_empty_albums_and_artists(&tx)?;
518 tx.commit()?;
519 Ok(ids.len() as u64)
520}
521
522fn prune_empty_albums_and_artists(
524 tx: &rusqlite::Transaction<'_>,
525) -> Result<(), crate::db::connection::DbError> {
526 tx.execute(
527 "DELETE FROM albums WHERE NOT EXISTS
528 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
529 [],
530 )?;
531 tx.execute(
532 "DELETE FROM similar_artists WHERE NOT EXISTS
533 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
534 [],
535 )?;
536 tx.execute(
537 "DELETE FROM artists WHERE NOT EXISTS
538 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
539 AND NOT EXISTS
540 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
541 [],
542 )?;
543 Ok(())
544}
545
546#[derive(Debug, Clone, Copy, Default)]
548pub struct CacheCleared {
549 pub files: u64,
550 pub bytes: u64,
551}
552
553pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
558 let dir = cfg.cache_dir();
559 let mut cleared = CacheCleared::default();
560 for entry in walkdir::WalkDir::new(&dir)
561 .into_iter()
562 .filter_map(Result::ok)
563 .filter(|e| e.file_type().is_file())
564 {
565 if let Ok(meta) = entry.metadata() {
566 cleared.bytes += meta.len();
567 cleared.files += 1;
568 }
569 }
570 let _ = std::fs::remove_dir_all(&dir);
571 let _ = std::fs::create_dir_all(&dir);
572 let _ = queries::clear_cached_paths(&db.conn);
573 cleared
574}
575
576pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
583 let mut cleared = CacheCleared::default();
584 let paths = match queries::cached_paths_for(&db.conn, track_ids) {
585 Ok(paths) => paths,
586 Err(e) => {
587 log::warn!("could not read cached paths: {e}");
588 return cleared;
589 }
590 };
591 for path in &paths {
592 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
593 match std::fs::remove_file(path) {
594 Ok(()) => {
595 cleared.files += 1;
596 cleared.bytes += size;
597 }
598 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
600 Err(e) => log::warn!("could not remove {path}: {e}"),
601 }
602 }
603 if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
604 log::warn!("removed downloads but failed to forget them ({e})");
605 }
606 cleared
607}
608
609pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
622 let mut swept = CacheCleared::default();
623 for entry in walkdir::WalkDir::new(cfg.cache_dir())
624 .into_iter()
625 .filter_map(Result::ok)
626 .filter(|e| e.file_type().is_file())
627 .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
628 {
629 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
630 match std::fs::remove_file(entry.path()) {
631 Ok(()) => {
632 swept.files += 1;
633 swept.bytes += size;
634 }
635 Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
636 }
637 }
638 if swept.files > 0 {
639 log::info!(
640 "swept {} unfinished download(s), {} bytes",
641 swept.files,
642 swept.bytes
643 );
644 }
645 swept
646}
647
648pub fn relocate_cached_paths(db: &Database, cache_dir: &Path) -> rusqlite::Result<usize> {
661 let prefix = format!("{}/", cache_dir.to_string_lossy().trim_end_matches('/'));
662 let stale: Vec<(i64, String)> = db
663 .conn
664 .prepare(
665 "SELECT id, cached_path FROM tracks
666 WHERE cached_path IS NOT NULL AND substr(cached_path, 1, ?2) != ?1",
667 )?
668 .query_map(
669 rusqlite::params![prefix, prefix.chars().count() as i64],
670 |r| Ok((r.get(0)?, r.get(1)?)),
671 )?
672 .collect::<rusqlite::Result<_>>()?;
673 if stale.is_empty() {
674 return Ok(0);
675 }
676
677 let tx = crate::db::queries::write_transaction(&db.conn)?;
678 let mut moved = 0;
679 for (id, old) in &stale {
680 let tail: Vec<_> = Path::new(old).components().rev().take(3).collect();
681 if tail.len() < 3 {
682 continue;
683 }
684 let new = tail
685 .iter()
686 .rev()
687 .fold(cache_dir.to_path_buf(), |p, c| p.join(c));
688 if new.is_file() {
689 tx.execute(
690 "UPDATE tracks SET cached_path = ?1 WHERE id = ?2",
691 rusqlite::params![new.to_string_lossy(), id],
692 )?;
693 moved += 1;
694 }
695 }
696 tx.commit()?;
697 if moved > 0 {
698 log::info!(
699 "re-rooted {moved} cached path(s) under {}",
700 cache_dir.display()
701 );
702 }
703 Ok(moved)
704}
705
706pub fn requeue_cleared_downloads(
713 state: &Arc<SharedPlayerState>,
714 tx: &crossbeam_channel::Sender<PlayerCommand>,
715) {
716 let stale = state.reset_items_with_missing_files();
717 if stale.is_empty() {
718 return;
719 }
720 log::info!(
721 "{} queued tracks lost their copy — fetching again",
722 stale.len()
723 );
724 spawn_downloads(stale, tx.clone(), state.clone());
725}
726
727pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
739 let cfg = Config::load().unwrap_or_default();
740 if !cfg.remote.enabled {
741 return;
742 }
743 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
744 log::warn!("not syncing favourite: {} has no remote id", path.display());
745 return;
746 };
747 let Some(client) = subsonic_client(&cfg) else {
748 log::warn!("not syncing favourite: no usable server credentials");
749 return;
750 };
751 std::thread::Builder::new()
752 .name("koan-fav-sync".into())
753 .spawn(move || {
754 let result = if star {
755 client.star(&remote_id)
756 } else {
757 client.unstar(&remote_id)
758 };
759 match result {
760 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
761 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
762 }
763 })
764 .ok();
765}
766
767#[derive(Debug, Default)]
769pub struct FullSync {
770 pub library: crate::remote::sync::SyncResult,
771 pub favourites: FavouriteSync,
772 pub playlists: crate::playlists::PlaylistSync,
773}
774
775pub fn sync_remote(
783 db: &Database,
784 client: &SubsonicClient,
785 full: bool,
786 url: &str,
787 username: &str,
788 progress: &(dyn Fn(crate::remote::sync::SyncProgress) + Sync),
789) -> Result<FullSync, crate::remote::sync::SyncError> {
790 static SYNCING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
795 let _one_at_a_time = SYNCING.lock();
796 let library = crate::remote::sync::sync_library(db, client, full, url, username, progress)?;
797 Ok(FullSync {
798 library,
799 favourites: reconcile_favourites(db, client),
800 playlists: crate::playlists::reconcile_playlists(db, client, url, username),
801 })
802}
803
804#[derive(Debug, Default, Clone, Copy)]
806pub struct FavouriteSync {
807 pub pushed: usize,
808 pub imported: usize,
809}
810
811pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
823 let mut out = FavouriteSync::default();
824
825 let starred = match client.get_starred_all() {
826 Ok(s) => s,
827 Err(e) => {
828 log::warn!("could not fetch starred items from the server: {e}");
829 return out;
830 }
831 };
832 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
833 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
834 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
835
836 let unstarred = |ids: Vec<String>, starred: &[String]| {
837 let starred: std::collections::HashSet<&String> = starred.iter().collect();
838 ids.into_iter()
839 .filter(|id| !starred.contains(id))
840 .collect::<Vec<_>>()
841 };
842 let tracks = queries::favourites_with_remote_id(&db.conn, queries::LOCAL_USER)
843 .unwrap_or_default()
844 .into_iter()
845 .map(|(_, id)| id)
846 .collect();
847 for remote_id in unstarred(tracks, &songs) {
848 if client.star(&remote_id).is_ok() {
849 out.pushed += 1;
850 }
851 }
852 let local_albums = queries::favourite_albums_with_remote_id(&db.conn, queries::LOCAL_USER)
853 .unwrap_or_default()
854 .into_iter()
855 .map(|(_, id)| id)
856 .collect();
857 for remote_id in unstarred(local_albums, &albums) {
858 if client.star_album(&remote_id).is_ok() {
859 out.pushed += 1;
860 }
861 }
862 let local_artists = queries::favourite_artists_with_remote_id(&db.conn, queries::LOCAL_USER)
863 .unwrap_or_default()
864 .into_iter()
865 .map(|(_, id)| id)
866 .collect();
867 for remote_id in unstarred(local_artists, &artists) {
868 if client.star_artist(&remote_id).is_ok() {
869 out.pushed += 1;
870 }
871 }
872
873 out.imported +=
874 queries::import_remote_favourites(&db.conn, queries::LOCAL_USER, &songs).unwrap_or(0);
875 out.imported += queries::import_remote_favourite_albums(&db.conn, queries::LOCAL_USER, &albums)
876 .unwrap_or(0);
877 out.imported +=
878 queries::import_remote_favourite_artists(&db.conn, queries::LOCAL_USER, &artists)
879 .unwrap_or(0);
880 out
881}
882
883#[derive(Debug, Clone, Copy, PartialEq, Eq)]
886pub enum FavouriteKind {
887 Track,
888 Album,
889 Artist,
890}
891
892pub fn sync_collection_favourite_to_remote(
897 db: &Database,
898 kind: FavouriteKind,
899 id: i64,
900 star: bool,
901) {
902 let cfg = Config::load().unwrap_or_default();
903 if !cfg.remote.enabled {
904 return;
905 }
906 let remote_id = match kind {
907 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
908 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
909 FavouriteKind::Track => return,
910 };
911 let Ok(Some(remote_id)) = remote_id else {
912 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
913 return;
914 };
915 let Some(client) = subsonic_client(&cfg) else {
916 log::warn!("not syncing favourite: no usable server credentials");
917 return;
918 };
919 std::thread::Builder::new()
920 .name("koan-fav-sync".into())
921 .spawn(move || {
922 let result = match (kind, star) {
923 (FavouriteKind::Album, true) => client.star_album(&remote_id),
924 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
925 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
926 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
927 (FavouriteKind::Track, _) => Ok(()),
928 };
929 match result {
930 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
931 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
932 }
933 })
934 .ok();
935}
936
937#[derive(Debug, thiserror::Error)]
939pub enum SignInError {
940 #[error("the server did not accept those credentials: {0}")]
941 Rejected(#[from] crate::remote::client::SubsonicError),
942 #[error("could not write the configuration: {0}")]
943 Config(#[from] crate::config::ConfigError),
944}
945
946pub fn set_remote_credentials(
959 url: &str,
960 username: &str,
961 password: &str,
962) -> Result<(), SignInError> {
963 let url = url.trim_end_matches('/');
964 SubsonicClient::new(url, username, password).ping()?;
965
966 Config::persist(|cfg| {
967 cfg.remote.enabled = true;
968 cfg.remote.url = url.to_string();
969 cfg.remote.username = username.to_string();
970 cfg.remote.password = password.to_string();
971 })?;
972 crate::remote::link::nudge();
975 Ok(())
976}
977
978pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
982 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
983}
984
985pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
992 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
993 return None;
994 }
995 let password = get_remote_password(cfg)?;
996 Some(SubsonicAuth::new(
997 &cfg.remote.url,
998 &cfg.remote.username,
999 &password,
1000 ))
1001}
1002
1003pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
1014 let auth = subsonic_auth(cfg)?;
1015
1016 let mut slot = SUBSONIC_CLIENT.lock();
1017 if let Some((cached, client)) = slot.as_ref()
1018 && *cached == auth
1019 {
1020 return Some(client.clone());
1021 }
1022
1023 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
1024 *slot = Some((auth, client.clone()));
1025 Some(client)
1026}
1027
1028type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
1029
1030static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
1031 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
1032
1033#[derive(Debug, thiserror::Error)]
1040pub enum ShareError {
1041 #[error("sharing.public_url is not set, so there is no address to give out")]
1042 NoPublicUrl,
1043 #[error("none of these tracks are in the library")]
1044 NothingToShare,
1045 #[error("none of these tracks are on the server, so a link has nothing to point at")]
1046 NothingRemote,
1047 #[error("the server refused to share these: {0}")]
1048 Server(#[from] crate::remote::client::SubsonicError),
1049 #[error(transparent)]
1050 Database(#[from] crate::db::connection::DbError),
1051}
1052
1053#[derive(Debug, Clone)]
1055pub struct ShareOutcome {
1056 pub url: String,
1057 pub id: String,
1059 pub shared: usize,
1061 pub skipped: usize,
1063}
1064
1065#[derive(Debug, Clone, PartialEq, Eq)]
1067pub enum ShareTarget {
1068 Tracks(Vec<i64>),
1070 Album {
1072 album_id: i64,
1073 start_track_id: Option<i64>,
1074 },
1075 Artist(i64),
1077}
1078
1079pub fn resolve_share(
1085 conn: &rusqlite::Connection,
1086 target: &ShareTarget,
1087) -> Result<(Slice, Vec<i64>), ShareError> {
1088 let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
1089 Ok(queries::tracks_for_album(conn, album_id)?
1090 .into_iter()
1091 .map(|t| t.id)
1092 .collect())
1093 };
1094 let (slice, ids) = match target {
1095 ShareTarget::Tracks(ids) => {
1096 let rows = queries::tracks_by_ids(conn, ids)?;
1097 match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
1098 ([one], Some(album_id)) => {
1099 return resolve_share(
1100 conn,
1101 &ShareTarget::Album {
1102 album_id,
1103 start_track_id: Some(*one),
1104 },
1105 );
1106 }
1107 _ => {
1108 let ids = ids
1110 .iter()
1111 .copied()
1112 .filter(|id| rows.iter().any(|t| t.id == *id))
1113 .collect();
1114 (Slice::TRACKS, ids)
1115 }
1116 }
1117 }
1118 ShareTarget::Album {
1119 album_id,
1120 start_track_id,
1121 } => {
1122 let ids = album_tracks(*album_id)?;
1123 let slice = Slice {
1124 kind: ShareKind::Album,
1125 subject_id: Some(*album_id),
1126 start_track_id: start_track_id.filter(|s| ids.contains(s)),
1127 };
1128 (slice, ids)
1129 }
1130 ShareTarget::Artist(artist_id) => {
1131 let mut ids = Vec::new();
1132 for album in queries::albums_for_artist(conn, *artist_id)? {
1133 ids.extend(album_tracks(album.id)?);
1134 }
1135 let slice = Slice {
1136 kind: ShareKind::Artist,
1137 subject_id: Some(*artist_id),
1138 start_track_id: None,
1139 };
1140 (slice, ids)
1141 }
1142 };
1143 if ids.is_empty() {
1144 return Err(ShareError::NothingToShare);
1145 }
1146 Ok((slice, ids))
1147}
1148
1149pub fn create_share(
1164 db: &Database,
1165 user: i64,
1166 cfg: &Config,
1167 target: &ShareTarget,
1168 description: Option<&str>,
1169) -> Result<ShareOutcome, ShareError> {
1170 let Some(client) = subsonic_client(cfg) else {
1171 return create_native_share(db, user, cfg, target, description);
1172 };
1173 let resolved;
1176 let track_ids = match target {
1177 ShareTarget::Tracks(ids) => ids.as_slice(),
1178 _ => {
1179 resolved = resolve_share(&db.conn, target)?.1;
1180 resolved.as_slice()
1181 }
1182 };
1183
1184 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
1186
1187 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
1188 if shared == 0 {
1189 return Err(ShareError::NothingRemote);
1190 }
1191
1192 let one_album = rows
1196 .first()
1197 .and_then(|f| f.album_id)
1198 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
1199 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
1200
1201 let remote_ids: Vec<String> = match one_album {
1202 Some(rid) => vec![album_share_id(&client, rid)],
1203 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
1204 };
1205
1206 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
1207 let share = client.create_share(&refs, description)?;
1208
1209 let url = share
1212 .url
1213 .clone()
1214 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
1215
1216 Ok(ShareOutcome {
1217 url,
1218 id: share.id,
1219 shared,
1220 skipped: track_ids.len().saturating_sub(shared),
1221 })
1222}
1223
1224pub fn create_native_share(
1230 db: &Database,
1231 user: i64,
1232 cfg: &Config,
1233 target: &ShareTarget,
1234 description: Option<&str>,
1235) -> Result<ShareOutcome, ShareError> {
1236 let base = cfg
1237 .sharing
1238 .public_url
1239 .as_deref()
1240 .filter(|u| !u.trim().is_empty())
1241 .ok_or(ShareError::NoPublicUrl)?;
1242 let (slice, ids) = resolve_share(&db.conn, target)?;
1243 let now = std::time::SystemTime::now()
1244 .duration_since(std::time::UNIX_EPOCH)
1245 .map_or(0, |d| d.as_secs() as i64);
1246 let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
1247 Ok(ShareOutcome {
1248 url: share_url(base, &share.id),
1249 id: share.id,
1250 shared: ids.len(),
1251 skipped: match (target, slice.kind) {
1253 (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
1254 _ => 0,
1255 },
1256 })
1257}
1258
1259pub fn share_url(public_url: &str, id: &str) -> String {
1261 format!("{}/share/{id}", public_url.trim_end_matches('/'))
1262}
1263
1264fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
1271 let koan = crate::remote::profile::is_koan(client.auth());
1272 album_share_id_for(koan, remote_id)
1273}
1274
1275fn album_share_id_for(koan: bool, remote_id: String) -> String {
1276 if koan && remote_id.parse::<i64>().is_ok() {
1277 format!("al-{remote_id}")
1278 } else {
1279 remote_id
1280 }
1281}
1282
1283fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
1287 let (remote_id, total): (Option<String>, i64) = conn
1288 .query_row(
1289 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
1290 FROM albums al WHERE al.id = ?1",
1291 [album_id],
1292 |row| Ok((row.get(0)?, row.get(1)?)),
1293 )
1294 .ok()?;
1295 (total == selected as i64).then_some(remote_id).flatten()
1296}
1297
1298pub fn shuffle<T>(items: &mut [T]) {
1307 let mut seed = [0u8; 8];
1308 if getrandom::fill(&mut seed).is_err() {
1309 return; }
1311 let mut state = u64::from_le_bytes(seed) | 1;
1312 for i in (1..items.len()).rev() {
1313 state ^= state << 13;
1315 state ^= state >> 7;
1316 state ^= state << 17;
1317 items.swap(i, (state % (i as u64 + 1)) as usize);
1318 }
1319}
1320
1321pub fn truncate_bytes(s: &str, max: usize) -> &str {
1323 if s.len() <= max {
1324 return s;
1325 }
1326 let mut end = max;
1327 while end > 0 && !s.is_char_boundary(end) {
1328 end -= 1;
1329 }
1330 &s[..end]
1331}
1332
1333pub fn sanitise_filename(s: &str) -> String {
1338 let cleaned: String = s
1339 .chars()
1340 .map(|c| match c {
1341 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1342 _ => c,
1343 })
1344 .collect::<String>()
1345 .trim()
1346 .to_string();
1347
1348 let cleaned = truncate_bytes(&cleaned, 240).trim_end().to_string();
1349 match cleaned.as_str() {
1350 "." | ".." => "_".into(),
1351 _ => cleaned,
1352 }
1353}
1354
1355pub fn sanitise_extension(codec: &str) -> Option<String> {
1359 let ext: String = codec
1360 .chars()
1361 .filter(char::is_ascii_alphanumeric)
1362 .take(16)
1363 .collect::<String>()
1364 .to_lowercase();
1365 (!ext.is_empty()).then_some(ext)
1366}
1367
1368pub fn path_within(dir: &Path, path: &Path) -> bool {
1371 path.strip_prefix(dir).is_ok_and(|rest| {
1372 rest.components()
1373 .all(|c| matches!(c, std::path::Component::Normal(_)))
1374 })
1375}
1376
1377pub fn year_of(date: &str) -> Option<&str> {
1380 date.get(..4)
1381}
1382
1383pub fn cache_path_for_track(
1386 cache_dir: &Path,
1387 track: &queries::TrackRow,
1388 album_date: Option<&str>,
1389) -> PathBuf {
1390 let artist_dir = sanitise_filename(&track.artist_name);
1391
1392 let year = album_date
1393 .and_then(year_of)
1394 .map(|y| format!("({}) ", y))
1395 .unwrap_or_default();
1396 let codec = track
1397 .codec
1398 .as_deref()
1399 .map(|c| format!(" [{}]", c))
1400 .unwrap_or_default();
1401 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1402
1403 let disc_prefix = match track.disc {
1404 Some(d) if d > 1 => format!("{}-", d),
1405 _ => String::new(),
1406 };
1407 let track_num = track
1408 .track_number
1409 .map(|n| format!("{:02}. ", n))
1410 .unwrap_or_default();
1411
1412 let ext = track
1413 .codec
1414 .as_deref()
1415 .and_then(sanitise_extension)
1416 .unwrap_or_else(|| "flac".into());
1417
1418 let filename = sanitise_filename(&format!(
1419 "{}{}{} - {}",
1420 disc_prefix, track_num, track.artist_name, track.title
1421 ));
1422
1423 cache_dir
1424 .join(artist_dir)
1425 .join(album_dir)
1426 .join(format!("{}.{}", filename, ext))
1427}
1428
1429fn resolve_item_path(
1437 cfg: &Config,
1438 track: &queries::TrackRow,
1439 remote_url: Option<&str>,
1440 album_date: Option<&str>,
1441) -> (PathBuf, ItemState) {
1442 match queries::choose_playback_source(
1443 track.path.as_deref(),
1444 track.cached_path.as_deref(),
1445 remote_url,
1446 ) {
1447 Some(queries::PlaybackSource::Local(p)) => (p, ItemState::Ready),
1448 Some(queries::PlaybackSource::Cached(p)) => {
1453 let state = if is_cached_audio(&p) {
1454 ItemState::Ready
1455 } else {
1456 ItemState::Pending
1457 };
1458 (p, state)
1459 }
1460 Some(queries::PlaybackSource::Remote(_)) => {
1461 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1462 if dest.exists() && is_cached_audio(&dest) {
1463 (dest, ItemState::Ready)
1464 } else {
1465 (dest, ItemState::Pending)
1466 }
1467 }
1468 _ => {
1469 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1471 (dest, ItemState::Pending)
1472 }
1473 }
1474}
1475
1476pub fn playlist_item_from_track(
1478 track: &queries::TrackRow,
1479 album_date: Option<&str>,
1480 dest: PathBuf,
1481 state: ItemState,
1482) -> PlaylistItem {
1483 let year = album_date.and_then(year_of).map(str::to_string);
1484 PlaylistItem {
1485 playlist_entry_id: None,
1486 id: QueueItemId::new(),
1487 db_id: Some(track.id),
1488 path: dest,
1489 title: track.title.clone(),
1490 artist: track.artist_name.clone(),
1491 album_artist: track.album_artist_name.clone(),
1492 album: track.album_title.clone(),
1493 year,
1494 codec: track.codec.clone(),
1495 track_number: track.track_number.map(|n| n as i64),
1496 disc: track.disc.map(|n| n as i64),
1497 duration_ms: track.duration_ms.map(|d| d as u64),
1498 state,
1499 }
1500}
1501
1502pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1509 let cfg = Config::load().unwrap_or_default();
1510 let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
1511 let extras = queries::queue_item_extras(&db.conn, &ids).unwrap_or_default();
1512
1513 tracks
1514 .iter()
1515 .map(|track| {
1516 let extra = extras.get(&track.id);
1517 let remote_url = extra.and_then(|e| e.remote_url.as_deref());
1518 let album_date = extra.and_then(|e| e.album_date.as_deref());
1519 let (path, state) = resolve_item_path(&cfg, track, remote_url, album_date);
1520 playlist_item_from_track(track, album_date, path, state)
1521 })
1522 .collect()
1523}
1524
1525fn is_cached_audio(path: &std::path::Path) -> bool {
1535 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1536 match std::fs::metadata(path) {
1537 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1538 Ok(_) => {
1539 let mut first = [0u8; 1];
1540 match std::fs::File::open(path)
1541 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1542 {
1543 Ok(b) => b != b'{' && b != b'<',
1544 Err(_) => false,
1545 }
1546 }
1547 Err(_) => false,
1548 }
1549}
1550
1551pub fn download_track(
1558 db_id: i64,
1559 queue_id: QueueItemId,
1560 tx: &crossbeam_channel::Sender<PlayerCommand>,
1561 log_buf: &Arc<Mutex<Vec<String>>>,
1562 state: &Arc<SharedPlayerState>,
1563 cfg: &Config,
1564 client: &SubsonicClient,
1565) {
1566 let db = match crate::db::pool::shared().get() {
1571 Ok(db) => db,
1572 Err(e) => {
1573 fail_track(state, tx, queue_id, format!("db error: {}", e));
1574 return;
1575 }
1576 };
1577 let track = match queries::get_track_row(&db.conn, db_id) {
1578 Ok(Some(t)) => t,
1579 _ => {
1580 fail_track(state, tx, queue_id, "track not found".into());
1581 return;
1582 }
1583 };
1584
1585 let remote_id = match &track.remote_id {
1586 Some(rid) => rid.clone(),
1587 None => {
1588 if let Some(ref path) = track.path {
1590 let p = std::path::PathBuf::from(path);
1591 if p.exists() {
1592 state.update_paths(&[(queue_id, p)]);
1593 state.update_item_state(queue_id, ItemState::Ready);
1594 if state.is_cursor(queue_id) {
1595 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1596 }
1597 return;
1598 }
1599 }
1600 fail_track(
1601 state,
1602 tx,
1603 queue_id,
1604 "not in the library folder, and no remote copy to fetch".into(),
1605 );
1606 return;
1607 }
1608 };
1609
1610 if let Some(ref local_path) = track.path {
1612 let p = std::path::PathBuf::from(local_path);
1613 if p.exists() {
1614 log::info!("download_track: local file exists, using {}", p.display());
1615 state.update_paths(&[(queue_id, p)]);
1616 state.update_item_state(queue_id, ItemState::Ready);
1617 if state.is_cursor(queue_id) {
1618 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1619 }
1620 return;
1621 }
1622 }
1623
1624 let album_date: Option<String> = track
1625 .album_id
1626 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1627
1628 let cache_dir = cfg.cache_dir();
1629 let dest = cache_path_for_track(&cache_dir, &track, album_date.as_deref());
1630 if !path_within(&cache_dir, &dest) {
1631 fail_track(
1632 state,
1633 tx,
1634 queue_id,
1635 format!("cache path escapes the cache: {}", dest.display()),
1636 );
1637 return;
1638 }
1639
1640 if dest.exists() && !is_cached_audio(&dest) {
1646 log::warn!(
1647 "discarding non-audio cache entry {} (likely a stored server error)",
1648 dest.display()
1649 );
1650 let _ = std::fs::remove_file(&dest);
1651 }
1652 if dest.exists() {
1653 state.update_paths(&[(queue_id, dest)]);
1654 state.update_item_state(queue_id, ItemState::Ready);
1655 if state.is_cursor(queue_id) {
1656 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1657 }
1658 return;
1659 }
1660
1661 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1664
1665 let bytes_written = crate::remote::downloads::ByteFeed::new();
1666
1667 let store = crate::remote::downloads::store();
1670 store.queued(crate::remote::downloads::Download {
1671 id: queue_id,
1672 track_id: db_id,
1673 title: track.title.clone(),
1674 artist: track.artist_name.clone(),
1675 source: crate::remote::download::part_path(&dest),
1676 dest: dest.clone(),
1677 total: 0,
1678 written: bytes_written.clone(),
1679 state: crate::remote::downloads::DownloadState::Queued,
1680 bytes_per_second: 0,
1681 });
1682
1683 let progress_qid = queue_id;
1684 let bytes_written_progress = bytes_written.clone();
1685 let progress_tx = tx.clone();
1686 let stream_ready_flag = std::sync::atomic::AtomicBool::new(false);
1687 let announced_total = AtomicU64::new(u64::MAX);
1689 let gone = || state.get_item(queue_id).is_none();
1691 let result =
1692 client.download_with_progress(&remote_id, &dest, &gone, move |downloaded, total| {
1693 bytes_written_progress.set(downloaded);
1694 store.progressed();
1697 if announced_total.swap(total, Ordering::Relaxed) != total {
1698 store.started(progress_qid, total, bytes_written_progress.clone());
1701 }
1702 if !stream_ready_flag.load(Ordering::Relaxed)
1703 && downloaded >= crate::player::state::STREAM_THRESHOLD
1704 {
1705 stream_ready_flag.store(true, Ordering::Relaxed);
1706 progress_tx
1707 .send(PlayerCommand::TrackStreamReady(progress_qid))
1708 .ok();
1709 }
1710 });
1711
1712 if let Err(SubsonicError::Download(DownloadError::Cancelled)) = result {
1713 store.withdrawn(queue_id);
1714 bytes_written.done();
1715 return;
1716 }
1717
1718 if let Err(e) = result {
1723 store.failed(queue_id, e.to_string());
1724 fail_track(state, tx, queue_id, e.to_string());
1725 bytes_written.done();
1726 push_log(log_buf, format!("x {} — {}", track.title, e));
1727 return;
1728 }
1729 store.finished(queue_id);
1730
1731 state.update_paths(&[(queue_id, dest.clone())]);
1732 state.update_item_state(queue_id, ItemState::Ready);
1733 bytes_written.done();
1734 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1736 log::warn!(
1737 "cached {} but failed to record it ({}) — it will not be evicted",
1738 dest.display(),
1739 e
1740 );
1741 }
1742
1743 push_log(
1744 log_buf,
1745 format!("+ {} — {}", track.title, track.artist_name),
1746 );
1747
1748 if state.is_cursor(queue_id) {
1749 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1750 }
1751}
1752
1753pub(crate) fn fail_track(
1759 state: &Arc<SharedPlayerState>,
1760 tx: &crossbeam_channel::Sender<PlayerCommand>,
1761 queue_id: QueueItemId,
1762 reason: String,
1763) {
1764 state.update_item_state(queue_id, ItemState::Failed(reason));
1765 if state.is_cursor(queue_id) {
1766 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1767 }
1768}
1769
1770fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1773 match log_buf.lock() {
1774 Ok(mut buf) => buf.push(msg),
1775 Err(_) => log::info!("{}", msg),
1776 }
1777}
1778
1779pub fn remote_unavailable(cfg: &Config) -> String {
1785 if !cfg.remote.enabled {
1786 return "no remote server is configured".into();
1787 }
1788 if cfg.remote.url.is_empty() {
1789 return "the remote server has no address".into();
1790 }
1791 if get_remote_password(cfg).is_none() {
1792 return "no password is stored for the remote server".into();
1793 }
1794 "the remote server could not be reached".into()
1797}
1798
1799pub fn spawn_downloads(
1805 pending: Vec<(i64, QueueItemId)>,
1806 tx: crossbeam_channel::Sender<PlayerCommand>,
1807 state: Arc<SharedPlayerState>,
1808) {
1809 if pending.is_empty() {
1810 return;
1811 }
1812 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1813}
1814
1815#[cfg(test)]
1816mod year_tests {
1817 use super::year_of;
1818
1819 #[test]
1820 fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1821 assert_eq!(year_of("1997-05-21"), Some("1997"));
1822 assert_eq!(year_of("199"), None);
1823 assert_eq!(year_of("1997"), None);
1825 }
1826}
1827
1828#[cfg(test)]
1829mod rebuild_tests {
1830 use super::*;
1831 use crate::db::queries::sample_meta;
1832
1833 fn test_db() -> Database {
1834 let conn = rusqlite::Connection::open_in_memory().unwrap();
1835 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1836 crate::db::schema::create_tables(&conn).unwrap();
1837 Database { conn }
1838 }
1839
1840 #[test]
1841 fn cached_paths_follow_a_moved_cache_directory() {
1842 let old = tempfile::tempdir().unwrap();
1843 let new = tempfile::tempdir().unwrap();
1844 let db = test_db();
1845
1846 let mut rows = Vec::new();
1847 for name in ["moved", "gone", "current"] {
1848 let mut meta = sample_meta(name, "Artist", "Album");
1849 meta.source = "remote".into();
1850 meta.path = None;
1851 meta.remote_id = Some(name.into());
1852 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1853 let tail = format!("Artist/Album/{name}.flac");
1854 if name != "gone" {
1856 let file = new.path().join(&tail);
1857 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1858 std::fs::write(&file, b"audio").unwrap();
1859 }
1860 let stored = if name == "current" {
1861 new.path()
1862 } else {
1863 old.path()
1864 }
1865 .join(&tail);
1866 queries::set_cached_path(&db.conn, id, &stored.to_string_lossy()).unwrap();
1867 rows.push((id, tail));
1868 }
1869
1870 assert_eq!(relocate_cached_paths(&db, new.path()).unwrap(), 1);
1871
1872 let cached = |id: i64| -> String {
1873 db.conn
1874 .query_row("SELECT cached_path FROM tracks WHERE id = ?1", [id], |r| {
1875 r.get(0)
1876 })
1877 .unwrap()
1878 };
1879 let expect = |root: &Path, tail: &str| root.join(tail).to_string_lossy().into_owned();
1880 assert_eq!(
1881 cached(rows[0].0),
1882 expect(new.path(), &rows[0].1),
1883 "re-rooted"
1884 );
1885 assert_eq!(
1886 cached(rows[1].0),
1887 expect(old.path(), &rows[1].1),
1888 "no file, left alone"
1889 );
1890 assert_eq!(
1891 cached(rows[2].0),
1892 expect(new.path(), &rows[2].1),
1893 "already current"
1894 );
1895 assert_eq!(
1896 relocate_cached_paths(&db, new.path()).unwrap(),
1897 0,
1898 "idempotent"
1899 );
1900 }
1901
1902 #[test]
1903 fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1904 let dir = tempfile::tempdir().unwrap();
1905 let db = test_db();
1906
1907 let mut cached = Vec::new();
1908 for name in ["one", "two"] {
1909 let mut meta = sample_meta(name, "Artist", "Album");
1910 meta.source = "remote".into();
1911 meta.path = None;
1912 meta.remote_id = Some(name.into());
1913 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1914 let file = dir.path().join(format!("{name}.opus"));
1915 std::fs::write(&file, vec![0u8; 2048]).unwrap();
1916 queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1917 cached.push((id, file));
1918 }
1919
1920 let cleared = clear_downloads_for(&db, &[cached[0].0]);
1921 assert_eq!(cleared.files, 1);
1922 assert_eq!(cleared.bytes, 2048);
1923 assert!(!cached[0].1.exists(), "the copy asked for is gone");
1924 assert!(cached[1].1.exists(), "the other one is untouched");
1925
1926 assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1929 assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1930 assert!(
1931 queries::cached_paths_for(&db.conn, &[cached[0].0])
1932 .unwrap()
1933 .is_empty()
1934 );
1935 }
1936
1937 #[test]
1938 fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1939 let db = test_db();
1940 let mut meta = sample_meta("ghost", "Artist", "Album");
1941 meta.source = "remote".into();
1942 meta.path = None;
1943 meta.remote_id = Some("ghost".into());
1944 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1945 queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1946
1947 let cleared = clear_downloads_for(&db, &[id]);
1948 assert_eq!(cleared.files, 0, "nothing was there to remove");
1949 assert!(
1951 queries::cached_paths_for(&db.conn, &[id])
1952 .unwrap()
1953 .is_empty()
1954 );
1955 }
1956
1957 #[test]
1958 fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1959 let dir = tempfile::tempdir().unwrap();
1960 let cache = dir.path().join("cache");
1961 std::fs::create_dir_all(cache.join("Artist")).unwrap();
1962
1963 let finished = cache.join("Artist/whole.opus");
1964 let half = cache.join("Artist/half.opus.part");
1965 std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1966 std::fs::write(&half, vec![0u8; 4096]).unwrap();
1967
1968 let cfg = Config {
1969 remote: crate::config::RemoteConfig {
1970 cache_dir: Some(cache.clone()),
1971 ..Default::default()
1972 },
1973 ..Default::default()
1974 };
1975
1976 let swept = sweep_partial_downloads(&cfg);
1977 assert_eq!(swept.files, 1);
1978 assert_eq!(swept.bytes, 4096);
1979 assert!(!half.exists(), "the unfinished one is gone");
1980 assert!(finished.exists(), "a downloaded track is not touched");
1981 }
1982
1983 #[test]
1984 fn sweeping_an_empty_cache_is_not_an_error() {
1985 let dir = tempfile::tempdir().unwrap();
1986 let cfg = Config {
1987 remote: crate::config::RemoteConfig {
1988 cache_dir: Some(dir.path().join("nothing-here")),
1989 ..Default::default()
1990 },
1991 ..Default::default()
1992 };
1993 assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1994 }
1995
1996 #[test]
1997 fn clearing_no_tracks_does_nothing() {
1998 let db = test_db();
1999 assert_eq!(clear_downloads_for(&db, &[]).files, 0);
2000 }
2001
2002 #[test]
2003 fn rebuild_drops_the_index_and_keeps_favourites() {
2004 let db = test_db();
2005 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2006 meta.path = Some("/music/windowlicker.flac".into());
2007 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
2008
2009 queries::toggle_favourite(
2011 &db.conn,
2012 crate::db::queries::LOCAL_USER,
2013 Path::new("/music/windowlicker.flac"),
2014 )
2015 .unwrap();
2016 db.conn
2017 .execute(
2018 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
2019 VALUES (?1, 'test', 'la la la', 0)",
2020 [track_id],
2021 )
2022 .unwrap();
2023
2024 let summary = rebuild_index(&db).unwrap();
2025 assert_eq!(summary.tracks, 1);
2026 assert_eq!(summary.albums, 1);
2027
2028 let tracks: i64 = db
2029 .conn
2030 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2031 .unwrap();
2032 assert_eq!(tracks, 0, "the index is gone");
2033
2034 let favourites: i64 = db
2035 .conn
2036 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
2037 .unwrap();
2038 assert_eq!(favourites, 1, "favourites survive — they key on the path");
2039
2040 let lyrics: i64 = db
2041 .conn
2042 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
2043 .unwrap();
2044 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
2045 }
2046
2047 #[test]
2048 fn rebuilding_an_empty_library_is_not_an_error() {
2049 let db = test_db();
2050 let summary = rebuild_index(&db).unwrap();
2051 assert_eq!(summary.tracks, 0);
2052 }
2053}
2054
2055#[cfg(test)]
2056mod share_tests {
2057 use super::*;
2058 use crate::db::queries::sample_meta;
2059
2060 fn test_db() -> Database {
2061 let conn = rusqlite::Connection::open_in_memory().unwrap();
2062 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2063 crate::db::schema::create_tables(&conn).unwrap();
2064 Database { conn }
2065 }
2066
2067 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
2069 let ids: Vec<i64> = ["One", "Two", "Three"]
2070 .iter()
2071 .enumerate()
2072 .map(|(i, title)| {
2073 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
2074 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
2075 meta.track_number = Some(i as i32 + 1);
2076 queries::upsert_track(&db.conn, &meta).unwrap()
2077 })
2078 .collect();
2079 let album_id: i64 = db
2080 .conn
2081 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
2082 r.get(0)
2083 })
2084 .unwrap();
2085 db.conn
2086 .execute(
2087 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
2088 [album_id],
2089 )
2090 .unwrap();
2091 (album_id, ids)
2092 }
2093
2094 #[test]
2095 fn whole_album_collapses_to_the_album_link() {
2096 let db = test_db();
2097 let (album_id, ids) = album_of_three(&db);
2098 assert_eq!(
2099 album_remote_id(&db.conn, album_id, ids.len()),
2100 Some("al-1".into())
2101 );
2102 }
2103
2104 #[test]
2105 fn part_of_an_album_does_not() {
2106 let db = test_db();
2107 let (album_id, _) = album_of_three(&db);
2108 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
2111 }
2112
2113 #[test]
2114 fn a_local_only_album_has_no_link_to_collapse_to() {
2115 let db = test_db();
2116 let (album_id, ids) = album_of_three(&db);
2117 db.conn
2118 .execute(
2119 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
2120 [album_id],
2121 )
2122 .unwrap();
2123 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
2124 }
2125}
2126
2127#[cfg(test)]
2128mod client_cache_tests {
2129 use super::*;
2130
2131 #[test]
2132 fn one_subsonic_client_is_shared_per_credentials() {
2133 crate::config::isolate_config_for_tests();
2134 let mut cfg = Config::default();
2135 cfg.remote.enabled = true;
2136 cfg.remote.url = "https://shared-client.invalid".into();
2137 cfg.remote.username = "koan".into();
2138 cfg.remote.password = "first".into();
2139
2140 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
2141 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
2142 assert!(
2143 Arc::ptr_eq(&first, &again),
2144 "rebuilding drops the connection pool and re-handshakes TLS per request"
2145 );
2146
2147 cfg.remote.password = "second".into();
2148 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
2149 assert!(
2150 !Arc::ptr_eq(&first, &relogged),
2151 "new credentials must not keep serving the client signed with the old ones"
2152 );
2153 }
2154}
2155
2156#[cfg(test)]
2157mod native_share_tests {
2158 use super::*;
2159 use crate::db::queries::{sample_meta, upsert_track};
2160
2161 #[test]
2162 fn a_standalone_server_shares_natively_in_the_order_asked() {
2163 let conn = rusqlite::Connection::open_in_memory().unwrap();
2164 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2165 crate::db::schema::create_tables(&conn).unwrap();
2166 let db = Database { conn };
2167 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2168 let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
2169 let mut cfg = Config::default();
2170 assert!(matches!(
2171 create_share(
2172 &db,
2173 queries::LOCAL_USER,
2174 &cfg,
2175 &ShareTarget::Tracks(vec![a]),
2176 None
2177 ),
2178 Err(ShareError::NoPublicUrl)
2179 ));
2180 cfg.sharing.public_url = Some("https://koan.example/".into());
2181 let out = create_share(
2182 &db,
2183 queries::LOCAL_USER,
2184 &cfg,
2185 &ShareTarget::Tracks(vec![b, 9999, a]),
2186 Some("mix"),
2187 )
2188 .unwrap();
2189 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2190 assert_eq!((out.shared, out.skipped), (2, 1));
2191 let share = queries::shares::get_share(&db.conn, &out.id)
2192 .unwrap()
2193 .unwrap();
2194 assert_eq!(share.track_ids, [b, a]);
2195 assert!(matches!(
2196 create_share(
2197 &db,
2198 queries::LOCAL_USER,
2199 &cfg,
2200 &ShareTarget::Tracks(vec![9999]),
2201 None
2202 ),
2203 Err(ShareError::NothingToShare)
2204 ));
2205 }
2206
2207 #[test]
2208 fn a_server_with_an_upstream_still_shares_natively() {
2209 let conn = rusqlite::Connection::open_in_memory().unwrap();
2211 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2212 crate::db::schema::create_tables(&conn).unwrap();
2213 let db = Database { conn };
2214 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2215 let mut cfg = Config::default();
2216 cfg.remote.enabled = true;
2217 cfg.remote.url = "https://upstream.invalid".into();
2218 cfg.remote.username = "someone".into();
2219 cfg.remote.password = "secret".into();
2220 cfg.sharing.public_url = Some("https://koan.example".into());
2221 let out = create_native_share(
2222 &db,
2223 queries::LOCAL_USER,
2224 &cfg,
2225 &ShareTarget::Tracks(vec![a]),
2226 None,
2227 )
2228 .unwrap();
2229 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2230 assert!(
2231 queries::shares::get_share(&db.conn, &out.id)
2232 .unwrap()
2233 .is_some()
2234 );
2235 }
2236
2237 fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
2238 let mut meta = sample_meta(title, "Rrose", album);
2239 meta.track_number = Some(n);
2240 meta.date = Some(date.into());
2241 upsert_track(&db.conn, &meta).unwrap()
2242 }
2243
2244 #[test]
2245 fn shares_are_slices_fixed_when_made() {
2246 let conn = rusqlite::Connection::open_in_memory().unwrap();
2247 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2248 crate::db::schema::create_tables(&conn).unwrap();
2249 let db = Database { conn };
2250 let later = album_track(&db, "L1", "Later", 1, "2021");
2251 let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
2252 let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
2253 let album_of = |t| {
2254 queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
2255 .album_id
2256 .unwrap()
2257 };
2258 let (earlier, later_album) = (album_of(a1), album_of(later));
2259 let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
2260 .artist_id
2261 .unwrap();
2262
2263 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
2265 assert_eq!(
2266 (slice.kind, slice.subject_id, slice.start_track_id),
2267 (ShareKind::Album, Some(earlier), Some(a2))
2268 );
2269 assert_eq!(ids, [a1, a2]);
2270
2271 let (slice, ids) = resolve_share(
2273 &db.conn,
2274 &ShareTarget::Album {
2275 album_id: later_album,
2276 start_track_id: Some(a1),
2277 },
2278 )
2279 .unwrap();
2280 assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
2281 assert_eq!(ids, [later]);
2282
2283 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
2285 assert_eq!(
2286 (slice.kind, slice.subject_id),
2287 (ShareKind::Artist, Some(artist))
2288 );
2289 assert_eq!(ids, [a1, a2, later]);
2290
2291 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
2293 assert_eq!(slice, Slice::TRACKS);
2294 assert_eq!(ids, [later, a1]);
2295
2296 assert!(matches!(
2297 resolve_share(&db.conn, &ShareTarget::Artist(9999)),
2298 Err(ShareError::NothingToShare)
2299 ));
2300 }
2301}
2302
2303#[cfg(test)]
2304mod album_share_id_tests {
2305 use super::album_share_id_for;
2306
2307 #[test]
2308 fn a_koan_album_is_named_as_an_album() {
2309 assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
2310 assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
2312 assert_eq!(album_share_id_for(false, "46215".into()), "46215");
2313 assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
2314 }
2315}
2316
2317#[cfg(test)]
2318mod cache_path_tests {
2319 use super::*;
2320
2321 fn track(artist: &str, album: &str, codec: &str) -> queries::TrackRow {
2322 queries::TrackRow {
2323 id: 1,
2324 album_id: None,
2325 artist_id: None,
2326 artist_name: artist.into(),
2327 album_artist_name: artist.into(),
2328 album_title: album.into(),
2329 disc: None,
2330 track_number: Some(1),
2331 title: "Song".into(),
2332 duration_ms: None,
2333 path: None,
2334 codec: Some(codec.into()),
2335 sample_rate: None,
2336 bit_depth: None,
2337 channels: None,
2338 bitrate: None,
2339 genre: None,
2340 source: "remote".into(),
2341 remote_id: Some("r1".into()),
2342 cached_path: None,
2343 }
2344 }
2345
2346 #[test]
2347 fn a_server_suffix_cannot_leave_the_cache() {
2348 let cache = Path::new("/cache");
2349 for codec in [
2350 "flac/../../../../x",
2351 "..",
2352 "../..",
2353 "/etc/passwd",
2354 "\\..\\..",
2355 ] {
2356 let path = cache_path_for_track(cache, &track("A", "B", codec), None);
2357 assert!(path_within(cache, &path), "{codec}: {}", path.display());
2358 }
2359 let path = cache_path_for_track(cache, &track("A", "B", "flac/../../../../x"), None);
2360 assert_eq!(path.extension().unwrap(), "flacx");
2361 }
2362
2363 #[test]
2364 fn dot_names_cannot_climb_out() {
2365 let cache = Path::new("/cache");
2366 let path = cache_path_for_track(cache, &track("..", ".", ".."), None);
2367 assert!(path_within(cache, &path), "{}", path.display());
2368 assert_eq!(sanitise_filename(".."), "_");
2369 assert_eq!(sanitise_filename(" . "), "_");
2370 assert_eq!(sanitise_filename("..."), "...");
2371 }
2372
2373 #[test]
2374 fn an_empty_suffix_falls_back_to_flac() {
2375 assert_eq!(sanitise_extension("../"), None);
2376 assert_eq!(sanitise_extension("FLAC"), Some("flac".into()));
2377 let path = cache_path_for_track(Path::new("/c"), &track("A", "B", "./"), None);
2378 assert_eq!(path.extension().unwrap(), "flac");
2379 }
2380
2381 #[test]
2382 fn path_within_rejects_parent_components() {
2383 let dir = Path::new("/cache");
2384 assert!(path_within(dir, Path::new("/cache/a/b.flac")));
2385 assert!(!path_within(dir, Path::new("/cache/a/../../x")));
2386 assert!(!path_within(dir, Path::new("/elsewhere/x")));
2387 }
2388}
2389
2390#[cfg(test)]
2391mod favourite_sync_tests {
2392 use super::*;
2393 use crate::db::queries::sample_meta;
2394
2395 fn serve(stars: Arc<Mutex<Vec<String>>>) -> String {
2397 use std::io::{BufRead, Write};
2398 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2399 let url = format!("http://{}", listener.local_addr().unwrap());
2400 std::thread::spawn(move || {
2401 for mut stream in listener.incoming().flatten() {
2402 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
2403 let mut request = String::new();
2404 reader.read_line(&mut request).unwrap();
2405 let mut line = String::new();
2406 while reader.read_line(&mut line).unwrap_or(0) > 2 {
2407 line.clear();
2408 }
2409 let target = request.split_whitespace().nth(1).unwrap_or("");
2410 let (path, query) = target.split_once('?').unwrap_or((target, ""));
2411 let body = match path.rsplit('/').next().unwrap() {
2412 "getStarred2" => {
2413 r#"{"subsonic-response":{"status":"ok","starred2":{"song":[{"id":"s1","title":"One"}]}}}"#
2414 }
2415 "star" => {
2416 if let Some((_, id)) = query
2417 .split('&')
2418 .filter_map(|kv| kv.split_once('='))
2419 .find(|(k, _)| *k == "id")
2420 {
2421 stars.lock().unwrap().push(id.to_string());
2422 }
2423 r#"{"subsonic-response":{"status":"ok"}}"#
2424 }
2425 _ => r#"{"subsonic-response":{"status":"ok"}}"#,
2426 };
2427 let _ = write!(
2428 stream,
2429 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2430 body.len()
2431 );
2432 }
2433 });
2434 url
2435 }
2436
2437 #[test]
2438 fn only_favourites_the_server_lacks_are_starred() {
2439 let dir = tempfile::tempdir().unwrap();
2440 let db = Database::open(&dir.path().join("koan.db")).unwrap();
2441 for (title, remote_id) in [("One", "s1"), ("Two", "s2")] {
2442 let mut meta = sample_meta(title, "Artist", "Album");
2443 meta.path = Some(format!("/music/{title}.flac"));
2444 meta.remote_id = Some(remote_id.into());
2445 queries::upsert_track(&db.conn, &meta).unwrap();
2446 queries::add_favourite(
2447 &db.conn,
2448 queries::LOCAL_USER,
2449 Path::new(&format!("/music/{title}.flac")),
2450 )
2451 .unwrap();
2452 }
2453
2454 let stars = Arc::new(Mutex::new(Vec::new()));
2455 let url = serve(stars.clone());
2456 let sync = reconcile_favourites(&db, &SubsonicClient::new(&url, "u", "pw"));
2457 assert_eq!(sync.pushed, 1);
2458 assert_eq!(*stars.lock().unwrap(), ["s2"]);
2459 }
2460}