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::{LoadState, PlaylistItem, QueueItemId, SharedPlayerState};
15use crate::remote::client::{SubsonicAuth, SubsonicClient};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum PasswordSource {
31 Keychain,
33 Config,
35 Missing,
37 Unreadable(String),
41}
42
43pub fn get_remote_password(cfg: &Config) -> Option<String> {
49 remote_password(cfg).0
50}
51
52pub fn remote_password(cfg: &Config) -> (Option<String>, PasswordSource) {
54 let refusal = match crate::credentials::get_password(&cfg.remote.url) {
55 Ok(pw) if !pw.is_empty() => return (Some(pw), PasswordSource::Keychain),
56 Ok(_) | Err(crate::credentials::CredentialError::NotFound) => None,
58 Err(e) => Some(e.to_string()),
59 };
60
61 if !cfg.remote.password.is_empty() {
62 return (Some(cfg.remote.password.clone()), PasswordSource::Config);
63 }
64
65 (
66 None,
67 refusal.map_or(PasswordSource::Missing, PasswordSource::Unreadable),
68 )
69}
70
71pub fn spawn_library_watch(
84 db_path: std::path::PathBuf,
85 on_state: impl Fn(bool) + Send + Sync + 'static,
86) -> Option<std::thread::JoinHandle<()>> {
87 use notify::{RecursiveMode, Watcher};
88
89 std::thread::Builder::new()
90 .name("koan-library-watch".into())
91 .spawn(move || {
92 let scan_now = |reason: &str| {
93 let cfg = Config::load().unwrap_or_default();
94 if cfg.library.folders.is_empty() {
95 return;
96 }
97 let Ok(db) = Database::open(&db_path) else {
98 return;
99 };
100 on_state(true);
101 let result = crate::index::scanner::full_scan(
102 &db,
103 &cfg.library.folders,
104 crate::index::scanner::ScanOptions::default(),
105 None,
106 );
107 on_state(false);
108 log::info!(
109 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
110 result.added,
111 result.updated,
112 result.removed,
113 result.skipped
114 );
115 };
116
117 std::thread::sleep(std::time::Duration::from_secs(3));
119 scan_now("startup");
120
121 let (tx, rx) = std::sync::mpsc::channel();
122 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
123 let _ = tx.send(event);
124 }) else {
125 log::warn!("could not watch the library folders");
126 return;
127 };
128
129 let cfg = Config::load().unwrap_or_default();
130 for folder in &cfg.library.folders {
131 if let Err(e) = watcher.watch(folder, RecursiveMode::Recursive) {
132 log::warn!("could not watch {}: {e}", folder.display());
133 }
134 }
135
136 const SETTLE: std::time::Duration = std::time::Duration::from_secs(5);
139 while let Ok(first) = rx.recv() {
140 if first.is_err() {
141 continue;
142 }
143 while rx.recv_timeout(SETTLE).is_ok() {}
144 scan_now("watched change");
145 }
146 })
147 .ok()
148}
149
150pub fn spawn_auto_sync(
163 db_path: std::path::PathBuf,
164 on_state: impl Fn(bool) + Send + 'static,
165) -> Option<std::thread::JoinHandle<()>> {
166 std::thread::Builder::new()
167 .name("koan-auto-sync".into())
168 .spawn(move || {
169 std::thread::sleep(std::time::Duration::from_secs(5));
170 loop {
171 let cfg = Config::load().unwrap_or_default();
172 if !cfg.remote.enabled || !cfg.remote.auto_sync {
173 std::thread::sleep(std::time::Duration::from_secs(60));
176 continue;
177 }
178
179 if let Some(client) = subsonic_client(&cfg)
180 && let Ok(db) = Database::open(&db_path)
181 {
182 on_state(true);
183 match sync_remote(&db, &client, false, &cfg.remote.url, &cfg.remote.username) {
184 Ok(s) => log::info!(
185 "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
186 favourites {}↑ {}↓; playlists {}↓ {}↑",
187 s.library.artists_synced,
188 s.library.albums_synced,
189 s.library.tracks_synced,
190 s.library.albums_failed,
191 s.favourites.pushed,
192 s.favourites.imported,
193 s.playlists.pulled,
194 s.playlists.pushed,
195 ),
196 Err(e) => log::warn!("auto sync failed: {e}"),
197 }
198 on_state(false);
199 }
200
201 match cfg.remote.auto_sync_interval_mins {
202 0 => return,
204 mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
205 }
206 }
207 })
208 .ok()
209}
210
211#[derive(Debug, Clone, Copy, Default)]
213pub struct RebuildSummary {
214 pub tracks: u64,
215 pub albums: u64,
216 pub artists: u64,
217}
218
219pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
230 let count = |sql: &str| -> u64 {
231 db.conn
232 .query_row(sql, [], |r| r.get::<_, i64>(0))
233 .unwrap_or(0) as u64
234 };
235 let summary = RebuildSummary {
236 tracks: count("SELECT COUNT(*) FROM tracks"),
237 albums: count("SELECT COUNT(*) FROM albums"),
238 artists: count("SELECT COUNT(*) FROM artists"),
239 };
240
241 db.conn.execute_batch(
244 "BEGIN;
245 DELETE FROM track_vectors;
246 DELETE FROM lyrics_cache;
247 DELETE FROM play_history;
248 DELETE FROM scan_cache;
249 DELETE FROM tracks_fts;
250 DELETE FROM tracks;
251 DELETE FROM similar_artists;
252 DELETE FROM albums;
253 DELETE FROM artists;
254 COMMIT;",
255 )?;
256 let _ = db.conn.execute_batch("VACUUM");
257 Ok(summary)
258}
259
260pub fn cache_size_bytes(cfg: &Config) -> u64 {
262 walkdir::WalkDir::new(cfg.cache_dir())
263 .into_iter()
264 .filter_map(Result::ok)
265 .filter(|e| e.file_type().is_file())
266 .filter_map(|e| e.metadata().ok())
267 .map(|m| m.len())
268 .sum()
269}
270
271pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
276 let prefix = format!(
277 "{}{}%",
278 folder
279 .to_string_lossy()
280 .trim_end_matches(std::path::MAIN_SEPARATOR),
281 std::path::MAIN_SEPARATOR
282 );
283 db.conn
284 .query_row(
285 "SELECT COUNT(*) FROM tracks WHERE path LIKE ?1",
286 [&prefix],
287 |r| r.get::<_, i64>(0),
288 )
289 .unwrap_or(0) as u64
290}
291
292pub fn tracks_from_server(db: &Database) -> u64 {
294 db.conn
295 .query_row(
296 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
297 [],
298 |r| r.get::<_, i64>(0),
299 )
300 .unwrap_or(0) as u64
301}
302
303pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
316 let prefix = format!(
318 "{}{}%",
319 folder
320 .to_string_lossy()
321 .trim_end_matches(std::path::MAIN_SEPARATOR),
322 std::path::MAIN_SEPARATOR
323 );
324
325 let tx = db.conn.unchecked_transaction()?;
326 tx.execute(
328 "UPDATE tracks SET path = NULL, source = 'remote'
329 WHERE path LIKE ?1 AND remote_id IS NOT NULL",
330 [&prefix],
331 )?;
332
333 let ids: Vec<i64> = {
334 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path LIKE ?1")?;
335 let rows = stmt.query_map([&prefix], |r| r.get(0))?;
336 rows.filter_map(Result::ok).collect()
337 };
338 for id in &ids {
339 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
340 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
341 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
342 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
343 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
344 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
345 }
346 prune_empty_albums_and_artists(&tx)?;
347 tx.commit()?;
348 Ok(ids.len() as u64)
349}
350
351pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
357 let tx = db.conn.unchecked_transaction()?;
358
359 let ids: Vec<i64> = {
360 let mut stmt =
361 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
362 let rows = stmt.query_map([], |r| r.get(0))?;
363 rows.filter_map(Result::ok).collect()
364 };
365 for id in &ids {
366 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
367 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
368 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
369 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
370 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
371 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
372 }
373 tx.execute(
375 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
376 WHERE remote_id IS NOT NULL",
377 [],
378 )?;
379 tx.execute("DELETE FROM similar_artists", [])?;
380 prune_empty_albums_and_artists(&tx)?;
381 tx.commit()?;
382 Ok(ids.len() as u64)
383}
384
385fn prune_empty_albums_and_artists(
387 tx: &rusqlite::Transaction<'_>,
388) -> Result<(), crate::db::connection::DbError> {
389 tx.execute(
390 "DELETE FROM albums WHERE NOT EXISTS
391 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
392 [],
393 )?;
394 tx.execute(
395 "DELETE FROM similar_artists WHERE NOT EXISTS
396 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
397 [],
398 )?;
399 tx.execute(
400 "DELETE FROM artists WHERE NOT EXISTS
401 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
402 AND NOT EXISTS
403 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
404 [],
405 )?;
406 Ok(())
407}
408
409#[derive(Debug, Clone, Copy, Default)]
411pub struct CacheCleared {
412 pub files: u64,
413 pub bytes: u64,
414}
415
416pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
421 let dir = cfg.cache_dir();
422 let mut cleared = CacheCleared::default();
423 for entry in walkdir::WalkDir::new(&dir)
424 .into_iter()
425 .filter_map(Result::ok)
426 .filter(|e| e.file_type().is_file())
427 {
428 if let Ok(meta) = entry.metadata() {
429 cleared.bytes += meta.len();
430 cleared.files += 1;
431 }
432 }
433 let _ = std::fs::remove_dir_all(&dir);
434 let _ = std::fs::create_dir_all(&dir);
435 let _ = queries::clear_cached_paths(&db.conn);
436 cleared
437}
438
439pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
451 let cfg = Config::load().unwrap_or_default();
452 if !cfg.remote.enabled {
453 return;
454 }
455 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
456 log::warn!("not syncing favourite: {} has no remote id", path.display());
457 return;
458 };
459 let Some(client) = subsonic_client(&cfg) else {
460 log::warn!("not syncing favourite: no usable server credentials");
461 return;
462 };
463 std::thread::Builder::new()
464 .name("koan-fav-sync".into())
465 .spawn(move || {
466 let result = if star {
467 client.star(&remote_id)
468 } else {
469 client.unstar(&remote_id)
470 };
471 match result {
472 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
473 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
474 }
475 })
476 .ok();
477}
478
479#[derive(Debug, Default)]
481pub struct FullSync {
482 pub library: crate::remote::sync::SyncResult,
483 pub favourites: FavouriteSync,
484 pub playlists: crate::playlists::PlaylistSync,
485}
486
487pub fn sync_remote(
498 db: &Database,
499 client: &SubsonicClient,
500 full: bool,
501 url: &str,
502 username: &str,
503) -> Result<FullSync, crate::remote::sync::SyncError> {
504 let library = crate::remote::sync::sync_library(db, client, full, url, username)?;
505 Ok(FullSync {
506 library,
507 favourites: reconcile_favourites(db, client),
508 playlists: crate::playlists::reconcile_playlists(db, client, username),
509 })
510}
511
512#[derive(Debug, Default, Clone, Copy)]
514pub struct FavouriteSync {
515 pub pushed: usize,
516 pub imported: usize,
517}
518
519pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
530 let mut out = FavouriteSync::default();
531
532 let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
533 for (_path, remote_id) in &tracks {
534 if client.star(remote_id).is_ok() {
535 out.pushed += 1;
536 }
537 }
538 for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
539 if client.star_album(&remote_id).is_ok() {
540 out.pushed += 1;
541 }
542 }
543 for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
544 {
545 if client.star_artist(&remote_id).is_ok() {
546 out.pushed += 1;
547 }
548 }
549
550 let starred = match client.get_starred_all() {
551 Ok(s) => s,
552 Err(e) => {
553 log::warn!("could not fetch starred items from the server: {e}");
554 return out;
555 }
556 };
557
558 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
559 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
560 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
561 out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
562 out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
563 out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
564 out
565}
566
567#[derive(Debug, Clone, Copy, PartialEq, Eq)]
570pub enum FavouriteKind {
571 Track,
572 Album,
573 Artist,
574}
575
576pub fn sync_collection_favourite_to_remote(
581 db: &Database,
582 kind: FavouriteKind,
583 id: i64,
584 star: bool,
585) {
586 let cfg = Config::load().unwrap_or_default();
587 if !cfg.remote.enabled {
588 return;
589 }
590 let remote_id = match kind {
591 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
592 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
593 FavouriteKind::Track => return,
594 };
595 let Ok(Some(remote_id)) = remote_id else {
596 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
597 return;
598 };
599 let Some(client) = subsonic_client(&cfg) else {
600 log::warn!("not syncing favourite: no usable server credentials");
601 return;
602 };
603 std::thread::Builder::new()
604 .name("koan-fav-sync".into())
605 .spawn(move || {
606 let result = match (kind, star) {
607 (FavouriteKind::Album, true) => client.star_album(&remote_id),
608 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
609 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
610 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
611 (FavouriteKind::Track, _) => Ok(()),
612 };
613 match result {
614 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
615 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
616 }
617 })
618 .ok();
619}
620
621#[derive(Debug, thiserror::Error)]
623pub enum SignInError {
624 #[error("the server did not accept those credentials: {0}")]
625 Rejected(#[from] crate::remote::client::SubsonicError),
626 #[error("could not save the password: {0}")]
627 Credentials(#[from] crate::credentials::CredentialError),
628 #[error("could not write the configuration: {0}")]
629 Config(#[from] crate::config::ConfigError),
630}
631
632pub fn set_remote_credentials(
645 url: &str,
646 username: &str,
647 password: &str,
648) -> Result<(), SignInError> {
649 let url = url.trim_end_matches('/');
650 SubsonicClient::new(url, username, password).ping()?;
651 crate::credentials::store_password(url, password)?;
652
653 Config::persist(|cfg| {
654 cfg.remote.enabled = true;
655 cfg.remote.url = url.to_string();
656 cfg.remote.username = username.to_string();
657 cfg.remote.password = String::new();
660 })?;
661 Ok(())
662}
663
664pub const SUBSONIC_CREDENTIAL_ACCOUNT: &str = "koan-subsonic";
666
667pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
675 if let Ok(secret) = crate::credentials::get_password(SUBSONIC_CREDENTIAL_ACCOUNT)
676 && !secret.is_empty()
677 {
678 return Some(secret);
679 }
680 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
681}
682
683pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
690 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
691 return None;
692 }
693 let password = get_remote_password(cfg)?;
694 Some(SubsonicAuth::new(
695 &cfg.remote.url,
696 &cfg.remote.username,
697 &password,
698 ))
699}
700
701pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
713 let auth = subsonic_auth(cfg)?;
714
715 let mut slot = SUBSONIC_CLIENT.lock();
716 if let Some((cached, client)) = slot.as_ref()
717 && *cached == auth
718 {
719 return Some(client.clone());
720 }
721
722 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
723 *slot = Some((auth, client.clone()));
724 Some(client)
725}
726
727type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
728
729static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
730 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
731
732#[derive(Debug, thiserror::Error)]
741pub enum ShareError {
742 #[error("no remote server is configured")]
743 NoRemote,
744 #[error("none of these tracks are on the server, so a link has nothing to point at")]
745 NothingRemote,
746 #[error("the server refused to share these: {0}")]
747 Server(#[from] crate::remote::client::SubsonicError),
748 #[error(transparent)]
749 Database(#[from] crate::db::connection::DbError),
750}
751
752#[derive(Debug, Clone)]
754pub struct ShareOutcome {
755 pub url: String,
756 pub id: String,
758 pub shared: usize,
760 pub skipped: usize,
762}
763
764pub fn create_share(
773 db: &Database,
774 cfg: &Config,
775 track_ids: &[i64],
776 description: Option<&str>,
777) -> Result<ShareOutcome, ShareError> {
778 let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
779
780 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
782
783 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
784 if shared == 0 {
785 return Err(ShareError::NothingRemote);
786 }
787
788 let one_album = rows
792 .first()
793 .and_then(|f| f.album_id)
794 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
795 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
796
797 let remote_ids: Vec<String> = match one_album {
798 Some(rid) => vec![rid],
799 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
800 };
801
802 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
803 let share = client.create_share(&refs, description)?;
804
805 let url = share
808 .url
809 .clone()
810 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
811
812 Ok(ShareOutcome {
813 url,
814 id: share.id,
815 shared,
816 skipped: track_ids.len().saturating_sub(shared),
817 })
818}
819
820fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
824 let (remote_id, total): (Option<String>, i64) = conn
825 .query_row(
826 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
827 FROM albums al WHERE al.id = ?1",
828 [album_id],
829 |row| Ok((row.get(0)?, row.get(1)?)),
830 )
831 .ok()?;
832 (total == selected as i64).then_some(remote_id).flatten()
833}
834
835pub fn shuffle<T>(items: &mut [T]) {
844 let mut seed = [0u8; 8];
845 if getrandom::fill(&mut seed).is_err() {
846 return; }
848 let mut state = u64::from_le_bytes(seed) | 1;
849 for i in (1..items.len()).rev() {
850 state ^= state << 13;
852 state ^= state >> 7;
853 state ^= state << 17;
854 items.swap(i, (state % (i as u64 + 1)) as usize);
855 }
856}
857
858pub fn truncate_bytes(s: &str, max: usize) -> &str {
860 if s.len() <= max {
861 return s;
862 }
863 let mut end = max;
864 while end > 0 && !s.is_char_boundary(end) {
865 end -= 1;
866 }
867 &s[..end]
868}
869
870pub fn sanitise_filename(s: &str) -> String {
873 let cleaned: String = s
874 .chars()
875 .map(|c| match c {
876 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
877 _ => c,
878 })
879 .collect::<String>()
880 .trim()
881 .to_string();
882
883 truncate_bytes(&cleaned, 240).trim_end().to_string()
884}
885
886pub fn cache_path_for_track(
889 cache_dir: &Path,
890 track: &queries::TrackRow,
891 album_date: Option<&str>,
892) -> PathBuf {
893 let artist_dir = sanitise_filename(&track.artist_name);
894
895 let year = album_date
896 .and_then(|d| if d.len() >= 4 { Some(&d[..4]) } else { None })
897 .map(|y| format!("({}) ", y))
898 .unwrap_or_default();
899 let codec = track
900 .codec
901 .as_deref()
902 .map(|c| format!(" [{}]", c))
903 .unwrap_or_default();
904 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
905
906 let disc_prefix = match track.disc {
907 Some(d) if d > 1 => format!("{}-", d),
908 _ => String::new(),
909 };
910 let track_num = track
911 .track_number
912 .map(|n| format!("{:02}. ", n))
913 .unwrap_or_default();
914
915 let ext = track
916 .codec
917 .as_deref()
918 .map(|c| c.to_lowercase())
919 .unwrap_or_else(|| "flac".into());
920
921 let filename = sanitise_filename(&format!(
922 "{}{}{} - {}",
923 disc_prefix, track_num, track.artist_name, track.title
924 ));
925
926 cache_dir
927 .join(artist_dir)
928 .join(album_dir)
929 .join(format!("{}.{}", filename, ext))
930}
931
932pub fn resolve_item_path(
939 db: &Database,
940 cfg: &Config,
941 id: i64,
942 track: &queries::TrackRow,
943 album_date: Option<&str>,
944) -> (PathBuf, LoadState) {
945 match queries::resolve_playback_path(&db.conn, id) {
946 Ok(Some(queries::PlaybackSource::Local(p))) => (p, LoadState::Ready),
947 Ok(Some(queries::PlaybackSource::Cached(p))) => {
952 let state = if is_cached_audio(&p) {
953 LoadState::Ready
954 } else {
955 LoadState::Pending
956 };
957 (p, state)
958 }
959 Ok(Some(queries::PlaybackSource::Remote(_))) => {
960 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
961 if dest.exists() && is_cached_audio(&dest) {
962 (dest, LoadState::Ready)
963 } else {
964 (dest, LoadState::Pending)
965 }
966 }
967 _ => {
968 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
970 (dest, LoadState::Pending)
971 }
972 }
973}
974
975pub fn playlist_item_from_track(
977 track: &queries::TrackRow,
978 album_date: Option<&str>,
979 dest: PathBuf,
980 load_state: LoadState,
981) -> PlaylistItem {
982 let year = album_date.and_then(|d| {
983 if d.len() >= 4 {
984 Some(d[..4].to_string())
985 } else {
986 None
987 }
988 });
989 PlaylistItem {
990 playlist_entry_id: None,
991 id: QueueItemId::new(),
992 db_id: Some(track.id),
993 path: dest,
994 title: track.title.clone(),
995 artist: track.artist_name.clone(),
996 album_artist: track.album_artist_name.clone(),
997 album: track.album_title.clone(),
998 year,
999 codec: track.codec.clone(),
1000 track_number: track.track_number.map(|n| n as i64),
1001 disc: track.disc.map(|n| n as i64),
1002 duration_ms: track.duration_ms.map(|d| d as u64),
1003 load_state,
1004 }
1005}
1006
1007pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1014 use std::collections::HashMap;
1015
1016 let cfg = Config::load().unwrap_or_default();
1017 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
1018
1019 tracks
1020 .iter()
1021 .map(|track| {
1022 let album_date = match track.album_id {
1023 Some(aid) => album_dates
1024 .entry(aid)
1025 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
1026 .clone(),
1027 None => None,
1028 };
1029 let (path, load_state) =
1030 resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1031 playlist_item_from_track(track, album_date.as_deref(), path, load_state)
1032 })
1033 .collect()
1034}
1035
1036pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
1038 let album_date = track
1039 .album_id
1040 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1041
1042 let cfg = Config::load().unwrap_or_default();
1043 let (path, load_state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1044
1045 let year = album_date.as_deref().and_then(|d| {
1046 if d.len() >= 4 {
1047 Some(d[..4].to_string())
1048 } else {
1049 None
1050 }
1051 });
1052
1053 PlaylistItem {
1054 playlist_entry_id: None,
1055 id: QueueItemId::new(),
1056 db_id: Some(track.id),
1057 path,
1058 title: track.title.clone(),
1059 artist: track.artist_name.clone(),
1060 album_artist: track.album_artist_name.clone(),
1061 album: track.album_title.clone(),
1062 year,
1063 codec: track.codec.clone(),
1064 track_number: track.track_number.map(|n| n as i64),
1065 disc: track.disc.map(|n| n as i64),
1066 duration_ms: track.duration_ms.map(|d| d as u64),
1067 load_state,
1068 }
1069}
1070
1071fn is_cached_audio(path: &std::path::Path) -> bool {
1081 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1082 match std::fs::metadata(path) {
1083 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1084 Ok(_) => {
1085 let mut first = [0u8; 1];
1086 match std::fs::File::open(path)
1087 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1088 {
1089 Ok(b) => b != b'{' && b != b'<',
1090 Err(_) => false,
1091 }
1092 }
1093 Err(_) => false,
1094 }
1095}
1096
1097pub fn download_track(
1104 db_id: i64,
1105 queue_id: QueueItemId,
1106 tx: &crossbeam_channel::Sender<PlayerCommand>,
1107 log_buf: &Arc<Mutex<Vec<String>>>,
1108 state: &Arc<SharedPlayerState>,
1109 cfg: &Config,
1110 client: &SubsonicClient,
1111) {
1112 let db = match Database::open_default() {
1113 Ok(db) => db,
1114 Err(e) => {
1115 fail_track(state, tx, queue_id, format!("db error: {}", e));
1116 return;
1117 }
1118 };
1119 let track = match queries::get_track_row(&db.conn, db_id) {
1120 Ok(Some(t)) => t,
1121 _ => {
1122 fail_track(state, tx, queue_id, "track not found".into());
1123 return;
1124 }
1125 };
1126
1127 let remote_id = match &track.remote_id {
1128 Some(rid) => rid.clone(),
1129 None => {
1130 if let Some(ref path) = track.path {
1132 let p = std::path::PathBuf::from(path);
1133 if p.exists() {
1134 state.update_paths(&[(queue_id, p)]);
1135 state.update_load_state(queue_id, LoadState::Ready);
1136 if state.is_cursor(queue_id) {
1137 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1138 }
1139 return;
1140 }
1141 }
1142 fail_track(
1143 state,
1144 tx,
1145 queue_id,
1146 "not in the library folder, and no remote copy to fetch".into(),
1147 );
1148 return;
1149 }
1150 };
1151
1152 if let Some(ref local_path) = track.path {
1154 let p = std::path::PathBuf::from(local_path);
1155 if p.exists() {
1156 log::info!("download_track: local file exists, using {}", p.display());
1157 state.update_paths(&[(queue_id, p)]);
1158 state.update_load_state(queue_id, LoadState::Ready);
1159 if state.is_cursor(queue_id) {
1160 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1161 }
1162 return;
1163 }
1164 }
1165
1166 let album_date: Option<String> = track
1167 .album_id
1168 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1169
1170 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1171
1172 if dest.exists() && !is_cached_audio(&dest) {
1178 log::warn!(
1179 "discarding non-audio cache entry {} (likely a stored server error)",
1180 dest.display()
1181 );
1182 let _ = std::fs::remove_file(&dest);
1183 }
1184 if dest.exists() {
1185 state.update_paths(&[(queue_id, dest)]);
1186 state.update_load_state(queue_id, LoadState::Ready);
1187 if state.is_cursor(queue_id) {
1188 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1189 }
1190 return;
1191 }
1192
1193 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1196
1197 let bytes_written: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1198
1199 let progress_state = state.clone();
1200 let progress_qid = queue_id;
1201 let bytes_written_progress = bytes_written.clone();
1202 let progress_tx = tx.clone();
1203 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1204 let stream_ready_flag = stream_ready_sent.clone();
1205 let announced_total = AtomicU64::new(u64::MAX);
1213 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1214 bytes_written_progress.store(downloaded, Ordering::Release);
1215 if announced_total.swap(total, Ordering::Relaxed) != total {
1216 progress_state.update_load_state(
1217 progress_qid,
1218 LoadState::Downloading {
1219 total,
1220 bytes_written: bytes_written_progress.clone(),
1221 },
1222 );
1223 }
1224 if !stream_ready_flag.load(Ordering::Relaxed)
1225 && downloaded >= crate::player::state::STREAM_THRESHOLD
1226 {
1227 stream_ready_flag.store(true, Ordering::Relaxed);
1228 progress_tx
1229 .send(PlayerCommand::TrackStreamReady(progress_qid))
1230 .ok();
1231 }
1232 });
1233
1234 if let Err(e) = result {
1235 fail_track(state, tx, queue_id, e.to_string());
1236 push_log(log_buf, format!("x {} — {}", track.title, e));
1237 return;
1238 }
1239
1240 state.update_paths(&[(queue_id, dest.clone())]);
1242 state.update_load_state(queue_id, LoadState::Ready);
1243 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1245 log::warn!(
1246 "cached {} but failed to record it ({}) — it will not be evicted",
1247 dest.display(),
1248 e
1249 );
1250 }
1251
1252 push_log(
1253 log_buf,
1254 format!("+ {} — {}", track.title, track.artist_name),
1255 );
1256
1257 if state.is_cursor(queue_id) {
1258 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1259 }
1260}
1261
1262pub(crate) fn fail_track(
1268 state: &Arc<SharedPlayerState>,
1269 tx: &crossbeam_channel::Sender<PlayerCommand>,
1270 queue_id: QueueItemId,
1271 reason: String,
1272) {
1273 state.update_load_state(queue_id, LoadState::Failed(reason));
1274 if state.is_cursor(queue_id) {
1275 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1276 }
1277}
1278
1279fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1282 match log_buf.lock() {
1283 Ok(mut buf) => buf.push(msg),
1284 Err(_) => log::info!("{}", msg),
1285 }
1286}
1287
1288pub fn remote_unavailable(cfg: &Config) -> String {
1295 if !cfg.remote.enabled {
1296 return "no remote server is configured".into();
1297 }
1298 if cfg.remote.url.is_empty() {
1299 return "the remote server has no address".into();
1300 }
1301 match remote_password(cfg).1 {
1302 PasswordSource::Missing => "no password is stored for the remote server".into(),
1303 PasswordSource::Unreadable(why) => {
1304 format!("the remote password is in the keychain but could not be read: {why}")
1305 }
1306 PasswordSource::Keychain | PasswordSource::Config => {
1309 "the remote server could not be reached".into()
1310 }
1311 }
1312}
1313
1314pub fn spawn_downloads(
1324 pending: Vec<(i64, QueueItemId)>,
1325 tx: crossbeam_channel::Sender<PlayerCommand>,
1326 state: Arc<SharedPlayerState>,
1327) {
1328 if pending.is_empty() {
1329 return;
1330 }
1331 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1332}
1333
1334#[cfg(test)]
1335mod rebuild_tests {
1336 use super::*;
1337 use crate::db::queries::sample_meta;
1338
1339 fn test_db() -> Database {
1340 let conn = rusqlite::Connection::open_in_memory().unwrap();
1341 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1342 crate::db::schema::create_tables(&conn).unwrap();
1343 Database { conn }
1344 }
1345
1346 #[test]
1347 fn rebuild_drops_the_index_and_keeps_favourites() {
1348 let db = test_db();
1349 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1350 meta.path = Some("/music/windowlicker.flac".into());
1351 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1352
1353 queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1355 db.conn
1356 .execute(
1357 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1358 VALUES (?1, 'test', 'la la la', 0)",
1359 [track_id],
1360 )
1361 .unwrap();
1362
1363 let summary = rebuild_index(&db).unwrap();
1364 assert_eq!(summary.tracks, 1);
1365 assert_eq!(summary.albums, 1);
1366
1367 let tracks: i64 = db
1368 .conn
1369 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1370 .unwrap();
1371 assert_eq!(tracks, 0, "the index is gone");
1372
1373 let favourites: i64 = db
1374 .conn
1375 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1376 .unwrap();
1377 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1378
1379 let lyrics: i64 = db
1380 .conn
1381 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1382 .unwrap();
1383 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1384 }
1385
1386 #[test]
1387 fn rebuilding_an_empty_library_is_not_an_error() {
1388 let db = test_db();
1389 let summary = rebuild_index(&db).unwrap();
1390 assert_eq!(summary.tracks, 0);
1391 }
1392}
1393
1394#[cfg(test)]
1395mod share_tests {
1396 use super::*;
1397 use crate::db::queries::sample_meta;
1398
1399 fn test_db() -> Database {
1400 let conn = rusqlite::Connection::open_in_memory().unwrap();
1401 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1402 crate::db::schema::create_tables(&conn).unwrap();
1403 Database { conn }
1404 }
1405
1406 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1408 let ids: Vec<i64> = ["One", "Two", "Three"]
1409 .iter()
1410 .enumerate()
1411 .map(|(i, title)| {
1412 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1413 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1414 meta.track_number = Some(i as i32 + 1);
1415 queries::upsert_track(&db.conn, &meta).unwrap()
1416 })
1417 .collect();
1418 let album_id: i64 = db
1419 .conn
1420 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1421 r.get(0)
1422 })
1423 .unwrap();
1424 db.conn
1425 .execute(
1426 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1427 [album_id],
1428 )
1429 .unwrap();
1430 (album_id, ids)
1431 }
1432
1433 #[test]
1434 fn whole_album_collapses_to_the_album_link() {
1435 let db = test_db();
1436 let (album_id, ids) = album_of_three(&db);
1437 assert_eq!(
1438 album_remote_id(&db.conn, album_id, ids.len()),
1439 Some("al-1".into())
1440 );
1441 }
1442
1443 #[test]
1444 fn part_of_an_album_does_not() {
1445 let db = test_db();
1446 let (album_id, _) = album_of_three(&db);
1447 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1450 }
1451
1452 #[test]
1453 fn a_local_only_album_has_no_link_to_collapse_to() {
1454 let db = test_db();
1455 let (album_id, ids) = album_of_three(&db);
1456 db.conn
1457 .execute(
1458 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1459 [album_id],
1460 )
1461 .unwrap();
1462 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1463 }
1464}
1465
1466#[cfg(test)]
1467mod client_cache_tests {
1468 use super::*;
1469
1470 #[test]
1471 fn one_subsonic_client_is_shared_per_credentials() {
1472 crate::config::isolate_config_for_tests();
1473 let mut cfg = Config::default();
1474 cfg.remote.enabled = true;
1475 cfg.remote.url = "https://shared-client.invalid".into();
1476 cfg.remote.username = "koan".into();
1477 cfg.remote.password = "first".into();
1478
1479 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
1480 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
1481 assert!(
1482 Arc::ptr_eq(&first, &again),
1483 "rebuilding drops the connection pool and re-handshakes TLS per request"
1484 );
1485
1486 cfg.remote.password = "second".into();
1487 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
1488 assert!(
1489 !Arc::ptr_eq(&first, &relogged),
1490 "new credentials must not keep serving the client signed with the old ones"
1491 );
1492 }
1493}