1use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU64, Ordering};
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::{Credential, SubsonicAuth, SubsonicClient, SubsonicError};
14use crate::remote::download::DownloadError;
15
16pub fn remote_credential(cfg: &Config) -> Option<Credential> {
24 if !cfg.remote.api_key.is_empty() {
25 return Some(Credential::ApiKey(cfg.remote.api_key.clone()));
26 }
27 (!cfg.remote.password.is_empty()).then(|| Credential::Password(cfg.remote.password.clone()))
28}
29
30pub fn spawn_library_watch(
54 db_path: std::path::PathBuf,
55 on_state: impl Fn(bool) + Send + Sync + 'static,
56) -> Option<std::thread::JoinHandle<()>> {
57 use std::collections::BTreeSet;
58 use std::time::{Duration, Instant};
59
60 use notify::{RecursiveMode, Watcher};
61
62 use crate::index::scanner::{self, ScanOptions};
63 use crate::index::watch::{WatchedRoot, scan_target};
64
65 const SETTLE: Duration = Duration::from_secs(5);
68 const CHECK: Duration = Duration::from_secs(30);
70 const MAX_DIRS: usize = 200;
72 const RESCAN: Duration = Duration::from_secs(15 * 60);
76
77 std::thread::Builder::new()
78 .name("koan-library-watch".into())
79 .spawn(move || {
80 let scan = |reason: &str, folders: &[PathBuf], dirs: Option<&[PathBuf]>| {
81 if folders.is_empty() {
82 return;
83 }
84 let Ok(db) = Database::open_existing(&db_path) else {
85 return;
86 };
87 on_state(true);
88 let result = match dirs {
89 Some(dirs) => {
90 scanner::scan_dirs(&db, folders, dirs, ScanOptions::default(), None)
91 }
92 None => scanner::full_scan(&db, folders, ScanOptions::default(), None),
93 };
94 on_state(false);
95 log::info!(
96 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
97 result.added,
98 result.updated,
99 result.removed,
100 result.skipped
101 );
102 };
103 let folders = || Config::cached().library.folders.clone();
104
105 let (tx, rx) = std::sync::mpsc::channel();
106 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
107 let _ = tx.send(event);
108 }) else {
109 log::warn!("could not watch the library folders");
110 return;
111 };
112
113 let mut roots: Vec<WatchedRoot> = Vec::new();
117 let mut rewatch = |roots: &mut Vec<WatchedRoot>| {
118 let wanted: Vec<WatchedRoot> = folders()
119 .iter()
120 .filter_map(|f| WatchedRoot::resolve(f))
121 .collect();
122 roots.retain(|root| {
123 let keep = wanted.contains(root);
124 if !keep {
125 let _ = watcher.unwatch(&root.path);
126 }
127 keep
128 });
129 let mut fresh = Vec::new();
130 for root in wanted {
131 if roots.contains(&root) {
132 continue;
133 }
134 match watcher.watch(&root.path, RecursiveMode::Recursive) {
135 Ok(()) => {
136 fresh.push(root.path.clone());
137 roots.push(root);
138 }
139 Err(e) => log::warn!("could not watch {}: {e}", root.path.display()),
140 }
141 }
142 fresh
143 };
144
145 std::thread::sleep(Duration::from_secs(3));
147 rewatch(&mut roots);
148 scan("startup", &folders(), None);
149
150 let mut dirs = BTreeSet::new();
151 let mut everything = false;
152 let mut settle_at: Option<Instant> = None;
153 let mut check_at = Instant::now() + CHECK;
154 let mut rescan_at = Instant::now() + RESCAN;
155 loop {
156 crate::quiet::wait_until_awake();
159 let now = Instant::now();
160 let wake = settle_at
161 .map_or(check_at, |at| at.min(check_at))
162 .min(rescan_at);
163 match rx.recv_timeout(wake.saturating_duration_since(now)) {
164 Ok(Ok(event)) if event.need_rescan() => {
165 everything = true;
166 settle_at = Some(Instant::now() + SETTLE);
167 }
168 Ok(Ok(event)) => {
169 let mut heard = false;
170 for dir in event
171 .paths
172 .iter()
173 .filter_map(|p| scan_target(&event.kind, p, &roots))
174 {
175 dirs.insert(dir);
176 heard = true;
177 }
178 if heard {
179 settle_at = Some(Instant::now() + SETTLE);
180 }
181 }
182 Ok(Err(e)) => log::debug!("library watch: {e}"),
183 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
184 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
185 }
186
187 let now = Instant::now();
188 if settle_at.is_some_and(|at| now >= at) {
189 let changed =
190 scanner::minimal_dirs(std::mem::take(&mut dirs).into_iter().collect());
191 if everything || changed.len() > MAX_DIRS {
192 scan("watched change", &folders(), None);
193 rescan_at = Instant::now() + RESCAN;
194 } else {
195 scan("watched change", &folders(), Some(&changed));
196 }
197 everything = false;
198 settle_at = None;
199 }
200 if now >= rescan_at {
201 scan("periodic", &folders(), None);
202 rescan_at = Instant::now() + RESCAN;
203 }
204 if now >= check_at {
205 let fresh = rewatch(&mut roots);
206 if !fresh.is_empty() {
207 scan("newly watched", &fresh, None);
208 }
209 check_at = Instant::now() + CHECK;
210 }
211 }
212 })
213 .ok()
214}
215
216pub fn spawn_auto_sync(
232 db_path: std::path::PathBuf,
233 on_state: impl Fn(bool) + Send + 'static,
234 on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
235) -> Option<std::thread::JoinHandle<()>> {
236 std::thread::Builder::new()
237 .name("koan-auto-sync".into())
238 .spawn(move || {
239 std::thread::sleep(std::time::Duration::from_secs(5));
240 loop {
241 crate::quiet::wait_until_awake();
243 let cfg = Config::load().unwrap_or_default();
244 if !cfg.remote.enabled || !cfg.remote.auto_sync {
245 std::thread::sleep(std::time::Duration::from_secs(60));
248 continue;
249 }
250
251 if let Some(client) = subsonic_client(&cfg)
252 && let Ok(db) = Database::open_existing(&db_path)
253 {
254 on_state(true);
255 match sync_remote(
256 &db,
257 &client,
258 Walk::IfChanged,
259 &cfg.remote.url,
260 &cfg.remote.username,
261 &on_progress,
262 ) {
263 Ok(s) => log::info!(
264 "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
265 favourites {}↑ {}↓; playlists {}↓ {}↑",
266 s.library.artists_synced,
267 s.library.albums_synced,
268 s.library.tracks_synced,
269 s.library.albums_failed,
270 s.favourites.pushed,
271 s.favourites.imported,
272 s.playlists.pulled,
273 s.playlists.pushed,
274 ),
275 Err(e) => log::warn!("auto sync failed: {e}"),
276 }
277 on_state(false);
278 }
279
280 let mins = cfg.remote.auto_sync_interval_mins;
284 if mins == 0 {
285 return;
286 }
287 loop {
288 std::thread::sleep(std::time::Duration::from_secs(mins * 60));
289 if !crate::remote::profile::current().is_some_and(|p| p.links()) {
290 break;
291 }
292 }
293 }
294 })
295 .ok()
296}
297
298#[derive(Debug, Clone, Copy, Default)]
300pub struct RebuildSummary {
301 pub tracks: u64,
302 pub albums: u64,
303 pub artists: u64,
304}
305
306pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
316 let count = |sql: &str| -> u64 {
317 db.conn
318 .query_row(sql, [], |r| r.get::<_, i64>(0))
319 .unwrap_or(0) as u64
320 };
321 let summary = RebuildSummary {
322 tracks: count("SELECT COUNT(*) FROM tracks"),
323 albums: count("SELECT COUNT(*) FROM albums"),
324 artists: count("SELECT COUNT(*) FROM artists"),
325 };
326
327 db.conn.execute_batch(
328 "BEGIN;
329 DELETE FROM local_files;
330 DELETE FROM remote_entries;
331 DELETE FROM scan_cache;
332 UPDATE remote_servers SET library_version = NULL;
333 COMMIT;",
334 )?;
335 Ok(summary)
336}
337
338pub fn evict_cache(
345 db: &Database,
346 cfg: &Config,
347 keep: &std::collections::HashSet<i64>,
348 verbose: bool,
349) -> u64 {
350 let Some(limit) = cfg.cache_limit_bytes().map(|l| l as i64) else {
351 return 0;
352 };
353 let mut current = match queries::total_cache_size(&db.conn) {
354 Ok(s) => s,
355 Err(e) => {
356 log::warn!("cache eviction: failed to query cache size: {e}");
357 return 0;
358 }
359 };
360 if current <= limit {
361 if verbose {
362 log::info!("cache within limit: {current} / {limit} bytes");
363 }
364 return 0;
365 }
366 let files = match queries::cached_files_lru(&db.conn) {
367 Ok(f) => f,
368 Err(e) => {
369 log::warn!("cache eviction: failed to query cached files: {e}");
370 return 0;
371 }
372 };
373 let mut gone = Vec::new();
374 let mut freed: i64 = 0;
375 for file in files.iter().filter(|f| !keep.contains(&f.track_id)) {
376 if current <= limit {
377 break;
378 }
379 match std::fs::remove_file(&file.path) {
380 Ok(()) => {}
381 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
382 Err(e) => {
383 log::warn!("cache eviction: failed to delete {}: {e}", file.path);
384 continue;
385 }
386 }
387 log::info!(
388 "evicted: {} ({} bytes{})",
389 file.path,
390 file.size,
391 if file.pinned { ", pinned" } else { "" }
392 );
393 gone.push(file.track_id);
394 current -= file.size;
395 freed += file.size;
396 }
397 if let Err(e) = queries::clear_cached_paths_for(&db.conn, &gone) {
398 log::warn!("cache eviction: failed to clear DB: {e}");
399 }
400 remove_empty_dirs(&cfg.cache_dir());
401 if freed > 0 {
402 log::info!("cache eviction freed {freed} bytes");
403 }
404 freed as u64
405}
406
407pub fn playback_window(
415 db: &Database,
416 limit: u64,
417 upcoming: &[i64],
418) -> Result<usize, crate::db::connection::DbError> {
419 let total = queries::total_cache_size(&db.conn)?;
420 let evictable: std::collections::HashMap<i64, i64> = queries::cached_files_lru(&db.conn)?
421 .into_iter()
422 .filter(|f| !f.pinned)
423 .map(|f| (f.track_id, f.size))
424 .collect();
425 let estimates = queries::download_estimates(&db.conn, upcoming)?;
426
427 let mut used = (total - evictable.values().sum::<i64>()).max(0) as u64;
428 let mut counted = std::collections::HashSet::new();
429 for (n, id) in upcoming.iter().enumerate() {
430 if counted.insert(*id) {
431 let cost = evictable.get(id).or_else(|| estimates.get(id));
432 used += cost.copied().unwrap_or(0).max(0) as u64;
433 }
434 if n >= 2 && used > limit {
435 return Ok(n);
436 }
437 }
438 Ok(upcoming.len())
439}
440
441fn remove_empty_dirs(dir: &Path) {
443 if !dir.is_dir() {
444 return;
445 }
446 for entry in walkdir::WalkDir::new(dir)
447 .contents_first(true)
448 .into_iter()
449 .filter_map(Result::ok)
450 .filter(|e| e.file_type().is_dir() && e.path() != dir)
451 {
452 let _ = std::fs::remove_dir(entry.path());
453 }
454}
455
456pub fn cache_size_bytes(cfg: &Config) -> u64 {
458 walkdir::WalkDir::new(cfg.cache_dir())
459 .into_iter()
460 .filter_map(Result::ok)
461 .filter(|e| e.file_type().is_file())
462 .filter_map(|e| e.metadata().ok())
463 .map(|m| m.len())
464 .sum()
465}
466
467pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
472 let (lower, upper) = queries::folder_prefix_range(folder);
473 db.conn
474 .query_row(
475 "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
476 [&lower, &upper],
477 |r| r.get::<_, i64>(0),
478 )
479 .unwrap_or(0) as u64
480}
481
482pub fn tracks_from_server(db: &Database) -> u64 {
484 db.conn
485 .query_row(
486 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
487 [],
488 |r| r.get::<_, i64>(0),
489 )
490 .unwrap_or(0) as u64
491}
492
493pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
506 let folder = &crate::index::spelling::on_disk(folder);
508 let (lower, upper) = queries::folder_prefix_range(folder);
509
510 let tx = crate::db::queries::write_transaction(&db.conn)?;
511 let paths: Vec<String> = {
512 let mut stmt = tx.prepare("SELECT path FROM local_files WHERE path >= ?1 AND path < ?2")?;
513 let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
514 rows.collect::<rusqlite::Result<_>>()?
515 };
516 for path in &paths {
518 queries::sources::remove(&tx, queries::sources::Kind::Local, path)?;
519 }
520 tx.commit()?;
521 Ok(paths.len() as u64)
522}
523
524pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
530 let tx = crate::db::queries::write_transaction(&db.conn)?;
531 let ids: Vec<String> = {
532 let mut stmt = tx.prepare("SELECT remote_id FROM remote_entries")?;
533 let rows = stmt.query_map([], |r| r.get(0))?;
534 rows.collect::<rusqlite::Result<_>>()?
535 };
536 let mut removed = 0;
537 for id in &ids {
538 let track: i64 = tx.query_row(
539 "SELECT track_id FROM remote_entries WHERE remote_id = ?1",
540 [id],
541 |r| r.get(0),
542 )?;
543 queries::sources::remove(&tx, queries::sources::Kind::Remote, id)?;
544 let kept: bool = tx.query_row(
545 "SELECT EXISTS (SELECT 1 FROM tracks WHERE id = ?1)",
546 [track],
547 |r| r.get(0),
548 )?;
549 removed += u64::from(!kept);
550 }
551 tx.commit()?;
552 Ok(removed)
553}
554
555#[derive(Debug, Clone, Copy, Default)]
557pub struct CacheCleared {
558 pub files: u64,
559 pub bytes: u64,
560}
561
562pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
567 let dir = cfg.cache_dir();
568 let mut cleared = CacheCleared::default();
569 for entry in walkdir::WalkDir::new(&dir)
570 .into_iter()
571 .filter_map(Result::ok)
572 .filter(|e| e.file_type().is_file())
573 {
574 if let Ok(meta) = entry.metadata() {
575 cleared.bytes += meta.len();
576 cleared.files += 1;
577 }
578 }
579 let _ = std::fs::remove_dir_all(&dir);
580 let _ = std::fs::create_dir_all(&dir);
581 let _ = queries::clear_cached_paths(&db.conn);
582 cleared
583}
584
585pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
592 let mut cleared = CacheCleared::default();
593 let paths = match queries::cached_paths_for(&db.conn, track_ids) {
594 Ok(paths) => paths,
595 Err(e) => {
596 log::warn!("could not read cached paths: {e}");
597 return cleared;
598 }
599 };
600 for path in &paths {
601 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
602 match std::fs::remove_file(path) {
603 Ok(()) => {
604 cleared.files += 1;
605 cleared.bytes += size;
606 }
607 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
609 Err(e) => log::warn!("could not remove {path}: {e}"),
610 }
611 }
612 if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
613 log::warn!("removed downloads but failed to forget them ({e})");
614 }
615 cleared
616}
617
618pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
631 let mut swept = CacheCleared::default();
632 for entry in walkdir::WalkDir::new(cfg.cache_dir())
633 .into_iter()
634 .filter_map(Result::ok)
635 .filter(|e| e.file_type().is_file())
636 .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
637 {
638 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
639 match std::fs::remove_file(entry.path()) {
640 Ok(()) => {
641 swept.files += 1;
642 swept.bytes += size;
643 }
644 Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
645 }
646 }
647 if swept.files > 0 {
648 log::info!(
649 "swept {} unfinished download(s), {} bytes",
650 swept.files,
651 swept.bytes
652 );
653 }
654 swept
655}
656
657pub fn relocate_cached_paths(db: &Database, cache_dir: &Path) -> rusqlite::Result<usize> {
670 let prefix = format!("{}/", cache_dir.to_string_lossy().trim_end_matches('/'));
671 let stale: Vec<(i64, String)> = db
672 .conn
673 .prepare(
674 "SELECT id, cached_path FROM tracks
675 WHERE cached_path IS NOT NULL AND substr(cached_path, 1, ?2) != ?1",
676 )?
677 .query_map(
678 rusqlite::params![prefix, prefix.chars().count() as i64],
679 |r| Ok((r.get(0)?, r.get(1)?)),
680 )?
681 .collect::<rusqlite::Result<_>>()?;
682 if stale.is_empty() {
683 return Ok(0);
684 }
685
686 let tx = crate::db::queries::write_transaction(&db.conn)?;
687 let mut moved = 0;
688 for (id, old) in &stale {
689 let tail: Vec<_> = Path::new(old).components().rev().take(3).collect();
690 if tail.len() < 3 {
691 continue;
692 }
693 let new = tail
694 .iter()
695 .rev()
696 .fold(cache_dir.to_path_buf(), |p, c| p.join(c));
697 if new.is_file() {
698 tx.execute(
699 "UPDATE tracks SET cached_path = ?1 WHERE id = ?2",
700 rusqlite::params![new.to_string_lossy(), id],
701 )?;
702 moved += 1;
703 }
704 }
705 tx.commit()?;
706 if moved > 0 {
707 log::info!(
708 "re-rooted {moved} cached path(s) under {}",
709 cache_dir.display()
710 );
711 }
712 Ok(moved)
713}
714
715pub fn requeue_cleared_downloads(state: &SharedPlayerState) {
724 let stale = state.reset_items_with_missing_files();
725 if !stale.is_empty() {
726 log::info!(
727 "{} queued tracks lost their copy — fetching again",
728 stale.len()
729 );
730 }
731}
732
733pub fn sync_favourite_to_remote(db: &Database, track_id: i64, star: bool) {
745 let cfg = Config::load().unwrap_or_default();
746 if !cfg.remote.enabled {
747 return;
748 }
749 let Ok(Some(remote_id)) = queries::track_remote_id(&db.conn, track_id) else {
750 log::warn!("not syncing favourite: track {track_id} has no remote id");
751 return;
752 };
753 let Some(client) = subsonic_client(&cfg) else {
754 log::warn!("not syncing favourite: no usable server credentials");
755 return;
756 };
757 std::thread::Builder::new()
758 .name("koan-fav-sync".into())
759 .spawn(move || {
760 let result = if star {
761 client.star(&remote_id)
762 } else {
763 client.unstar(&remote_id)
764 };
765 match result {
766 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
767 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
768 }
769 })
770 .ok();
771}
772
773#[derive(Debug, Default)]
775pub struct Synced {
776 pub library: crate::remote::sync::SyncResult,
777 pub favourites: FavouriteSync,
778 pub playlists: crate::playlists::PlaylistSync,
779}
780
781#[derive(Debug, Clone, Copy, PartialEq, Eq)]
783pub enum Walk {
784 Always,
786 IfChanged,
790}
791
792pub fn sync_remote(
805 db: &Database,
806 client: &SubsonicClient,
807 walk: Walk,
808 url: &str,
809 username: &str,
810 progress: &(dyn Fn(crate::remote::sync::SyncProgress) + Sync),
811) -> Result<Synced, crate::remote::sync::SyncError> {
812 use crate::remote::sync;
813 static SYNCING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
818 let _one_at_a_time = SYNCING.lock();
819
820 let walked = sync::library_version(db, url);
821 let version = client
822 .library_modified(walked)
823 .inspect_err(|e| log::debug!("library version unavailable: {e}"))
824 .ok()
825 .flatten();
826 let library = if walk == Walk::IfChanged && version.is_some() && version == walked {
827 log::info!("library unchanged on the server; not walked");
828 sync::SyncResult::default()
829 } else {
830 let library = sync::sync_library(db, client, url, username, progress)?;
831 if library.is_complete()
832 && let Some(version) = version
833 {
834 sync::set_library_version(db, url, username, version)?;
835 }
836 library
837 };
838 Ok(Synced {
839 library,
840 favourites: reconcile_favourites(db, client),
841 playlists: crate::playlists::reconcile_playlists(db, client, url, username),
842 })
843}
844
845#[derive(Debug, Default, Clone, Copy)]
847pub struct FavouriteSync {
848 pub pushed: usize,
849 pub imported: usize,
850}
851
852pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
864 let mut out = FavouriteSync::default();
865
866 let starred = match client.get_starred_all() {
867 Ok(s) => s,
868 Err(e) => {
869 log::warn!("could not fetch starred items from the server: {e}");
870 return out;
871 }
872 };
873 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
874 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
875 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
876
877 let unstarred = |ids: Vec<String>, starred: &[String]| {
878 let starred: std::collections::HashSet<&String> = starred.iter().collect();
879 ids.into_iter()
880 .filter(|id| !starred.contains(id))
881 .collect::<Vec<_>>()
882 };
883 let tracks = queries::favourites_with_remote_id(&db.conn, queries::LOCAL_USER)
884 .unwrap_or_default()
885 .into_iter()
886 .map(|(_, id)| id)
887 .collect();
888 for remote_id in unstarred(tracks, &songs) {
889 if client.star(&remote_id).is_ok() {
890 out.pushed += 1;
891 }
892 }
893 let local_albums = queries::favourite_albums_with_remote_id(&db.conn, queries::LOCAL_USER)
894 .unwrap_or_default()
895 .into_iter()
896 .map(|(_, id)| id)
897 .collect();
898 for remote_id in unstarred(local_albums, &albums) {
899 if client.star_album(&remote_id).is_ok() {
900 out.pushed += 1;
901 }
902 }
903 let local_artists = queries::favourite_artists_with_remote_id(&db.conn, queries::LOCAL_USER)
904 .unwrap_or_default()
905 .into_iter()
906 .map(|(_, id)| id)
907 .collect();
908 for remote_id in unstarred(local_artists, &artists) {
909 if client.star_artist(&remote_id).is_ok() {
910 out.pushed += 1;
911 }
912 }
913
914 out.imported +=
915 queries::import_remote_favourites(&db.conn, queries::LOCAL_USER, &songs).unwrap_or(0);
916 out.imported += queries::import_remote_favourite_albums(&db.conn, queries::LOCAL_USER, &albums)
917 .unwrap_or(0);
918 out.imported +=
919 queries::import_remote_favourite_artists(&db.conn, queries::LOCAL_USER, &artists)
920 .unwrap_or(0);
921 out
922}
923
924#[derive(Debug, Clone, Copy, PartialEq, Eq)]
927pub enum FavouriteKind {
928 Track,
929 Album,
930 Artist,
931}
932
933pub fn sync_collection_favourite_to_remote(
938 db: &Database,
939 kind: FavouriteKind,
940 id: i64,
941 star: bool,
942) {
943 let cfg = Config::load().unwrap_or_default();
944 if !cfg.remote.enabled {
945 return;
946 }
947 let remote_id = match kind {
948 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
949 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
950 FavouriteKind::Track => return,
951 };
952 let Ok(Some(remote_id)) = remote_id else {
953 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
954 return;
955 };
956 let Some(client) = subsonic_client(&cfg) else {
957 log::warn!("not syncing favourite: no usable server credentials");
958 return;
959 };
960 std::thread::Builder::new()
961 .name("koan-fav-sync".into())
962 .spawn(move || {
963 let result = match (kind, star) {
964 (FavouriteKind::Album, true) => client.star_album(&remote_id),
965 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
966 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
967 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
968 (FavouriteKind::Track, _) => Ok(()),
969 };
970 match result {
971 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
972 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
973 }
974 })
975 .ok();
976}
977
978#[derive(Debug, thiserror::Error)]
980pub enum SignInError {
981 #[error("the server did not accept those credentials: {0}")]
982 Rejected(#[from] crate::remote::client::SubsonicError),
983 #[error("could not write the configuration: {0}")]
984 Config(#[from] crate::config::ConfigError),
985}
986
987pub fn set_remote_credentials(
1001 url: &str,
1002 username: &str,
1003 password: &str,
1004) -> Result<(), SignInError> {
1005 let url = url.trim_end_matches('/');
1006 SubsonicClient::new(url, username, password).ping()?;
1007
1008 remember_remote(url, username, Credential::Password(password.to_string()))
1009}
1010
1011pub fn join_with_invite(invite: &crate::invite::Invite) -> Result<(), SignInError> {
1015 let url = invite.server.trim_end_matches('/');
1016 match (&invite.token, &invite.password) {
1017 (Some(token), _) => {
1018 let device = crate::remote::link::LinkIdentity::this_device(None).name;
1019 let joined = crate::remote::client::redeem_invite(url, token, &device)?;
1020 let replaced = Config::load()
1023 .ok()
1024 .filter(|c| c.remote.url.trim_end_matches('/') == url)
1025 .filter(|c| c.remote.username == joined.username)
1026 .map(|c| c.remote.api_key)
1027 .filter(|k| !k.is_empty() && *k != joined.api_key);
1028 let revoke = |key: &str| {
1030 let credential = Credential::ApiKey(key.to_string());
1031 let client = SubsonicClient::from_auth(SubsonicAuth::with(
1032 url,
1033 &joined.username,
1034 credential,
1035 ));
1036 if let Err(e) = client.koan_revoke_own_key() {
1037 log::warn!("could not revoke an unused API key: {e}");
1038 }
1039 };
1040 let credential = Credential::ApiKey(joined.api_key.clone());
1041 if let Err(e) = remember_remote(url, &joined.username, credential) {
1042 revoke(&joined.api_key);
1043 return Err(e);
1044 }
1045 if let Some(old) = replaced {
1046 revoke(&old);
1047 }
1048 Ok(())
1049 }
1050 (None, Some(password)) => set_remote_credentials(url, &invite.username, password),
1051 (None, None) => Err(SignInError::Rejected(SubsonicError::BadResponse)),
1052 }
1053}
1054
1055fn remember_remote(url: &str, username: &str, credential: Credential) -> Result<(), SignInError> {
1058 Config::persist(|cfg| {
1059 cfg.remote.enabled = true;
1060 cfg.remote.url = url.to_string();
1061 cfg.remote.username = username.to_string();
1062 (cfg.remote.password, cfg.remote.api_key) = match &credential {
1063 Credential::Password(p) => (p.clone(), String::new()),
1064 Credential::ApiKey(k) => (String::new(), k.clone()),
1065 };
1066 })?;
1067 crate::remote::link::nudge();
1070 Ok(())
1071}
1072
1073pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
1077 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
1078}
1079
1080pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
1087 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
1088 return None;
1089 }
1090 Some(SubsonicAuth::with(
1091 &cfg.remote.url,
1092 &cfg.remote.username,
1093 remote_credential(cfg)?,
1094 ))
1095}
1096
1097pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
1108 let auth = subsonic_auth(cfg)?;
1109
1110 let mut slot = SUBSONIC_CLIENT.lock();
1111 if let Some((cached, client)) = slot.as_ref()
1112 && *cached == auth
1113 {
1114 return Some(client.clone());
1115 }
1116
1117 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
1118 *slot = Some((auth, client.clone()));
1119 Some(client)
1120}
1121
1122type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
1123
1124static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
1125 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
1126
1127#[derive(Debug, thiserror::Error)]
1134pub enum ShareError {
1135 #[error("sharing.public_url is not set, so there is no address to give out")]
1136 NoPublicUrl,
1137 #[error("none of these tracks are in the library")]
1138 NothingToShare,
1139 #[error("none of these tracks are on the server, so a link has nothing to point at")]
1140 NothingRemote,
1141 #[error("the server refused to share these: {0}")]
1142 Server(#[from] crate::remote::client::SubsonicError),
1143 #[error(transparent)]
1144 Database(#[from] crate::db::connection::DbError),
1145}
1146
1147#[derive(Debug, Clone)]
1149pub struct ShareOutcome {
1150 pub url: String,
1151 pub id: String,
1153 pub shared: usize,
1155 pub skipped: usize,
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Eq)]
1161pub enum ShareTarget {
1162 Tracks(Vec<i64>),
1164 Album {
1166 album_id: i64,
1167 start_track_id: Option<i64>,
1168 },
1169 Artist(i64),
1171}
1172
1173pub fn resolve_share(
1179 conn: &rusqlite::Connection,
1180 target: &ShareTarget,
1181) -> Result<(Slice, Vec<i64>), ShareError> {
1182 let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
1183 Ok(queries::tracks_for_album(conn, album_id)?
1184 .into_iter()
1185 .map(|t| t.id)
1186 .collect())
1187 };
1188 let (slice, ids) = match target {
1189 ShareTarget::Tracks(ids) => {
1190 let rows = queries::tracks_by_ids(conn, ids)?;
1191 match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
1192 ([one], Some(album_id)) => {
1193 return resolve_share(
1194 conn,
1195 &ShareTarget::Album {
1196 album_id,
1197 start_track_id: Some(*one),
1198 },
1199 );
1200 }
1201 _ => {
1202 let ids = ids
1204 .iter()
1205 .copied()
1206 .filter(|id| rows.iter().any(|t| t.id == *id))
1207 .collect();
1208 (Slice::TRACKS, ids)
1209 }
1210 }
1211 }
1212 ShareTarget::Album {
1213 album_id,
1214 start_track_id,
1215 } => {
1216 let ids = album_tracks(*album_id)?;
1217 let slice = Slice {
1218 kind: ShareKind::Album,
1219 subject_id: Some(*album_id),
1220 start_track_id: start_track_id.filter(|s| ids.contains(s)),
1221 };
1222 (slice, ids)
1223 }
1224 ShareTarget::Artist(artist_id) => {
1225 let mut ids = Vec::new();
1226 for album in queries::albums_for_artist(conn, *artist_id)? {
1227 ids.extend(album_tracks(album.id)?);
1228 }
1229 let slice = Slice {
1230 kind: ShareKind::Artist,
1231 subject_id: Some(*artist_id),
1232 start_track_id: None,
1233 };
1234 (slice, ids)
1235 }
1236 };
1237 if ids.is_empty() {
1238 return Err(ShareError::NothingToShare);
1239 }
1240 Ok((slice, ids))
1241}
1242
1243pub fn create_share(
1258 db: &Database,
1259 user: i64,
1260 cfg: &Config,
1261 target: &ShareTarget,
1262 description: Option<&str>,
1263) -> Result<ShareOutcome, ShareError> {
1264 let Some(client) = subsonic_client(cfg) else {
1265 return create_native_share(db, user, cfg, target, description);
1266 };
1267 let resolved;
1270 let track_ids = match target {
1271 ShareTarget::Tracks(ids) => ids.as_slice(),
1272 _ => {
1273 resolved = resolve_share(&db.conn, target)?.1;
1274 resolved.as_slice()
1275 }
1276 };
1277
1278 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
1280
1281 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
1282 if shared == 0 {
1283 return Err(ShareError::NothingRemote);
1284 }
1285
1286 let one_album = rows
1290 .first()
1291 .and_then(|f| f.album_id)
1292 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
1293 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
1294
1295 let remote_ids: Vec<String> = match one_album {
1296 Some(rid) => vec![album_share_id(&client, rid)],
1297 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
1298 };
1299
1300 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
1301 let share = client.create_share(&refs, description)?;
1302
1303 let url = share
1306 .url
1307 .clone()
1308 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
1309
1310 Ok(ShareOutcome {
1311 url,
1312 id: share.id,
1313 shared,
1314 skipped: track_ids.len().saturating_sub(shared),
1315 })
1316}
1317
1318pub fn create_native_share(
1324 db: &Database,
1325 user: i64,
1326 cfg: &Config,
1327 target: &ShareTarget,
1328 description: Option<&str>,
1329) -> Result<ShareOutcome, ShareError> {
1330 let base = cfg
1331 .sharing
1332 .public_url
1333 .as_deref()
1334 .filter(|u| !u.trim().is_empty())
1335 .ok_or(ShareError::NoPublicUrl)?;
1336 let (slice, ids) = resolve_share(&db.conn, target)?;
1337 let now = std::time::SystemTime::now()
1338 .duration_since(std::time::UNIX_EPOCH)
1339 .map_or(0, |d| d.as_secs() as i64);
1340 let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
1341 Ok(ShareOutcome {
1342 url: share_url(base, &share.id),
1343 id: share.id,
1344 shared: ids.len(),
1345 skipped: match (target, slice.kind) {
1347 (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
1348 _ => 0,
1349 },
1350 })
1351}
1352
1353pub fn share_url(public_url: &str, id: &str) -> String {
1355 format!("{}/share/{id}", public_url.trim_end_matches('/'))
1356}
1357
1358fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
1365 let koan = crate::remote::profile::is_koan(client.auth());
1366 album_share_id_for(koan, remote_id)
1367}
1368
1369fn album_share_id_for(koan: bool, remote_id: String) -> String {
1370 if koan && remote_id.parse::<i64>().is_ok() {
1371 format!("al-{remote_id}")
1372 } else {
1373 remote_id
1374 }
1375}
1376
1377fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
1381 let (remote_id, total): (Option<String>, i64) = conn
1382 .query_row(
1383 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
1384 FROM albums al WHERE al.id = ?1",
1385 [album_id],
1386 |row| Ok((row.get(0)?, row.get(1)?)),
1387 )
1388 .ok()?;
1389 (total == selected as i64).then_some(remote_id).flatten()
1390}
1391
1392pub fn shuffle<T>(items: &mut [T]) {
1401 let mut seed = [0u8; 8];
1402 if getrandom::fill(&mut seed).is_err() {
1403 return; }
1405 let mut state = u64::from_le_bytes(seed) | 1;
1406 for i in (1..items.len()).rev() {
1407 state ^= state << 13;
1409 state ^= state >> 7;
1410 state ^= state << 17;
1411 items.swap(i, (state % (i as u64 + 1)) as usize);
1412 }
1413}
1414
1415pub fn truncate_bytes(s: &str, max: usize) -> &str {
1417 if s.len() <= max {
1418 return s;
1419 }
1420 let mut end = max;
1421 while end > 0 && !s.is_char_boundary(end) {
1422 end -= 1;
1423 }
1424 &s[..end]
1425}
1426
1427pub fn sanitise_filename(s: &str) -> String {
1432 let cleaned: String = s
1433 .chars()
1434 .map(|c| match c {
1435 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1436 _ => c,
1437 })
1438 .collect::<String>()
1439 .trim()
1440 .to_string();
1441
1442 let cleaned = truncate_bytes(&cleaned, 240).trim_end().to_string();
1443 match cleaned.as_str() {
1444 "." | ".." => "_".into(),
1445 _ => cleaned,
1446 }
1447}
1448
1449pub fn sanitise_extension(codec: &str) -> Option<String> {
1453 let ext: String = codec
1454 .chars()
1455 .filter(char::is_ascii_alphanumeric)
1456 .take(16)
1457 .collect::<String>()
1458 .to_lowercase();
1459 (!ext.is_empty()).then_some(ext)
1460}
1461
1462pub fn path_within(dir: &Path, path: &Path) -> bool {
1465 path.strip_prefix(dir).is_ok_and(|rest| {
1466 rest.components()
1467 .all(|c| matches!(c, std::path::Component::Normal(_)))
1468 })
1469}
1470
1471pub fn year_of(date: &str) -> Option<&str> {
1474 date.get(..4)
1475}
1476
1477pub fn cache_path_for_track(
1480 cache_dir: &Path,
1481 track: &queries::TrackRow,
1482 album_date: Option<&str>,
1483) -> PathBuf {
1484 let artist_dir = sanitise_filename(&track.artist_name);
1485
1486 let year = album_date
1487 .and_then(year_of)
1488 .map(|y| format!("({}) ", y))
1489 .unwrap_or_default();
1490 let codec = track
1491 .codec
1492 .as_deref()
1493 .map(|c| format!(" [{}]", c))
1494 .unwrap_or_default();
1495 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1496
1497 let disc_prefix = match track.disc {
1498 Some(d) if d > 1 => format!("{}-", d),
1499 _ => String::new(),
1500 };
1501 let track_num = track
1502 .track_number
1503 .map(|n| format!("{:02}. ", n))
1504 .unwrap_or_default();
1505
1506 let ext = track
1507 .codec
1508 .as_deref()
1509 .and_then(sanitise_extension)
1510 .unwrap_or_else(|| "flac".into());
1511
1512 let filename = sanitise_filename(&format!(
1513 "{}{}{} - {}",
1514 disc_prefix, track_num, track.artist_name, track.title
1515 ));
1516
1517 cache_dir
1518 .join(artist_dir)
1519 .join(album_dir)
1520 .join(format!("{}.{}", filename, ext))
1521}
1522
1523fn resolve_item_path(
1531 cfg: &Config,
1532 track: &queries::TrackRow,
1533 remote_url: Option<&str>,
1534 album_date: Option<&str>,
1535) -> (PathBuf, ItemState) {
1536 match queries::choose_playback_source(
1537 track.path.as_deref(),
1538 track.cached_path.as_deref(),
1539 remote_url,
1540 ) {
1541 Some(queries::PlaybackSource::Local(p)) => (p, ItemState::Ready),
1542 Some(queries::PlaybackSource::Cached(p)) => {
1547 let state = if is_cached_audio(&p) {
1548 ItemState::Ready
1549 } else {
1550 ItemState::Pending
1551 };
1552 (p, state)
1553 }
1554 Some(queries::PlaybackSource::Remote(_)) => {
1555 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1556 if dest.exists() && is_cached_audio(&dest) {
1557 (dest, ItemState::Ready)
1558 } else {
1559 (dest, ItemState::Pending)
1560 }
1561 }
1562 _ => {
1563 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1565 (dest, ItemState::Pending)
1566 }
1567 }
1568}
1569
1570pub fn playlist_item_from_track(
1572 track: &queries::TrackRow,
1573 album_date: Option<&str>,
1574 dest: PathBuf,
1575 state: ItemState,
1576) -> PlaylistItem {
1577 let year = album_date.and_then(year_of).map(str::to_string);
1578 PlaylistItem {
1579 playlist_entry_id: None,
1580 id: QueueItemId::new(),
1581 db_id: Some(track.id),
1582 path: dest,
1583 title: track.title.clone(),
1584 artist: track.artist_name.clone(),
1585 album_artist: track.album_artist_name.clone(),
1586 album: track.album_title.clone(),
1587 year,
1588 codec: track.codec.clone(),
1589 track_number: track.track_number.map(|n| n as i64),
1590 disc: track.disc.map(|n| n as i64),
1591 duration_ms: track.duration_ms.map(|d| d as u64),
1592 state,
1593 pre_shuffle: None,
1594 }
1595}
1596
1597pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1604 let cfg = Config::load().unwrap_or_default();
1605 let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
1606 let extras = queries::queue_item_extras(&db.conn, &ids).unwrap_or_default();
1607
1608 tracks
1609 .iter()
1610 .map(|track| {
1611 let extra = extras.get(&track.id);
1612 let remote_url = extra.and_then(|e| e.remote_url.as_deref());
1613 let album_date = extra.and_then(|e| e.album_date.as_deref());
1614 let (path, state) = resolve_item_path(&cfg, track, remote_url, album_date);
1615 playlist_item_from_track(track, album_date, path, state)
1616 })
1617 .collect()
1618}
1619
1620fn is_cached_audio(path: &std::path::Path) -> bool {
1630 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1631 match std::fs::metadata(path) {
1632 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1633 Ok(_) => {
1634 let mut first = [0u8; 1];
1635 match std::fs::File::open(path)
1636 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1637 {
1638 Ok(b) => b != b'{' && b != b'<',
1639 Err(_) => false,
1640 }
1641 }
1642 Err(_) => false,
1643 }
1644}
1645
1646pub(crate) fn download_track(
1658 db_id: i64,
1659 cancelled: &dyn Fn() -> bool,
1660 tx: &crossbeam_channel::Sender<PlayerCommand>,
1661 state: &SharedPlayerState,
1662 cfg: &Config,
1663 client: &SubsonicClient,
1664) -> Option<Result<PathBuf, String>> {
1665 let db = match crate::db::pool::shared().get() {
1670 Ok(db) => db,
1671 Err(e) => return Some(Err(format!("db error: {e}"))),
1672 };
1673 let Ok(Some(track)) = queries::get_track_row(&db.conn, db_id) else {
1674 return Some(Err("track not found".into()));
1675 };
1676
1677 if let Some(p) = track.path.as_deref().map(PathBuf::from)
1679 && p.exists()
1680 {
1681 log::info!("download_track: local file exists, using {}", p.display());
1682 return Some(Ok(p));
1683 }
1684 let Some(remote_id) = track.remote_id.clone() else {
1685 return Some(Err(
1686 "not in the library folder, and no remote copy to fetch".into(),
1687 ));
1688 };
1689
1690 let album_date: Option<String> = track
1691 .album_id
1692 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1693
1694 let cache_dir = cfg.cache_dir();
1695 let dest = cache_path_for_track(&cache_dir, &track, album_date.as_deref());
1696 if !path_within(&cache_dir, &dest) {
1697 return Some(Err(format!(
1698 "cache path escapes the cache: {}",
1699 dest.display()
1700 )));
1701 }
1702
1703 if dest.exists() && !is_cached_audio(&dest) {
1709 log::warn!(
1710 "discarding non-audio cache entry {} (likely a stored server error)",
1711 dest.display()
1712 );
1713 let _ = std::fs::remove_file(&dest);
1714 }
1715 if dest.exists() {
1716 return Some(Ok(dest));
1717 }
1718
1719 let store = state.downloads();
1722 let bytes_written = store.announce(
1723 db_id,
1724 track.title.clone(),
1725 track.artist_name.clone(),
1726 crate::remote::download::part_path(&dest),
1727 dest.clone(),
1728 );
1729
1730 let progress_tx = tx.clone();
1731 let stream_ready_flag = std::sync::atomic::AtomicBool::new(false);
1732 let announced_total = AtomicU64::new(u64::MAX);
1734 let result =
1735 client.download_with_progress(&remote_id, &dest, cancelled, |downloaded, total| {
1736 bytes_written.set(downloaded);
1737 store.progressed();
1740 if announced_total.swap(total, Ordering::Relaxed) != total {
1741 store.started(db_id, total);
1742 }
1743 if !stream_ready_flag.load(Ordering::Relaxed)
1744 && downloaded >= crate::player::state::STREAM_THRESHOLD
1745 {
1746 stream_ready_flag.store(true, Ordering::Relaxed);
1747 for id in store.waiters(db_id) {
1750 progress_tx.send(PlayerCommand::TrackStreamReady(id)).ok();
1751 }
1752 }
1753 });
1754
1755 match result {
1756 Err(SubsonicError::Download(DownloadError::Cancelled)) => None,
1757 Err(e) => {
1758 log::warn!("x {} — {}", track.title, e);
1759 Some(Err(e.to_string()))
1760 }
1761 Ok(()) => {
1762 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1764 log::warn!(
1765 "cached {} but failed to record it ({}) — it will not be evicted",
1766 dest.display(),
1767 e
1768 );
1769 }
1770 log::info!("+ {} — {}", track.title, track.artist_name);
1771 Some(Ok(dest))
1772 }
1773 }
1774}
1775
1776pub fn remote_unavailable(cfg: &Config) -> String {
1782 if !cfg.remote.enabled {
1783 return "no remote server is configured".into();
1784 }
1785 if cfg.remote.url.is_empty() {
1786 return "the remote server has no address".into();
1787 }
1788 if remote_credential(cfg).is_none() {
1789 return "no password or API key is stored for the remote server".into();
1790 }
1791 "the remote server could not be reached".into()
1794}
1795
1796#[cfg(test)]
1797mod year_tests {
1798 use super::year_of;
1799
1800 #[test]
1801 fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1802 assert_eq!(year_of("1997-05-21"), Some("1997"));
1803 assert_eq!(year_of("199"), None);
1804 assert_eq!(year_of("1997"), None);
1806 }
1807}
1808
1809#[cfg(test)]
1810mod rebuild_tests {
1811 use super::*;
1812 use crate::db::queries::sample_meta;
1813
1814 fn test_db() -> Database {
1815 let conn = rusqlite::Connection::open_in_memory().unwrap();
1816 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1817 crate::db::schema::create_tables(&conn).unwrap();
1818 Database { conn }
1819 }
1820
1821 #[test]
1822 fn cached_paths_follow_a_moved_cache_directory() {
1823 let old = tempfile::tempdir().unwrap();
1824 let new = tempfile::tempdir().unwrap();
1825 let db = test_db();
1826
1827 let mut rows = Vec::new();
1828 for name in ["moved", "gone", "current"] {
1829 let mut meta = sample_meta(name, "Artist", "Album");
1830 meta.source = "remote".into();
1831 meta.path = None;
1832 meta.remote_id = Some(name.into());
1833 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1834 let tail = format!("Artist/Album/{name}.flac");
1835 if name != "gone" {
1837 let file = new.path().join(&tail);
1838 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1839 std::fs::write(&file, b"audio").unwrap();
1840 }
1841 let stored = if name == "current" {
1842 new.path()
1843 } else {
1844 old.path()
1845 }
1846 .join(&tail);
1847 queries::set_cached_path(&db.conn, id, &stored.to_string_lossy()).unwrap();
1848 rows.push((id, tail));
1849 }
1850
1851 assert_eq!(relocate_cached_paths(&db, new.path()).unwrap(), 1);
1852
1853 let cached = |id: i64| -> String {
1854 db.conn
1855 .query_row("SELECT cached_path FROM tracks WHERE id = ?1", [id], |r| {
1856 r.get(0)
1857 })
1858 .unwrap()
1859 };
1860 let expect = |root: &Path, tail: &str| root.join(tail).to_string_lossy().into_owned();
1861 assert_eq!(
1862 cached(rows[0].0),
1863 expect(new.path(), &rows[0].1),
1864 "re-rooted"
1865 );
1866 assert_eq!(
1867 cached(rows[1].0),
1868 expect(old.path(), &rows[1].1),
1869 "no file, left alone"
1870 );
1871 assert_eq!(
1872 cached(rows[2].0),
1873 expect(new.path(), &rows[2].1),
1874 "already current"
1875 );
1876 assert_eq!(
1877 relocate_cached_paths(&db, new.path()).unwrap(),
1878 0,
1879 "idempotent"
1880 );
1881 }
1882
1883 #[test]
1884 fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1885 let dir = tempfile::tempdir().unwrap();
1886 let db = test_db();
1887
1888 let mut cached = Vec::new();
1889 for name in ["one", "two"] {
1890 let mut meta = sample_meta(name, "Artist", "Album");
1891 meta.source = "remote".into();
1892 meta.path = None;
1893 meta.remote_id = Some(name.into());
1894 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1895 let file = dir.path().join(format!("{name}.opus"));
1896 std::fs::write(&file, vec![0u8; 2048]).unwrap();
1897 queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1898 cached.push((id, file));
1899 }
1900
1901 let cleared = clear_downloads_for(&db, &[cached[0].0]);
1902 assert_eq!(cleared.files, 1);
1903 assert_eq!(cleared.bytes, 2048);
1904 assert!(!cached[0].1.exists(), "the copy asked for is gone");
1905 assert!(cached[1].1.exists(), "the other one is untouched");
1906
1907 assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1910 assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1911 assert!(
1912 queries::cached_paths_for(&db.conn, &[cached[0].0])
1913 .unwrap()
1914 .is_empty()
1915 );
1916 }
1917
1918 #[test]
1919 fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1920 let db = test_db();
1921 let mut meta = sample_meta("ghost", "Artist", "Album");
1922 meta.source = "remote".into();
1923 meta.path = None;
1924 meta.remote_id = Some("ghost".into());
1925 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1926 queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1927
1928 let cleared = clear_downloads_for(&db, &[id]);
1929 assert_eq!(cleared.files, 0, "nothing was there to remove");
1930 assert!(
1932 queries::cached_paths_for(&db.conn, &[id])
1933 .unwrap()
1934 .is_empty()
1935 );
1936 }
1937
1938 #[test]
1939 fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1940 let dir = tempfile::tempdir().unwrap();
1941 let cache = dir.path().join("cache");
1942 std::fs::create_dir_all(cache.join("Artist")).unwrap();
1943
1944 let finished = cache.join("Artist/whole.opus");
1945 let half = cache.join("Artist/half.opus.part");
1946 std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1947 std::fs::write(&half, vec![0u8; 4096]).unwrap();
1948
1949 let cfg = Config {
1950 remote: crate::config::RemoteConfig {
1951 cache_dir: Some(cache.clone()),
1952 ..Default::default()
1953 },
1954 ..Default::default()
1955 };
1956
1957 let swept = sweep_partial_downloads(&cfg);
1958 assert_eq!(swept.files, 1);
1959 assert_eq!(swept.bytes, 4096);
1960 assert!(!half.exists(), "the unfinished one is gone");
1961 assert!(finished.exists(), "a downloaded track is not touched");
1962 }
1963
1964 #[test]
1965 fn sweeping_an_empty_cache_is_not_an_error() {
1966 let dir = tempfile::tempdir().unwrap();
1967 let cfg = Config {
1968 remote: crate::config::RemoteConfig {
1969 cache_dir: Some(dir.path().join("nothing-here")),
1970 ..Default::default()
1971 },
1972 ..Default::default()
1973 };
1974 assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1975 }
1976
1977 #[test]
1978 fn clearing_no_tracks_does_nothing() {
1979 let db = test_db();
1980 assert_eq!(clear_downloads_for(&db, &[]).files, 0);
1981 }
1982
1983 #[test]
1984 fn a_rebuild_re_reads_the_library_into_the_rows_it_has() {
1985 let db = test_db();
1986 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1987 meta.path = Some("/music/windowlicker.flac".into());
1988 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1989
1990 queries::toggle_favourite(&db.conn, crate::db::queries::LOCAL_USER, track_id).unwrap();
1991 db.conn
1992 .execute(
1993 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1994 VALUES (?1, 'test', 'la la la', 0)",
1995 [track_id],
1996 )
1997 .unwrap();
1998
1999 let summary = rebuild_index(&db).unwrap();
2000 assert_eq!(summary.tracks, 1);
2001 assert_eq!(summary.albums, 1);
2002 let count = |sql: &str| -> i64 { db.conn.query_row(sql, [], |r| r.get(0)).unwrap() };
2003 assert_eq!(
2004 count("SELECT COUNT(*) FROM local_files"),
2005 0,
2006 "every file is read again"
2007 );
2008
2009 assert_eq!(queries::upsert_track(&db.conn, &meta).unwrap(), track_id);
2011 assert_eq!(count("SELECT COUNT(*) FROM tracks"), 1);
2012 assert_eq!(count("SELECT COUNT(*) FROM favourites"), 1);
2013 assert_eq!(count("SELECT COUNT(*) FROM lyrics_cache"), 1);
2014 }
2015
2016 #[test]
2017 fn a_rebuilt_file_that_is_gone_goes_with_its_folder_scan() {
2018 let db = test_db();
2019 let tmp = tempfile::tempdir().unwrap();
2020 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker");
2021 meta.path = Some(tmp.path().join("gone.flac").to_string_lossy().into_owned());
2022 queries::upsert_track(&db.conn, &meta).unwrap();
2023 rebuild_index(&db).unwrap();
2024
2025 queries::remove_stale_tracks(&db.conn, tmp.path(), false).unwrap();
2026 let tracks: i64 = db
2027 .conn
2028 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2029 .unwrap();
2030 assert_eq!(tracks, 0);
2031 }
2032
2033 #[test]
2034 fn rebuilding_an_empty_library_is_not_an_error() {
2035 let db = test_db();
2036 let summary = rebuild_index(&db).unwrap();
2037 assert_eq!(summary.tracks, 0);
2038 }
2039}
2040
2041#[cfg(test)]
2042mod share_tests {
2043 use super::*;
2044 use crate::db::queries::sample_meta;
2045
2046 fn test_db() -> Database {
2047 let conn = rusqlite::Connection::open_in_memory().unwrap();
2048 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2049 crate::db::schema::create_tables(&conn).unwrap();
2050 Database { conn }
2051 }
2052
2053 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
2055 let ids: Vec<i64> = ["One", "Two", "Three"]
2056 .iter()
2057 .enumerate()
2058 .map(|(i, title)| {
2059 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
2060 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
2061 meta.track_number = Some(i as i32 + 1);
2062 queries::upsert_track(&db.conn, &meta).unwrap()
2063 })
2064 .collect();
2065 let album_id: i64 = db
2066 .conn
2067 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
2068 r.get(0)
2069 })
2070 .unwrap();
2071 db.conn
2072 .execute(
2073 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
2074 [album_id],
2075 )
2076 .unwrap();
2077 (album_id, ids)
2078 }
2079
2080 #[test]
2081 fn whole_album_collapses_to_the_album_link() {
2082 let db = test_db();
2083 let (album_id, ids) = album_of_three(&db);
2084 assert_eq!(
2085 album_remote_id(&db.conn, album_id, ids.len()),
2086 Some("al-1".into())
2087 );
2088 }
2089
2090 #[test]
2091 fn part_of_an_album_does_not() {
2092 let db = test_db();
2093 let (album_id, _) = album_of_three(&db);
2094 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
2097 }
2098
2099 #[test]
2100 fn a_local_only_album_has_no_link_to_collapse_to() {
2101 let db = test_db();
2102 let (album_id, ids) = album_of_three(&db);
2103 db.conn
2104 .execute(
2105 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
2106 [album_id],
2107 )
2108 .unwrap();
2109 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
2110 }
2111}
2112
2113#[cfg(test)]
2114mod client_cache_tests {
2115 use super::*;
2116
2117 #[test]
2118 fn one_subsonic_client_is_shared_per_credentials() {
2119 crate::config::isolate_config_for_tests();
2120 let mut cfg = Config::default();
2121 cfg.remote.enabled = true;
2122 cfg.remote.url = "https://shared-client.invalid".into();
2123 cfg.remote.username = "koan".into();
2124 cfg.remote.password = "first".into();
2125
2126 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
2127 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
2128 assert!(
2129 Arc::ptr_eq(&first, &again),
2130 "rebuilding drops the connection pool and re-handshakes TLS per request"
2131 );
2132
2133 cfg.remote.password = "second".into();
2134 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
2135 assert!(
2136 !Arc::ptr_eq(&first, &relogged),
2137 "new credentials must not keep serving the client signed with the old ones"
2138 );
2139 }
2140}
2141
2142#[cfg(test)]
2143mod native_share_tests {
2144 use super::*;
2145 use crate::db::queries::{sample_meta, upsert_track};
2146
2147 #[test]
2148 fn a_standalone_server_shares_natively_in_the_order_asked() {
2149 let conn = rusqlite::Connection::open_in_memory().unwrap();
2150 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2151 crate::db::schema::create_tables(&conn).unwrap();
2152 let db = Database { conn };
2153 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2154 let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
2155 let mut cfg = Config::default();
2156 assert!(matches!(
2157 create_share(
2158 &db,
2159 queries::LOCAL_USER,
2160 &cfg,
2161 &ShareTarget::Tracks(vec![a]),
2162 None
2163 ),
2164 Err(ShareError::NoPublicUrl)
2165 ));
2166 cfg.sharing.public_url = Some("https://koan.example/".into());
2167 let out = create_share(
2168 &db,
2169 queries::LOCAL_USER,
2170 &cfg,
2171 &ShareTarget::Tracks(vec![b, 9999, a]),
2172 Some("mix"),
2173 )
2174 .unwrap();
2175 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2176 assert_eq!((out.shared, out.skipped), (2, 1));
2177 let share = queries::shares::get_share(&db.conn, &out.id)
2178 .unwrap()
2179 .unwrap();
2180 assert_eq!(share.track_ids, [b, a]);
2181 assert!(matches!(
2182 create_share(
2183 &db,
2184 queries::LOCAL_USER,
2185 &cfg,
2186 &ShareTarget::Tracks(vec![9999]),
2187 None
2188 ),
2189 Err(ShareError::NothingToShare)
2190 ));
2191 }
2192
2193 #[test]
2194 fn a_server_with_an_upstream_still_shares_natively() {
2195 let conn = rusqlite::Connection::open_in_memory().unwrap();
2197 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2198 crate::db::schema::create_tables(&conn).unwrap();
2199 let db = Database { conn };
2200 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2201 let mut cfg = Config::default();
2202 cfg.remote.enabled = true;
2203 cfg.remote.url = "https://upstream.invalid".into();
2204 cfg.remote.username = "someone".into();
2205 cfg.remote.password = "secret".into();
2206 cfg.sharing.public_url = Some("https://koan.example".into());
2207 let out = create_native_share(
2208 &db,
2209 queries::LOCAL_USER,
2210 &cfg,
2211 &ShareTarget::Tracks(vec![a]),
2212 None,
2213 )
2214 .unwrap();
2215 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2216 assert!(
2217 queries::shares::get_share(&db.conn, &out.id)
2218 .unwrap()
2219 .is_some()
2220 );
2221 }
2222
2223 fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
2224 let mut meta = sample_meta(title, "Rrose", album);
2225 meta.track_number = Some(n);
2226 meta.date = Some(date.into());
2227 upsert_track(&db.conn, &meta).unwrap()
2228 }
2229
2230 #[test]
2231 fn shares_are_slices_fixed_when_made() {
2232 let conn = rusqlite::Connection::open_in_memory().unwrap();
2233 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2234 crate::db::schema::create_tables(&conn).unwrap();
2235 let db = Database { conn };
2236 let later = album_track(&db, "L1", "Later", 1, "2021");
2237 let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
2238 let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
2239 let album_of = |t| {
2240 queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
2241 .album_id
2242 .unwrap()
2243 };
2244 let (earlier, later_album) = (album_of(a1), album_of(later));
2245 let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
2246 .artist_id
2247 .unwrap();
2248
2249 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
2251 assert_eq!(
2252 (slice.kind, slice.subject_id, slice.start_track_id),
2253 (ShareKind::Album, Some(earlier), Some(a2))
2254 );
2255 assert_eq!(ids, [a1, a2]);
2256
2257 let (slice, ids) = resolve_share(
2259 &db.conn,
2260 &ShareTarget::Album {
2261 album_id: later_album,
2262 start_track_id: Some(a1),
2263 },
2264 )
2265 .unwrap();
2266 assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
2267 assert_eq!(ids, [later]);
2268
2269 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
2271 assert_eq!(
2272 (slice.kind, slice.subject_id),
2273 (ShareKind::Artist, Some(artist))
2274 );
2275 assert_eq!(ids, [a1, a2, later]);
2276
2277 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
2279 assert_eq!(slice, Slice::TRACKS);
2280 assert_eq!(ids, [later, a1]);
2281
2282 assert!(matches!(
2283 resolve_share(&db.conn, &ShareTarget::Artist(9999)),
2284 Err(ShareError::NothingToShare)
2285 ));
2286 }
2287}
2288
2289#[cfg(test)]
2290mod album_share_id_tests {
2291 use super::album_share_id_for;
2292
2293 #[test]
2294 fn a_koan_album_is_named_as_an_album() {
2295 assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
2296 assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
2298 assert_eq!(album_share_id_for(false, "46215".into()), "46215");
2299 assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
2300 }
2301}
2302
2303#[cfg(test)]
2304mod cache_path_tests {
2305 use super::*;
2306
2307 fn track(artist: &str, album: &str, codec: &str) -> queries::TrackRow {
2308 queries::TrackRow {
2309 id: 1,
2310 album_id: None,
2311 artist_id: None,
2312 artist_name: artist.into(),
2313 album_artist_name: artist.into(),
2314 album_title: album.into(),
2315 disc: None,
2316 track_number: Some(1),
2317 title: "Song".into(),
2318 duration_ms: None,
2319 path: None,
2320 codec: Some(codec.into()),
2321 sample_rate: None,
2322 bit_depth: None,
2323 channels: None,
2324 bitrate: None,
2325 genre: None,
2326 source: "remote".into(),
2327 remote_id: Some("r1".into()),
2328 cached_path: None,
2329 }
2330 }
2331
2332 #[test]
2333 fn a_server_suffix_cannot_leave_the_cache() {
2334 let cache = Path::new("/cache");
2335 for codec in [
2336 "flac/../../../../x",
2337 "..",
2338 "../..",
2339 "/etc/passwd",
2340 "\\..\\..",
2341 ] {
2342 let path = cache_path_for_track(cache, &track("A", "B", codec), None);
2343 assert!(path_within(cache, &path), "{codec}: {}", path.display());
2344 }
2345 let path = cache_path_for_track(cache, &track("A", "B", "flac/../../../../x"), None);
2346 assert_eq!(path.extension().unwrap(), "flacx");
2347 }
2348
2349 #[test]
2350 fn dot_names_cannot_climb_out() {
2351 let cache = Path::new("/cache");
2352 let path = cache_path_for_track(cache, &track("..", ".", ".."), None);
2353 assert!(path_within(cache, &path), "{}", path.display());
2354 assert_eq!(sanitise_filename(".."), "_");
2355 assert_eq!(sanitise_filename(" . "), "_");
2356 assert_eq!(sanitise_filename("..."), "...");
2357 }
2358
2359 #[test]
2360 fn an_empty_suffix_falls_back_to_flac() {
2361 assert_eq!(sanitise_extension("../"), None);
2362 assert_eq!(sanitise_extension("FLAC"), Some("flac".into()));
2363 let path = cache_path_for_track(Path::new("/c"), &track("A", "B", "./"), None);
2364 assert_eq!(path.extension().unwrap(), "flac");
2365 }
2366
2367 #[test]
2368 fn path_within_rejects_parent_components() {
2369 let dir = Path::new("/cache");
2370 assert!(path_within(dir, Path::new("/cache/a/b.flac")));
2371 assert!(!path_within(dir, Path::new("/cache/a/../../x")));
2372 assert!(!path_within(dir, Path::new("/elsewhere/x")));
2373 }
2374}
2375
2376#[cfg(test)]
2377mod favourite_sync_tests {
2378 use super::*;
2379 use crate::db::queries::sample_meta;
2380 use std::sync::Mutex;
2381
2382 fn serve(stars: Arc<Mutex<Vec<String>>>) -> String {
2384 use std::io::{BufRead, Write};
2385 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2386 let url = format!("http://{}", listener.local_addr().unwrap());
2387 std::thread::spawn(move || {
2388 for mut stream in listener.incoming().flatten() {
2389 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
2390 let mut request = String::new();
2391 reader.read_line(&mut request).unwrap();
2392 let mut line = String::new();
2393 while reader.read_line(&mut line).unwrap_or(0) > 2 {
2394 line.clear();
2395 }
2396 let target = request.split_whitespace().nth(1).unwrap_or("");
2397 let (path, query) = target.split_once('?').unwrap_or((target, ""));
2398 let body = match path.rsplit('/').next().unwrap() {
2399 "getStarred2" => {
2400 r#"{"subsonic-response":{"status":"ok","starred2":{"song":[{"id":"s1","title":"One"}]}}}"#
2401 }
2402 "star" => {
2403 if let Some((_, id)) = query
2404 .split('&')
2405 .filter_map(|kv| kv.split_once('='))
2406 .find(|(k, _)| *k == "id")
2407 {
2408 stars.lock().unwrap().push(id.to_string());
2409 }
2410 r#"{"subsonic-response":{"status":"ok"}}"#
2411 }
2412 _ => r#"{"subsonic-response":{"status":"ok"}}"#,
2413 };
2414 let _ = write!(
2415 stream,
2416 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2417 body.len()
2418 );
2419 }
2420 });
2421 url
2422 }
2423
2424 #[test]
2425 fn only_favourites_the_server_lacks_are_starred() {
2426 let dir = tempfile::tempdir().unwrap();
2427 let db = Database::open(&dir.path().join("koan.db")).unwrap();
2428 for (title, remote_id) in [("One", "s1"), ("Two", "s2")] {
2429 let mut meta = sample_meta(title, "Artist", "Album");
2430 meta.path = Some(format!("/music/{title}.flac"));
2431 meta.remote_id = Some(remote_id.into());
2432 let id = queries::upsert_track(&db.conn, &meta).unwrap();
2433 queries::add_favourite(&db.conn, queries::LOCAL_USER, id).unwrap();
2434 }
2435
2436 let stars = Arc::new(Mutex::new(Vec::new()));
2437 let url = serve(stars.clone());
2438 let sync = reconcile_favourites(&db, &SubsonicClient::new(&url, "u", "pw"));
2439 assert_eq!(sync.pushed, 1);
2440 assert_eq!(*stars.lock().unwrap(), ["s2"]);
2441 }
2442}