1use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::{Arc, Mutex};
9
10use crate::config::Config;
11use crate::db::connection::Database;
12use crate::db::queries;
13use crate::player::commands::PlayerCommand;
14use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
15use crate::remote::client::{SubsonicAuth, SubsonicClient};
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(
40 db_path: std::path::PathBuf,
41 on_state: impl Fn(bool) + Send + Sync + 'static,
42) -> Option<std::thread::JoinHandle<()>> {
43 use notify::{RecursiveMode, Watcher};
44
45 std::thread::Builder::new()
46 .name("koan-library-watch".into())
47 .spawn(move || {
48 let scan_now = |reason: &str| {
49 let cfg = Config::load().unwrap_or_default();
50 if cfg.library.folders.is_empty() {
51 return;
52 }
53 let Ok(db) = Database::open(&db_path) else {
54 return;
55 };
56 on_state(true);
57 let result = crate::index::scanner::full_scan(
58 &db,
59 &cfg.library.folders,
60 crate::index::scanner::ScanOptions::default(),
61 None,
62 );
63 on_state(false);
64 log::info!(
65 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
66 result.added,
67 result.updated,
68 result.removed,
69 result.skipped
70 );
71 };
72
73 std::thread::sleep(std::time::Duration::from_secs(3));
75 scan_now("startup");
76
77 let (tx, rx) = std::sync::mpsc::channel();
78 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
79 let _ = tx.send(event);
80 }) else {
81 log::warn!("could not watch the library folders");
82 return;
83 };
84
85 let cfg = Config::load().unwrap_or_default();
86 for folder in &cfg.library.folders {
87 if let Err(e) = watcher.watch(folder, RecursiveMode::Recursive) {
88 log::warn!("could not watch {}: {e}", folder.display());
89 }
90 }
91
92 const SETTLE: std::time::Duration = std::time::Duration::from_secs(5);
95 while let Ok(first) = rx.recv() {
96 if first.is_err() {
97 continue;
98 }
99 while rx.recv_timeout(SETTLE).is_ok() {}
100 scan_now("watched change");
101 }
102 })
103 .ok()
104}
105
106pub fn spawn_auto_sync(
119 db_path: std::path::PathBuf,
120 on_state: impl Fn(bool) + Send + 'static,
121) -> Option<std::thread::JoinHandle<()>> {
122 std::thread::Builder::new()
123 .name("koan-auto-sync".into())
124 .spawn(move || {
125 std::thread::sleep(std::time::Duration::from_secs(5));
126 loop {
127 let cfg = Config::load().unwrap_or_default();
128 if !cfg.remote.enabled || !cfg.remote.auto_sync {
129 std::thread::sleep(std::time::Duration::from_secs(60));
132 continue;
133 }
134
135 if let Some(client) = subsonic_client(&cfg)
136 && let Ok(db) = Database::open(&db_path)
137 {
138 on_state(true);
139 match sync_remote(&db, &client, false, &cfg.remote.url, &cfg.remote.username) {
140 Ok(s) => log::info!(
141 "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
142 favourites {}↑ {}↓; playlists {}↓ {}↑",
143 s.library.artists_synced,
144 s.library.albums_synced,
145 s.library.tracks_synced,
146 s.library.albums_failed,
147 s.favourites.pushed,
148 s.favourites.imported,
149 s.playlists.pulled,
150 s.playlists.pushed,
151 ),
152 Err(e) => log::warn!("auto sync failed: {e}"),
153 }
154 on_state(false);
155 }
156
157 match cfg.remote.auto_sync_interval_mins {
158 0 => return,
160 mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
161 }
162 }
163 })
164 .ok()
165}
166
167#[derive(Debug, Clone, Copy, Default)]
169pub struct RebuildSummary {
170 pub tracks: u64,
171 pub albums: u64,
172 pub artists: u64,
173}
174
175pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
186 let count = |sql: &str| -> u64 {
187 db.conn
188 .query_row(sql, [], |r| r.get::<_, i64>(0))
189 .unwrap_or(0) as u64
190 };
191 let summary = RebuildSummary {
192 tracks: count("SELECT COUNT(*) FROM tracks"),
193 albums: count("SELECT COUNT(*) FROM albums"),
194 artists: count("SELECT COUNT(*) FROM artists"),
195 };
196
197 db.conn.execute_batch(
200 "BEGIN;
201 DELETE FROM track_vectors;
202 DELETE FROM lyrics_cache;
203 DELETE FROM play_history;
204 DELETE FROM scan_cache;
205 DELETE FROM tracks_fts;
206 DELETE FROM tracks;
207 DELETE FROM similar_artists;
208 DELETE FROM albums;
209 DELETE FROM artists;
210 COMMIT;",
211 )?;
212 let _ = db.conn.execute_batch("VACUUM");
213 Ok(summary)
214}
215
216pub fn cache_size_bytes(cfg: &Config) -> u64 {
218 walkdir::WalkDir::new(cfg.cache_dir())
219 .into_iter()
220 .filter_map(Result::ok)
221 .filter(|e| e.file_type().is_file())
222 .filter_map(|e| e.metadata().ok())
223 .map(|m| m.len())
224 .sum()
225}
226
227pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
232 let (lower, upper) = queries::folder_prefix_range(folder);
233 db.conn
234 .query_row(
235 "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
236 [&lower, &upper],
237 |r| r.get::<_, i64>(0),
238 )
239 .unwrap_or(0) as u64
240}
241
242pub fn tracks_from_server(db: &Database) -> u64 {
244 db.conn
245 .query_row(
246 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
247 [],
248 |r| r.get::<_, i64>(0),
249 )
250 .unwrap_or(0) as u64
251}
252
253pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
266 let (lower, upper) = queries::folder_prefix_range(folder);
267
268 let tx = db.conn.unchecked_transaction()?;
269 tx.execute(
271 "UPDATE tracks SET path = NULL, source = 'remote'
272 WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
273 [&lower, &upper],
274 )?;
275
276 let ids: Vec<i64> = {
277 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
278 let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
279 rows.filter_map(Result::ok).collect()
280 };
281 for id in &ids {
282 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
283 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
284 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
285 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
286 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
287 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
288 }
289 prune_empty_albums_and_artists(&tx)?;
290 tx.commit()?;
291 Ok(ids.len() as u64)
292}
293
294pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
300 let tx = db.conn.unchecked_transaction()?;
301
302 let ids: Vec<i64> = {
303 let mut stmt =
304 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
305 let rows = stmt.query_map([], |r| r.get(0))?;
306 rows.filter_map(Result::ok).collect()
307 };
308 for id in &ids {
309 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
310 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
311 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
312 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
313 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
314 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
315 }
316 tx.execute(
318 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
319 WHERE remote_id IS NOT NULL",
320 [],
321 )?;
322 tx.execute("DELETE FROM similar_artists", [])?;
323 prune_empty_albums_and_artists(&tx)?;
324 tx.commit()?;
325 Ok(ids.len() as u64)
326}
327
328fn prune_empty_albums_and_artists(
330 tx: &rusqlite::Transaction<'_>,
331) -> Result<(), crate::db::connection::DbError> {
332 tx.execute(
333 "DELETE FROM albums WHERE NOT EXISTS
334 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
335 [],
336 )?;
337 tx.execute(
338 "DELETE FROM similar_artists WHERE NOT EXISTS
339 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
340 [],
341 )?;
342 tx.execute(
343 "DELETE FROM artists WHERE NOT EXISTS
344 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
345 AND NOT EXISTS
346 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
347 [],
348 )?;
349 Ok(())
350}
351
352#[derive(Debug, Clone, Copy, Default)]
354pub struct CacheCleared {
355 pub files: u64,
356 pub bytes: u64,
357}
358
359pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
364 let dir = cfg.cache_dir();
365 let mut cleared = CacheCleared::default();
366 for entry in walkdir::WalkDir::new(&dir)
367 .into_iter()
368 .filter_map(Result::ok)
369 .filter(|e| e.file_type().is_file())
370 {
371 if let Ok(meta) = entry.metadata() {
372 cleared.bytes += meta.len();
373 cleared.files += 1;
374 }
375 }
376 let _ = std::fs::remove_dir_all(&dir);
377 let _ = std::fs::create_dir_all(&dir);
378 let _ = queries::clear_cached_paths(&db.conn);
379 cleared
380}
381
382pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
389 let mut cleared = CacheCleared::default();
390 let paths = match queries::cached_paths_for(&db.conn, track_ids) {
391 Ok(paths) => paths,
392 Err(e) => {
393 log::warn!("could not read cached paths: {e}");
394 return cleared;
395 }
396 };
397 for path in &paths {
398 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
399 match std::fs::remove_file(path) {
400 Ok(()) => {
401 cleared.files += 1;
402 cleared.bytes += size;
403 }
404 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
406 Err(e) => log::warn!("could not remove {path}: {e}"),
407 }
408 }
409 if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
410 log::warn!("removed downloads but failed to forget them ({e})");
411 }
412 cleared
413}
414
415pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
428 let mut swept = CacheCleared::default();
429 for entry in walkdir::WalkDir::new(cfg.cache_dir())
430 .into_iter()
431 .filter_map(Result::ok)
432 .filter(|e| e.file_type().is_file())
433 .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
434 {
435 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
436 match std::fs::remove_file(entry.path()) {
437 Ok(()) => {
438 swept.files += 1;
439 swept.bytes += size;
440 }
441 Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
442 }
443 }
444 if swept.files > 0 {
445 log::info!(
446 "swept {} unfinished download(s), {} bytes",
447 swept.files,
448 swept.bytes
449 );
450 }
451 swept
452}
453
454pub fn requeue_cleared_downloads(
461 state: &Arc<SharedPlayerState>,
462 tx: &crossbeam_channel::Sender<PlayerCommand>,
463) {
464 let stale = state.reset_items_with_missing_files();
465 if stale.is_empty() {
466 return;
467 }
468 log::info!(
469 "{} queued tracks lost their copy — fetching again",
470 stale.len()
471 );
472 spawn_downloads(stale, tx.clone(), state.clone());
473}
474
475pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
487 let cfg = Config::load().unwrap_or_default();
488 if !cfg.remote.enabled {
489 return;
490 }
491 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
492 log::warn!("not syncing favourite: {} has no remote id", path.display());
493 return;
494 };
495 let Some(client) = subsonic_client(&cfg) else {
496 log::warn!("not syncing favourite: no usable server credentials");
497 return;
498 };
499 std::thread::Builder::new()
500 .name("koan-fav-sync".into())
501 .spawn(move || {
502 let result = if star {
503 client.star(&remote_id)
504 } else {
505 client.unstar(&remote_id)
506 };
507 match result {
508 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
509 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
510 }
511 })
512 .ok();
513}
514
515#[derive(Debug, Default)]
517pub struct FullSync {
518 pub library: crate::remote::sync::SyncResult,
519 pub favourites: FavouriteSync,
520 pub playlists: crate::playlists::PlaylistSync,
521}
522
523pub fn sync_remote(
534 db: &Database,
535 client: &SubsonicClient,
536 full: bool,
537 url: &str,
538 username: &str,
539) -> Result<FullSync, crate::remote::sync::SyncError> {
540 let library = crate::remote::sync::sync_library(db, client, full, url, username)?;
541 Ok(FullSync {
542 library,
543 favourites: reconcile_favourites(db, client),
544 playlists: crate::playlists::reconcile_playlists(db, client, username),
545 })
546}
547
548#[derive(Debug, Default, Clone, Copy)]
550pub struct FavouriteSync {
551 pub pushed: usize,
552 pub imported: usize,
553}
554
555pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
566 let mut out = FavouriteSync::default();
567
568 let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
569 for (_path, remote_id) in &tracks {
570 if client.star(remote_id).is_ok() {
571 out.pushed += 1;
572 }
573 }
574 for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
575 if client.star_album(&remote_id).is_ok() {
576 out.pushed += 1;
577 }
578 }
579 for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
580 {
581 if client.star_artist(&remote_id).is_ok() {
582 out.pushed += 1;
583 }
584 }
585
586 let starred = match client.get_starred_all() {
587 Ok(s) => s,
588 Err(e) => {
589 log::warn!("could not fetch starred items from the server: {e}");
590 return out;
591 }
592 };
593
594 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
595 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
596 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
597 out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
598 out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
599 out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
600 out
601}
602
603#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606pub enum FavouriteKind {
607 Track,
608 Album,
609 Artist,
610}
611
612pub fn sync_collection_favourite_to_remote(
617 db: &Database,
618 kind: FavouriteKind,
619 id: i64,
620 star: bool,
621) {
622 let cfg = Config::load().unwrap_or_default();
623 if !cfg.remote.enabled {
624 return;
625 }
626 let remote_id = match kind {
627 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
628 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
629 FavouriteKind::Track => return,
630 };
631 let Ok(Some(remote_id)) = remote_id else {
632 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
633 return;
634 };
635 let Some(client) = subsonic_client(&cfg) else {
636 log::warn!("not syncing favourite: no usable server credentials");
637 return;
638 };
639 std::thread::Builder::new()
640 .name("koan-fav-sync".into())
641 .spawn(move || {
642 let result = match (kind, star) {
643 (FavouriteKind::Album, true) => client.star_album(&remote_id),
644 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
645 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
646 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
647 (FavouriteKind::Track, _) => Ok(()),
648 };
649 match result {
650 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
651 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
652 }
653 })
654 .ok();
655}
656
657#[derive(Debug, thiserror::Error)]
659pub enum SignInError {
660 #[error("the server did not accept those credentials: {0}")]
661 Rejected(#[from] crate::remote::client::SubsonicError),
662 #[error("could not write the configuration: {0}")]
663 Config(#[from] crate::config::ConfigError),
664}
665
666pub fn set_remote_credentials(
679 url: &str,
680 username: &str,
681 password: &str,
682) -> Result<(), SignInError> {
683 let url = url.trim_end_matches('/');
684 SubsonicClient::new(url, username, password).ping()?;
685
686 Config::persist(|cfg| {
687 cfg.remote.enabled = true;
688 cfg.remote.url = url.to_string();
689 cfg.remote.username = username.to_string();
690 cfg.remote.password = password.to_string();
691 })?;
692 Ok(())
693}
694
695pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
699 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
700}
701
702pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
709 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
710 return None;
711 }
712 let password = get_remote_password(cfg)?;
713 Some(SubsonicAuth::new(
714 &cfg.remote.url,
715 &cfg.remote.username,
716 &password,
717 ))
718}
719
720pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
732 let auth = subsonic_auth(cfg)?;
733
734 let mut slot = SUBSONIC_CLIENT.lock();
735 if let Some((cached, client)) = slot.as_ref()
736 && *cached == auth
737 {
738 return Some(client.clone());
739 }
740
741 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
742 *slot = Some((auth, client.clone()));
743 Some(client)
744}
745
746type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
747
748static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
749 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
750
751#[derive(Debug, thiserror::Error)]
760pub enum ShareError {
761 #[error("no remote server is configured")]
762 NoRemote,
763 #[error("none of these tracks are on the server, so a link has nothing to point at")]
764 NothingRemote,
765 #[error("the server refused to share these: {0}")]
766 Server(#[from] crate::remote::client::SubsonicError),
767 #[error(transparent)]
768 Database(#[from] crate::db::connection::DbError),
769}
770
771#[derive(Debug, Clone)]
773pub struct ShareOutcome {
774 pub url: String,
775 pub id: String,
777 pub shared: usize,
779 pub skipped: usize,
781}
782
783pub fn create_share(
792 db: &Database,
793 cfg: &Config,
794 track_ids: &[i64],
795 description: Option<&str>,
796) -> Result<ShareOutcome, ShareError> {
797 let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
798
799 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
801
802 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
803 if shared == 0 {
804 return Err(ShareError::NothingRemote);
805 }
806
807 let one_album = rows
811 .first()
812 .and_then(|f| f.album_id)
813 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
814 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
815
816 let remote_ids: Vec<String> = match one_album {
817 Some(rid) => vec![rid],
818 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
819 };
820
821 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
822 let share = client.create_share(&refs, description)?;
823
824 let url = share
827 .url
828 .clone()
829 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
830
831 Ok(ShareOutcome {
832 url,
833 id: share.id,
834 shared,
835 skipped: track_ids.len().saturating_sub(shared),
836 })
837}
838
839fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
843 let (remote_id, total): (Option<String>, i64) = conn
844 .query_row(
845 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
846 FROM albums al WHERE al.id = ?1",
847 [album_id],
848 |row| Ok((row.get(0)?, row.get(1)?)),
849 )
850 .ok()?;
851 (total == selected as i64).then_some(remote_id).flatten()
852}
853
854pub fn shuffle<T>(items: &mut [T]) {
863 let mut seed = [0u8; 8];
864 if getrandom::fill(&mut seed).is_err() {
865 return; }
867 let mut state = u64::from_le_bytes(seed) | 1;
868 for i in (1..items.len()).rev() {
869 state ^= state << 13;
871 state ^= state >> 7;
872 state ^= state << 17;
873 items.swap(i, (state % (i as u64 + 1)) as usize);
874 }
875}
876
877pub fn truncate_bytes(s: &str, max: usize) -> &str {
879 if s.len() <= max {
880 return s;
881 }
882 let mut end = max;
883 while end > 0 && !s.is_char_boundary(end) {
884 end -= 1;
885 }
886 &s[..end]
887}
888
889pub fn sanitise_filename(s: &str) -> String {
892 let cleaned: String = s
893 .chars()
894 .map(|c| match c {
895 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
896 _ => c,
897 })
898 .collect::<String>()
899 .trim()
900 .to_string();
901
902 truncate_bytes(&cleaned, 240).trim_end().to_string()
903}
904
905pub fn cache_path_for_track(
908 cache_dir: &Path,
909 track: &queries::TrackRow,
910 album_date: Option<&str>,
911) -> PathBuf {
912 let artist_dir = sanitise_filename(&track.artist_name);
913
914 let year = album_date
915 .and_then(|d| if d.len() >= 4 { Some(&d[..4]) } else { None })
916 .map(|y| format!("({}) ", y))
917 .unwrap_or_default();
918 let codec = track
919 .codec
920 .as_deref()
921 .map(|c| format!(" [{}]", c))
922 .unwrap_or_default();
923 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
924
925 let disc_prefix = match track.disc {
926 Some(d) if d > 1 => format!("{}-", d),
927 _ => String::new(),
928 };
929 let track_num = track
930 .track_number
931 .map(|n| format!("{:02}. ", n))
932 .unwrap_or_default();
933
934 let ext = track
935 .codec
936 .as_deref()
937 .map(|c| c.to_lowercase())
938 .unwrap_or_else(|| "flac".into());
939
940 let filename = sanitise_filename(&format!(
941 "{}{}{} - {}",
942 disc_prefix, track_num, track.artist_name, track.title
943 ));
944
945 cache_dir
946 .join(artist_dir)
947 .join(album_dir)
948 .join(format!("{}.{}", filename, ext))
949}
950
951pub fn resolve_item_path(
959 db: &Database,
960 cfg: &Config,
961 id: i64,
962 track: &queries::TrackRow,
963 album_date: Option<&str>,
964) -> (PathBuf, ItemState) {
965 match queries::resolve_playback_path(&db.conn, id) {
966 Ok(Some(queries::PlaybackSource::Local(p))) => (p, ItemState::Ready),
967 Ok(Some(queries::PlaybackSource::Cached(p))) => {
972 let state = if is_cached_audio(&p) {
973 ItemState::Ready
974 } else {
975 ItemState::Pending
976 };
977 (p, state)
978 }
979 Ok(Some(queries::PlaybackSource::Remote(_))) => {
980 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
981 if dest.exists() && is_cached_audio(&dest) {
982 (dest, ItemState::Ready)
983 } else {
984 (dest, ItemState::Pending)
985 }
986 }
987 _ => {
988 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
990 (dest, ItemState::Pending)
991 }
992 }
993}
994
995pub fn playlist_item_from_track(
997 track: &queries::TrackRow,
998 album_date: Option<&str>,
999 dest: PathBuf,
1000 state: ItemState,
1001) -> PlaylistItem {
1002 let year = album_date.and_then(|d| {
1003 if d.len() >= 4 {
1004 Some(d[..4].to_string())
1005 } else {
1006 None
1007 }
1008 });
1009 PlaylistItem {
1010 playlist_entry_id: None,
1011 id: QueueItemId::new(),
1012 db_id: Some(track.id),
1013 path: dest,
1014 title: track.title.clone(),
1015 artist: track.artist_name.clone(),
1016 album_artist: track.album_artist_name.clone(),
1017 album: track.album_title.clone(),
1018 year,
1019 codec: track.codec.clone(),
1020 track_number: track.track_number.map(|n| n as i64),
1021 disc: track.disc.map(|n| n as i64),
1022 duration_ms: track.duration_ms.map(|d| d as u64),
1023 state,
1024 }
1025}
1026
1027pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1034 use std::collections::HashMap;
1035
1036 let cfg = Config::load().unwrap_or_default();
1037 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
1038
1039 tracks
1040 .iter()
1041 .map(|track| {
1042 let album_date = match track.album_id {
1043 Some(aid) => album_dates
1044 .entry(aid)
1045 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
1046 .clone(),
1047 None => None,
1048 };
1049 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1050 playlist_item_from_track(track, album_date.as_deref(), path, state)
1051 })
1052 .collect()
1053}
1054
1055pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
1057 let album_date = track
1058 .album_id
1059 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1060
1061 let cfg = Config::load().unwrap_or_default();
1062 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1063
1064 let year = album_date.as_deref().and_then(|d| {
1065 if d.len() >= 4 {
1066 Some(d[..4].to_string())
1067 } else {
1068 None
1069 }
1070 });
1071
1072 PlaylistItem {
1073 playlist_entry_id: None,
1074 id: QueueItemId::new(),
1075 db_id: Some(track.id),
1076 path,
1077 title: track.title.clone(),
1078 artist: track.artist_name.clone(),
1079 album_artist: track.album_artist_name.clone(),
1080 album: track.album_title.clone(),
1081 year,
1082 codec: track.codec.clone(),
1083 track_number: track.track_number.map(|n| n as i64),
1084 disc: track.disc.map(|n| n as i64),
1085 duration_ms: track.duration_ms.map(|d| d as u64),
1086 state,
1087 }
1088}
1089
1090fn is_cached_audio(path: &std::path::Path) -> bool {
1100 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1101 match std::fs::metadata(path) {
1102 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1103 Ok(_) => {
1104 let mut first = [0u8; 1];
1105 match std::fs::File::open(path)
1106 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1107 {
1108 Ok(b) => b != b'{' && b != b'<',
1109 Err(_) => false,
1110 }
1111 }
1112 Err(_) => false,
1113 }
1114}
1115
1116pub fn download_track(
1123 db_id: i64,
1124 queue_id: QueueItemId,
1125 tx: &crossbeam_channel::Sender<PlayerCommand>,
1126 log_buf: &Arc<Mutex<Vec<String>>>,
1127 state: &Arc<SharedPlayerState>,
1128 cfg: &Config,
1129 client: &SubsonicClient,
1130) {
1131 let db = match crate::db::pool::shared().get() {
1136 Ok(db) => db,
1137 Err(e) => {
1138 fail_track(state, tx, queue_id, format!("db error: {}", e));
1139 return;
1140 }
1141 };
1142 let track = match queries::get_track_row(&db.conn, db_id) {
1143 Ok(Some(t)) => t,
1144 _ => {
1145 fail_track(state, tx, queue_id, "track not found".into());
1146 return;
1147 }
1148 };
1149
1150 let remote_id = match &track.remote_id {
1151 Some(rid) => rid.clone(),
1152 None => {
1153 if let Some(ref path) = track.path {
1155 let p = std::path::PathBuf::from(path);
1156 if p.exists() {
1157 state.update_paths(&[(queue_id, p)]);
1158 state.update_item_state(queue_id, ItemState::Ready);
1159 if state.is_cursor(queue_id) {
1160 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1161 }
1162 return;
1163 }
1164 }
1165 fail_track(
1166 state,
1167 tx,
1168 queue_id,
1169 "not in the library folder, and no remote copy to fetch".into(),
1170 );
1171 return;
1172 }
1173 };
1174
1175 if let Some(ref local_path) = track.path {
1177 let p = std::path::PathBuf::from(local_path);
1178 if p.exists() {
1179 log::info!("download_track: local file exists, using {}", p.display());
1180 state.update_paths(&[(queue_id, p)]);
1181 state.update_item_state(queue_id, ItemState::Ready);
1182 if state.is_cursor(queue_id) {
1183 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1184 }
1185 return;
1186 }
1187 }
1188
1189 let album_date: Option<String> = track
1190 .album_id
1191 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1192
1193 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1194
1195 if dest.exists() && !is_cached_audio(&dest) {
1201 log::warn!(
1202 "discarding non-audio cache entry {} (likely a stored server error)",
1203 dest.display()
1204 );
1205 let _ = std::fs::remove_file(&dest);
1206 }
1207 if dest.exists() {
1208 state.update_paths(&[(queue_id, dest)]);
1209 state.update_item_state(queue_id, ItemState::Ready);
1210 if state.is_cursor(queue_id) {
1211 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1212 }
1213 return;
1214 }
1215
1216 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1219
1220 let bytes_written = crate::remote::downloads::ByteFeed::new();
1221
1222 let store = crate::remote::downloads::store();
1225 store.queued(crate::remote::downloads::Download {
1226 id: queue_id,
1227 track_id: db_id,
1228 title: track.title.clone(),
1229 artist: track.artist_name.clone(),
1230 source: crate::remote::download::part_path(&dest),
1231 dest: dest.clone(),
1232 total: 0,
1233 written: bytes_written.clone(),
1234 state: crate::remote::downloads::DownloadState::Queued,
1235 bytes_per_second: 0,
1236 });
1237
1238 let progress_qid = queue_id;
1239 let bytes_written_progress = bytes_written.clone();
1240 let progress_tx = tx.clone();
1241 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1242 let stream_ready_flag = stream_ready_sent.clone();
1243 let announced_total = AtomicU64::new(u64::MAX);
1245 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1246 bytes_written_progress.set(downloaded);
1247 store.progressed();
1250 if announced_total.swap(total, Ordering::Relaxed) != total {
1251 store.started(progress_qid, total, bytes_written_progress.clone());
1254 }
1255 if !stream_ready_flag.load(Ordering::Relaxed)
1256 && downloaded >= crate::player::state::STREAM_THRESHOLD
1257 {
1258 stream_ready_flag.store(true, Ordering::Relaxed);
1259 progress_tx
1260 .send(PlayerCommand::TrackStreamReady(progress_qid))
1261 .ok();
1262 }
1263 });
1264
1265 bytes_written.done();
1270
1271 if let Err(e) = result {
1272 store.failed(queue_id, e.to_string());
1273 fail_track(state, tx, queue_id, e.to_string());
1274 push_log(log_buf, format!("x {} — {}", track.title, e));
1275 return;
1276 }
1277 store.finished(queue_id);
1278
1279 state.update_paths(&[(queue_id, dest.clone())]);
1281 state.update_item_state(queue_id, ItemState::Ready);
1282 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1284 log::warn!(
1285 "cached {} but failed to record it ({}) — it will not be evicted",
1286 dest.display(),
1287 e
1288 );
1289 }
1290
1291 push_log(
1292 log_buf,
1293 format!("+ {} — {}", track.title, track.artist_name),
1294 );
1295
1296 if state.is_cursor(queue_id) {
1297 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1298 }
1299}
1300
1301pub(crate) fn fail_track(
1307 state: &Arc<SharedPlayerState>,
1308 tx: &crossbeam_channel::Sender<PlayerCommand>,
1309 queue_id: QueueItemId,
1310 reason: String,
1311) {
1312 state.update_item_state(queue_id, ItemState::Failed(reason));
1313 if state.is_cursor(queue_id) {
1314 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1315 }
1316}
1317
1318fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1321 match log_buf.lock() {
1322 Ok(mut buf) => buf.push(msg),
1323 Err(_) => log::info!("{}", msg),
1324 }
1325}
1326
1327pub fn remote_unavailable(cfg: &Config) -> String {
1333 if !cfg.remote.enabled {
1334 return "no remote server is configured".into();
1335 }
1336 if cfg.remote.url.is_empty() {
1337 return "the remote server has no address".into();
1338 }
1339 if get_remote_password(cfg).is_none() {
1340 return "no password is stored for the remote server".into();
1341 }
1342 "the remote server could not be reached".into()
1345}
1346
1347pub fn spawn_downloads(
1357 pending: Vec<(i64, QueueItemId)>,
1358 tx: crossbeam_channel::Sender<PlayerCommand>,
1359 state: Arc<SharedPlayerState>,
1360) {
1361 if pending.is_empty() {
1362 return;
1363 }
1364 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1365}
1366
1367#[cfg(test)]
1368mod rebuild_tests {
1369 use super::*;
1370 use crate::db::queries::sample_meta;
1371
1372 fn test_db() -> Database {
1373 let conn = rusqlite::Connection::open_in_memory().unwrap();
1374 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1375 crate::db::schema::create_tables(&conn).unwrap();
1376 Database { conn }
1377 }
1378
1379 #[test]
1380 fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1381 let dir = tempfile::tempdir().unwrap();
1382 let db = test_db();
1383
1384 let mut cached = Vec::new();
1385 for name in ["one", "two"] {
1386 let mut meta = sample_meta(name, "Artist", "Album");
1387 meta.source = "remote".into();
1388 meta.path = None;
1389 meta.remote_id = Some(name.into());
1390 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1391 let file = dir.path().join(format!("{name}.opus"));
1392 std::fs::write(&file, vec![0u8; 2048]).unwrap();
1393 queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1394 cached.push((id, file));
1395 }
1396
1397 let cleared = clear_downloads_for(&db, &[cached[0].0]);
1398 assert_eq!(cleared.files, 1);
1399 assert_eq!(cleared.bytes, 2048);
1400 assert!(!cached[0].1.exists(), "the copy asked for is gone");
1401 assert!(cached[1].1.exists(), "the other one is untouched");
1402
1403 assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1406 assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1407 assert!(
1408 queries::cached_paths_for(&db.conn, &[cached[0].0])
1409 .unwrap()
1410 .is_empty()
1411 );
1412 }
1413
1414 #[test]
1415 fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1416 let db = test_db();
1417 let mut meta = sample_meta("ghost", "Artist", "Album");
1418 meta.source = "remote".into();
1419 meta.path = None;
1420 meta.remote_id = Some("ghost".into());
1421 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1422 queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1423
1424 let cleared = clear_downloads_for(&db, &[id]);
1425 assert_eq!(cleared.files, 0, "nothing was there to remove");
1426 assert!(
1428 queries::cached_paths_for(&db.conn, &[id])
1429 .unwrap()
1430 .is_empty()
1431 );
1432 }
1433
1434 #[test]
1435 fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1436 let dir = tempfile::tempdir().unwrap();
1437 let cache = dir.path().join("cache");
1438 std::fs::create_dir_all(cache.join("Artist")).unwrap();
1439
1440 let finished = cache.join("Artist/whole.opus");
1441 let half = cache.join("Artist/half.opus.part");
1442 std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1443 std::fs::write(&half, vec![0u8; 4096]).unwrap();
1444
1445 let cfg = Config {
1446 remote: crate::config::RemoteConfig {
1447 cache_dir: Some(cache.clone()),
1448 ..Default::default()
1449 },
1450 ..Default::default()
1451 };
1452
1453 let swept = sweep_partial_downloads(&cfg);
1454 assert_eq!(swept.files, 1);
1455 assert_eq!(swept.bytes, 4096);
1456 assert!(!half.exists(), "the unfinished one is gone");
1457 assert!(finished.exists(), "a downloaded track is not touched");
1458 }
1459
1460 #[test]
1461 fn sweeping_an_empty_cache_is_not_an_error() {
1462 let dir = tempfile::tempdir().unwrap();
1463 let cfg = Config {
1464 remote: crate::config::RemoteConfig {
1465 cache_dir: Some(dir.path().join("nothing-here")),
1466 ..Default::default()
1467 },
1468 ..Default::default()
1469 };
1470 assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1471 }
1472
1473 #[test]
1474 fn clearing_no_tracks_does_nothing() {
1475 let db = test_db();
1476 assert_eq!(clear_downloads_for(&db, &[]).files, 0);
1477 }
1478
1479 #[test]
1480 fn rebuild_drops_the_index_and_keeps_favourites() {
1481 let db = test_db();
1482 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1483 meta.path = Some("/music/windowlicker.flac".into());
1484 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1485
1486 queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1488 db.conn
1489 .execute(
1490 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1491 VALUES (?1, 'test', 'la la la', 0)",
1492 [track_id],
1493 )
1494 .unwrap();
1495
1496 let summary = rebuild_index(&db).unwrap();
1497 assert_eq!(summary.tracks, 1);
1498 assert_eq!(summary.albums, 1);
1499
1500 let tracks: i64 = db
1501 .conn
1502 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1503 .unwrap();
1504 assert_eq!(tracks, 0, "the index is gone");
1505
1506 let favourites: i64 = db
1507 .conn
1508 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1509 .unwrap();
1510 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1511
1512 let lyrics: i64 = db
1513 .conn
1514 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1515 .unwrap();
1516 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1517 }
1518
1519 #[test]
1520 fn rebuilding_an_empty_library_is_not_an_error() {
1521 let db = test_db();
1522 let summary = rebuild_index(&db).unwrap();
1523 assert_eq!(summary.tracks, 0);
1524 }
1525}
1526
1527#[cfg(test)]
1528mod share_tests {
1529 use super::*;
1530 use crate::db::queries::sample_meta;
1531
1532 fn test_db() -> Database {
1533 let conn = rusqlite::Connection::open_in_memory().unwrap();
1534 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1535 crate::db::schema::create_tables(&conn).unwrap();
1536 Database { conn }
1537 }
1538
1539 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1541 let ids: Vec<i64> = ["One", "Two", "Three"]
1542 .iter()
1543 .enumerate()
1544 .map(|(i, title)| {
1545 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1546 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1547 meta.track_number = Some(i as i32 + 1);
1548 queries::upsert_track(&db.conn, &meta).unwrap()
1549 })
1550 .collect();
1551 let album_id: i64 = db
1552 .conn
1553 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1554 r.get(0)
1555 })
1556 .unwrap();
1557 db.conn
1558 .execute(
1559 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1560 [album_id],
1561 )
1562 .unwrap();
1563 (album_id, ids)
1564 }
1565
1566 #[test]
1567 fn whole_album_collapses_to_the_album_link() {
1568 let db = test_db();
1569 let (album_id, ids) = album_of_three(&db);
1570 assert_eq!(
1571 album_remote_id(&db.conn, album_id, ids.len()),
1572 Some("al-1".into())
1573 );
1574 }
1575
1576 #[test]
1577 fn part_of_an_album_does_not() {
1578 let db = test_db();
1579 let (album_id, _) = album_of_three(&db);
1580 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1583 }
1584
1585 #[test]
1586 fn a_local_only_album_has_no_link_to_collapse_to() {
1587 let db = test_db();
1588 let (album_id, ids) = album_of_three(&db);
1589 db.conn
1590 .execute(
1591 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1592 [album_id],
1593 )
1594 .unwrap();
1595 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1596 }
1597}
1598
1599#[cfg(test)]
1600mod client_cache_tests {
1601 use super::*;
1602
1603 #[test]
1604 fn one_subsonic_client_is_shared_per_credentials() {
1605 crate::config::isolate_config_for_tests();
1606 let mut cfg = Config::default();
1607 cfg.remote.enabled = true;
1608 cfg.remote.url = "https://shared-client.invalid".into();
1609 cfg.remote.username = "koan".into();
1610 cfg.remote.password = "first".into();
1611
1612 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
1613 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
1614 assert!(
1615 Arc::ptr_eq(&first, &again),
1616 "rebuilding drops the connection pool and re-handshakes TLS per request"
1617 );
1618
1619 cfg.remote.password = "second".into();
1620 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
1621 assert!(
1622 !Arc::ptr_eq(&first, &relogged),
1623 "new credentials must not keep serving the client signed with the old ones"
1624 );
1625 }
1626}