1use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use crate::config::Config;
9use crate::db::connection::Database;
10use crate::db::queries;
11use crate::db::queries::shares::{ShareKind, Slice};
12use crate::player::commands::PlayerCommand;
13use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
14use crate::remote::client::{SubsonicAuth, SubsonicClient, SubsonicError};
15use crate::remote::download::DownloadError;
16
17pub fn get_remote_password(cfg: &Config) -> Option<String> {
24 (!cfg.remote.password.is_empty()).then(|| cfg.remote.password.clone())
25}
26
27pub fn spawn_library_watch(
51 db_path: std::path::PathBuf,
52 on_state: impl Fn(bool) + Send + Sync + 'static,
53) -> Option<std::thread::JoinHandle<()>> {
54 use std::collections::BTreeSet;
55 use std::time::{Duration, Instant};
56
57 use notify::{RecursiveMode, Watcher};
58
59 use crate::index::scanner::{self, ScanOptions};
60 use crate::index::watch::{WatchedRoot, scan_target};
61
62 const SETTLE: Duration = Duration::from_secs(5);
65 const CHECK: Duration = Duration::from_secs(30);
67 const MAX_DIRS: usize = 200;
69 const RESCAN: Duration = Duration::from_secs(15 * 60);
73
74 std::thread::Builder::new()
75 .name("koan-library-watch".into())
76 .spawn(move || {
77 let scan = |reason: &str, folders: &[PathBuf], dirs: Option<&[PathBuf]>| {
78 if folders.is_empty() {
79 return;
80 }
81 let Ok(db) = Database::open_existing(&db_path) else {
82 return;
83 };
84 on_state(true);
85 let result = match dirs {
86 Some(dirs) => {
87 scanner::scan_dirs(&db, folders, dirs, ScanOptions::default(), None)
88 }
89 None => scanner::full_scan(&db, folders, ScanOptions::default(), None),
90 };
91 on_state(false);
92 log::info!(
93 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
94 result.added,
95 result.updated,
96 result.removed,
97 result.skipped
98 );
99 };
100 let folders = || Config::cached().library.folders.clone();
101
102 let (tx, rx) = std::sync::mpsc::channel();
103 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
104 let _ = tx.send(event);
105 }) else {
106 log::warn!("could not watch the library folders");
107 return;
108 };
109
110 let mut roots: Vec<WatchedRoot> = Vec::new();
114 let mut rewatch = |roots: &mut Vec<WatchedRoot>| {
115 let wanted: Vec<WatchedRoot> = folders()
116 .iter()
117 .filter_map(|f| WatchedRoot::resolve(f))
118 .collect();
119 roots.retain(|root| {
120 let keep = wanted.contains(root);
121 if !keep {
122 let _ = watcher.unwatch(&root.path);
123 }
124 keep
125 });
126 let mut fresh = Vec::new();
127 for root in wanted {
128 if roots.contains(&root) {
129 continue;
130 }
131 match watcher.watch(&root.path, RecursiveMode::Recursive) {
132 Ok(()) => {
133 fresh.push(root.path.clone());
134 roots.push(root);
135 }
136 Err(e) => log::warn!("could not watch {}: {e}", root.path.display()),
137 }
138 }
139 fresh
140 };
141
142 std::thread::sleep(Duration::from_secs(3));
144 rewatch(&mut roots);
145 scan("startup", &folders(), None);
146
147 let mut dirs = BTreeSet::new();
148 let mut everything = false;
149 let mut settle_at: Option<Instant> = None;
150 let mut check_at = Instant::now() + CHECK;
151 let mut rescan_at = Instant::now() + RESCAN;
152 loop {
153 crate::quiet::wait_until_awake();
156 let now = Instant::now();
157 let wake = settle_at
158 .map_or(check_at, |at| at.min(check_at))
159 .min(rescan_at);
160 match rx.recv_timeout(wake.saturating_duration_since(now)) {
161 Ok(Ok(event)) if event.need_rescan() => {
162 everything = true;
163 settle_at = Some(Instant::now() + SETTLE);
164 }
165 Ok(Ok(event)) => {
166 let mut heard = false;
167 for dir in event
168 .paths
169 .iter()
170 .filter_map(|p| scan_target(&event.kind, p, &roots))
171 {
172 dirs.insert(dir);
173 heard = true;
174 }
175 if heard {
176 settle_at = Some(Instant::now() + SETTLE);
177 }
178 }
179 Ok(Err(e)) => log::debug!("library watch: {e}"),
180 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
181 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
182 }
183
184 let now = Instant::now();
185 if settle_at.is_some_and(|at| now >= at) {
186 let changed =
187 scanner::minimal_dirs(std::mem::take(&mut dirs).into_iter().collect());
188 if everything || changed.len() > MAX_DIRS {
189 scan("watched change", &folders(), None);
190 rescan_at = Instant::now() + RESCAN;
191 } else {
192 scan("watched change", &folders(), Some(&changed));
193 }
194 everything = false;
195 settle_at = None;
196 }
197 if now >= rescan_at {
198 scan("periodic", &folders(), None);
199 rescan_at = Instant::now() + RESCAN;
200 }
201 if now >= check_at {
202 let fresh = rewatch(&mut roots);
203 if !fresh.is_empty() {
204 scan("newly watched", &fresh, None);
205 }
206 check_at = Instant::now() + CHECK;
207 }
208 }
209 })
210 .ok()
211}
212
213pub fn spawn_auto_sync(
229 db_path: std::path::PathBuf,
230 on_state: impl Fn(bool) + Send + 'static,
231 on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
232) -> Option<std::thread::JoinHandle<()>> {
233 std::thread::Builder::new()
234 .name("koan-auto-sync".into())
235 .spawn(move || {
236 std::thread::sleep(std::time::Duration::from_secs(5));
237 loop {
238 crate::quiet::wait_until_awake();
240 let cfg = Config::load().unwrap_or_default();
241 if !cfg.remote.enabled || !cfg.remote.auto_sync {
242 std::thread::sleep(std::time::Duration::from_secs(60));
245 continue;
246 }
247
248 if let Some(client) = subsonic_client(&cfg)
249 && let Ok(db) = Database::open_existing(&db_path)
250 {
251 on_state(true);
252 match sync_remote(
253 &db,
254 &client,
255 Walk::IfChanged,
256 &cfg.remote.url,
257 &cfg.remote.username,
258 &on_progress,
259 ) {
260 Ok(s) => log::info!(
261 "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
262 favourites {}↑ {}↓; playlists {}↓ {}↑",
263 s.library.artists_synced,
264 s.library.albums_synced,
265 s.library.tracks_synced,
266 s.library.albums_failed,
267 s.favourites.pushed,
268 s.favourites.imported,
269 s.playlists.pulled,
270 s.playlists.pushed,
271 ),
272 Err(e) => log::warn!("auto sync failed: {e}"),
273 }
274 on_state(false);
275 }
276
277 let mins = cfg.remote.auto_sync_interval_mins;
281 if mins == 0 {
282 return;
283 }
284 loop {
285 std::thread::sleep(std::time::Duration::from_secs(mins * 60));
286 if !crate::remote::profile::current().is_some_and(|p| p.links()) {
287 break;
288 }
289 }
290 }
291 })
292 .ok()
293}
294
295#[derive(Debug, Clone, Copy, Default)]
297pub struct RebuildSummary {
298 pub tracks: u64,
299 pub albums: u64,
300 pub artists: u64,
301}
302
303pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
314 let count = |sql: &str| -> u64 {
315 db.conn
316 .query_row(sql, [], |r| r.get::<_, i64>(0))
317 .unwrap_or(0) as u64
318 };
319 let summary = RebuildSummary {
320 tracks: count("SELECT COUNT(*) FROM tracks"),
321 albums: count("SELECT COUNT(*) FROM albums"),
322 artists: count("SELECT COUNT(*) FROM artists"),
323 };
324
325 db.conn.execute_batch(
328 "BEGIN;
329 DELETE FROM lyrics_cache;
330 DELETE FROM play_history;
331 DELETE FROM scan_cache;
332 DELETE FROM tracks_fts;
333 DELETE FROM tracks;
334 DELETE FROM albums;
335 DELETE FROM artists;
336 COMMIT;",
337 )?;
338 let _ = db.conn.execute_batch("VACUUM");
339 Ok(summary)
340}
341
342pub fn evict_cache(
347 db: &Database,
348 cfg: &Config,
349 keep: &std::collections::HashSet<i64>,
350 verbose: bool,
351) -> u64 {
352 let Some(limit) = cfg.cache_limit_bytes().map(|l| l as i64) else {
353 return 0;
354 };
355 let mut current = match queries::total_cache_size(&db.conn) {
356 Ok(s) => s,
357 Err(e) => {
358 log::warn!("cache eviction: failed to query cache size: {e}");
359 return 0;
360 }
361 };
362 if current <= limit {
363 if verbose {
364 log::info!("cache within limit: {current} / {limit} bytes");
365 }
366 return 0;
367 }
368 let albums = match queries::cached_albums_lru(&db.conn) {
369 Ok(a) => a,
370 Err(e) => {
371 log::warn!("cache eviction: failed to query cached albums: {e}");
372 return 0;
373 }
374 };
375 let mut freed: i64 = 0;
376 for album in &albums {
377 if current <= limit {
378 break;
379 }
380 if album.track_ids.iter().any(|id| keep.contains(id)) {
381 continue;
382 }
383 for path in &album.cached_paths {
384 match std::fs::remove_file(path) {
385 Ok(()) => {}
386 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
387 Err(e) => log::warn!("cache eviction: failed to delete {path}: {e}"),
388 }
389 }
390 if let Err(e) = queries::clear_cached_paths_for(&db.conn, &album.track_ids) {
391 log::warn!("cache eviction: failed to clear DB for album: {e}");
392 }
393 log::info!(
394 "evicted: {} — {} ({} bytes)",
395 album.artist_name,
396 album.album_title,
397 album.total_size
398 );
399 current -= album.total_size;
400 freed += album.total_size;
401 }
402 remove_empty_dirs(&cfg.cache_dir());
403 if freed > 0 {
404 log::info!("cache eviction freed {freed} bytes");
405 }
406 freed as u64
407}
408
409fn remove_empty_dirs(dir: &Path) {
411 if !dir.is_dir() {
412 return;
413 }
414 for entry in walkdir::WalkDir::new(dir)
415 .contents_first(true)
416 .into_iter()
417 .filter_map(Result::ok)
418 .filter(|e| e.file_type().is_dir() && e.path() != dir)
419 {
420 let _ = std::fs::remove_dir(entry.path());
421 }
422}
423
424pub fn cache_size_bytes(cfg: &Config) -> u64 {
426 walkdir::WalkDir::new(cfg.cache_dir())
427 .into_iter()
428 .filter_map(Result::ok)
429 .filter(|e| e.file_type().is_file())
430 .filter_map(|e| e.metadata().ok())
431 .map(|m| m.len())
432 .sum()
433}
434
435pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
440 let (lower, upper) = queries::folder_prefix_range(folder);
441 db.conn
442 .query_row(
443 "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
444 [&lower, &upper],
445 |r| r.get::<_, i64>(0),
446 )
447 .unwrap_or(0) as u64
448}
449
450pub fn tracks_from_server(db: &Database) -> u64 {
452 db.conn
453 .query_row(
454 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
455 [],
456 |r| r.get::<_, i64>(0),
457 )
458 .unwrap_or(0) as u64
459}
460
461pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
474 let folder = &crate::index::spelling::on_disk(folder);
476 let (lower, upper) = queries::folder_prefix_range(folder);
477
478 let tx = crate::db::queries::write_transaction(&db.conn)?;
479 tx.execute(
481 "UPDATE tracks SET path = NULL, source = 'remote'
482 WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
483 [&lower, &upper],
484 )?;
485
486 let ids: Vec<i64> = {
487 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
488 let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
489 rows.filter_map(Result::ok).collect()
490 };
491 delete_track_rows(&tx, &ids)?;
492 prune_empty_albums_and_artists(&tx)?;
493 tx.commit()?;
494 Ok(ids.len() as u64)
495}
496
497fn delete_track_rows(conn: &rusqlite::Connection, ids: &[i64]) -> rusqlite::Result<()> {
498 for id in ids {
499 conn.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
500 conn.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
501 conn.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
502 conn.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
503 conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
504 }
505 Ok(())
506}
507
508pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
514 let tx = crate::db::queries::write_transaction(&db.conn)?;
515
516 let ids: Vec<i64> = {
517 let mut stmt =
518 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
519 let rows = stmt.query_map([], |r| r.get(0))?;
520 rows.filter_map(Result::ok).collect()
521 };
522 delete_track_rows(&tx, &ids)?;
523 tx.execute(
525 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
526 WHERE remote_id IS NOT NULL",
527 [],
528 )?;
529 prune_empty_albums_and_artists(&tx)?;
530 tx.commit()?;
531 Ok(ids.len() as u64)
532}
533
534fn prune_empty_albums_and_artists(
536 tx: &rusqlite::Transaction<'_>,
537) -> Result<(), crate::db::connection::DbError> {
538 tx.execute(
539 "DELETE FROM albums WHERE NOT EXISTS
540 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
541 [],
542 )?;
543 tx.execute(
544 "DELETE FROM artists WHERE NOT EXISTS
545 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
546 AND NOT EXISTS
547 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
548 [],
549 )?;
550 Ok(())
551}
552
553#[derive(Debug, Clone, Copy, Default)]
555pub struct CacheCleared {
556 pub files: u64,
557 pub bytes: u64,
558}
559
560pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
565 let dir = cfg.cache_dir();
566 let mut cleared = CacheCleared::default();
567 for entry in walkdir::WalkDir::new(&dir)
568 .into_iter()
569 .filter_map(Result::ok)
570 .filter(|e| e.file_type().is_file())
571 {
572 if let Ok(meta) = entry.metadata() {
573 cleared.bytes += meta.len();
574 cleared.files += 1;
575 }
576 }
577 let _ = std::fs::remove_dir_all(&dir);
578 let _ = std::fs::create_dir_all(&dir);
579 let _ = queries::clear_cached_paths(&db.conn);
580 cleared
581}
582
583pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
590 let mut cleared = CacheCleared::default();
591 let paths = match queries::cached_paths_for(&db.conn, track_ids) {
592 Ok(paths) => paths,
593 Err(e) => {
594 log::warn!("could not read cached paths: {e}");
595 return cleared;
596 }
597 };
598 for path in &paths {
599 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
600 match std::fs::remove_file(path) {
601 Ok(()) => {
602 cleared.files += 1;
603 cleared.bytes += size;
604 }
605 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
607 Err(e) => log::warn!("could not remove {path}: {e}"),
608 }
609 }
610 if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
611 log::warn!("removed downloads but failed to forget them ({e})");
612 }
613 cleared
614}
615
616pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
629 let mut swept = CacheCleared::default();
630 for entry in walkdir::WalkDir::new(cfg.cache_dir())
631 .into_iter()
632 .filter_map(Result::ok)
633 .filter(|e| e.file_type().is_file())
634 .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
635 {
636 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
637 match std::fs::remove_file(entry.path()) {
638 Ok(()) => {
639 swept.files += 1;
640 swept.bytes += size;
641 }
642 Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
643 }
644 }
645 if swept.files > 0 {
646 log::info!(
647 "swept {} unfinished download(s), {} bytes",
648 swept.files,
649 swept.bytes
650 );
651 }
652 swept
653}
654
655pub fn relocate_cached_paths(db: &Database, cache_dir: &Path) -> rusqlite::Result<usize> {
668 let prefix = format!("{}/", cache_dir.to_string_lossy().trim_end_matches('/'));
669 let stale: Vec<(i64, String)> = db
670 .conn
671 .prepare(
672 "SELECT id, cached_path FROM tracks
673 WHERE cached_path IS NOT NULL AND substr(cached_path, 1, ?2) != ?1",
674 )?
675 .query_map(
676 rusqlite::params![prefix, prefix.chars().count() as i64],
677 |r| Ok((r.get(0)?, r.get(1)?)),
678 )?
679 .collect::<rusqlite::Result<_>>()?;
680 if stale.is_empty() {
681 return Ok(0);
682 }
683
684 let tx = crate::db::queries::write_transaction(&db.conn)?;
685 let mut moved = 0;
686 for (id, old) in &stale {
687 let tail: Vec<_> = Path::new(old).components().rev().take(3).collect();
688 if tail.len() < 3 {
689 continue;
690 }
691 let new = tail
692 .iter()
693 .rev()
694 .fold(cache_dir.to_path_buf(), |p, c| p.join(c));
695 if new.is_file() {
696 tx.execute(
697 "UPDATE tracks SET cached_path = ?1 WHERE id = ?2",
698 rusqlite::params![new.to_string_lossy(), id],
699 )?;
700 moved += 1;
701 }
702 }
703 tx.commit()?;
704 if moved > 0 {
705 log::info!(
706 "re-rooted {moved} cached path(s) under {}",
707 cache_dir.display()
708 );
709 }
710 Ok(moved)
711}
712
713pub fn requeue_cleared_downloads(
720 state: &Arc<SharedPlayerState>,
721 tx: &crossbeam_channel::Sender<PlayerCommand>,
722) {
723 let stale = state.reset_items_with_missing_files();
724 if stale.is_empty() {
725 return;
726 }
727 log::info!(
728 "{} queued tracks lost their copy — fetching again",
729 stale.len()
730 );
731 spawn_downloads(stale, tx.clone(), state.clone());
732}
733
734pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
746 let cfg = Config::load().unwrap_or_default();
747 if !cfg.remote.enabled {
748 return;
749 }
750 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
751 log::warn!("not syncing favourite: {} has no remote id", path.display());
752 return;
753 };
754 let Some(client) = subsonic_client(&cfg) else {
755 log::warn!("not syncing favourite: no usable server credentials");
756 return;
757 };
758 std::thread::Builder::new()
759 .name("koan-fav-sync".into())
760 .spawn(move || {
761 let result = if star {
762 client.star(&remote_id)
763 } else {
764 client.unstar(&remote_id)
765 };
766 match result {
767 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
768 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
769 }
770 })
771 .ok();
772}
773
774#[derive(Debug, Default)]
776pub struct Synced {
777 pub library: crate::remote::sync::SyncResult,
778 pub favourites: FavouriteSync,
779 pub playlists: crate::playlists::PlaylistSync,
780}
781
782#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub enum Walk {
785 Always,
787 IfChanged,
791}
792
793pub fn sync_remote(
806 db: &Database,
807 client: &SubsonicClient,
808 walk: Walk,
809 url: &str,
810 username: &str,
811 progress: &(dyn Fn(crate::remote::sync::SyncProgress) + Sync),
812) -> Result<Synced, crate::remote::sync::SyncError> {
813 use crate::remote::sync;
814 static SYNCING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
819 let _one_at_a_time = SYNCING.lock();
820
821 let walked = sync::library_version(db, url);
822 let version = client
823 .library_modified(walked)
824 .inspect_err(|e| log::debug!("library version unavailable: {e}"))
825 .ok()
826 .flatten();
827 let library = if walk == Walk::IfChanged && version.is_some() && version == walked {
828 log::info!("library unchanged on the server; not walked");
829 sync::SyncResult::default()
830 } else {
831 let library = sync::sync_library(db, client, url, username, progress)?;
832 if library.is_complete()
833 && let Some(version) = version
834 {
835 sync::set_library_version(db, url, username, version)?;
836 }
837 library
838 };
839 Ok(Synced {
840 library,
841 favourites: reconcile_favourites(db, client),
842 playlists: crate::playlists::reconcile_playlists(db, client, url, username),
843 })
844}
845
846#[derive(Debug, Default, Clone, Copy)]
848pub struct FavouriteSync {
849 pub pushed: usize,
850 pub imported: usize,
851}
852
853pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
865 let mut out = FavouriteSync::default();
866
867 let starred = match client.get_starred_all() {
868 Ok(s) => s,
869 Err(e) => {
870 log::warn!("could not fetch starred items from the server: {e}");
871 return out;
872 }
873 };
874 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
875 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
876 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
877
878 let unstarred = |ids: Vec<String>, starred: &[String]| {
879 let starred: std::collections::HashSet<&String> = starred.iter().collect();
880 ids.into_iter()
881 .filter(|id| !starred.contains(id))
882 .collect::<Vec<_>>()
883 };
884 let tracks = queries::favourites_with_remote_id(&db.conn, queries::LOCAL_USER)
885 .unwrap_or_default()
886 .into_iter()
887 .map(|(_, id)| id)
888 .collect();
889 for remote_id in unstarred(tracks, &songs) {
890 if client.star(&remote_id).is_ok() {
891 out.pushed += 1;
892 }
893 }
894 let local_albums = queries::favourite_albums_with_remote_id(&db.conn, queries::LOCAL_USER)
895 .unwrap_or_default()
896 .into_iter()
897 .map(|(_, id)| id)
898 .collect();
899 for remote_id in unstarred(local_albums, &albums) {
900 if client.star_album(&remote_id).is_ok() {
901 out.pushed += 1;
902 }
903 }
904 let local_artists = queries::favourite_artists_with_remote_id(&db.conn, queries::LOCAL_USER)
905 .unwrap_or_default()
906 .into_iter()
907 .map(|(_, id)| id)
908 .collect();
909 for remote_id in unstarred(local_artists, &artists) {
910 if client.star_artist(&remote_id).is_ok() {
911 out.pushed += 1;
912 }
913 }
914
915 out.imported +=
916 queries::import_remote_favourites(&db.conn, queries::LOCAL_USER, &songs).unwrap_or(0);
917 out.imported += queries::import_remote_favourite_albums(&db.conn, queries::LOCAL_USER, &albums)
918 .unwrap_or(0);
919 out.imported +=
920 queries::import_remote_favourite_artists(&db.conn, queries::LOCAL_USER, &artists)
921 .unwrap_or(0);
922 out
923}
924
925#[derive(Debug, Clone, Copy, PartialEq, Eq)]
928pub enum FavouriteKind {
929 Track,
930 Album,
931 Artist,
932}
933
934pub fn sync_collection_favourite_to_remote(
939 db: &Database,
940 kind: FavouriteKind,
941 id: i64,
942 star: bool,
943) {
944 let cfg = Config::load().unwrap_or_default();
945 if !cfg.remote.enabled {
946 return;
947 }
948 let remote_id = match kind {
949 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
950 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
951 FavouriteKind::Track => return,
952 };
953 let Ok(Some(remote_id)) = remote_id else {
954 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
955 return;
956 };
957 let Some(client) = subsonic_client(&cfg) else {
958 log::warn!("not syncing favourite: no usable server credentials");
959 return;
960 };
961 std::thread::Builder::new()
962 .name("koan-fav-sync".into())
963 .spawn(move || {
964 let result = match (kind, star) {
965 (FavouriteKind::Album, true) => client.star_album(&remote_id),
966 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
967 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
968 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
969 (FavouriteKind::Track, _) => Ok(()),
970 };
971 match result {
972 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
973 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
974 }
975 })
976 .ok();
977}
978
979#[derive(Debug, thiserror::Error)]
981pub enum SignInError {
982 #[error("the server did not accept those credentials: {0}")]
983 Rejected(#[from] crate::remote::client::SubsonicError),
984 #[error("could not write the configuration: {0}")]
985 Config(#[from] crate::config::ConfigError),
986}
987
988pub 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 Config::persist(|cfg| {
1009 cfg.remote.enabled = true;
1010 cfg.remote.url = url.to_string();
1011 cfg.remote.username = username.to_string();
1012 cfg.remote.password = password.to_string();
1013 })?;
1014 crate::remote::link::nudge();
1017 Ok(())
1018}
1019
1020pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
1024 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
1025}
1026
1027pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
1034 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
1035 return None;
1036 }
1037 let password = get_remote_password(cfg)?;
1038 Some(SubsonicAuth::new(
1039 &cfg.remote.url,
1040 &cfg.remote.username,
1041 &password,
1042 ))
1043}
1044
1045pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
1056 let auth = subsonic_auth(cfg)?;
1057
1058 let mut slot = SUBSONIC_CLIENT.lock();
1059 if let Some((cached, client)) = slot.as_ref()
1060 && *cached == auth
1061 {
1062 return Some(client.clone());
1063 }
1064
1065 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
1066 *slot = Some((auth, client.clone()));
1067 Some(client)
1068}
1069
1070type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
1071
1072static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
1073 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
1074
1075#[derive(Debug, thiserror::Error)]
1082pub enum ShareError {
1083 #[error("sharing.public_url is not set, so there is no address to give out")]
1084 NoPublicUrl,
1085 #[error("none of these tracks are in the library")]
1086 NothingToShare,
1087 #[error("none of these tracks are on the server, so a link has nothing to point at")]
1088 NothingRemote,
1089 #[error("the server refused to share these: {0}")]
1090 Server(#[from] crate::remote::client::SubsonicError),
1091 #[error(transparent)]
1092 Database(#[from] crate::db::connection::DbError),
1093}
1094
1095#[derive(Debug, Clone)]
1097pub struct ShareOutcome {
1098 pub url: String,
1099 pub id: String,
1101 pub shared: usize,
1103 pub skipped: usize,
1105}
1106
1107#[derive(Debug, Clone, PartialEq, Eq)]
1109pub enum ShareTarget {
1110 Tracks(Vec<i64>),
1112 Album {
1114 album_id: i64,
1115 start_track_id: Option<i64>,
1116 },
1117 Artist(i64),
1119}
1120
1121pub fn resolve_share(
1127 conn: &rusqlite::Connection,
1128 target: &ShareTarget,
1129) -> Result<(Slice, Vec<i64>), ShareError> {
1130 let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
1131 Ok(queries::tracks_for_album(conn, album_id)?
1132 .into_iter()
1133 .map(|t| t.id)
1134 .collect())
1135 };
1136 let (slice, ids) = match target {
1137 ShareTarget::Tracks(ids) => {
1138 let rows = queries::tracks_by_ids(conn, ids)?;
1139 match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
1140 ([one], Some(album_id)) => {
1141 return resolve_share(
1142 conn,
1143 &ShareTarget::Album {
1144 album_id,
1145 start_track_id: Some(*one),
1146 },
1147 );
1148 }
1149 _ => {
1150 let ids = ids
1152 .iter()
1153 .copied()
1154 .filter(|id| rows.iter().any(|t| t.id == *id))
1155 .collect();
1156 (Slice::TRACKS, ids)
1157 }
1158 }
1159 }
1160 ShareTarget::Album {
1161 album_id,
1162 start_track_id,
1163 } => {
1164 let ids = album_tracks(*album_id)?;
1165 let slice = Slice {
1166 kind: ShareKind::Album,
1167 subject_id: Some(*album_id),
1168 start_track_id: start_track_id.filter(|s| ids.contains(s)),
1169 };
1170 (slice, ids)
1171 }
1172 ShareTarget::Artist(artist_id) => {
1173 let mut ids = Vec::new();
1174 for album in queries::albums_for_artist(conn, *artist_id)? {
1175 ids.extend(album_tracks(album.id)?);
1176 }
1177 let slice = Slice {
1178 kind: ShareKind::Artist,
1179 subject_id: Some(*artist_id),
1180 start_track_id: None,
1181 };
1182 (slice, ids)
1183 }
1184 };
1185 if ids.is_empty() {
1186 return Err(ShareError::NothingToShare);
1187 }
1188 Ok((slice, ids))
1189}
1190
1191pub fn create_share(
1206 db: &Database,
1207 user: i64,
1208 cfg: &Config,
1209 target: &ShareTarget,
1210 description: Option<&str>,
1211) -> Result<ShareOutcome, ShareError> {
1212 let Some(client) = subsonic_client(cfg) else {
1213 return create_native_share(db, user, cfg, target, description);
1214 };
1215 let resolved;
1218 let track_ids = match target {
1219 ShareTarget::Tracks(ids) => ids.as_slice(),
1220 _ => {
1221 resolved = resolve_share(&db.conn, target)?.1;
1222 resolved.as_slice()
1223 }
1224 };
1225
1226 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
1228
1229 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
1230 if shared == 0 {
1231 return Err(ShareError::NothingRemote);
1232 }
1233
1234 let one_album = rows
1238 .first()
1239 .and_then(|f| f.album_id)
1240 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
1241 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
1242
1243 let remote_ids: Vec<String> = match one_album {
1244 Some(rid) => vec![album_share_id(&client, rid)],
1245 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
1246 };
1247
1248 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
1249 let share = client.create_share(&refs, description)?;
1250
1251 let url = share
1254 .url
1255 .clone()
1256 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
1257
1258 Ok(ShareOutcome {
1259 url,
1260 id: share.id,
1261 shared,
1262 skipped: track_ids.len().saturating_sub(shared),
1263 })
1264}
1265
1266pub fn create_native_share(
1272 db: &Database,
1273 user: i64,
1274 cfg: &Config,
1275 target: &ShareTarget,
1276 description: Option<&str>,
1277) -> Result<ShareOutcome, ShareError> {
1278 let base = cfg
1279 .sharing
1280 .public_url
1281 .as_deref()
1282 .filter(|u| !u.trim().is_empty())
1283 .ok_or(ShareError::NoPublicUrl)?;
1284 let (slice, ids) = resolve_share(&db.conn, target)?;
1285 let now = std::time::SystemTime::now()
1286 .duration_since(std::time::UNIX_EPOCH)
1287 .map_or(0, |d| d.as_secs() as i64);
1288 let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
1289 Ok(ShareOutcome {
1290 url: share_url(base, &share.id),
1291 id: share.id,
1292 shared: ids.len(),
1293 skipped: match (target, slice.kind) {
1295 (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
1296 _ => 0,
1297 },
1298 })
1299}
1300
1301pub fn share_url(public_url: &str, id: &str) -> String {
1303 format!("{}/share/{id}", public_url.trim_end_matches('/'))
1304}
1305
1306fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
1313 let koan = crate::remote::profile::is_koan(client.auth());
1314 album_share_id_for(koan, remote_id)
1315}
1316
1317fn album_share_id_for(koan: bool, remote_id: String) -> String {
1318 if koan && remote_id.parse::<i64>().is_ok() {
1319 format!("al-{remote_id}")
1320 } else {
1321 remote_id
1322 }
1323}
1324
1325fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
1329 let (remote_id, total): (Option<String>, i64) = conn
1330 .query_row(
1331 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
1332 FROM albums al WHERE al.id = ?1",
1333 [album_id],
1334 |row| Ok((row.get(0)?, row.get(1)?)),
1335 )
1336 .ok()?;
1337 (total == selected as i64).then_some(remote_id).flatten()
1338}
1339
1340pub fn shuffle<T>(items: &mut [T]) {
1349 let mut seed = [0u8; 8];
1350 if getrandom::fill(&mut seed).is_err() {
1351 return; }
1353 let mut state = u64::from_le_bytes(seed) | 1;
1354 for i in (1..items.len()).rev() {
1355 state ^= state << 13;
1357 state ^= state >> 7;
1358 state ^= state << 17;
1359 items.swap(i, (state % (i as u64 + 1)) as usize);
1360 }
1361}
1362
1363pub fn truncate_bytes(s: &str, max: usize) -> &str {
1365 if s.len() <= max {
1366 return s;
1367 }
1368 let mut end = max;
1369 while end > 0 && !s.is_char_boundary(end) {
1370 end -= 1;
1371 }
1372 &s[..end]
1373}
1374
1375pub fn sanitise_filename(s: &str) -> String {
1380 let cleaned: String = s
1381 .chars()
1382 .map(|c| match c {
1383 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1384 _ => c,
1385 })
1386 .collect::<String>()
1387 .trim()
1388 .to_string();
1389
1390 let cleaned = truncate_bytes(&cleaned, 240).trim_end().to_string();
1391 match cleaned.as_str() {
1392 "." | ".." => "_".into(),
1393 _ => cleaned,
1394 }
1395}
1396
1397pub fn sanitise_extension(codec: &str) -> Option<String> {
1401 let ext: String = codec
1402 .chars()
1403 .filter(char::is_ascii_alphanumeric)
1404 .take(16)
1405 .collect::<String>()
1406 .to_lowercase();
1407 (!ext.is_empty()).then_some(ext)
1408}
1409
1410pub fn path_within(dir: &Path, path: &Path) -> bool {
1413 path.strip_prefix(dir).is_ok_and(|rest| {
1414 rest.components()
1415 .all(|c| matches!(c, std::path::Component::Normal(_)))
1416 })
1417}
1418
1419pub fn year_of(date: &str) -> Option<&str> {
1422 date.get(..4)
1423}
1424
1425pub fn cache_path_for_track(
1428 cache_dir: &Path,
1429 track: &queries::TrackRow,
1430 album_date: Option<&str>,
1431) -> PathBuf {
1432 let artist_dir = sanitise_filename(&track.artist_name);
1433
1434 let year = album_date
1435 .and_then(year_of)
1436 .map(|y| format!("({}) ", y))
1437 .unwrap_or_default();
1438 let codec = track
1439 .codec
1440 .as_deref()
1441 .map(|c| format!(" [{}]", c))
1442 .unwrap_or_default();
1443 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1444
1445 let disc_prefix = match track.disc {
1446 Some(d) if d > 1 => format!("{}-", d),
1447 _ => String::new(),
1448 };
1449 let track_num = track
1450 .track_number
1451 .map(|n| format!("{:02}. ", n))
1452 .unwrap_or_default();
1453
1454 let ext = track
1455 .codec
1456 .as_deref()
1457 .and_then(sanitise_extension)
1458 .unwrap_or_else(|| "flac".into());
1459
1460 let filename = sanitise_filename(&format!(
1461 "{}{}{} - {}",
1462 disc_prefix, track_num, track.artist_name, track.title
1463 ));
1464
1465 cache_dir
1466 .join(artist_dir)
1467 .join(album_dir)
1468 .join(format!("{}.{}", filename, ext))
1469}
1470
1471fn resolve_item_path(
1479 cfg: &Config,
1480 track: &queries::TrackRow,
1481 remote_url: Option<&str>,
1482 album_date: Option<&str>,
1483) -> (PathBuf, ItemState) {
1484 match queries::choose_playback_source(
1485 track.path.as_deref(),
1486 track.cached_path.as_deref(),
1487 remote_url,
1488 ) {
1489 Some(queries::PlaybackSource::Local(p)) => (p, ItemState::Ready),
1490 Some(queries::PlaybackSource::Cached(p)) => {
1495 let state = if is_cached_audio(&p) {
1496 ItemState::Ready
1497 } else {
1498 ItemState::Pending
1499 };
1500 (p, state)
1501 }
1502 Some(queries::PlaybackSource::Remote(_)) => {
1503 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1504 if dest.exists() && is_cached_audio(&dest) {
1505 (dest, ItemState::Ready)
1506 } else {
1507 (dest, ItemState::Pending)
1508 }
1509 }
1510 _ => {
1511 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1513 (dest, ItemState::Pending)
1514 }
1515 }
1516}
1517
1518pub fn playlist_item_from_track(
1520 track: &queries::TrackRow,
1521 album_date: Option<&str>,
1522 dest: PathBuf,
1523 state: ItemState,
1524) -> PlaylistItem {
1525 let year = album_date.and_then(year_of).map(str::to_string);
1526 PlaylistItem {
1527 playlist_entry_id: None,
1528 id: QueueItemId::new(),
1529 db_id: Some(track.id),
1530 path: dest,
1531 title: track.title.clone(),
1532 artist: track.artist_name.clone(),
1533 album_artist: track.album_artist_name.clone(),
1534 album: track.album_title.clone(),
1535 year,
1536 codec: track.codec.clone(),
1537 track_number: track.track_number.map(|n| n as i64),
1538 disc: track.disc.map(|n| n as i64),
1539 duration_ms: track.duration_ms.map(|d| d as u64),
1540 state,
1541 }
1542}
1543
1544pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1551 let cfg = Config::load().unwrap_or_default();
1552 let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
1553 let extras = queries::queue_item_extras(&db.conn, &ids).unwrap_or_default();
1554
1555 tracks
1556 .iter()
1557 .map(|track| {
1558 let extra = extras.get(&track.id);
1559 let remote_url = extra.and_then(|e| e.remote_url.as_deref());
1560 let album_date = extra.and_then(|e| e.album_date.as_deref());
1561 let (path, state) = resolve_item_path(&cfg, track, remote_url, album_date);
1562 playlist_item_from_track(track, album_date, path, state)
1563 })
1564 .collect()
1565}
1566
1567fn is_cached_audio(path: &std::path::Path) -> bool {
1577 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1578 match std::fs::metadata(path) {
1579 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1580 Ok(_) => {
1581 let mut first = [0u8; 1];
1582 match std::fs::File::open(path)
1583 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1584 {
1585 Ok(b) => b != b'{' && b != b'<',
1586 Err(_) => false,
1587 }
1588 }
1589 Err(_) => false,
1590 }
1591}
1592
1593pub fn download_track(
1600 db_id: i64,
1601 queue_id: QueueItemId,
1602 tx: &crossbeam_channel::Sender<PlayerCommand>,
1603 log_buf: &Arc<Mutex<Vec<String>>>,
1604 state: &Arc<SharedPlayerState>,
1605 cfg: &Config,
1606 client: &SubsonicClient,
1607) {
1608 if !in_queue_soon(state, queue_id, Duration::from_secs(5)) {
1609 return;
1610 }
1611
1612 let db = match crate::db::pool::shared().get() {
1617 Ok(db) => db,
1618 Err(e) => {
1619 fail_track(state, tx, queue_id, format!("db error: {}", e));
1620 return;
1621 }
1622 };
1623 let track = match queries::get_track_row(&db.conn, db_id) {
1624 Ok(Some(t)) => t,
1625 _ => {
1626 fail_track(state, tx, queue_id, "track not found".into());
1627 return;
1628 }
1629 };
1630
1631 let remote_id = match &track.remote_id {
1632 Some(rid) => rid.clone(),
1633 None => {
1634 if let Some(ref path) = track.path {
1636 let p = std::path::PathBuf::from(path);
1637 if p.exists() {
1638 state.update_paths(&[(queue_id, p)]);
1639 state.update_item_state(queue_id, ItemState::Ready);
1640 if state.is_cursor(queue_id) {
1641 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1642 }
1643 return;
1644 }
1645 }
1646 fail_track(
1647 state,
1648 tx,
1649 queue_id,
1650 "not in the library folder, and no remote copy to fetch".into(),
1651 );
1652 return;
1653 }
1654 };
1655
1656 if let Some(ref local_path) = track.path {
1658 let p = std::path::PathBuf::from(local_path);
1659 if p.exists() {
1660 log::info!("download_track: local file exists, using {}", p.display());
1661 state.update_paths(&[(queue_id, p)]);
1662 state.update_item_state(queue_id, ItemState::Ready);
1663 if state.is_cursor(queue_id) {
1664 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1665 }
1666 return;
1667 }
1668 }
1669
1670 let album_date: Option<String> = track
1671 .album_id
1672 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1673
1674 let cache_dir = cfg.cache_dir();
1675 let dest = cache_path_for_track(&cache_dir, &track, album_date.as_deref());
1676 if !path_within(&cache_dir, &dest) {
1677 fail_track(
1678 state,
1679 tx,
1680 queue_id,
1681 format!("cache path escapes the cache: {}", dest.display()),
1682 );
1683 return;
1684 }
1685
1686 if dest.exists() && !is_cached_audio(&dest) {
1692 log::warn!(
1693 "discarding non-audio cache entry {} (likely a stored server error)",
1694 dest.display()
1695 );
1696 let _ = std::fs::remove_file(&dest);
1697 }
1698 if dest.exists() {
1699 state.update_paths(&[(queue_id, dest)]);
1700 state.update_item_state(queue_id, ItemState::Ready);
1701 if state.is_cursor(queue_id) {
1702 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1703 }
1704 return;
1705 }
1706
1707 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1710
1711 let bytes_written = crate::remote::downloads::ByteFeed::new();
1712
1713 let store = crate::remote::downloads::store();
1716 store.queued(crate::remote::downloads::Download {
1717 id: queue_id,
1718 track_id: db_id,
1719 title: track.title.clone(),
1720 artist: track.artist_name.clone(),
1721 source: crate::remote::download::part_path(&dest),
1722 dest: dest.clone(),
1723 total: 0,
1724 written: bytes_written.clone(),
1725 state: crate::remote::downloads::DownloadState::Queued,
1726 bytes_per_second: 0,
1727 });
1728
1729 let progress_qid = queue_id;
1730 let bytes_written_progress = bytes_written.clone();
1731 let progress_tx = tx.clone();
1732 let stream_ready_flag = std::sync::atomic::AtomicBool::new(false);
1733 let announced_total = AtomicU64::new(u64::MAX);
1735 let gone = || state.get_item(queue_id).is_none();
1737 let result =
1738 client.download_with_progress(&remote_id, &dest, &gone, move |downloaded, total| {
1739 bytes_written_progress.set(downloaded);
1740 store.progressed();
1743 if announced_total.swap(total, Ordering::Relaxed) != total {
1744 store.started(progress_qid, total, bytes_written_progress.clone());
1747 }
1748 if !stream_ready_flag.load(Ordering::Relaxed)
1749 && downloaded >= crate::player::state::STREAM_THRESHOLD
1750 {
1751 stream_ready_flag.store(true, Ordering::Relaxed);
1752 progress_tx
1753 .send(PlayerCommand::TrackStreamReady(progress_qid))
1754 .ok();
1755 }
1756 });
1757
1758 if let Err(SubsonicError::Download(DownloadError::Cancelled)) = result {
1759 store.withdrawn(queue_id);
1760 bytes_written.done();
1761 return;
1762 }
1763
1764 if let Err(e) = result {
1769 store.failed(queue_id, e.to_string());
1770 fail_track(state, tx, queue_id, e.to_string());
1771 bytes_written.done();
1772 push_log(log_buf, format!("x {} — {}", track.title, e));
1773 return;
1774 }
1775 store.finished(queue_id);
1776
1777 state.update_paths(&[(queue_id, dest.clone())]);
1778 state.update_item_state(queue_id, ItemState::Ready);
1779 bytes_written.done();
1780 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1782 log::warn!(
1783 "cached {} but failed to record it ({}) — it will not be evicted",
1784 dest.display(),
1785 e
1786 );
1787 }
1788
1789 push_log(
1790 log_buf,
1791 format!("+ {} — {}", track.title, track.artist_name),
1792 );
1793
1794 if state.is_cursor(queue_id) {
1795 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1796 }
1797}
1798
1799fn in_queue_soon(state: &SharedPlayerState, id: QueueItemId, timeout: Duration) -> bool {
1809 let changed = crate::signal::engine_changed();
1810 let deadline = std::time::Instant::now() + timeout;
1811 let mut seen = changed.generation();
1812 loop {
1813 if state.get_item(id).is_some() {
1814 return true;
1815 }
1816 let now = std::time::Instant::now();
1817 if now >= deadline {
1818 return false;
1819 }
1820 seen = changed.wait_until(seen, deadline - now);
1821 }
1822}
1823
1824pub(crate) fn fail_track(
1830 state: &Arc<SharedPlayerState>,
1831 tx: &crossbeam_channel::Sender<PlayerCommand>,
1832 queue_id: QueueItemId,
1833 reason: String,
1834) {
1835 state.update_item_state(queue_id, ItemState::Failed(reason));
1836 if state.is_cursor(queue_id) {
1837 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1838 }
1839}
1840
1841fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1844 match log_buf.lock() {
1845 Ok(mut buf) => buf.push(msg),
1846 Err(_) => log::info!("{}", msg),
1847 }
1848}
1849
1850pub fn remote_unavailable(cfg: &Config) -> String {
1856 if !cfg.remote.enabled {
1857 return "no remote server is configured".into();
1858 }
1859 if cfg.remote.url.is_empty() {
1860 return "the remote server has no address".into();
1861 }
1862 if get_remote_password(cfg).is_none() {
1863 return "no password is stored for the remote server".into();
1864 }
1865 "the remote server could not be reached".into()
1868}
1869
1870pub fn spawn_downloads(
1876 pending: Vec<(i64, QueueItemId)>,
1877 tx: crossbeam_channel::Sender<PlayerCommand>,
1878 state: Arc<SharedPlayerState>,
1879) {
1880 if pending.is_empty() {
1881 return;
1882 }
1883 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1884}
1885
1886#[cfg(test)]
1887mod queue_arrival_tests {
1888 use super::*;
1889 use crate::player::state::{ItemState, PlaylistItem};
1890
1891 fn item() -> PlaylistItem {
1892 PlaylistItem {
1893 playlist_entry_id: None,
1894 id: QueueItemId::new(),
1895 db_id: Some(1),
1896 path: std::path::PathBuf::from("/cache/one.flac"),
1897 title: "One".into(),
1898 artist: "Artist".into(),
1899 album_artist: "Artist".into(),
1900 album: "Album".into(),
1901 year: None,
1902 codec: None,
1903 track_number: None,
1904 disc: None,
1905 duration_ms: None,
1906 state: ItemState::Pending,
1907 }
1908 }
1909
1910 #[test]
1913 fn a_download_waits_for_its_track_to_reach_the_queue() {
1914 let state = Arc::new(SharedPlayerState::new());
1915 let track = item();
1916 let id = track.id;
1917 let player = state.clone();
1918 std::thread::spawn(move || {
1919 std::thread::sleep(Duration::from_millis(50));
1920 player.add_items(vec![track]);
1921 });
1922 assert!(in_queue_soon(&state, id, Duration::from_secs(2)));
1923 }
1924
1925 #[test]
1926 fn a_track_that_never_arrives_is_not_waited_for_long() {
1927 let state = SharedPlayerState::new();
1928 let started = std::time::Instant::now();
1929 assert!(!in_queue_soon(
1930 &state,
1931 QueueItemId::new(),
1932 Duration::from_millis(100)
1933 ));
1934 assert!(started.elapsed() < Duration::from_secs(1));
1935 }
1936}
1937
1938#[cfg(test)]
1939mod year_tests {
1940 use super::year_of;
1941
1942 #[test]
1943 fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1944 assert_eq!(year_of("1997-05-21"), Some("1997"));
1945 assert_eq!(year_of("199"), None);
1946 assert_eq!(year_of("1997"), None);
1948 }
1949}
1950
1951#[cfg(test)]
1952mod rebuild_tests {
1953 use super::*;
1954 use crate::db::queries::sample_meta;
1955
1956 fn test_db() -> Database {
1957 let conn = rusqlite::Connection::open_in_memory().unwrap();
1958 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1959 crate::db::schema::create_tables(&conn).unwrap();
1960 Database { conn }
1961 }
1962
1963 #[test]
1964 fn cached_paths_follow_a_moved_cache_directory() {
1965 let old = tempfile::tempdir().unwrap();
1966 let new = tempfile::tempdir().unwrap();
1967 let db = test_db();
1968
1969 let mut rows = Vec::new();
1970 for name in ["moved", "gone", "current"] {
1971 let mut meta = sample_meta(name, "Artist", "Album");
1972 meta.source = "remote".into();
1973 meta.path = None;
1974 meta.remote_id = Some(name.into());
1975 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1976 let tail = format!("Artist/Album/{name}.flac");
1977 if name != "gone" {
1979 let file = new.path().join(&tail);
1980 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1981 std::fs::write(&file, b"audio").unwrap();
1982 }
1983 let stored = if name == "current" {
1984 new.path()
1985 } else {
1986 old.path()
1987 }
1988 .join(&tail);
1989 queries::set_cached_path(&db.conn, id, &stored.to_string_lossy()).unwrap();
1990 rows.push((id, tail));
1991 }
1992
1993 assert_eq!(relocate_cached_paths(&db, new.path()).unwrap(), 1);
1994
1995 let cached = |id: i64| -> String {
1996 db.conn
1997 .query_row("SELECT cached_path FROM tracks WHERE id = ?1", [id], |r| {
1998 r.get(0)
1999 })
2000 .unwrap()
2001 };
2002 let expect = |root: &Path, tail: &str| root.join(tail).to_string_lossy().into_owned();
2003 assert_eq!(
2004 cached(rows[0].0),
2005 expect(new.path(), &rows[0].1),
2006 "re-rooted"
2007 );
2008 assert_eq!(
2009 cached(rows[1].0),
2010 expect(old.path(), &rows[1].1),
2011 "no file, left alone"
2012 );
2013 assert_eq!(
2014 cached(rows[2].0),
2015 expect(new.path(), &rows[2].1),
2016 "already current"
2017 );
2018 assert_eq!(
2019 relocate_cached_paths(&db, new.path()).unwrap(),
2020 0,
2021 "idempotent"
2022 );
2023 }
2024
2025 #[test]
2026 fn clearing_one_download_leaves_the_others_and_the_library_alone() {
2027 let dir = tempfile::tempdir().unwrap();
2028 let db = test_db();
2029
2030 let mut cached = Vec::new();
2031 for name in ["one", "two"] {
2032 let mut meta = sample_meta(name, "Artist", "Album");
2033 meta.source = "remote".into();
2034 meta.path = None;
2035 meta.remote_id = Some(name.into());
2036 let id = queries::upsert_track(&db.conn, &meta).unwrap();
2037 let file = dir.path().join(format!("{name}.opus"));
2038 std::fs::write(&file, vec![0u8; 2048]).unwrap();
2039 queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
2040 cached.push((id, file));
2041 }
2042
2043 let cleared = clear_downloads_for(&db, &[cached[0].0]);
2044 assert_eq!(cleared.files, 1);
2045 assert_eq!(cleared.bytes, 2048);
2046 assert!(!cached[0].1.exists(), "the copy asked for is gone");
2047 assert!(cached[1].1.exists(), "the other one is untouched");
2048
2049 assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
2052 assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
2053 assert!(
2054 queries::cached_paths_for(&db.conn, &[cached[0].0])
2055 .unwrap()
2056 .is_empty()
2057 );
2058 }
2059
2060 #[test]
2061 fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
2062 let db = test_db();
2063 let mut meta = sample_meta("ghost", "Artist", "Album");
2064 meta.source = "remote".into();
2065 meta.path = None;
2066 meta.remote_id = Some("ghost".into());
2067 let id = queries::upsert_track(&db.conn, &meta).unwrap();
2068 queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
2069
2070 let cleared = clear_downloads_for(&db, &[id]);
2071 assert_eq!(cleared.files, 0, "nothing was there to remove");
2072 assert!(
2074 queries::cached_paths_for(&db.conn, &[id])
2075 .unwrap()
2076 .is_empty()
2077 );
2078 }
2079
2080 #[test]
2081 fn sweeping_removes_half_finished_downloads_and_nothing_else() {
2082 let dir = tempfile::tempdir().unwrap();
2083 let cache = dir.path().join("cache");
2084 std::fs::create_dir_all(cache.join("Artist")).unwrap();
2085
2086 let finished = cache.join("Artist/whole.opus");
2087 let half = cache.join("Artist/half.opus.part");
2088 std::fs::write(&finished, vec![0u8; 1024]).unwrap();
2089 std::fs::write(&half, vec![0u8; 4096]).unwrap();
2090
2091 let cfg = Config {
2092 remote: crate::config::RemoteConfig {
2093 cache_dir: Some(cache.clone()),
2094 ..Default::default()
2095 },
2096 ..Default::default()
2097 };
2098
2099 let swept = sweep_partial_downloads(&cfg);
2100 assert_eq!(swept.files, 1);
2101 assert_eq!(swept.bytes, 4096);
2102 assert!(!half.exists(), "the unfinished one is gone");
2103 assert!(finished.exists(), "a downloaded track is not touched");
2104 }
2105
2106 #[test]
2107 fn sweeping_an_empty_cache_is_not_an_error() {
2108 let dir = tempfile::tempdir().unwrap();
2109 let cfg = Config {
2110 remote: crate::config::RemoteConfig {
2111 cache_dir: Some(dir.path().join("nothing-here")),
2112 ..Default::default()
2113 },
2114 ..Default::default()
2115 };
2116 assert_eq!(sweep_partial_downloads(&cfg).files, 0);
2117 }
2118
2119 #[test]
2120 fn clearing_no_tracks_does_nothing() {
2121 let db = test_db();
2122 assert_eq!(clear_downloads_for(&db, &[]).files, 0);
2123 }
2124
2125 #[test]
2126 fn rebuild_drops_the_index_and_keeps_favourites() {
2127 let db = test_db();
2128 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2129 meta.path = Some("/music/windowlicker.flac".into());
2130 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
2131
2132 queries::toggle_favourite(
2134 &db.conn,
2135 crate::db::queries::LOCAL_USER,
2136 Path::new("/music/windowlicker.flac"),
2137 )
2138 .unwrap();
2139 db.conn
2140 .execute(
2141 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
2142 VALUES (?1, 'test', 'la la la', 0)",
2143 [track_id],
2144 )
2145 .unwrap();
2146
2147 let summary = rebuild_index(&db).unwrap();
2148 assert_eq!(summary.tracks, 1);
2149 assert_eq!(summary.albums, 1);
2150
2151 let tracks: i64 = db
2152 .conn
2153 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2154 .unwrap();
2155 assert_eq!(tracks, 0, "the index is gone");
2156
2157 let favourites: i64 = db
2158 .conn
2159 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
2160 .unwrap();
2161 assert_eq!(favourites, 1, "favourites survive — they key on the path");
2162
2163 let lyrics: i64 = db
2164 .conn
2165 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
2166 .unwrap();
2167 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
2168 }
2169
2170 #[test]
2171 fn rebuilding_an_empty_library_is_not_an_error() {
2172 let db = test_db();
2173 let summary = rebuild_index(&db).unwrap();
2174 assert_eq!(summary.tracks, 0);
2175 }
2176}
2177
2178#[cfg(test)]
2179mod share_tests {
2180 use super::*;
2181 use crate::db::queries::sample_meta;
2182
2183 fn test_db() -> Database {
2184 let conn = rusqlite::Connection::open_in_memory().unwrap();
2185 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2186 crate::db::schema::create_tables(&conn).unwrap();
2187 Database { conn }
2188 }
2189
2190 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
2192 let ids: Vec<i64> = ["One", "Two", "Three"]
2193 .iter()
2194 .enumerate()
2195 .map(|(i, title)| {
2196 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
2197 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
2198 meta.track_number = Some(i as i32 + 1);
2199 queries::upsert_track(&db.conn, &meta).unwrap()
2200 })
2201 .collect();
2202 let album_id: i64 = db
2203 .conn
2204 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
2205 r.get(0)
2206 })
2207 .unwrap();
2208 db.conn
2209 .execute(
2210 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
2211 [album_id],
2212 )
2213 .unwrap();
2214 (album_id, ids)
2215 }
2216
2217 #[test]
2218 fn whole_album_collapses_to_the_album_link() {
2219 let db = test_db();
2220 let (album_id, ids) = album_of_three(&db);
2221 assert_eq!(
2222 album_remote_id(&db.conn, album_id, ids.len()),
2223 Some("al-1".into())
2224 );
2225 }
2226
2227 #[test]
2228 fn part_of_an_album_does_not() {
2229 let db = test_db();
2230 let (album_id, _) = album_of_three(&db);
2231 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
2234 }
2235
2236 #[test]
2237 fn a_local_only_album_has_no_link_to_collapse_to() {
2238 let db = test_db();
2239 let (album_id, ids) = album_of_three(&db);
2240 db.conn
2241 .execute(
2242 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
2243 [album_id],
2244 )
2245 .unwrap();
2246 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
2247 }
2248}
2249
2250#[cfg(test)]
2251mod client_cache_tests {
2252 use super::*;
2253
2254 #[test]
2255 fn one_subsonic_client_is_shared_per_credentials() {
2256 crate::config::isolate_config_for_tests();
2257 let mut cfg = Config::default();
2258 cfg.remote.enabled = true;
2259 cfg.remote.url = "https://shared-client.invalid".into();
2260 cfg.remote.username = "koan".into();
2261 cfg.remote.password = "first".into();
2262
2263 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
2264 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
2265 assert!(
2266 Arc::ptr_eq(&first, &again),
2267 "rebuilding drops the connection pool and re-handshakes TLS per request"
2268 );
2269
2270 cfg.remote.password = "second".into();
2271 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
2272 assert!(
2273 !Arc::ptr_eq(&first, &relogged),
2274 "new credentials must not keep serving the client signed with the old ones"
2275 );
2276 }
2277}
2278
2279#[cfg(test)]
2280mod native_share_tests {
2281 use super::*;
2282 use crate::db::queries::{sample_meta, upsert_track};
2283
2284 #[test]
2285 fn a_standalone_server_shares_natively_in_the_order_asked() {
2286 let conn = rusqlite::Connection::open_in_memory().unwrap();
2287 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2288 crate::db::schema::create_tables(&conn).unwrap();
2289 let db = Database { conn };
2290 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2291 let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
2292 let mut cfg = Config::default();
2293 assert!(matches!(
2294 create_share(
2295 &db,
2296 queries::LOCAL_USER,
2297 &cfg,
2298 &ShareTarget::Tracks(vec![a]),
2299 None
2300 ),
2301 Err(ShareError::NoPublicUrl)
2302 ));
2303 cfg.sharing.public_url = Some("https://koan.example/".into());
2304 let out = create_share(
2305 &db,
2306 queries::LOCAL_USER,
2307 &cfg,
2308 &ShareTarget::Tracks(vec![b, 9999, a]),
2309 Some("mix"),
2310 )
2311 .unwrap();
2312 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2313 assert_eq!((out.shared, out.skipped), (2, 1));
2314 let share = queries::shares::get_share(&db.conn, &out.id)
2315 .unwrap()
2316 .unwrap();
2317 assert_eq!(share.track_ids, [b, a]);
2318 assert!(matches!(
2319 create_share(
2320 &db,
2321 queries::LOCAL_USER,
2322 &cfg,
2323 &ShareTarget::Tracks(vec![9999]),
2324 None
2325 ),
2326 Err(ShareError::NothingToShare)
2327 ));
2328 }
2329
2330 #[test]
2331 fn a_server_with_an_upstream_still_shares_natively() {
2332 let conn = rusqlite::Connection::open_in_memory().unwrap();
2334 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2335 crate::db::schema::create_tables(&conn).unwrap();
2336 let db = Database { conn };
2337 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2338 let mut cfg = Config::default();
2339 cfg.remote.enabled = true;
2340 cfg.remote.url = "https://upstream.invalid".into();
2341 cfg.remote.username = "someone".into();
2342 cfg.remote.password = "secret".into();
2343 cfg.sharing.public_url = Some("https://koan.example".into());
2344 let out = create_native_share(
2345 &db,
2346 queries::LOCAL_USER,
2347 &cfg,
2348 &ShareTarget::Tracks(vec![a]),
2349 None,
2350 )
2351 .unwrap();
2352 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2353 assert!(
2354 queries::shares::get_share(&db.conn, &out.id)
2355 .unwrap()
2356 .is_some()
2357 );
2358 }
2359
2360 fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
2361 let mut meta = sample_meta(title, "Rrose", album);
2362 meta.track_number = Some(n);
2363 meta.date = Some(date.into());
2364 upsert_track(&db.conn, &meta).unwrap()
2365 }
2366
2367 #[test]
2368 fn shares_are_slices_fixed_when_made() {
2369 let conn = rusqlite::Connection::open_in_memory().unwrap();
2370 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2371 crate::db::schema::create_tables(&conn).unwrap();
2372 let db = Database { conn };
2373 let later = album_track(&db, "L1", "Later", 1, "2021");
2374 let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
2375 let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
2376 let album_of = |t| {
2377 queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
2378 .album_id
2379 .unwrap()
2380 };
2381 let (earlier, later_album) = (album_of(a1), album_of(later));
2382 let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
2383 .artist_id
2384 .unwrap();
2385
2386 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
2388 assert_eq!(
2389 (slice.kind, slice.subject_id, slice.start_track_id),
2390 (ShareKind::Album, Some(earlier), Some(a2))
2391 );
2392 assert_eq!(ids, [a1, a2]);
2393
2394 let (slice, ids) = resolve_share(
2396 &db.conn,
2397 &ShareTarget::Album {
2398 album_id: later_album,
2399 start_track_id: Some(a1),
2400 },
2401 )
2402 .unwrap();
2403 assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
2404 assert_eq!(ids, [later]);
2405
2406 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
2408 assert_eq!(
2409 (slice.kind, slice.subject_id),
2410 (ShareKind::Artist, Some(artist))
2411 );
2412 assert_eq!(ids, [a1, a2, later]);
2413
2414 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
2416 assert_eq!(slice, Slice::TRACKS);
2417 assert_eq!(ids, [later, a1]);
2418
2419 assert!(matches!(
2420 resolve_share(&db.conn, &ShareTarget::Artist(9999)),
2421 Err(ShareError::NothingToShare)
2422 ));
2423 }
2424}
2425
2426#[cfg(test)]
2427mod album_share_id_tests {
2428 use super::album_share_id_for;
2429
2430 #[test]
2431 fn a_koan_album_is_named_as_an_album() {
2432 assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
2433 assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
2435 assert_eq!(album_share_id_for(false, "46215".into()), "46215");
2436 assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
2437 }
2438}
2439
2440#[cfg(test)]
2441mod cache_path_tests {
2442 use super::*;
2443
2444 fn track(artist: &str, album: &str, codec: &str) -> queries::TrackRow {
2445 queries::TrackRow {
2446 id: 1,
2447 album_id: None,
2448 artist_id: None,
2449 artist_name: artist.into(),
2450 album_artist_name: artist.into(),
2451 album_title: album.into(),
2452 disc: None,
2453 track_number: Some(1),
2454 title: "Song".into(),
2455 duration_ms: None,
2456 path: None,
2457 codec: Some(codec.into()),
2458 sample_rate: None,
2459 bit_depth: None,
2460 channels: None,
2461 bitrate: None,
2462 genre: None,
2463 source: "remote".into(),
2464 remote_id: Some("r1".into()),
2465 cached_path: None,
2466 }
2467 }
2468
2469 #[test]
2470 fn a_server_suffix_cannot_leave_the_cache() {
2471 let cache = Path::new("/cache");
2472 for codec in [
2473 "flac/../../../../x",
2474 "..",
2475 "../..",
2476 "/etc/passwd",
2477 "\\..\\..",
2478 ] {
2479 let path = cache_path_for_track(cache, &track("A", "B", codec), None);
2480 assert!(path_within(cache, &path), "{codec}: {}", path.display());
2481 }
2482 let path = cache_path_for_track(cache, &track("A", "B", "flac/../../../../x"), None);
2483 assert_eq!(path.extension().unwrap(), "flacx");
2484 }
2485
2486 #[test]
2487 fn dot_names_cannot_climb_out() {
2488 let cache = Path::new("/cache");
2489 let path = cache_path_for_track(cache, &track("..", ".", ".."), None);
2490 assert!(path_within(cache, &path), "{}", path.display());
2491 assert_eq!(sanitise_filename(".."), "_");
2492 assert_eq!(sanitise_filename(" . "), "_");
2493 assert_eq!(sanitise_filename("..."), "...");
2494 }
2495
2496 #[test]
2497 fn an_empty_suffix_falls_back_to_flac() {
2498 assert_eq!(sanitise_extension("../"), None);
2499 assert_eq!(sanitise_extension("FLAC"), Some("flac".into()));
2500 let path = cache_path_for_track(Path::new("/c"), &track("A", "B", "./"), None);
2501 assert_eq!(path.extension().unwrap(), "flac");
2502 }
2503
2504 #[test]
2505 fn path_within_rejects_parent_components() {
2506 let dir = Path::new("/cache");
2507 assert!(path_within(dir, Path::new("/cache/a/b.flac")));
2508 assert!(!path_within(dir, Path::new("/cache/a/../../x")));
2509 assert!(!path_within(dir, Path::new("/elsewhere/x")));
2510 }
2511}
2512
2513#[cfg(test)]
2514mod favourite_sync_tests {
2515 use super::*;
2516 use crate::db::queries::sample_meta;
2517
2518 fn serve(stars: Arc<Mutex<Vec<String>>>) -> String {
2520 use std::io::{BufRead, Write};
2521 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2522 let url = format!("http://{}", listener.local_addr().unwrap());
2523 std::thread::spawn(move || {
2524 for mut stream in listener.incoming().flatten() {
2525 let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
2526 let mut request = String::new();
2527 reader.read_line(&mut request).unwrap();
2528 let mut line = String::new();
2529 while reader.read_line(&mut line).unwrap_or(0) > 2 {
2530 line.clear();
2531 }
2532 let target = request.split_whitespace().nth(1).unwrap_or("");
2533 let (path, query) = target.split_once('?').unwrap_or((target, ""));
2534 let body = match path.rsplit('/').next().unwrap() {
2535 "getStarred2" => {
2536 r#"{"subsonic-response":{"status":"ok","starred2":{"song":[{"id":"s1","title":"One"}]}}}"#
2537 }
2538 "star" => {
2539 if let Some((_, id)) = query
2540 .split('&')
2541 .filter_map(|kv| kv.split_once('='))
2542 .find(|(k, _)| *k == "id")
2543 {
2544 stars.lock().unwrap().push(id.to_string());
2545 }
2546 r#"{"subsonic-response":{"status":"ok"}}"#
2547 }
2548 _ => r#"{"subsonic-response":{"status":"ok"}}"#,
2549 };
2550 let _ = write!(
2551 stream,
2552 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2553 body.len()
2554 );
2555 }
2556 });
2557 url
2558 }
2559
2560 #[test]
2561 fn only_favourites_the_server_lacks_are_starred() {
2562 let dir = tempfile::tempdir().unwrap();
2563 let db = Database::open(&dir.path().join("koan.db")).unwrap();
2564 for (title, remote_id) in [("One", "s1"), ("Two", "s2")] {
2565 let mut meta = sample_meta(title, "Artist", "Album");
2566 meta.path = Some(format!("/music/{title}.flac"));
2567 meta.remote_id = Some(remote_id.into());
2568 queries::upsert_track(&db.conn, &meta).unwrap();
2569 queries::add_favourite(
2570 &db.conn,
2571 queries::LOCAL_USER,
2572 Path::new(&format!("/music/{title}.flac")),
2573 )
2574 .unwrap();
2575 }
2576
2577 let stars = Arc::new(Mutex::new(Vec::new()));
2578 let url = serve(stars.clone());
2579 let sync = reconcile_favourites(&db, &SubsonicClient::new(&url, "u", "pw"));
2580 assert_eq!(sync.pushed, 1);
2581 assert_eq!(*stars.lock().unwrap(), ["s2"]);
2582 }
2583}