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 folder = &crate::index::spelling::on_disk(folder);
268 let (lower, upper) = queries::folder_prefix_range(folder);
269
270 let tx = db.conn.unchecked_transaction()?;
271 tx.execute(
273 "UPDATE tracks SET path = NULL, source = 'remote'
274 WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
275 [&lower, &upper],
276 )?;
277
278 let ids: Vec<i64> = {
279 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
280 let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
281 rows.filter_map(Result::ok).collect()
282 };
283 for id in &ids {
284 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
285 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
286 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
287 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
288 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
289 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
290 }
291 prune_empty_albums_and_artists(&tx)?;
292 tx.commit()?;
293 Ok(ids.len() as u64)
294}
295
296pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
302 let tx = db.conn.unchecked_transaction()?;
303
304 let ids: Vec<i64> = {
305 let mut stmt =
306 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
307 let rows = stmt.query_map([], |r| r.get(0))?;
308 rows.filter_map(Result::ok).collect()
309 };
310 for id in &ids {
311 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
312 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
313 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
314 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
315 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
316 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
317 }
318 tx.execute(
320 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
321 WHERE remote_id IS NOT NULL",
322 [],
323 )?;
324 tx.execute("DELETE FROM similar_artists", [])?;
325 prune_empty_albums_and_artists(&tx)?;
326 tx.commit()?;
327 Ok(ids.len() as u64)
328}
329
330fn prune_empty_albums_and_artists(
332 tx: &rusqlite::Transaction<'_>,
333) -> Result<(), crate::db::connection::DbError> {
334 tx.execute(
335 "DELETE FROM albums WHERE NOT EXISTS
336 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
337 [],
338 )?;
339 tx.execute(
340 "DELETE FROM similar_artists WHERE NOT EXISTS
341 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
342 [],
343 )?;
344 tx.execute(
345 "DELETE FROM artists WHERE NOT EXISTS
346 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
347 AND NOT EXISTS
348 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
349 [],
350 )?;
351 Ok(())
352}
353
354#[derive(Debug, Clone, Copy, Default)]
356pub struct CacheCleared {
357 pub files: u64,
358 pub bytes: u64,
359}
360
361pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
366 let dir = cfg.cache_dir();
367 let mut cleared = CacheCleared::default();
368 for entry in walkdir::WalkDir::new(&dir)
369 .into_iter()
370 .filter_map(Result::ok)
371 .filter(|e| e.file_type().is_file())
372 {
373 if let Ok(meta) = entry.metadata() {
374 cleared.bytes += meta.len();
375 cleared.files += 1;
376 }
377 }
378 let _ = std::fs::remove_dir_all(&dir);
379 let _ = std::fs::create_dir_all(&dir);
380 let _ = queries::clear_cached_paths(&db.conn);
381 cleared
382}
383
384pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
391 let mut cleared = CacheCleared::default();
392 let paths = match queries::cached_paths_for(&db.conn, track_ids) {
393 Ok(paths) => paths,
394 Err(e) => {
395 log::warn!("could not read cached paths: {e}");
396 return cleared;
397 }
398 };
399 for path in &paths {
400 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
401 match std::fs::remove_file(path) {
402 Ok(()) => {
403 cleared.files += 1;
404 cleared.bytes += size;
405 }
406 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
408 Err(e) => log::warn!("could not remove {path}: {e}"),
409 }
410 }
411 if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
412 log::warn!("removed downloads but failed to forget them ({e})");
413 }
414 cleared
415}
416
417pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
430 let mut swept = CacheCleared::default();
431 for entry in walkdir::WalkDir::new(cfg.cache_dir())
432 .into_iter()
433 .filter_map(Result::ok)
434 .filter(|e| e.file_type().is_file())
435 .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
436 {
437 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
438 match std::fs::remove_file(entry.path()) {
439 Ok(()) => {
440 swept.files += 1;
441 swept.bytes += size;
442 }
443 Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
444 }
445 }
446 if swept.files > 0 {
447 log::info!(
448 "swept {} unfinished download(s), {} bytes",
449 swept.files,
450 swept.bytes
451 );
452 }
453 swept
454}
455
456pub fn requeue_cleared_downloads(
463 state: &Arc<SharedPlayerState>,
464 tx: &crossbeam_channel::Sender<PlayerCommand>,
465) {
466 let stale = state.reset_items_with_missing_files();
467 if stale.is_empty() {
468 return;
469 }
470 log::info!(
471 "{} queued tracks lost their copy — fetching again",
472 stale.len()
473 );
474 spawn_downloads(stale, tx.clone(), state.clone());
475}
476
477pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
489 let cfg = Config::load().unwrap_or_default();
490 if !cfg.remote.enabled {
491 return;
492 }
493 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
494 log::warn!("not syncing favourite: {} has no remote id", path.display());
495 return;
496 };
497 let Some(client) = subsonic_client(&cfg) else {
498 log::warn!("not syncing favourite: no usable server credentials");
499 return;
500 };
501 std::thread::Builder::new()
502 .name("koan-fav-sync".into())
503 .spawn(move || {
504 let result = if star {
505 client.star(&remote_id)
506 } else {
507 client.unstar(&remote_id)
508 };
509 match result {
510 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
511 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
512 }
513 })
514 .ok();
515}
516
517#[derive(Debug, Default)]
519pub struct FullSync {
520 pub library: crate::remote::sync::SyncResult,
521 pub favourites: FavouriteSync,
522 pub playlists: crate::playlists::PlaylistSync,
523}
524
525pub fn sync_remote(
536 db: &Database,
537 client: &SubsonicClient,
538 full: bool,
539 url: &str,
540 username: &str,
541) -> Result<FullSync, crate::remote::sync::SyncError> {
542 let library = crate::remote::sync::sync_library(db, client, full, url, username)?;
543 Ok(FullSync {
544 library,
545 favourites: reconcile_favourites(db, client),
546 playlists: crate::playlists::reconcile_playlists(db, client, username),
547 })
548}
549
550#[derive(Debug, Default, Clone, Copy)]
552pub struct FavouriteSync {
553 pub pushed: usize,
554 pub imported: usize,
555}
556
557pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
568 let mut out = FavouriteSync::default();
569
570 let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
571 for (_path, remote_id) in &tracks {
572 if client.star(remote_id).is_ok() {
573 out.pushed += 1;
574 }
575 }
576 for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
577 if client.star_album(&remote_id).is_ok() {
578 out.pushed += 1;
579 }
580 }
581 for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
582 {
583 if client.star_artist(&remote_id).is_ok() {
584 out.pushed += 1;
585 }
586 }
587
588 let starred = match client.get_starred_all() {
589 Ok(s) => s,
590 Err(e) => {
591 log::warn!("could not fetch starred items from the server: {e}");
592 return out;
593 }
594 };
595
596 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
597 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
598 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
599 out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
600 out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
601 out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
602 out
603}
604
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum FavouriteKind {
609 Track,
610 Album,
611 Artist,
612}
613
614pub fn sync_collection_favourite_to_remote(
619 db: &Database,
620 kind: FavouriteKind,
621 id: i64,
622 star: bool,
623) {
624 let cfg = Config::load().unwrap_or_default();
625 if !cfg.remote.enabled {
626 return;
627 }
628 let remote_id = match kind {
629 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
630 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
631 FavouriteKind::Track => return,
632 };
633 let Ok(Some(remote_id)) = remote_id else {
634 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
635 return;
636 };
637 let Some(client) = subsonic_client(&cfg) else {
638 log::warn!("not syncing favourite: no usable server credentials");
639 return;
640 };
641 std::thread::Builder::new()
642 .name("koan-fav-sync".into())
643 .spawn(move || {
644 let result = match (kind, star) {
645 (FavouriteKind::Album, true) => client.star_album(&remote_id),
646 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
647 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
648 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
649 (FavouriteKind::Track, _) => Ok(()),
650 };
651 match result {
652 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
653 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
654 }
655 })
656 .ok();
657}
658
659#[derive(Debug, thiserror::Error)]
661pub enum SignInError {
662 #[error("the server did not accept those credentials: {0}")]
663 Rejected(#[from] crate::remote::client::SubsonicError),
664 #[error("could not write the configuration: {0}")]
665 Config(#[from] crate::config::ConfigError),
666}
667
668pub fn set_remote_credentials(
681 url: &str,
682 username: &str,
683 password: &str,
684) -> Result<(), SignInError> {
685 let url = url.trim_end_matches('/');
686 SubsonicClient::new(url, username, password).ping()?;
687
688 Config::persist(|cfg| {
689 cfg.remote.enabled = true;
690 cfg.remote.url = url.to_string();
691 cfg.remote.username = username.to_string();
692 cfg.remote.password = password.to_string();
693 })?;
694 Ok(())
695}
696
697pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
701 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
702}
703
704pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
711 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
712 return None;
713 }
714 let password = get_remote_password(cfg)?;
715 Some(SubsonicAuth::new(
716 &cfg.remote.url,
717 &cfg.remote.username,
718 &password,
719 ))
720}
721
722pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
734 let auth = subsonic_auth(cfg)?;
735
736 let mut slot = SUBSONIC_CLIENT.lock();
737 if let Some((cached, client)) = slot.as_ref()
738 && *cached == auth
739 {
740 return Some(client.clone());
741 }
742
743 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
744 *slot = Some((auth, client.clone()));
745 Some(client)
746}
747
748type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
749
750static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
751 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
752
753#[derive(Debug, thiserror::Error)]
762pub enum ShareError {
763 #[error("no remote server is configured")]
764 NoRemote,
765 #[error("none of these tracks are on the server, so a link has nothing to point at")]
766 NothingRemote,
767 #[error("the server refused to share these: {0}")]
768 Server(#[from] crate::remote::client::SubsonicError),
769 #[error(transparent)]
770 Database(#[from] crate::db::connection::DbError),
771}
772
773#[derive(Debug, Clone)]
775pub struct ShareOutcome {
776 pub url: String,
777 pub id: String,
779 pub shared: usize,
781 pub skipped: usize,
783}
784
785pub fn create_share(
794 db: &Database,
795 cfg: &Config,
796 track_ids: &[i64],
797 description: Option<&str>,
798) -> Result<ShareOutcome, ShareError> {
799 let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
800
801 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
803
804 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
805 if shared == 0 {
806 return Err(ShareError::NothingRemote);
807 }
808
809 let one_album = rows
813 .first()
814 .and_then(|f| f.album_id)
815 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
816 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
817
818 let remote_ids: Vec<String> = match one_album {
819 Some(rid) => vec![rid],
820 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
821 };
822
823 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
824 let share = client.create_share(&refs, description)?;
825
826 let url = share
829 .url
830 .clone()
831 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
832
833 Ok(ShareOutcome {
834 url,
835 id: share.id,
836 shared,
837 skipped: track_ids.len().saturating_sub(shared),
838 })
839}
840
841fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
845 let (remote_id, total): (Option<String>, i64) = conn
846 .query_row(
847 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
848 FROM albums al WHERE al.id = ?1",
849 [album_id],
850 |row| Ok((row.get(0)?, row.get(1)?)),
851 )
852 .ok()?;
853 (total == selected as i64).then_some(remote_id).flatten()
854}
855
856pub fn shuffle<T>(items: &mut [T]) {
865 let mut seed = [0u8; 8];
866 if getrandom::fill(&mut seed).is_err() {
867 return; }
869 let mut state = u64::from_le_bytes(seed) | 1;
870 for i in (1..items.len()).rev() {
871 state ^= state << 13;
873 state ^= state >> 7;
874 state ^= state << 17;
875 items.swap(i, (state % (i as u64 + 1)) as usize);
876 }
877}
878
879pub fn truncate_bytes(s: &str, max: usize) -> &str {
881 if s.len() <= max {
882 return s;
883 }
884 let mut end = max;
885 while end > 0 && !s.is_char_boundary(end) {
886 end -= 1;
887 }
888 &s[..end]
889}
890
891pub fn sanitise_filename(s: &str) -> String {
894 let cleaned: String = s
895 .chars()
896 .map(|c| match c {
897 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
898 _ => c,
899 })
900 .collect::<String>()
901 .trim()
902 .to_string();
903
904 truncate_bytes(&cleaned, 240).trim_end().to_string()
905}
906
907pub fn year_of(date: &str) -> Option<&str> {
910 date.get(..4)
911}
912
913pub fn cache_path_for_track(
916 cache_dir: &Path,
917 track: &queries::TrackRow,
918 album_date: Option<&str>,
919) -> PathBuf {
920 let artist_dir = sanitise_filename(&track.artist_name);
921
922 let year = album_date
923 .and_then(year_of)
924 .map(|y| format!("({}) ", y))
925 .unwrap_or_default();
926 let codec = track
927 .codec
928 .as_deref()
929 .map(|c| format!(" [{}]", c))
930 .unwrap_or_default();
931 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
932
933 let disc_prefix = match track.disc {
934 Some(d) if d > 1 => format!("{}-", d),
935 _ => String::new(),
936 };
937 let track_num = track
938 .track_number
939 .map(|n| format!("{:02}. ", n))
940 .unwrap_or_default();
941
942 let ext = track
943 .codec
944 .as_deref()
945 .map(|c| c.to_lowercase())
946 .unwrap_or_else(|| "flac".into());
947
948 let filename = sanitise_filename(&format!(
949 "{}{}{} - {}",
950 disc_prefix, track_num, track.artist_name, track.title
951 ));
952
953 cache_dir
954 .join(artist_dir)
955 .join(album_dir)
956 .join(format!("{}.{}", filename, ext))
957}
958
959pub fn resolve_item_path(
967 db: &Database,
968 cfg: &Config,
969 id: i64,
970 track: &queries::TrackRow,
971 album_date: Option<&str>,
972) -> (PathBuf, ItemState) {
973 match queries::resolve_playback_path(&db.conn, id) {
974 Ok(Some(queries::PlaybackSource::Local(p))) => (p, ItemState::Ready),
975 Ok(Some(queries::PlaybackSource::Cached(p))) => {
980 let state = if is_cached_audio(&p) {
981 ItemState::Ready
982 } else {
983 ItemState::Pending
984 };
985 (p, state)
986 }
987 Ok(Some(queries::PlaybackSource::Remote(_))) => {
988 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
989 if dest.exists() && is_cached_audio(&dest) {
990 (dest, ItemState::Ready)
991 } else {
992 (dest, ItemState::Pending)
993 }
994 }
995 _ => {
996 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
998 (dest, ItemState::Pending)
999 }
1000 }
1001}
1002
1003pub fn playlist_item_from_track(
1005 track: &queries::TrackRow,
1006 album_date: Option<&str>,
1007 dest: PathBuf,
1008 state: ItemState,
1009) -> PlaylistItem {
1010 let year = album_date.and_then(year_of).map(str::to_string);
1011 PlaylistItem {
1012 playlist_entry_id: None,
1013 id: QueueItemId::new(),
1014 db_id: Some(track.id),
1015 path: dest,
1016 title: track.title.clone(),
1017 artist: track.artist_name.clone(),
1018 album_artist: track.album_artist_name.clone(),
1019 album: track.album_title.clone(),
1020 year,
1021 codec: track.codec.clone(),
1022 track_number: track.track_number.map(|n| n as i64),
1023 disc: track.disc.map(|n| n as i64),
1024 duration_ms: track.duration_ms.map(|d| d as u64),
1025 state,
1026 }
1027}
1028
1029pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1036 use std::collections::HashMap;
1037
1038 let cfg = Config::load().unwrap_or_default();
1039 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
1040
1041 tracks
1042 .iter()
1043 .map(|track| {
1044 let album_date = match track.album_id {
1045 Some(aid) => album_dates
1046 .entry(aid)
1047 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
1048 .clone(),
1049 None => None,
1050 };
1051 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1052 playlist_item_from_track(track, album_date.as_deref(), path, state)
1053 })
1054 .collect()
1055}
1056
1057pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
1059 let album_date = track
1060 .album_id
1061 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1062
1063 let cfg = Config::load().unwrap_or_default();
1064 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1065
1066 let year = album_date.as_deref().and_then(year_of).map(str::to_string);
1067
1068 PlaylistItem {
1069 playlist_entry_id: None,
1070 id: QueueItemId::new(),
1071 db_id: Some(track.id),
1072 path,
1073 title: track.title.clone(),
1074 artist: track.artist_name.clone(),
1075 album_artist: track.album_artist_name.clone(),
1076 album: track.album_title.clone(),
1077 year,
1078 codec: track.codec.clone(),
1079 track_number: track.track_number.map(|n| n as i64),
1080 disc: track.disc.map(|n| n as i64),
1081 duration_ms: track.duration_ms.map(|d| d as u64),
1082 state,
1083 }
1084}
1085
1086fn is_cached_audio(path: &std::path::Path) -> bool {
1096 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1097 match std::fs::metadata(path) {
1098 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1099 Ok(_) => {
1100 let mut first = [0u8; 1];
1101 match std::fs::File::open(path)
1102 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1103 {
1104 Ok(b) => b != b'{' && b != b'<',
1105 Err(_) => false,
1106 }
1107 }
1108 Err(_) => false,
1109 }
1110}
1111
1112pub fn download_track(
1119 db_id: i64,
1120 queue_id: QueueItemId,
1121 tx: &crossbeam_channel::Sender<PlayerCommand>,
1122 log_buf: &Arc<Mutex<Vec<String>>>,
1123 state: &Arc<SharedPlayerState>,
1124 cfg: &Config,
1125 client: &SubsonicClient,
1126) {
1127 let db = match crate::db::pool::shared().get() {
1132 Ok(db) => db,
1133 Err(e) => {
1134 fail_track(state, tx, queue_id, format!("db error: {}", e));
1135 return;
1136 }
1137 };
1138 let track = match queries::get_track_row(&db.conn, db_id) {
1139 Ok(Some(t)) => t,
1140 _ => {
1141 fail_track(state, tx, queue_id, "track not found".into());
1142 return;
1143 }
1144 };
1145
1146 let remote_id = match &track.remote_id {
1147 Some(rid) => rid.clone(),
1148 None => {
1149 if let Some(ref path) = track.path {
1151 let p = std::path::PathBuf::from(path);
1152 if p.exists() {
1153 state.update_paths(&[(queue_id, p)]);
1154 state.update_item_state(queue_id, ItemState::Ready);
1155 if state.is_cursor(queue_id) {
1156 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1157 }
1158 return;
1159 }
1160 }
1161 fail_track(
1162 state,
1163 tx,
1164 queue_id,
1165 "not in the library folder, and no remote copy to fetch".into(),
1166 );
1167 return;
1168 }
1169 };
1170
1171 if let Some(ref local_path) = track.path {
1173 let p = std::path::PathBuf::from(local_path);
1174 if p.exists() {
1175 log::info!("download_track: local file exists, using {}", p.display());
1176 state.update_paths(&[(queue_id, p)]);
1177 state.update_item_state(queue_id, ItemState::Ready);
1178 if state.is_cursor(queue_id) {
1179 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1180 }
1181 return;
1182 }
1183 }
1184
1185 let album_date: Option<String> = track
1186 .album_id
1187 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1188
1189 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1190
1191 if dest.exists() && !is_cached_audio(&dest) {
1197 log::warn!(
1198 "discarding non-audio cache entry {} (likely a stored server error)",
1199 dest.display()
1200 );
1201 let _ = std::fs::remove_file(&dest);
1202 }
1203 if dest.exists() {
1204 state.update_paths(&[(queue_id, dest)]);
1205 state.update_item_state(queue_id, ItemState::Ready);
1206 if state.is_cursor(queue_id) {
1207 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1208 }
1209 return;
1210 }
1211
1212 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1215
1216 let bytes_written = crate::remote::downloads::ByteFeed::new();
1217
1218 let store = crate::remote::downloads::store();
1221 store.queued(crate::remote::downloads::Download {
1222 id: queue_id,
1223 track_id: db_id,
1224 title: track.title.clone(),
1225 artist: track.artist_name.clone(),
1226 source: crate::remote::download::part_path(&dest),
1227 dest: dest.clone(),
1228 total: 0,
1229 written: bytes_written.clone(),
1230 state: crate::remote::downloads::DownloadState::Queued,
1231 bytes_per_second: 0,
1232 });
1233
1234 let progress_qid = queue_id;
1235 let bytes_written_progress = bytes_written.clone();
1236 let progress_tx = tx.clone();
1237 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1238 let stream_ready_flag = stream_ready_sent.clone();
1239 let announced_total = AtomicU64::new(u64::MAX);
1241 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1242 bytes_written_progress.set(downloaded);
1243 store.progressed();
1246 if announced_total.swap(total, Ordering::Relaxed) != total {
1247 store.started(progress_qid, total, bytes_written_progress.clone());
1250 }
1251 if !stream_ready_flag.load(Ordering::Relaxed)
1252 && downloaded >= crate::player::state::STREAM_THRESHOLD
1253 {
1254 stream_ready_flag.store(true, Ordering::Relaxed);
1255 progress_tx
1256 .send(PlayerCommand::TrackStreamReady(progress_qid))
1257 .ok();
1258 }
1259 });
1260
1261 if let Err(e) = result {
1266 store.failed(queue_id, e.to_string());
1267 fail_track(state, tx, queue_id, e.to_string());
1268 bytes_written.done();
1269 push_log(log_buf, format!("x {} — {}", track.title, e));
1270 return;
1271 }
1272 store.finished(queue_id);
1273
1274 state.update_paths(&[(queue_id, dest.clone())]);
1275 state.update_item_state(queue_id, ItemState::Ready);
1276 bytes_written.done();
1277 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1279 log::warn!(
1280 "cached {} but failed to record it ({}) — it will not be evicted",
1281 dest.display(),
1282 e
1283 );
1284 }
1285
1286 push_log(
1287 log_buf,
1288 format!("+ {} — {}", track.title, track.artist_name),
1289 );
1290
1291 if state.is_cursor(queue_id) {
1292 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1293 }
1294}
1295
1296pub(crate) fn fail_track(
1302 state: &Arc<SharedPlayerState>,
1303 tx: &crossbeam_channel::Sender<PlayerCommand>,
1304 queue_id: QueueItemId,
1305 reason: String,
1306) {
1307 state.update_item_state(queue_id, ItemState::Failed(reason));
1308 if state.is_cursor(queue_id) {
1309 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1310 }
1311}
1312
1313fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1316 match log_buf.lock() {
1317 Ok(mut buf) => buf.push(msg),
1318 Err(_) => log::info!("{}", msg),
1319 }
1320}
1321
1322pub fn remote_unavailable(cfg: &Config) -> String {
1328 if !cfg.remote.enabled {
1329 return "no remote server is configured".into();
1330 }
1331 if cfg.remote.url.is_empty() {
1332 return "the remote server has no address".into();
1333 }
1334 if get_remote_password(cfg).is_none() {
1335 return "no password is stored for the remote server".into();
1336 }
1337 "the remote server could not be reached".into()
1340}
1341
1342pub fn spawn_downloads(
1352 pending: Vec<(i64, QueueItemId)>,
1353 tx: crossbeam_channel::Sender<PlayerCommand>,
1354 state: Arc<SharedPlayerState>,
1355) {
1356 if pending.is_empty() {
1357 return;
1358 }
1359 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1360}
1361
1362#[cfg(test)]
1363mod year_tests {
1364 use super::year_of;
1365
1366 #[test]
1367 fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1368 assert_eq!(year_of("1997-05-21"), Some("1997"));
1369 assert_eq!(year_of("199"), None);
1370 assert_eq!(year_of("1997"), None);
1372 }
1373}
1374
1375#[cfg(test)]
1376mod rebuild_tests {
1377 use super::*;
1378 use crate::db::queries::sample_meta;
1379
1380 fn test_db() -> Database {
1381 let conn = rusqlite::Connection::open_in_memory().unwrap();
1382 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1383 crate::db::schema::create_tables(&conn).unwrap();
1384 Database { conn }
1385 }
1386
1387 #[test]
1388 fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1389 let dir = tempfile::tempdir().unwrap();
1390 let db = test_db();
1391
1392 let mut cached = Vec::new();
1393 for name in ["one", "two"] {
1394 let mut meta = sample_meta(name, "Artist", "Album");
1395 meta.source = "remote".into();
1396 meta.path = None;
1397 meta.remote_id = Some(name.into());
1398 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1399 let file = dir.path().join(format!("{name}.opus"));
1400 std::fs::write(&file, vec![0u8; 2048]).unwrap();
1401 queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1402 cached.push((id, file));
1403 }
1404
1405 let cleared = clear_downloads_for(&db, &[cached[0].0]);
1406 assert_eq!(cleared.files, 1);
1407 assert_eq!(cleared.bytes, 2048);
1408 assert!(!cached[0].1.exists(), "the copy asked for is gone");
1409 assert!(cached[1].1.exists(), "the other one is untouched");
1410
1411 assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1414 assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1415 assert!(
1416 queries::cached_paths_for(&db.conn, &[cached[0].0])
1417 .unwrap()
1418 .is_empty()
1419 );
1420 }
1421
1422 #[test]
1423 fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1424 let db = test_db();
1425 let mut meta = sample_meta("ghost", "Artist", "Album");
1426 meta.source = "remote".into();
1427 meta.path = None;
1428 meta.remote_id = Some("ghost".into());
1429 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1430 queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1431
1432 let cleared = clear_downloads_for(&db, &[id]);
1433 assert_eq!(cleared.files, 0, "nothing was there to remove");
1434 assert!(
1436 queries::cached_paths_for(&db.conn, &[id])
1437 .unwrap()
1438 .is_empty()
1439 );
1440 }
1441
1442 #[test]
1443 fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1444 let dir = tempfile::tempdir().unwrap();
1445 let cache = dir.path().join("cache");
1446 std::fs::create_dir_all(cache.join("Artist")).unwrap();
1447
1448 let finished = cache.join("Artist/whole.opus");
1449 let half = cache.join("Artist/half.opus.part");
1450 std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1451 std::fs::write(&half, vec![0u8; 4096]).unwrap();
1452
1453 let cfg = Config {
1454 remote: crate::config::RemoteConfig {
1455 cache_dir: Some(cache.clone()),
1456 ..Default::default()
1457 },
1458 ..Default::default()
1459 };
1460
1461 let swept = sweep_partial_downloads(&cfg);
1462 assert_eq!(swept.files, 1);
1463 assert_eq!(swept.bytes, 4096);
1464 assert!(!half.exists(), "the unfinished one is gone");
1465 assert!(finished.exists(), "a downloaded track is not touched");
1466 }
1467
1468 #[test]
1469 fn sweeping_an_empty_cache_is_not_an_error() {
1470 let dir = tempfile::tempdir().unwrap();
1471 let cfg = Config {
1472 remote: crate::config::RemoteConfig {
1473 cache_dir: Some(dir.path().join("nothing-here")),
1474 ..Default::default()
1475 },
1476 ..Default::default()
1477 };
1478 assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1479 }
1480
1481 #[test]
1482 fn clearing_no_tracks_does_nothing() {
1483 let db = test_db();
1484 assert_eq!(clear_downloads_for(&db, &[]).files, 0);
1485 }
1486
1487 #[test]
1488 fn rebuild_drops_the_index_and_keeps_favourites() {
1489 let db = test_db();
1490 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1491 meta.path = Some("/music/windowlicker.flac".into());
1492 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1493
1494 queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1496 db.conn
1497 .execute(
1498 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1499 VALUES (?1, 'test', 'la la la', 0)",
1500 [track_id],
1501 )
1502 .unwrap();
1503
1504 let summary = rebuild_index(&db).unwrap();
1505 assert_eq!(summary.tracks, 1);
1506 assert_eq!(summary.albums, 1);
1507
1508 let tracks: i64 = db
1509 .conn
1510 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1511 .unwrap();
1512 assert_eq!(tracks, 0, "the index is gone");
1513
1514 let favourites: i64 = db
1515 .conn
1516 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1517 .unwrap();
1518 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1519
1520 let lyrics: i64 = db
1521 .conn
1522 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1523 .unwrap();
1524 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1525 }
1526
1527 #[test]
1528 fn rebuilding_an_empty_library_is_not_an_error() {
1529 let db = test_db();
1530 let summary = rebuild_index(&db).unwrap();
1531 assert_eq!(summary.tracks, 0);
1532 }
1533}
1534
1535#[cfg(test)]
1536mod share_tests {
1537 use super::*;
1538 use crate::db::queries::sample_meta;
1539
1540 fn test_db() -> Database {
1541 let conn = rusqlite::Connection::open_in_memory().unwrap();
1542 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1543 crate::db::schema::create_tables(&conn).unwrap();
1544 Database { conn }
1545 }
1546
1547 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1549 let ids: Vec<i64> = ["One", "Two", "Three"]
1550 .iter()
1551 .enumerate()
1552 .map(|(i, title)| {
1553 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1554 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1555 meta.track_number = Some(i as i32 + 1);
1556 queries::upsert_track(&db.conn, &meta).unwrap()
1557 })
1558 .collect();
1559 let album_id: i64 = db
1560 .conn
1561 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1562 r.get(0)
1563 })
1564 .unwrap();
1565 db.conn
1566 .execute(
1567 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1568 [album_id],
1569 )
1570 .unwrap();
1571 (album_id, ids)
1572 }
1573
1574 #[test]
1575 fn whole_album_collapses_to_the_album_link() {
1576 let db = test_db();
1577 let (album_id, ids) = album_of_three(&db);
1578 assert_eq!(
1579 album_remote_id(&db.conn, album_id, ids.len()),
1580 Some("al-1".into())
1581 );
1582 }
1583
1584 #[test]
1585 fn part_of_an_album_does_not() {
1586 let db = test_db();
1587 let (album_id, _) = album_of_three(&db);
1588 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1591 }
1592
1593 #[test]
1594 fn a_local_only_album_has_no_link_to_collapse_to() {
1595 let db = test_db();
1596 let (album_id, ids) = album_of_three(&db);
1597 db.conn
1598 .execute(
1599 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1600 [album_id],
1601 )
1602 .unwrap();
1603 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1604 }
1605}
1606
1607#[cfg(test)]
1608mod client_cache_tests {
1609 use super::*;
1610
1611 #[test]
1612 fn one_subsonic_client_is_shared_per_credentials() {
1613 crate::config::isolate_config_for_tests();
1614 let mut cfg = Config::default();
1615 cfg.remote.enabled = true;
1616 cfg.remote.url = "https://shared-client.invalid".into();
1617 cfg.remote.username = "koan".into();
1618 cfg.remote.password = "first".into();
1619
1620 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
1621 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
1622 assert!(
1623 Arc::ptr_eq(&first, &again),
1624 "rebuilding drops the connection pool and re-handshakes TLS per request"
1625 );
1626
1627 cfg.remote.password = "second".into();
1628 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
1629 assert!(
1630 !Arc::ptr_eq(&first, &relogged),
1631 "new credentials must not keep serving the client signed with the old ones"
1632 );
1633 }
1634}