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> {
27 if let Ok(pw) = crate::credentials::get_password(&cfg.remote.url)
28 && !pw.is_empty()
29 {
30 return Some(pw);
31 }
32 if !cfg.remote.password.is_empty() {
33 return Some(cfg.remote.password.clone());
34 }
35 None
36}
37
38pub fn spawn_library_watch(
51 db_path: std::path::PathBuf,
52 on_state: impl Fn(bool) + Send + Sync + 'static,
53) -> Option<std::thread::JoinHandle<()>> {
54 use notify::{RecursiveMode, Watcher};
55
56 std::thread::Builder::new()
57 .name("koan-library-watch".into())
58 .spawn(move || {
59 let scan_now = |reason: &str| {
60 let cfg = Config::load().unwrap_or_default();
61 if cfg.library.folders.is_empty() {
62 return;
63 }
64 let Ok(db) = Database::open(&db_path) else {
65 return;
66 };
67 on_state(true);
68 let result = crate::index::scanner::full_scan(
69 &db,
70 &cfg.library.folders,
71 crate::index::scanner::ScanOptions::default(),
72 None,
73 );
74 on_state(false);
75 log::info!(
76 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
77 result.added,
78 result.updated,
79 result.removed,
80 result.skipped
81 );
82 };
83
84 std::thread::sleep(std::time::Duration::from_secs(3));
86 scan_now("startup");
87
88 let (tx, rx) = std::sync::mpsc::channel();
89 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
90 let _ = tx.send(event);
91 }) else {
92 log::warn!("could not watch the library folders");
93 return;
94 };
95
96 let cfg = Config::load().unwrap_or_default();
97 for folder in &cfg.library.folders {
98 if let Err(e) = watcher.watch(folder, RecursiveMode::Recursive) {
99 log::warn!("could not watch {}: {e}", folder.display());
100 }
101 }
102
103 const SETTLE: std::time::Duration = std::time::Duration::from_secs(5);
106 while let Ok(first) = rx.recv() {
107 if first.is_err() {
108 continue;
109 }
110 while rx.recv_timeout(SETTLE).is_ok() {}
111 scan_now("watched change");
112 }
113 })
114 .ok()
115}
116
117pub fn spawn_auto_sync(
130 db_path: std::path::PathBuf,
131 on_state: impl Fn(bool) + Send + 'static,
132) -> Option<std::thread::JoinHandle<()>> {
133 std::thread::Builder::new()
134 .name("koan-auto-sync".into())
135 .spawn(move || {
136 std::thread::sleep(std::time::Duration::from_secs(5));
137 loop {
138 let cfg = Config::load().unwrap_or_default();
139 if !cfg.remote.enabled || !cfg.remote.auto_sync {
140 std::thread::sleep(std::time::Duration::from_secs(60));
143 continue;
144 }
145
146 if let Some(client) = subsonic_client(&cfg)
147 && let Ok(db) = Database::open(&db_path)
148 {
149 on_state(true);
150 match crate::remote::sync::sync_library(
151 &db,
152 &client,
153 false,
154 &cfg.remote.url,
155 &cfg.remote.username,
156 ) {
157 Ok(r) => log::info!(
158 "auto sync: {} artists, {} albums, {} tracks ({} albums failed)",
159 r.artists_synced,
160 r.albums_synced,
161 r.tracks_synced,
162 r.albums_failed
163 ),
164 Err(e) => log::warn!("auto sync failed: {e}"),
165 }
166 on_state(false);
167 }
168
169 match cfg.remote.auto_sync_interval_mins {
170 0 => return,
172 mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
173 }
174 }
175 })
176 .ok()
177}
178
179#[derive(Debug, Clone, Copy, Default)]
181pub struct RebuildSummary {
182 pub tracks: u64,
183 pub albums: u64,
184 pub artists: u64,
185}
186
187pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
198 let count = |sql: &str| -> u64 {
199 db.conn
200 .query_row(sql, [], |r| r.get::<_, i64>(0))
201 .unwrap_or(0) as u64
202 };
203 let summary = RebuildSummary {
204 tracks: count("SELECT COUNT(*) FROM tracks"),
205 albums: count("SELECT COUNT(*) FROM albums"),
206 artists: count("SELECT COUNT(*) FROM artists"),
207 };
208
209 db.conn.execute_batch(
212 "BEGIN;
213 DELETE FROM track_vectors;
214 DELETE FROM lyrics_cache;
215 DELETE FROM play_history;
216 DELETE FROM scan_cache;
217 DELETE FROM tracks_fts;
218 DELETE FROM tracks;
219 DELETE FROM similar_artists;
220 DELETE FROM albums;
221 DELETE FROM artists;
222 COMMIT;",
223 )?;
224 let _ = db.conn.execute_batch("VACUUM");
225 Ok(summary)
226}
227
228pub fn cache_size_bytes(cfg: &Config) -> u64 {
230 walkdir::WalkDir::new(cfg.cache_dir())
231 .into_iter()
232 .filter_map(Result::ok)
233 .filter(|e| e.file_type().is_file())
234 .filter_map(|e| e.metadata().ok())
235 .map(|m| m.len())
236 .sum()
237}
238
239pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
244 let prefix = format!(
245 "{}{}%",
246 folder
247 .to_string_lossy()
248 .trim_end_matches(std::path::MAIN_SEPARATOR),
249 std::path::MAIN_SEPARATOR
250 );
251 db.conn
252 .query_row(
253 "SELECT COUNT(*) FROM tracks WHERE path LIKE ?1",
254 [&prefix],
255 |r| r.get::<_, i64>(0),
256 )
257 .unwrap_or(0) as u64
258}
259
260pub fn tracks_from_server(db: &Database) -> u64 {
262 db.conn
263 .query_row(
264 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
265 [],
266 |r| r.get::<_, i64>(0),
267 )
268 .unwrap_or(0) as u64
269}
270
271pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
284 let prefix = format!(
286 "{}{}%",
287 folder
288 .to_string_lossy()
289 .trim_end_matches(std::path::MAIN_SEPARATOR),
290 std::path::MAIN_SEPARATOR
291 );
292
293 let tx = db.conn.unchecked_transaction()?;
294 tx.execute(
296 "UPDATE tracks SET path = NULL, source = 'remote'
297 WHERE path LIKE ?1 AND remote_id IS NOT NULL",
298 [&prefix],
299 )?;
300
301 let ids: Vec<i64> = {
302 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path LIKE ?1")?;
303 let rows = stmt.query_map([&prefix], |r| r.get(0))?;
304 rows.filter_map(Result::ok).collect()
305 };
306 for id in &ids {
307 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
308 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
309 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
310 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
311 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
312 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
313 }
314 prune_empty_albums_and_artists(&tx)?;
315 tx.commit()?;
316 Ok(ids.len() as u64)
317}
318
319pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
325 let tx = db.conn.unchecked_transaction()?;
326
327 let ids: Vec<i64> = {
328 let mut stmt =
329 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
330 let rows = stmt.query_map([], |r| r.get(0))?;
331 rows.filter_map(Result::ok).collect()
332 };
333 for id in &ids {
334 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
335 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
336 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
337 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
338 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
339 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
340 }
341 tx.execute(
343 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
344 WHERE remote_id IS NOT NULL",
345 [],
346 )?;
347 tx.execute("DELETE FROM similar_artists", [])?;
348 prune_empty_albums_and_artists(&tx)?;
349 tx.commit()?;
350 Ok(ids.len() as u64)
351}
352
353fn prune_empty_albums_and_artists(
355 tx: &rusqlite::Transaction<'_>,
356) -> Result<(), crate::db::connection::DbError> {
357 tx.execute(
358 "DELETE FROM albums WHERE NOT EXISTS
359 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
360 [],
361 )?;
362 tx.execute(
363 "DELETE FROM similar_artists WHERE NOT EXISTS
364 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
365 [],
366 )?;
367 tx.execute(
368 "DELETE FROM artists WHERE NOT EXISTS
369 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
370 AND NOT EXISTS
371 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
372 [],
373 )?;
374 Ok(())
375}
376
377#[derive(Debug, Clone, Copy, Default)]
379pub struct CacheCleared {
380 pub files: u64,
381 pub bytes: u64,
382}
383
384pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
389 let dir = cfg.cache_dir();
390 let mut cleared = CacheCleared::default();
391 for entry in walkdir::WalkDir::new(&dir)
392 .into_iter()
393 .filter_map(Result::ok)
394 .filter(|e| e.file_type().is_file())
395 {
396 if let Ok(meta) = entry.metadata() {
397 cleared.bytes += meta.len();
398 cleared.files += 1;
399 }
400 }
401 let _ = std::fs::remove_dir_all(&dir);
402 let _ = std::fs::create_dir_all(&dir);
403 let _ = queries::clear_cached_paths(&db.conn);
404 cleared
405}
406
407pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
419 let cfg = Config::load().unwrap_or_default();
420 if !cfg.remote.enabled {
421 return;
422 }
423 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
424 log::warn!("not syncing favourite: {} has no remote id", path.display());
425 return;
426 };
427 let Some(client) = subsonic_client(&cfg) else {
428 log::warn!("not syncing favourite: no usable server credentials");
429 return;
430 };
431 std::thread::Builder::new()
432 .name("koan-fav-sync".into())
433 .spawn(move || {
434 let result = if star {
435 client.star(&remote_id)
436 } else {
437 client.unstar(&remote_id)
438 };
439 match result {
440 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
441 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
442 }
443 })
444 .ok();
445}
446
447#[derive(Debug, Default, Clone, Copy)]
449pub struct FavouriteSync {
450 pub pushed: usize,
451 pub imported: usize,
452}
453
454pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
465 let mut out = FavouriteSync::default();
466
467 let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
468 for (_path, remote_id) in &tracks {
469 if client.star(remote_id).is_ok() {
470 out.pushed += 1;
471 }
472 }
473 for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
474 if client.star_album(&remote_id).is_ok() {
475 out.pushed += 1;
476 }
477 }
478 for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
479 {
480 if client.star_artist(&remote_id).is_ok() {
481 out.pushed += 1;
482 }
483 }
484
485 let starred = match client.get_starred_all() {
486 Ok(s) => s,
487 Err(e) => {
488 log::warn!("could not fetch starred items from the server: {e}");
489 return out;
490 }
491 };
492
493 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
494 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
495 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
496 out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
497 out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
498 out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
499 out
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
505pub enum FavouriteKind {
506 Track,
507 Album,
508 Artist,
509}
510
511pub fn sync_collection_favourite_to_remote(
516 db: &Database,
517 kind: FavouriteKind,
518 id: i64,
519 star: bool,
520) {
521 let cfg = Config::load().unwrap_or_default();
522 if !cfg.remote.enabled {
523 return;
524 }
525 let remote_id = match kind {
526 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
527 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
528 FavouriteKind::Track => return,
529 };
530 let Ok(Some(remote_id)) = remote_id else {
531 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
532 return;
533 };
534 let Some(client) = subsonic_client(&cfg) else {
535 log::warn!("not syncing favourite: no usable server credentials");
536 return;
537 };
538 std::thread::Builder::new()
539 .name("koan-fav-sync".into())
540 .spawn(move || {
541 let result = match (kind, star) {
542 (FavouriteKind::Album, true) => client.star_album(&remote_id),
543 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
544 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
545 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
546 (FavouriteKind::Track, _) => Ok(()),
547 };
548 match result {
549 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
550 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
551 }
552 })
553 .ok();
554}
555
556#[derive(Debug, thiserror::Error)]
558pub enum SignInError {
559 #[error("the server did not accept those credentials: {0}")]
560 Rejected(#[from] crate::remote::client::SubsonicError),
561 #[error("could not save the password: {0}")]
562 Credentials(#[from] crate::credentials::CredentialError),
563 #[error("could not write the configuration: {0}")]
564 Config(#[from] crate::config::ConfigError),
565}
566
567pub fn set_remote_credentials(
580 url: &str,
581 username: &str,
582 password: &str,
583) -> Result<(), SignInError> {
584 let url = url.trim_end_matches('/');
585 SubsonicClient::new(url, username, password).ping()?;
586 crate::credentials::store_password(url, password)?;
587
588 let mut values = toml::map::Map::new();
589 values.insert("enabled".into(), toml::Value::Boolean(true));
590 values.insert("url".into(), toml::Value::String(url.to_string()));
591 values.insert("username".into(), toml::Value::String(username.to_string()));
592 values.insert("password".into(), toml::Value::String(String::new()));
595 Config::patch_local("remote", &values)?;
596 Ok(())
597}
598
599pub const SUBSONIC_CREDENTIAL_ACCOUNT: &str = "koan-subsonic";
601
602pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
606 if !cfg.subsonic.password.is_empty() {
607 return Some(cfg.subsonic.password.clone());
608 }
609 crate::credentials::get_password(SUBSONIC_CREDENTIAL_ACCOUNT)
610 .ok()
611 .filter(|p| !p.is_empty())
612}
613
614pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
621 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
622 return None;
623 }
624 let password = get_remote_password(cfg)?;
625 Some(SubsonicAuth::new(
626 &cfg.remote.url,
627 &cfg.remote.username,
628 &password,
629 ))
630}
631
632pub fn subsonic_client(cfg: &Config) -> Option<SubsonicClient> {
634 subsonic_auth(cfg).map(SubsonicClient::from_auth)
635}
636
637#[derive(Debug, thiserror::Error)]
646pub enum ShareError {
647 #[error("no remote server is configured")]
648 NoRemote,
649 #[error("none of these tracks are on the server, so a link has nothing to point at")]
650 NothingRemote,
651 #[error("the server refused to share these: {0}")]
652 Server(#[from] crate::remote::client::SubsonicError),
653 #[error(transparent)]
654 Database(#[from] crate::db::connection::DbError),
655}
656
657#[derive(Debug, Clone)]
659pub struct ShareOutcome {
660 pub url: String,
661 pub id: String,
663 pub shared: usize,
665 pub skipped: usize,
667}
668
669pub fn create_share(
678 db: &Database,
679 cfg: &Config,
680 track_ids: &[i64],
681 description: Option<&str>,
682) -> Result<ShareOutcome, ShareError> {
683 let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
684
685 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
687
688 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
689 if shared == 0 {
690 return Err(ShareError::NothingRemote);
691 }
692
693 let one_album = rows
697 .first()
698 .and_then(|f| f.album_id)
699 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
700 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
701
702 let remote_ids: Vec<String> = match one_album {
703 Some(rid) => vec![rid],
704 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
705 };
706
707 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
708 let share = client.create_share(&refs, description)?;
709
710 let url = share
713 .url
714 .clone()
715 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
716
717 Ok(ShareOutcome {
718 url,
719 id: share.id,
720 shared,
721 skipped: track_ids.len().saturating_sub(shared),
722 })
723}
724
725fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
729 let (remote_id, total): (Option<String>, i64) = conn
730 .query_row(
731 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
732 FROM albums al WHERE al.id = ?1",
733 [album_id],
734 |row| Ok((row.get(0)?, row.get(1)?)),
735 )
736 .ok()?;
737 (total == selected as i64).then_some(remote_id).flatten()
738}
739
740pub fn truncate_bytes(s: &str, max: usize) -> &str {
746 if s.len() <= max {
747 return s;
748 }
749 let mut end = max;
750 while end > 0 && !s.is_char_boundary(end) {
751 end -= 1;
752 }
753 &s[..end]
754}
755
756pub fn sanitise_filename(s: &str) -> String {
759 let cleaned: String = s
760 .chars()
761 .map(|c| match c {
762 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
763 _ => c,
764 })
765 .collect::<String>()
766 .trim()
767 .to_string();
768
769 truncate_bytes(&cleaned, 240).trim_end().to_string()
770}
771
772pub fn cache_path_for_track(
775 cache_dir: &Path,
776 track: &queries::TrackRow,
777 album_date: Option<&str>,
778) -> PathBuf {
779 let artist_dir = sanitise_filename(&track.artist_name);
780
781 let year = album_date
782 .and_then(|d| if d.len() >= 4 { Some(&d[..4]) } else { None })
783 .map(|y| format!("({}) ", y))
784 .unwrap_or_default();
785 let codec = track
786 .codec
787 .as_deref()
788 .map(|c| format!(" [{}]", c))
789 .unwrap_or_default();
790 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
791
792 let disc_prefix = match track.disc {
793 Some(d) if d > 1 => format!("{}-", d),
794 _ => String::new(),
795 };
796 let track_num = track
797 .track_number
798 .map(|n| format!("{:02}. ", n))
799 .unwrap_or_default();
800
801 let ext = track
802 .codec
803 .as_deref()
804 .map(|c| c.to_lowercase())
805 .unwrap_or_else(|| "flac".into());
806
807 let filename = sanitise_filename(&format!(
808 "{}{}{} - {}",
809 disc_prefix, track_num, track.artist_name, track.title
810 ));
811
812 cache_dir
813 .join(artist_dir)
814 .join(album_dir)
815 .join(format!("{}.{}", filename, ext))
816}
817
818pub fn resolve_item_path(
825 db: &Database,
826 cfg: &Config,
827 id: i64,
828 track: &queries::TrackRow,
829 album_date: Option<&str>,
830) -> (PathBuf, LoadState) {
831 match queries::resolve_playback_path(&db.conn, id) {
832 Ok(Some(queries::PlaybackSource::Local(p))) => (p, LoadState::Ready),
833 Ok(Some(queries::PlaybackSource::Cached(p))) => {
838 let state = if is_cached_audio(&p) {
839 LoadState::Ready
840 } else {
841 LoadState::Pending
842 };
843 (p, state)
844 }
845 Ok(Some(queries::PlaybackSource::Remote(_))) => {
846 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
847 if dest.exists() && is_cached_audio(&dest) {
848 (dest, LoadState::Ready)
849 } else {
850 (dest, LoadState::Pending)
851 }
852 }
853 _ => {
854 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
856 (dest, LoadState::Pending)
857 }
858 }
859}
860
861pub fn playlist_item_from_track(
863 track: &queries::TrackRow,
864 album_date: Option<&str>,
865 dest: PathBuf,
866 load_state: LoadState,
867) -> PlaylistItem {
868 let year = album_date.and_then(|d| {
869 if d.len() >= 4 {
870 Some(d[..4].to_string())
871 } else {
872 None
873 }
874 });
875 PlaylistItem {
876 id: QueueItemId::new(),
877 db_id: Some(track.id),
878 path: dest,
879 title: track.title.clone(),
880 artist: track.artist_name.clone(),
881 album_artist: track.album_artist_name.clone(),
882 album: track.album_title.clone(),
883 year,
884 codec: track.codec.clone(),
885 track_number: track.track_number.map(|n| n as i64),
886 disc: track.disc.map(|n| n as i64),
887 duration_ms: track.duration_ms.map(|d| d as u64),
888 load_state,
889 }
890}
891
892pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
899 use std::collections::HashMap;
900
901 let cfg = Config::load().unwrap_or_default();
902 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
903
904 tracks
905 .iter()
906 .map(|track| {
907 let album_date = match track.album_id {
908 Some(aid) => album_dates
909 .entry(aid)
910 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
911 .clone(),
912 None => None,
913 };
914 let (path, load_state) =
915 resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
916 playlist_item_from_track(track, album_date.as_deref(), path, load_state)
917 })
918 .collect()
919}
920
921pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
923 let album_date = track
924 .album_id
925 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
926
927 let cfg = Config::load().unwrap_or_default();
928 let (path, load_state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
929
930 let year = album_date.as_deref().and_then(|d| {
931 if d.len() >= 4 {
932 Some(d[..4].to_string())
933 } else {
934 None
935 }
936 });
937
938 PlaylistItem {
939 id: QueueItemId::new(),
940 db_id: Some(track.id),
941 path,
942 title: track.title.clone(),
943 artist: track.artist_name.clone(),
944 album_artist: track.album_artist_name.clone(),
945 album: track.album_title.clone(),
946 year,
947 codec: track.codec.clone(),
948 track_number: track.track_number.map(|n| n as i64),
949 disc: track.disc.map(|n| n as i64),
950 duration_ms: track.duration_ms.map(|d| d as u64),
951 load_state,
952 }
953}
954
955fn is_cached_audio(path: &std::path::Path) -> bool {
965 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
966 match std::fs::metadata(path) {
967 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
968 Ok(_) => {
969 let mut first = [0u8; 1];
970 match std::fs::File::open(path)
971 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
972 {
973 Ok(b) => b != b'{' && b != b'<',
974 Err(_) => false,
975 }
976 }
977 Err(_) => false,
978 }
979}
980
981pub fn download_track(
988 db_id: i64,
989 queue_id: QueueItemId,
990 tx: &crossbeam_channel::Sender<PlayerCommand>,
991 log_buf: &Arc<Mutex<Vec<String>>>,
992 state: &Arc<SharedPlayerState>,
993 cfg: &Config,
994 client: &SubsonicClient,
995) {
996 let db = match Database::open_default() {
997 Ok(db) => db,
998 Err(e) => {
999 state.update_load_state(queue_id, LoadState::Failed(format!("db error: {}", e)));
1000 return;
1001 }
1002 };
1003 let track = match queries::get_track_row(&db.conn, db_id) {
1004 Ok(Some(t)) => t,
1005 _ => {
1006 state.update_load_state(queue_id, LoadState::Failed("track not found".into()));
1007 return;
1008 }
1009 };
1010
1011 let remote_id = match &track.remote_id {
1012 Some(rid) => rid.clone(),
1013 None => {
1014 if let Some(ref path) = track.path {
1016 let p = std::path::PathBuf::from(path);
1017 if p.exists() {
1018 state.update_paths(&[(queue_id, p)]);
1019 state.update_load_state(queue_id, LoadState::Ready);
1020 if state.is_cursor(queue_id) {
1021 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1022 }
1023 return;
1024 }
1025 }
1026 state.update_load_state(queue_id, LoadState::Failed("no remote_id".into()));
1027 return;
1028 }
1029 };
1030
1031 if let Some(ref local_path) = track.path {
1033 let p = std::path::PathBuf::from(local_path);
1034 if p.exists() {
1035 log::info!("download_track: local file exists, using {}", p.display());
1036 state.update_paths(&[(queue_id, p)]);
1037 state.update_load_state(queue_id, LoadState::Ready);
1038 if state.is_cursor(queue_id) {
1039 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1040 }
1041 return;
1042 }
1043 }
1044
1045 let album_date: Option<String> = track
1046 .album_id
1047 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1048
1049 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1050
1051 if dest.exists() && !is_cached_audio(&dest) {
1057 log::warn!(
1058 "discarding non-audio cache entry {} (likely a stored server error)",
1059 dest.display()
1060 );
1061 let _ = std::fs::remove_file(&dest);
1062 }
1063 if dest.exists() {
1064 state.update_paths(&[(queue_id, dest)]);
1065 state.update_load_state(queue_id, LoadState::Ready);
1066 if state.is_cursor(queue_id) {
1067 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1068 }
1069 return;
1070 }
1071
1072 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1075
1076 let bytes_written: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
1077
1078 let progress_state = state.clone();
1079 let progress_qid = queue_id;
1080 let bytes_written_progress = bytes_written.clone();
1081 let progress_tx = tx.clone();
1082 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1083 let stream_ready_flag = stream_ready_sent.clone();
1084 let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1085 bytes_written_progress.store(downloaded, Ordering::Release);
1086 progress_state.update_load_state(
1087 progress_qid,
1088 LoadState::Downloading {
1089 downloaded,
1090 total,
1091 bytes_written: bytes_written_progress.clone(),
1092 },
1093 );
1094 if !stream_ready_flag.load(Ordering::Relaxed)
1095 && downloaded >= crate::player::state::STREAM_THRESHOLD
1096 {
1097 stream_ready_flag.store(true, Ordering::Relaxed);
1098 progress_tx
1099 .send(PlayerCommand::TrackStreamReady(progress_qid))
1100 .ok();
1101 }
1102 });
1103
1104 if let Err(e) = result {
1105 state.update_load_state(queue_id, LoadState::Failed(e.to_string()));
1106 push_log(log_buf, format!("x {} — {}", track.title, e));
1107 return;
1108 }
1109
1110 state.update_paths(&[(queue_id, dest.clone())]);
1112 state.update_load_state(queue_id, LoadState::Ready);
1113 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1115 log::warn!(
1116 "cached {} but failed to record it ({}) — it will not be evicted",
1117 dest.display(),
1118 e
1119 );
1120 }
1121
1122 push_log(
1123 log_buf,
1124 format!("+ {} — {}", track.title, track.artist_name),
1125 );
1126
1127 if state.is_cursor(queue_id) {
1128 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1129 }
1130}
1131
1132fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1135 match log_buf.lock() {
1136 Ok(mut buf) => buf.push(msg),
1137 Err(_) => log::info!("{}", msg),
1138 }
1139}
1140
1141pub fn spawn_downloads(
1143 pending: Vec<(i64, QueueItemId)>,
1144 tx: crossbeam_channel::Sender<PlayerCommand>,
1145 state: Arc<SharedPlayerState>,
1146) {
1147 let log_buf: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1148 if let Err(e) = std::thread::Builder::new()
1149 .name("koan-download".into())
1150 .spawn(move || {
1151 let cfg = Config::load().unwrap_or_default();
1152 let Some(client) = subsonic_client(&cfg) else {
1153 log::warn!(
1154 "remote not configured -- skipping {} downloads",
1155 pending.len()
1156 );
1157 return;
1158 };
1159 for (db_id, queue_id) in pending {
1160 download_track(db_id, queue_id, &tx, &log_buf, &state, &cfg, &client);
1161 }
1162 })
1163 {
1164 log::error!("failed to spawn download thread: {}", e);
1165 }
1166}
1167
1168#[cfg(test)]
1169mod rebuild_tests {
1170 use super::*;
1171 use crate::db::queries::sample_meta;
1172
1173 fn test_db() -> Database {
1174 let conn = rusqlite::Connection::open_in_memory().unwrap();
1175 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1176 crate::db::schema::create_tables(&conn).unwrap();
1177 Database { conn }
1178 }
1179
1180 #[test]
1181 fn rebuild_drops_the_index_and_keeps_favourites() {
1182 let db = test_db();
1183 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1184 meta.path = Some("/music/windowlicker.flac".into());
1185 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1186
1187 queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1189 db.conn
1190 .execute(
1191 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1192 VALUES (?1, 'test', 'la la la', 0)",
1193 [track_id],
1194 )
1195 .unwrap();
1196
1197 let summary = rebuild_index(&db).unwrap();
1198 assert_eq!(summary.tracks, 1);
1199 assert_eq!(summary.albums, 1);
1200
1201 let tracks: i64 = db
1202 .conn
1203 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1204 .unwrap();
1205 assert_eq!(tracks, 0, "the index is gone");
1206
1207 let favourites: i64 = db
1208 .conn
1209 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1210 .unwrap();
1211 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1212
1213 let lyrics: i64 = db
1214 .conn
1215 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1216 .unwrap();
1217 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1218 }
1219
1220 #[test]
1221 fn rebuilding_an_empty_library_is_not_an_error() {
1222 let db = test_db();
1223 let summary = rebuild_index(&db).unwrap();
1224 assert_eq!(summary.tracks, 0);
1225 }
1226}
1227
1228#[cfg(test)]
1229mod share_tests {
1230 use super::*;
1231 use crate::db::queries::sample_meta;
1232
1233 fn test_db() -> Database {
1234 let conn = rusqlite::Connection::open_in_memory().unwrap();
1235 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1236 crate::db::schema::create_tables(&conn).unwrap();
1237 Database { conn }
1238 }
1239
1240 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1242 let ids: Vec<i64> = ["One", "Two", "Three"]
1243 .iter()
1244 .enumerate()
1245 .map(|(i, title)| {
1246 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1247 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1248 meta.track_number = Some(i as i32 + 1);
1249 queries::upsert_track(&db.conn, &meta).unwrap()
1250 })
1251 .collect();
1252 let album_id: i64 = db
1253 .conn
1254 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1255 r.get(0)
1256 })
1257 .unwrap();
1258 db.conn
1259 .execute(
1260 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1261 [album_id],
1262 )
1263 .unwrap();
1264 (album_id, ids)
1265 }
1266
1267 #[test]
1268 fn whole_album_collapses_to_the_album_link() {
1269 let db = test_db();
1270 let (album_id, ids) = album_of_three(&db);
1271 assert_eq!(
1272 album_remote_id(&db.conn, album_id, ids.len()),
1273 Some("al-1".into())
1274 );
1275 }
1276
1277 #[test]
1278 fn part_of_an_album_does_not() {
1279 let db = test_db();
1280 let (album_id, _) = album_of_three(&db);
1281 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1284 }
1285
1286 #[test]
1287 fn a_local_only_album_has_no_link_to_collapse_to() {
1288 let db = test_db();
1289 let (album_id, ids) = album_of_three(&db);
1290 db.conn
1291 .execute(
1292 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1293 [album_id],
1294 )
1295 .unwrap();
1296 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1297 }
1298}