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 crate::remote::sync::sync_library(
184 &db,
185 &client,
186 false,
187 &cfg.remote.url,
188 &cfg.remote.username,
189 ) {
190 Ok(r) => log::info!(
191 "auto sync: {} artists, {} albums, {} tracks ({} albums failed)",
192 r.artists_synced,
193 r.albums_synced,
194 r.tracks_synced,
195 r.albums_failed
196 ),
197 Err(e) => log::warn!("auto sync failed: {e}"),
198 }
199 on_state(false);
200 }
201
202 match cfg.remote.auto_sync_interval_mins {
203 0 => return,
205 mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
206 }
207 }
208 })
209 .ok()
210}
211
212#[derive(Debug, Clone, Copy, Default)]
214pub struct RebuildSummary {
215 pub tracks: u64,
216 pub albums: u64,
217 pub artists: u64,
218}
219
220pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
231 let count = |sql: &str| -> u64 {
232 db.conn
233 .query_row(sql, [], |r| r.get::<_, i64>(0))
234 .unwrap_or(0) as u64
235 };
236 let summary = RebuildSummary {
237 tracks: count("SELECT COUNT(*) FROM tracks"),
238 albums: count("SELECT COUNT(*) FROM albums"),
239 artists: count("SELECT COUNT(*) FROM artists"),
240 };
241
242 db.conn.execute_batch(
245 "BEGIN;
246 DELETE FROM track_vectors;
247 DELETE FROM lyrics_cache;
248 DELETE FROM play_history;
249 DELETE FROM scan_cache;
250 DELETE FROM tracks_fts;
251 DELETE FROM tracks;
252 DELETE FROM similar_artists;
253 DELETE FROM albums;
254 DELETE FROM artists;
255 COMMIT;",
256 )?;
257 let _ = db.conn.execute_batch("VACUUM");
258 Ok(summary)
259}
260
261pub fn cache_size_bytes(cfg: &Config) -> u64 {
263 walkdir::WalkDir::new(cfg.cache_dir())
264 .into_iter()
265 .filter_map(Result::ok)
266 .filter(|e| e.file_type().is_file())
267 .filter_map(|e| e.metadata().ok())
268 .map(|m| m.len())
269 .sum()
270}
271
272pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
277 let prefix = format!(
278 "{}{}%",
279 folder
280 .to_string_lossy()
281 .trim_end_matches(std::path::MAIN_SEPARATOR),
282 std::path::MAIN_SEPARATOR
283 );
284 db.conn
285 .query_row(
286 "SELECT COUNT(*) FROM tracks WHERE path LIKE ?1",
287 [&prefix],
288 |r| r.get::<_, i64>(0),
289 )
290 .unwrap_or(0) as u64
291}
292
293pub fn tracks_from_server(db: &Database) -> u64 {
295 db.conn
296 .query_row(
297 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
298 [],
299 |r| r.get::<_, i64>(0),
300 )
301 .unwrap_or(0) as u64
302}
303
304pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
317 let prefix = format!(
319 "{}{}%",
320 folder
321 .to_string_lossy()
322 .trim_end_matches(std::path::MAIN_SEPARATOR),
323 std::path::MAIN_SEPARATOR
324 );
325
326 let tx = db.conn.unchecked_transaction()?;
327 tx.execute(
329 "UPDATE tracks SET path = NULL, source = 'remote'
330 WHERE path LIKE ?1 AND remote_id IS NOT NULL",
331 [&prefix],
332 )?;
333
334 let ids: Vec<i64> = {
335 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path LIKE ?1")?;
336 let rows = stmt.query_map([&prefix], |r| r.get(0))?;
337 rows.filter_map(Result::ok).collect()
338 };
339 for id in &ids {
340 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
341 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
342 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
343 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
344 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
345 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
346 }
347 prune_empty_albums_and_artists(&tx)?;
348 tx.commit()?;
349 Ok(ids.len() as u64)
350}
351
352pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
358 let tx = db.conn.unchecked_transaction()?;
359
360 let ids: Vec<i64> = {
361 let mut stmt =
362 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
363 let rows = stmt.query_map([], |r| r.get(0))?;
364 rows.filter_map(Result::ok).collect()
365 };
366 for id in &ids {
367 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
368 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
369 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
370 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
371 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
372 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
373 }
374 tx.execute(
376 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
377 WHERE remote_id IS NOT NULL",
378 [],
379 )?;
380 tx.execute("DELETE FROM similar_artists", [])?;
381 prune_empty_albums_and_artists(&tx)?;
382 tx.commit()?;
383 Ok(ids.len() as u64)
384}
385
386fn prune_empty_albums_and_artists(
388 tx: &rusqlite::Transaction<'_>,
389) -> Result<(), crate::db::connection::DbError> {
390 tx.execute(
391 "DELETE FROM albums WHERE NOT EXISTS
392 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
393 [],
394 )?;
395 tx.execute(
396 "DELETE FROM similar_artists WHERE NOT EXISTS
397 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
398 [],
399 )?;
400 tx.execute(
401 "DELETE FROM artists WHERE NOT EXISTS
402 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
403 AND NOT EXISTS
404 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
405 [],
406 )?;
407 Ok(())
408}
409
410#[derive(Debug, Clone, Copy, Default)]
412pub struct CacheCleared {
413 pub files: u64,
414 pub bytes: u64,
415}
416
417pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
422 let dir = cfg.cache_dir();
423 let mut cleared = CacheCleared::default();
424 for entry in walkdir::WalkDir::new(&dir)
425 .into_iter()
426 .filter_map(Result::ok)
427 .filter(|e| e.file_type().is_file())
428 {
429 if let Ok(meta) = entry.metadata() {
430 cleared.bytes += meta.len();
431 cleared.files += 1;
432 }
433 }
434 let _ = std::fs::remove_dir_all(&dir);
435 let _ = std::fs::create_dir_all(&dir);
436 let _ = queries::clear_cached_paths(&db.conn);
437 cleared
438}
439
440pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
452 let cfg = Config::load().unwrap_or_default();
453 if !cfg.remote.enabled {
454 return;
455 }
456 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
457 log::warn!("not syncing favourite: {} has no remote id", path.display());
458 return;
459 };
460 let Some(client) = subsonic_client(&cfg) else {
461 log::warn!("not syncing favourite: no usable server credentials");
462 return;
463 };
464 std::thread::Builder::new()
465 .name("koan-fav-sync".into())
466 .spawn(move || {
467 let result = if star {
468 client.star(&remote_id)
469 } else {
470 client.unstar(&remote_id)
471 };
472 match result {
473 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
474 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
475 }
476 })
477 .ok();
478}
479
480#[derive(Debug, Default, Clone, Copy)]
482pub struct FavouriteSync {
483 pub pushed: usize,
484 pub imported: usize,
485}
486
487pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
498 let mut out = FavouriteSync::default();
499
500 let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
501 for (_path, remote_id) in &tracks {
502 if client.star(remote_id).is_ok() {
503 out.pushed += 1;
504 }
505 }
506 for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
507 if client.star_album(&remote_id).is_ok() {
508 out.pushed += 1;
509 }
510 }
511 for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
512 {
513 if client.star_artist(&remote_id).is_ok() {
514 out.pushed += 1;
515 }
516 }
517
518 let starred = match client.get_starred_all() {
519 Ok(s) => s,
520 Err(e) => {
521 log::warn!("could not fetch starred items from the server: {e}");
522 return out;
523 }
524 };
525
526 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
527 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
528 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
529 out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
530 out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
531 out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
532 out
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538pub enum FavouriteKind {
539 Track,
540 Album,
541 Artist,
542}
543
544pub fn sync_collection_favourite_to_remote(
549 db: &Database,
550 kind: FavouriteKind,
551 id: i64,
552 star: bool,
553) {
554 let cfg = Config::load().unwrap_or_default();
555 if !cfg.remote.enabled {
556 return;
557 }
558 let remote_id = match kind {
559 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
560 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
561 FavouriteKind::Track => return,
562 };
563 let Ok(Some(remote_id)) = remote_id else {
564 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
565 return;
566 };
567 let Some(client) = subsonic_client(&cfg) else {
568 log::warn!("not syncing favourite: no usable server credentials");
569 return;
570 };
571 std::thread::Builder::new()
572 .name("koan-fav-sync".into())
573 .spawn(move || {
574 let result = match (kind, star) {
575 (FavouriteKind::Album, true) => client.star_album(&remote_id),
576 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
577 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
578 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
579 (FavouriteKind::Track, _) => Ok(()),
580 };
581 match result {
582 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
583 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
584 }
585 })
586 .ok();
587}
588
589#[derive(Debug, thiserror::Error)]
591pub enum SignInError {
592 #[error("the server did not accept those credentials: {0}")]
593 Rejected(#[from] crate::remote::client::SubsonicError),
594 #[error("could not save the password: {0}")]
595 Credentials(#[from] crate::credentials::CredentialError),
596 #[error("could not write the configuration: {0}")]
597 Config(#[from] crate::config::ConfigError),
598}
599
600pub fn set_remote_credentials(
613 url: &str,
614 username: &str,
615 password: &str,
616) -> Result<(), SignInError> {
617 let url = url.trim_end_matches('/');
618 SubsonicClient::new(url, username, password).ping()?;
619 crate::credentials::store_password(url, password)?;
620
621 let mut values = toml::map::Map::new();
622 values.insert("enabled".into(), toml::Value::Boolean(true));
623 values.insert("url".into(), toml::Value::String(url.to_string()));
624 values.insert("username".into(), toml::Value::String(username.to_string()));
625 values.insert("password".into(), toml::Value::String(String::new()));
628 Config::patch_local("remote", &values)?;
629 Ok(())
630}
631
632pub const SUBSONIC_CREDENTIAL_ACCOUNT: &str = "koan-subsonic";
634
635pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
639 if !cfg.subsonic.password.is_empty() {
640 return Some(cfg.subsonic.password.clone());
641 }
642 crate::credentials::get_password(SUBSONIC_CREDENTIAL_ACCOUNT)
643 .ok()
644 .filter(|p| !p.is_empty())
645}
646
647pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
654 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
655 return None;
656 }
657 let password = get_remote_password(cfg)?;
658 Some(SubsonicAuth::new(
659 &cfg.remote.url,
660 &cfg.remote.username,
661 &password,
662 ))
663}
664
665pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
677 let auth = subsonic_auth(cfg)?;
678
679 let mut slot = SUBSONIC_CLIENT.lock();
680 if let Some((cached, client)) = slot.as_ref()
681 && *cached == auth
682 {
683 return Some(client.clone());
684 }
685
686 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
687 *slot = Some((auth, client.clone()));
688 Some(client)
689}
690
691type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
692
693static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
694 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
695
696#[derive(Debug, thiserror::Error)]
705pub enum ShareError {
706 #[error("no remote server is configured")]
707 NoRemote,
708 #[error("none of these tracks are on the server, so a link has nothing to point at")]
709 NothingRemote,
710 #[error("the server refused to share these: {0}")]
711 Server(#[from] crate::remote::client::SubsonicError),
712 #[error(transparent)]
713 Database(#[from] crate::db::connection::DbError),
714}
715
716#[derive(Debug, Clone)]
718pub struct ShareOutcome {
719 pub url: String,
720 pub id: String,
722 pub shared: usize,
724 pub skipped: usize,
726}
727
728pub fn create_share(
737 db: &Database,
738 cfg: &Config,
739 track_ids: &[i64],
740 description: Option<&str>,
741) -> Result<ShareOutcome, ShareError> {
742 let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
743
744 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
746
747 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
748 if shared == 0 {
749 return Err(ShareError::NothingRemote);
750 }
751
752 let one_album = rows
756 .first()
757 .and_then(|f| f.album_id)
758 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
759 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
760
761 let remote_ids: Vec<String> = match one_album {
762 Some(rid) => vec![rid],
763 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
764 };
765
766 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
767 let share = client.create_share(&refs, description)?;
768
769 let url = share
772 .url
773 .clone()
774 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
775
776 Ok(ShareOutcome {
777 url,
778 id: share.id,
779 shared,
780 skipped: track_ids.len().saturating_sub(shared),
781 })
782}
783
784fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
788 let (remote_id, total): (Option<String>, i64) = conn
789 .query_row(
790 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
791 FROM albums al WHERE al.id = ?1",
792 [album_id],
793 |row| Ok((row.get(0)?, row.get(1)?)),
794 )
795 .ok()?;
796 (total == selected as i64).then_some(remote_id).flatten()
797}
798
799pub fn truncate_bytes(s: &str, max: usize) -> &str {
805 if s.len() <= max {
806 return s;
807 }
808 let mut end = max;
809 while end > 0 && !s.is_char_boundary(end) {
810 end -= 1;
811 }
812 &s[..end]
813}
814
815pub fn sanitise_filename(s: &str) -> String {
818 let cleaned: String = s
819 .chars()
820 .map(|c| match c {
821 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
822 _ => c,
823 })
824 .collect::<String>()
825 .trim()
826 .to_string();
827
828 truncate_bytes(&cleaned, 240).trim_end().to_string()
829}
830
831pub fn cache_path_for_track(
834 cache_dir: &Path,
835 track: &queries::TrackRow,
836 album_date: Option<&str>,
837) -> PathBuf {
838 let artist_dir = sanitise_filename(&track.artist_name);
839
840 let year = album_date
841 .and_then(|d| if d.len() >= 4 { Some(&d[..4]) } else { None })
842 .map(|y| format!("({}) ", y))
843 .unwrap_or_default();
844 let codec = track
845 .codec
846 .as_deref()
847 .map(|c| format!(" [{}]", c))
848 .unwrap_or_default();
849 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
850
851 let disc_prefix = match track.disc {
852 Some(d) if d > 1 => format!("{}-", d),
853 _ => String::new(),
854 };
855 let track_num = track
856 .track_number
857 .map(|n| format!("{:02}. ", n))
858 .unwrap_or_default();
859
860 let ext = track
861 .codec
862 .as_deref()
863 .map(|c| c.to_lowercase())
864 .unwrap_or_else(|| "flac".into());
865
866 let filename = sanitise_filename(&format!(
867 "{}{}{} - {}",
868 disc_prefix, track_num, track.artist_name, track.title
869 ));
870
871 cache_dir
872 .join(artist_dir)
873 .join(album_dir)
874 .join(format!("{}.{}", filename, ext))
875}
876
877pub fn resolve_item_path(
884 db: &Database,
885 cfg: &Config,
886 id: i64,
887 track: &queries::TrackRow,
888 album_date: Option<&str>,
889) -> (PathBuf, LoadState) {
890 match queries::resolve_playback_path(&db.conn, id) {
891 Ok(Some(queries::PlaybackSource::Local(p))) => (p, LoadState::Ready),
892 Ok(Some(queries::PlaybackSource::Cached(p))) => {
897 let state = if is_cached_audio(&p) {
898 LoadState::Ready
899 } else {
900 LoadState::Pending
901 };
902 (p, state)
903 }
904 Ok(Some(queries::PlaybackSource::Remote(_))) => {
905 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
906 if dest.exists() && is_cached_audio(&dest) {
907 (dest, LoadState::Ready)
908 } else {
909 (dest, LoadState::Pending)
910 }
911 }
912 _ => {
913 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
915 (dest, LoadState::Pending)
916 }
917 }
918}
919
920pub fn playlist_item_from_track(
922 track: &queries::TrackRow,
923 album_date: Option<&str>,
924 dest: PathBuf,
925 load_state: LoadState,
926) -> PlaylistItem {
927 let year = album_date.and_then(|d| {
928 if d.len() >= 4 {
929 Some(d[..4].to_string())
930 } else {
931 None
932 }
933 });
934 PlaylistItem {
935 id: QueueItemId::new(),
936 db_id: Some(track.id),
937 path: dest,
938 title: track.title.clone(),
939 artist: track.artist_name.clone(),
940 album_artist: track.album_artist_name.clone(),
941 album: track.album_title.clone(),
942 year,
943 codec: track.codec.clone(),
944 track_number: track.track_number.map(|n| n as i64),
945 disc: track.disc.map(|n| n as i64),
946 duration_ms: track.duration_ms.map(|d| d as u64),
947 load_state,
948 }
949}
950
951pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
958 use std::collections::HashMap;
959
960 let cfg = Config::load().unwrap_or_default();
961 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
962
963 tracks
964 .iter()
965 .map(|track| {
966 let album_date = match track.album_id {
967 Some(aid) => album_dates
968 .entry(aid)
969 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
970 .clone(),
971 None => None,
972 };
973 let (path, load_state) =
974 resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
975 playlist_item_from_track(track, album_date.as_deref(), path, load_state)
976 })
977 .collect()
978}
979
980pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
982 let album_date = track
983 .album_id
984 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
985
986 let cfg = Config::load().unwrap_or_default();
987 let (path, load_state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
988
989 let year = album_date.as_deref().and_then(|d| {
990 if d.len() >= 4 {
991 Some(d[..4].to_string())
992 } else {
993 None
994 }
995 });
996
997 PlaylistItem {
998 id: QueueItemId::new(),
999 db_id: Some(track.id),
1000 path,
1001 title: track.title.clone(),
1002 artist: track.artist_name.clone(),
1003 album_artist: track.album_artist_name.clone(),
1004 album: track.album_title.clone(),
1005 year,
1006 codec: track.codec.clone(),
1007 track_number: track.track_number.map(|n| n as i64),
1008 disc: track.disc.map(|n| n as i64),
1009 duration_ms: track.duration_ms.map(|d| d as u64),
1010 load_state,
1011 }
1012}
1013
1014fn is_cached_audio(path: &std::path::Path) -> bool {
1024 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1025 match std::fs::metadata(path) {
1026 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1027 Ok(_) => {
1028 let mut first = [0u8; 1];
1029 match std::fs::File::open(path)
1030 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1031 {
1032 Ok(b) => b != b'{' && b != b'<',
1033 Err(_) => false,
1034 }
1035 }
1036 Err(_) => false,
1037 }
1038}
1039
1040pub fn download_track(
1047 db_id: i64,
1048 queue_id: QueueItemId,
1049 tx: &crossbeam_channel::Sender<PlayerCommand>,
1050 log_buf: &Arc<Mutex<Vec<String>>>,
1051 state: &Arc<SharedPlayerState>,
1052 cfg: &Config,
1053 client: &SubsonicClient,
1054) {
1055 let db = match Database::open_default() {
1056 Ok(db) => db,
1057 Err(e) => {
1058 fail_track(state, tx, queue_id, format!("db error: {}", e));
1059 return;
1060 }
1061 };
1062 let track = match queries::get_track_row(&db.conn, db_id) {
1063 Ok(Some(t)) => t,
1064 _ => {
1065 fail_track(state, tx, queue_id, "track not found".into());
1066 return;
1067 }
1068 };
1069
1070 let remote_id = match &track.remote_id {
1071 Some(rid) => rid.clone(),
1072 None => {
1073 if let Some(ref path) = track.path {
1075 let p = std::path::PathBuf::from(path);
1076 if p.exists() {
1077 state.update_paths(&[(queue_id, p)]);
1078 state.update_load_state(queue_id, LoadState::Ready);
1079 if state.is_cursor(queue_id) {
1080 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1081 }
1082 return;
1083 }
1084 }
1085 fail_track(
1086 state,
1087 tx,
1088 queue_id,
1089 "not in the library folder, and no remote copy to fetch".into(),
1090 );
1091 return;
1092 }
1093 };
1094
1095 if let Some(ref local_path) = track.path {
1097 let p = std::path::PathBuf::from(local_path);
1098 if p.exists() {
1099 log::info!("download_track: local file exists, using {}", p.display());
1100 state.update_paths(&[(queue_id, p)]);
1101 state.update_load_state(queue_id, LoadState::Ready);
1102 if state.is_cursor(queue_id) {
1103 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1104 }
1105 return;
1106 }
1107 }
1108
1109 let album_date: Option<String> = track
1110 .album_id
1111 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1112
1113 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1114
1115 if dest.exists() && !is_cached_audio(&dest) {
1121 log::warn!(
1122 "discarding non-audio cache entry {} (likely a stored server error)",
1123 dest.display()
1124 );
1125 let _ = std::fs::remove_file(&dest);
1126 }
1127 if dest.exists() {
1128 state.update_paths(&[(queue_id, dest)]);
1129 state.update_load_state(queue_id, LoadState::Ready);
1130 if state.is_cursor(queue_id) {
1131 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1132 }
1133 return;
1134 }
1135
1136 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1139
1140 let bytes_written: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1141
1142 let progress_state = state.clone();
1143 let progress_qid = queue_id;
1144 let bytes_written_progress = bytes_written.clone();
1145 let progress_tx = tx.clone();
1146 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1147 let stream_ready_flag = stream_ready_sent.clone();
1148 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1149 bytes_written_progress.store(downloaded, Ordering::Release);
1150 progress_state.update_load_state(
1151 progress_qid,
1152 LoadState::Downloading {
1153 downloaded,
1154 total,
1155 bytes_written: bytes_written_progress.clone(),
1156 },
1157 );
1158 if !stream_ready_flag.load(Ordering::Relaxed)
1159 && downloaded >= crate::player::state::STREAM_THRESHOLD
1160 {
1161 stream_ready_flag.store(true, Ordering::Relaxed);
1162 progress_tx
1163 .send(PlayerCommand::TrackStreamReady(progress_qid))
1164 .ok();
1165 }
1166 });
1167
1168 if let Err(e) = result {
1169 fail_track(state, tx, queue_id, e.to_string());
1170 push_log(log_buf, format!("x {} — {}", track.title, e));
1171 return;
1172 }
1173
1174 state.update_paths(&[(queue_id, dest.clone())]);
1176 state.update_load_state(queue_id, LoadState::Ready);
1177 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1179 log::warn!(
1180 "cached {} but failed to record it ({}) — it will not be evicted",
1181 dest.display(),
1182 e
1183 );
1184 }
1185
1186 push_log(
1187 log_buf,
1188 format!("+ {} — {}", track.title, track.artist_name),
1189 );
1190
1191 if state.is_cursor(queue_id) {
1192 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1193 }
1194}
1195
1196pub(crate) fn fail_track(
1202 state: &Arc<SharedPlayerState>,
1203 tx: &crossbeam_channel::Sender<PlayerCommand>,
1204 queue_id: QueueItemId,
1205 reason: String,
1206) {
1207 state.update_load_state(queue_id, LoadState::Failed(reason));
1208 if state.is_cursor(queue_id) {
1209 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1210 }
1211}
1212
1213fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1216 match log_buf.lock() {
1217 Ok(mut buf) => buf.push(msg),
1218 Err(_) => log::info!("{}", msg),
1219 }
1220}
1221
1222pub fn remote_unavailable(cfg: &Config) -> String {
1229 if !cfg.remote.enabled {
1230 return "no remote server is configured".into();
1231 }
1232 if cfg.remote.url.is_empty() {
1233 return "the remote server has no address".into();
1234 }
1235 match remote_password(cfg).1 {
1236 PasswordSource::Missing => "no password is stored for the remote server".into(),
1237 PasswordSource::Unreadable(why) => {
1238 format!("the remote password is in the keychain but could not be read: {why}")
1239 }
1240 PasswordSource::Keychain | PasswordSource::Config => {
1243 "the remote server could not be reached".into()
1244 }
1245 }
1246}
1247
1248pub fn spawn_downloads(
1258 pending: Vec<(i64, QueueItemId)>,
1259 tx: crossbeam_channel::Sender<PlayerCommand>,
1260 state: Arc<SharedPlayerState>,
1261) {
1262 if pending.is_empty() {
1263 return;
1264 }
1265 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1266}
1267
1268#[cfg(test)]
1269mod rebuild_tests {
1270 use super::*;
1271 use crate::db::queries::sample_meta;
1272
1273 fn test_db() -> Database {
1274 let conn = rusqlite::Connection::open_in_memory().unwrap();
1275 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1276 crate::db::schema::create_tables(&conn).unwrap();
1277 Database { conn }
1278 }
1279
1280 #[test]
1281 fn rebuild_drops_the_index_and_keeps_favourites() {
1282 let db = test_db();
1283 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1284 meta.path = Some("/music/windowlicker.flac".into());
1285 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1286
1287 queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1289 db.conn
1290 .execute(
1291 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1292 VALUES (?1, 'test', 'la la la', 0)",
1293 [track_id],
1294 )
1295 .unwrap();
1296
1297 let summary = rebuild_index(&db).unwrap();
1298 assert_eq!(summary.tracks, 1);
1299 assert_eq!(summary.albums, 1);
1300
1301 let tracks: i64 = db
1302 .conn
1303 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1304 .unwrap();
1305 assert_eq!(tracks, 0, "the index is gone");
1306
1307 let favourites: i64 = db
1308 .conn
1309 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1310 .unwrap();
1311 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1312
1313 let lyrics: i64 = db
1314 .conn
1315 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1316 .unwrap();
1317 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1318 }
1319
1320 #[test]
1321 fn rebuilding_an_empty_library_is_not_an_error() {
1322 let db = test_db();
1323 let summary = rebuild_index(&db).unwrap();
1324 assert_eq!(summary.tracks, 0);
1325 }
1326}
1327
1328#[cfg(test)]
1329mod share_tests {
1330 use super::*;
1331 use crate::db::queries::sample_meta;
1332
1333 fn test_db() -> Database {
1334 let conn = rusqlite::Connection::open_in_memory().unwrap();
1335 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1336 crate::db::schema::create_tables(&conn).unwrap();
1337 Database { conn }
1338 }
1339
1340 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1342 let ids: Vec<i64> = ["One", "Two", "Three"]
1343 .iter()
1344 .enumerate()
1345 .map(|(i, title)| {
1346 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1347 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1348 meta.track_number = Some(i as i32 + 1);
1349 queries::upsert_track(&db.conn, &meta).unwrap()
1350 })
1351 .collect();
1352 let album_id: i64 = db
1353 .conn
1354 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1355 r.get(0)
1356 })
1357 .unwrap();
1358 db.conn
1359 .execute(
1360 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1361 [album_id],
1362 )
1363 .unwrap();
1364 (album_id, ids)
1365 }
1366
1367 #[test]
1368 fn whole_album_collapses_to_the_album_link() {
1369 let db = test_db();
1370 let (album_id, ids) = album_of_three(&db);
1371 assert_eq!(
1372 album_remote_id(&db.conn, album_id, ids.len()),
1373 Some("al-1".into())
1374 );
1375 }
1376
1377 #[test]
1378 fn part_of_an_album_does_not() {
1379 let db = test_db();
1380 let (album_id, _) = album_of_three(&db);
1381 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1384 }
1385
1386 #[test]
1387 fn a_local_only_album_has_no_link_to_collapse_to() {
1388 let db = test_db();
1389 let (album_id, ids) = album_of_three(&db);
1390 db.conn
1391 .execute(
1392 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1393 [album_id],
1394 )
1395 .unwrap();
1396 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1397 }
1398}
1399
1400#[cfg(test)]
1401mod client_cache_tests {
1402 use super::*;
1403
1404 #[test]
1405 fn one_subsonic_client_is_shared_per_credentials() {
1406 crate::config::isolate_config_for_tests();
1407 let mut cfg = Config::default();
1408 cfg.remote.enabled = true;
1409 cfg.remote.url = "https://shared-client.invalid".into();
1410 cfg.remote.username = "koan".into();
1411 cfg.remote.password = "first".into();
1412
1413 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
1414 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
1415 assert!(
1416 Arc::ptr_eq(&first, &again),
1417 "rebuilding drops the connection pool and re-handshakes TLS per request"
1418 );
1419
1420 cfg.remote.password = "second".into();
1421 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
1422 assert!(
1423 !Arc::ptr_eq(&first, &relogged),
1424 "new credentials must not keep serving the client signed with the old ones"
1425 );
1426 }
1427}