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
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 prefix = format!(
233 "{}{}%",
234 folder
235 .to_string_lossy()
236 .trim_end_matches(std::path::MAIN_SEPARATOR),
237 std::path::MAIN_SEPARATOR
238 );
239 db.conn
240 .query_row(
241 "SELECT COUNT(*) FROM tracks WHERE path LIKE ?1",
242 [&prefix],
243 |r| r.get::<_, i64>(0),
244 )
245 .unwrap_or(0) as u64
246}
247
248pub fn tracks_from_server(db: &Database) -> u64 {
250 db.conn
251 .query_row(
252 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
253 [],
254 |r| r.get::<_, i64>(0),
255 )
256 .unwrap_or(0) as u64
257}
258
259pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
272 let prefix = format!(
274 "{}{}%",
275 folder
276 .to_string_lossy()
277 .trim_end_matches(std::path::MAIN_SEPARATOR),
278 std::path::MAIN_SEPARATOR
279 );
280
281 let tx = db.conn.unchecked_transaction()?;
282 tx.execute(
284 "UPDATE tracks SET path = NULL, source = 'remote'
285 WHERE path LIKE ?1 AND remote_id IS NOT NULL",
286 [&prefix],
287 )?;
288
289 let ids: Vec<i64> = {
290 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path LIKE ?1")?;
291 let rows = stmt.query_map([&prefix], |r| r.get(0))?;
292 rows.filter_map(Result::ok).collect()
293 };
294 for id in &ids {
295 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
296 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
297 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
298 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
299 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
300 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
301 }
302 prune_empty_albums_and_artists(&tx)?;
303 tx.commit()?;
304 Ok(ids.len() as u64)
305}
306
307pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
313 let tx = db.conn.unchecked_transaction()?;
314
315 let ids: Vec<i64> = {
316 let mut stmt =
317 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
318 let rows = stmt.query_map([], |r| r.get(0))?;
319 rows.filter_map(Result::ok).collect()
320 };
321 for id in &ids {
322 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
323 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
324 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
325 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
326 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
327 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
328 }
329 tx.execute(
331 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
332 WHERE remote_id IS NOT NULL",
333 [],
334 )?;
335 tx.execute("DELETE FROM similar_artists", [])?;
336 prune_empty_albums_and_artists(&tx)?;
337 tx.commit()?;
338 Ok(ids.len() as u64)
339}
340
341fn prune_empty_albums_and_artists(
343 tx: &rusqlite::Transaction<'_>,
344) -> Result<(), crate::db::connection::DbError> {
345 tx.execute(
346 "DELETE FROM albums WHERE NOT EXISTS
347 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
348 [],
349 )?;
350 tx.execute(
351 "DELETE FROM similar_artists WHERE NOT EXISTS
352 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
353 [],
354 )?;
355 tx.execute(
356 "DELETE FROM artists WHERE NOT EXISTS
357 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
358 AND NOT EXISTS
359 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
360 [],
361 )?;
362 Ok(())
363}
364
365#[derive(Debug, Clone, Copy, Default)]
367pub struct CacheCleared {
368 pub files: u64,
369 pub bytes: u64,
370}
371
372pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
377 let dir = cfg.cache_dir();
378 let mut cleared = CacheCleared::default();
379 for entry in walkdir::WalkDir::new(&dir)
380 .into_iter()
381 .filter_map(Result::ok)
382 .filter(|e| e.file_type().is_file())
383 {
384 if let Ok(meta) = entry.metadata() {
385 cleared.bytes += meta.len();
386 cleared.files += 1;
387 }
388 }
389 let _ = std::fs::remove_dir_all(&dir);
390 let _ = std::fs::create_dir_all(&dir);
391 let _ = queries::clear_cached_paths(&db.conn);
392 cleared
393}
394
395pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
407 let cfg = Config::load().unwrap_or_default();
408 if !cfg.remote.enabled {
409 return;
410 }
411 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
412 log::warn!("not syncing favourite: {} has no remote id", path.display());
413 return;
414 };
415 let Some(client) = subsonic_client(&cfg) else {
416 log::warn!("not syncing favourite: no usable server credentials");
417 return;
418 };
419 std::thread::Builder::new()
420 .name("koan-fav-sync".into())
421 .spawn(move || {
422 let result = if star {
423 client.star(&remote_id)
424 } else {
425 client.unstar(&remote_id)
426 };
427 match result {
428 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
429 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
430 }
431 })
432 .ok();
433}
434
435#[derive(Debug, Default)]
437pub struct FullSync {
438 pub library: crate::remote::sync::SyncResult,
439 pub favourites: FavouriteSync,
440 pub playlists: crate::playlists::PlaylistSync,
441}
442
443pub fn sync_remote(
454 db: &Database,
455 client: &SubsonicClient,
456 full: bool,
457 url: &str,
458 username: &str,
459) -> Result<FullSync, crate::remote::sync::SyncError> {
460 let library = crate::remote::sync::sync_library(db, client, full, url, username)?;
461 Ok(FullSync {
462 library,
463 favourites: reconcile_favourites(db, client),
464 playlists: crate::playlists::reconcile_playlists(db, client, username),
465 })
466}
467
468#[derive(Debug, Default, Clone, Copy)]
470pub struct FavouriteSync {
471 pub pushed: usize,
472 pub imported: usize,
473}
474
475pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
486 let mut out = FavouriteSync::default();
487
488 let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
489 for (_path, remote_id) in &tracks {
490 if client.star(remote_id).is_ok() {
491 out.pushed += 1;
492 }
493 }
494 for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
495 if client.star_album(&remote_id).is_ok() {
496 out.pushed += 1;
497 }
498 }
499 for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
500 {
501 if client.star_artist(&remote_id).is_ok() {
502 out.pushed += 1;
503 }
504 }
505
506 let starred = match client.get_starred_all() {
507 Ok(s) => s,
508 Err(e) => {
509 log::warn!("could not fetch starred items from the server: {e}");
510 return out;
511 }
512 };
513
514 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
515 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
516 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
517 out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
518 out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
519 out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
520 out
521}
522
523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526pub enum FavouriteKind {
527 Track,
528 Album,
529 Artist,
530}
531
532pub fn sync_collection_favourite_to_remote(
537 db: &Database,
538 kind: FavouriteKind,
539 id: i64,
540 star: bool,
541) {
542 let cfg = Config::load().unwrap_or_default();
543 if !cfg.remote.enabled {
544 return;
545 }
546 let remote_id = match kind {
547 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
548 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
549 FavouriteKind::Track => return,
550 };
551 let Ok(Some(remote_id)) = remote_id else {
552 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
553 return;
554 };
555 let Some(client) = subsonic_client(&cfg) else {
556 log::warn!("not syncing favourite: no usable server credentials");
557 return;
558 };
559 std::thread::Builder::new()
560 .name("koan-fav-sync".into())
561 .spawn(move || {
562 let result = match (kind, star) {
563 (FavouriteKind::Album, true) => client.star_album(&remote_id),
564 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
565 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
566 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
567 (FavouriteKind::Track, _) => Ok(()),
568 };
569 match result {
570 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
571 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
572 }
573 })
574 .ok();
575}
576
577#[derive(Debug, thiserror::Error)]
579pub enum SignInError {
580 #[error("the server did not accept those credentials: {0}")]
581 Rejected(#[from] crate::remote::client::SubsonicError),
582 #[error("could not write the configuration: {0}")]
583 Config(#[from] crate::config::ConfigError),
584}
585
586pub fn set_remote_credentials(
599 url: &str,
600 username: &str,
601 password: &str,
602) -> Result<(), SignInError> {
603 let url = url.trim_end_matches('/');
604 SubsonicClient::new(url, username, password).ping()?;
605
606 Config::persist(|cfg| {
607 cfg.remote.enabled = true;
608 cfg.remote.url = url.to_string();
609 cfg.remote.username = username.to_string();
610 cfg.remote.password = password.to_string();
611 })?;
612 Ok(())
613}
614
615pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
619 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
620}
621
622pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
629 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
630 return None;
631 }
632 let password = get_remote_password(cfg)?;
633 Some(SubsonicAuth::new(
634 &cfg.remote.url,
635 &cfg.remote.username,
636 &password,
637 ))
638}
639
640pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
652 let auth = subsonic_auth(cfg)?;
653
654 let mut slot = SUBSONIC_CLIENT.lock();
655 if let Some((cached, client)) = slot.as_ref()
656 && *cached == auth
657 {
658 return Some(client.clone());
659 }
660
661 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
662 *slot = Some((auth, client.clone()));
663 Some(client)
664}
665
666type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
667
668static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
669 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
670
671#[derive(Debug, thiserror::Error)]
680pub enum ShareError {
681 #[error("no remote server is configured")]
682 NoRemote,
683 #[error("none of these tracks are on the server, so a link has nothing to point at")]
684 NothingRemote,
685 #[error("the server refused to share these: {0}")]
686 Server(#[from] crate::remote::client::SubsonicError),
687 #[error(transparent)]
688 Database(#[from] crate::db::connection::DbError),
689}
690
691#[derive(Debug, Clone)]
693pub struct ShareOutcome {
694 pub url: String,
695 pub id: String,
697 pub shared: usize,
699 pub skipped: usize,
701}
702
703pub fn create_share(
712 db: &Database,
713 cfg: &Config,
714 track_ids: &[i64],
715 description: Option<&str>,
716) -> Result<ShareOutcome, ShareError> {
717 let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
718
719 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
721
722 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
723 if shared == 0 {
724 return Err(ShareError::NothingRemote);
725 }
726
727 let one_album = rows
731 .first()
732 .and_then(|f| f.album_id)
733 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
734 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
735
736 let remote_ids: Vec<String> = match one_album {
737 Some(rid) => vec![rid],
738 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
739 };
740
741 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
742 let share = client.create_share(&refs, description)?;
743
744 let url = share
747 .url
748 .clone()
749 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
750
751 Ok(ShareOutcome {
752 url,
753 id: share.id,
754 shared,
755 skipped: track_ids.len().saturating_sub(shared),
756 })
757}
758
759fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
763 let (remote_id, total): (Option<String>, i64) = conn
764 .query_row(
765 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
766 FROM albums al WHERE al.id = ?1",
767 [album_id],
768 |row| Ok((row.get(0)?, row.get(1)?)),
769 )
770 .ok()?;
771 (total == selected as i64).then_some(remote_id).flatten()
772}
773
774pub fn shuffle<T>(items: &mut [T]) {
783 let mut seed = [0u8; 8];
784 if getrandom::fill(&mut seed).is_err() {
785 return; }
787 let mut state = u64::from_le_bytes(seed) | 1;
788 for i in (1..items.len()).rev() {
789 state ^= state << 13;
791 state ^= state >> 7;
792 state ^= state << 17;
793 items.swap(i, (state % (i as u64 + 1)) as usize);
794 }
795}
796
797pub fn truncate_bytes(s: &str, max: usize) -> &str {
799 if s.len() <= max {
800 return s;
801 }
802 let mut end = max;
803 while end > 0 && !s.is_char_boundary(end) {
804 end -= 1;
805 }
806 &s[..end]
807}
808
809pub fn sanitise_filename(s: &str) -> String {
812 let cleaned: String = s
813 .chars()
814 .map(|c| match c {
815 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
816 _ => c,
817 })
818 .collect::<String>()
819 .trim()
820 .to_string();
821
822 truncate_bytes(&cleaned, 240).trim_end().to_string()
823}
824
825pub fn cache_path_for_track(
828 cache_dir: &Path,
829 track: &queries::TrackRow,
830 album_date: Option<&str>,
831) -> PathBuf {
832 let artist_dir = sanitise_filename(&track.artist_name);
833
834 let year = album_date
835 .and_then(|d| if d.len() >= 4 { Some(&d[..4]) } else { None })
836 .map(|y| format!("({}) ", y))
837 .unwrap_or_default();
838 let codec = track
839 .codec
840 .as_deref()
841 .map(|c| format!(" [{}]", c))
842 .unwrap_or_default();
843 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
844
845 let disc_prefix = match track.disc {
846 Some(d) if d > 1 => format!("{}-", d),
847 _ => String::new(),
848 };
849 let track_num = track
850 .track_number
851 .map(|n| format!("{:02}. ", n))
852 .unwrap_or_default();
853
854 let ext = track
855 .codec
856 .as_deref()
857 .map(|c| c.to_lowercase())
858 .unwrap_or_else(|| "flac".into());
859
860 let filename = sanitise_filename(&format!(
861 "{}{}{} - {}",
862 disc_prefix, track_num, track.artist_name, track.title
863 ));
864
865 cache_dir
866 .join(artist_dir)
867 .join(album_dir)
868 .join(format!("{}.{}", filename, ext))
869}
870
871pub fn resolve_item_path(
878 db: &Database,
879 cfg: &Config,
880 id: i64,
881 track: &queries::TrackRow,
882 album_date: Option<&str>,
883) -> (PathBuf, LoadState) {
884 match queries::resolve_playback_path(&db.conn, id) {
885 Ok(Some(queries::PlaybackSource::Local(p))) => (p, LoadState::Ready),
886 Ok(Some(queries::PlaybackSource::Cached(p))) => {
891 let state = if is_cached_audio(&p) {
892 LoadState::Ready
893 } else {
894 LoadState::Pending
895 };
896 (p, state)
897 }
898 Ok(Some(queries::PlaybackSource::Remote(_))) => {
899 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
900 if dest.exists() && is_cached_audio(&dest) {
901 (dest, LoadState::Ready)
902 } else {
903 (dest, LoadState::Pending)
904 }
905 }
906 _ => {
907 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
909 (dest, LoadState::Pending)
910 }
911 }
912}
913
914pub fn playlist_item_from_track(
916 track: &queries::TrackRow,
917 album_date: Option<&str>,
918 dest: PathBuf,
919 load_state: LoadState,
920) -> PlaylistItem {
921 let year = album_date.and_then(|d| {
922 if d.len() >= 4 {
923 Some(d[..4].to_string())
924 } else {
925 None
926 }
927 });
928 PlaylistItem {
929 playlist_entry_id: None,
930 id: QueueItemId::new(),
931 db_id: Some(track.id),
932 path: dest,
933 title: track.title.clone(),
934 artist: track.artist_name.clone(),
935 album_artist: track.album_artist_name.clone(),
936 album: track.album_title.clone(),
937 year,
938 codec: track.codec.clone(),
939 track_number: track.track_number.map(|n| n as i64),
940 disc: track.disc.map(|n| n as i64),
941 duration_ms: track.duration_ms.map(|d| d as u64),
942 load_state,
943 }
944}
945
946pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
953 use std::collections::HashMap;
954
955 let cfg = Config::load().unwrap_or_default();
956 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
957
958 tracks
959 .iter()
960 .map(|track| {
961 let album_date = match track.album_id {
962 Some(aid) => album_dates
963 .entry(aid)
964 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
965 .clone(),
966 None => None,
967 };
968 let (path, load_state) =
969 resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
970 playlist_item_from_track(track, album_date.as_deref(), path, load_state)
971 })
972 .collect()
973}
974
975pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
977 let album_date = track
978 .album_id
979 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
980
981 let cfg = Config::load().unwrap_or_default();
982 let (path, load_state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
983
984 let year = album_date.as_deref().and_then(|d| {
985 if d.len() >= 4 {
986 Some(d[..4].to_string())
987 } else {
988 None
989 }
990 });
991
992 PlaylistItem {
993 playlist_entry_id: None,
994 id: QueueItemId::new(),
995 db_id: Some(track.id),
996 path,
997 title: track.title.clone(),
998 artist: track.artist_name.clone(),
999 album_artist: track.album_artist_name.clone(),
1000 album: track.album_title.clone(),
1001 year,
1002 codec: track.codec.clone(),
1003 track_number: track.track_number.map(|n| n as i64),
1004 disc: track.disc.map(|n| n as i64),
1005 duration_ms: track.duration_ms.map(|d| d as u64),
1006 load_state,
1007 }
1008}
1009
1010fn is_cached_audio(path: &std::path::Path) -> bool {
1020 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1021 match std::fs::metadata(path) {
1022 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1023 Ok(_) => {
1024 let mut first = [0u8; 1];
1025 match std::fs::File::open(path)
1026 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1027 {
1028 Ok(b) => b != b'{' && b != b'<',
1029 Err(_) => false,
1030 }
1031 }
1032 Err(_) => false,
1033 }
1034}
1035
1036pub fn download_track(
1043 db_id: i64,
1044 queue_id: QueueItemId,
1045 tx: &crossbeam_channel::Sender<PlayerCommand>,
1046 log_buf: &Arc<Mutex<Vec<String>>>,
1047 state: &Arc<SharedPlayerState>,
1048 cfg: &Config,
1049 client: &SubsonicClient,
1050) {
1051 let db = match Database::open_default() {
1052 Ok(db) => db,
1053 Err(e) => {
1054 fail_track(state, tx, queue_id, format!("db error: {}", e));
1055 return;
1056 }
1057 };
1058 let track = match queries::get_track_row(&db.conn, db_id) {
1059 Ok(Some(t)) => t,
1060 _ => {
1061 fail_track(state, tx, queue_id, "track not found".into());
1062 return;
1063 }
1064 };
1065
1066 let remote_id = match &track.remote_id {
1067 Some(rid) => rid.clone(),
1068 None => {
1069 if let Some(ref path) = track.path {
1071 let p = std::path::PathBuf::from(path);
1072 if p.exists() {
1073 state.update_paths(&[(queue_id, p)]);
1074 state.update_load_state(queue_id, LoadState::Ready);
1075 if state.is_cursor(queue_id) {
1076 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1077 }
1078 return;
1079 }
1080 }
1081 fail_track(
1082 state,
1083 tx,
1084 queue_id,
1085 "not in the library folder, and no remote copy to fetch".into(),
1086 );
1087 return;
1088 }
1089 };
1090
1091 if let Some(ref local_path) = track.path {
1093 let p = std::path::PathBuf::from(local_path);
1094 if p.exists() {
1095 log::info!("download_track: local file exists, using {}", p.display());
1096 state.update_paths(&[(queue_id, p)]);
1097 state.update_load_state(queue_id, LoadState::Ready);
1098 if state.is_cursor(queue_id) {
1099 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1100 }
1101 return;
1102 }
1103 }
1104
1105 let album_date: Option<String> = track
1106 .album_id
1107 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1108
1109 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1110
1111 if dest.exists() && !is_cached_audio(&dest) {
1117 log::warn!(
1118 "discarding non-audio cache entry {} (likely a stored server error)",
1119 dest.display()
1120 );
1121 let _ = std::fs::remove_file(&dest);
1122 }
1123 if dest.exists() {
1124 state.update_paths(&[(queue_id, dest)]);
1125 state.update_load_state(queue_id, LoadState::Ready);
1126 if state.is_cursor(queue_id) {
1127 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1128 }
1129 return;
1130 }
1131
1132 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1135
1136 let bytes_written: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1137
1138 let progress_state = state.clone();
1139 let progress_qid = queue_id;
1140 let bytes_written_progress = bytes_written.clone();
1141 let progress_tx = tx.clone();
1142 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1143 let stream_ready_flag = stream_ready_sent.clone();
1144 let announced_total = AtomicU64::new(u64::MAX);
1152 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1153 bytes_written_progress.store(downloaded, Ordering::Release);
1154 if announced_total.swap(total, Ordering::Relaxed) != total {
1155 progress_state.update_load_state(
1156 progress_qid,
1157 LoadState::Downloading {
1158 total,
1159 bytes_written: bytes_written_progress.clone(),
1160 },
1161 );
1162 }
1163 if !stream_ready_flag.load(Ordering::Relaxed)
1164 && downloaded >= crate::player::state::STREAM_THRESHOLD
1165 {
1166 stream_ready_flag.store(true, Ordering::Relaxed);
1167 progress_tx
1168 .send(PlayerCommand::TrackStreamReady(progress_qid))
1169 .ok();
1170 }
1171 });
1172
1173 if let Err(e) = result {
1174 fail_track(state, tx, queue_id, e.to_string());
1175 push_log(log_buf, format!("x {} — {}", track.title, e));
1176 return;
1177 }
1178
1179 state.update_paths(&[(queue_id, dest.clone())]);
1181 state.update_load_state(queue_id, LoadState::Ready);
1182 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1184 log::warn!(
1185 "cached {} but failed to record it ({}) — it will not be evicted",
1186 dest.display(),
1187 e
1188 );
1189 }
1190
1191 push_log(
1192 log_buf,
1193 format!("+ {} — {}", track.title, track.artist_name),
1194 );
1195
1196 if state.is_cursor(queue_id) {
1197 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1198 }
1199}
1200
1201pub(crate) fn fail_track(
1207 state: &Arc<SharedPlayerState>,
1208 tx: &crossbeam_channel::Sender<PlayerCommand>,
1209 queue_id: QueueItemId,
1210 reason: String,
1211) {
1212 state.update_load_state(queue_id, LoadState::Failed(reason));
1213 if state.is_cursor(queue_id) {
1214 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1215 }
1216}
1217
1218fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1221 match log_buf.lock() {
1222 Ok(mut buf) => buf.push(msg),
1223 Err(_) => log::info!("{}", msg),
1224 }
1225}
1226
1227pub fn remote_unavailable(cfg: &Config) -> String {
1233 if !cfg.remote.enabled {
1234 return "no remote server is configured".into();
1235 }
1236 if cfg.remote.url.is_empty() {
1237 return "the remote server has no address".into();
1238 }
1239 if get_remote_password(cfg).is_none() {
1240 return "no password is stored for the remote server".into();
1241 }
1242 "the remote server could not be reached".into()
1245}
1246
1247pub fn spawn_downloads(
1257 pending: Vec<(i64, QueueItemId)>,
1258 tx: crossbeam_channel::Sender<PlayerCommand>,
1259 state: Arc<SharedPlayerState>,
1260) {
1261 if pending.is_empty() {
1262 return;
1263 }
1264 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1265}
1266
1267#[cfg(test)]
1268mod rebuild_tests {
1269 use super::*;
1270 use crate::db::queries::sample_meta;
1271
1272 fn test_db() -> Database {
1273 let conn = rusqlite::Connection::open_in_memory().unwrap();
1274 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1275 crate::db::schema::create_tables(&conn).unwrap();
1276 Database { conn }
1277 }
1278
1279 #[test]
1280 fn rebuild_drops_the_index_and_keeps_favourites() {
1281 let db = test_db();
1282 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1283 meta.path = Some("/music/windowlicker.flac".into());
1284 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1285
1286 queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1288 db.conn
1289 .execute(
1290 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1291 VALUES (?1, 'test', 'la la la', 0)",
1292 [track_id],
1293 )
1294 .unwrap();
1295
1296 let summary = rebuild_index(&db).unwrap();
1297 assert_eq!(summary.tracks, 1);
1298 assert_eq!(summary.albums, 1);
1299
1300 let tracks: i64 = db
1301 .conn
1302 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1303 .unwrap();
1304 assert_eq!(tracks, 0, "the index is gone");
1305
1306 let favourites: i64 = db
1307 .conn
1308 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1309 .unwrap();
1310 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1311
1312 let lyrics: i64 = db
1313 .conn
1314 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1315 .unwrap();
1316 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1317 }
1318
1319 #[test]
1320 fn rebuilding_an_empty_library_is_not_an_error() {
1321 let db = test_db();
1322 let summary = rebuild_index(&db).unwrap();
1323 assert_eq!(summary.tracks, 0);
1324 }
1325}
1326
1327#[cfg(test)]
1328mod share_tests {
1329 use super::*;
1330 use crate::db::queries::sample_meta;
1331
1332 fn test_db() -> Database {
1333 let conn = rusqlite::Connection::open_in_memory().unwrap();
1334 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1335 crate::db::schema::create_tables(&conn).unwrap();
1336 Database { conn }
1337 }
1338
1339 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1341 let ids: Vec<i64> = ["One", "Two", "Three"]
1342 .iter()
1343 .enumerate()
1344 .map(|(i, title)| {
1345 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1346 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1347 meta.track_number = Some(i as i32 + 1);
1348 queries::upsert_track(&db.conn, &meta).unwrap()
1349 })
1350 .collect();
1351 let album_id: i64 = db
1352 .conn
1353 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1354 r.get(0)
1355 })
1356 .unwrap();
1357 db.conn
1358 .execute(
1359 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1360 [album_id],
1361 )
1362 .unwrap();
1363 (album_id, ids)
1364 }
1365
1366 #[test]
1367 fn whole_album_collapses_to_the_album_link() {
1368 let db = test_db();
1369 let (album_id, ids) = album_of_three(&db);
1370 assert_eq!(
1371 album_remote_id(&db.conn, album_id, ids.len()),
1372 Some("al-1".into())
1373 );
1374 }
1375
1376 #[test]
1377 fn part_of_an_album_does_not() {
1378 let db = test_db();
1379 let (album_id, _) = album_of_three(&db);
1380 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1383 }
1384
1385 #[test]
1386 fn a_local_only_album_has_no_link_to_collapse_to() {
1387 let db = test_db();
1388 let (album_id, ids) = album_of_three(&db);
1389 db.conn
1390 .execute(
1391 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1392 [album_id],
1393 )
1394 .unwrap();
1395 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1396 }
1397}
1398
1399#[cfg(test)]
1400mod client_cache_tests {
1401 use super::*;
1402
1403 #[test]
1404 fn one_subsonic_client_is_shared_per_credentials() {
1405 crate::config::isolate_config_for_tests();
1406 let mut cfg = Config::default();
1407 cfg.remote.enabled = true;
1408 cfg.remote.url = "https://shared-client.invalid".into();
1409 cfg.remote.username = "koan".into();
1410 cfg.remote.password = "first".into();
1411
1412 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
1413 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
1414 assert!(
1415 Arc::ptr_eq(&first, &again),
1416 "rebuilding drops the connection pool and re-handshakes TLS per request"
1417 );
1418
1419 cfg.remote.password = "second".into();
1420 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
1421 assert!(
1422 !Arc::ptr_eq(&first, &relogged),
1423 "new credentials must not keep serving the client signed with the old ones"
1424 );
1425 }
1426}