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