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