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::db::queries::shares::{ShareKind, Slice};
14use crate::player::commands::PlayerCommand;
15use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
16use crate::remote::client::{SubsonicAuth, SubsonicClient, SubsonicError};
17use crate::remote::download::DownloadError;
18
19pub fn get_remote_password(cfg: &Config) -> Option<String> {
26 (!cfg.remote.password.is_empty()).then(|| cfg.remote.password.clone())
27}
28
29pub fn spawn_library_watch(
53 db_path: std::path::PathBuf,
54 on_state: impl Fn(bool) + Send + Sync + 'static,
55) -> Option<std::thread::JoinHandle<()>> {
56 use std::collections::BTreeSet;
57 use std::time::{Duration, Instant};
58
59 use notify::{RecursiveMode, Watcher};
60
61 use crate::index::scanner::{self, ScanOptions};
62 use crate::index::watch::{WatchedRoot, scan_target};
63
64 const SETTLE: Duration = Duration::from_secs(5);
67 const CHECK: Duration = Duration::from_secs(30);
69 const MAX_DIRS: usize = 200;
71 const RESCAN: Duration = Duration::from_secs(15 * 60);
75
76 std::thread::Builder::new()
77 .name("koan-library-watch".into())
78 .spawn(move || {
79 let scan = |reason: &str, folders: &[PathBuf], dirs: Option<&[PathBuf]>| {
80 if folders.is_empty() {
81 return;
82 }
83 let Ok(db) = Database::open(&db_path) else {
84 return;
85 };
86 on_state(true);
87 let result = match dirs {
88 Some(dirs) => {
89 scanner::scan_dirs(&db, folders, dirs, ScanOptions::default(), None)
90 }
91 None => scanner::full_scan(&db, folders, ScanOptions::default(), None),
92 };
93 on_state(false);
94 log::info!(
95 "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
96 result.added,
97 result.updated,
98 result.removed,
99 result.skipped
100 );
101 };
102 let folders = || Config::cached().library.folders.clone();
103
104 let (tx, rx) = std::sync::mpsc::channel();
105 let Ok(mut watcher) = notify::recommended_watcher(move |event| {
106 let _ = tx.send(event);
107 }) else {
108 log::warn!("could not watch the library folders");
109 return;
110 };
111
112 let mut roots: Vec<WatchedRoot> = Vec::new();
116 let mut rewatch = |roots: &mut Vec<WatchedRoot>| {
117 let wanted: Vec<WatchedRoot> = folders()
118 .iter()
119 .filter_map(|f| WatchedRoot::resolve(f))
120 .collect();
121 roots.retain(|root| {
122 let keep = wanted.contains(root);
123 if !keep {
124 let _ = watcher.unwatch(&root.path);
125 }
126 keep
127 });
128 let mut fresh = Vec::new();
129 for root in wanted {
130 if roots.contains(&root) {
131 continue;
132 }
133 match watcher.watch(&root.path, RecursiveMode::Recursive) {
134 Ok(()) => {
135 fresh.push(root.path.clone());
136 roots.push(root);
137 }
138 Err(e) => log::warn!("could not watch {}: {e}", root.path.display()),
139 }
140 }
141 fresh
142 };
143
144 std::thread::sleep(Duration::from_secs(3));
146 rewatch(&mut roots);
147 scan("startup", &folders(), None);
148
149 let mut dirs = BTreeSet::new();
150 let mut everything = false;
151 let mut settle_at: Option<Instant> = None;
152 let mut check_at = Instant::now() + CHECK;
153 let mut rescan_at = Instant::now() + RESCAN;
154 loop {
155 let now = Instant::now();
156 let wake = settle_at
157 .map_or(check_at, |at| at.min(check_at))
158 .min(rescan_at);
159 match rx.recv_timeout(wake.saturating_duration_since(now)) {
160 Ok(Ok(event)) if event.need_rescan() => {
161 everything = true;
162 settle_at = Some(Instant::now() + SETTLE);
163 }
164 Ok(Ok(event)) => {
165 let mut heard = false;
166 for dir in event
167 .paths
168 .iter()
169 .filter_map(|p| scan_target(&event.kind, p, &roots))
170 {
171 dirs.insert(dir);
172 heard = true;
173 }
174 if heard {
175 settle_at = Some(Instant::now() + SETTLE);
176 }
177 }
178 Ok(Err(e)) => log::debug!("library watch: {e}"),
179 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
180 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
181 }
182
183 let now = Instant::now();
184 if settle_at.is_some_and(|at| now >= at) {
185 let changed =
186 scanner::minimal_dirs(std::mem::take(&mut dirs).into_iter().collect());
187 if everything || changed.len() > MAX_DIRS {
188 scan("watched change", &folders(), None);
189 rescan_at = Instant::now() + RESCAN;
190 } else {
191 scan("watched change", &folders(), Some(&changed));
192 }
193 everything = false;
194 settle_at = None;
195 }
196 if now >= rescan_at {
197 scan("periodic", &folders(), None);
198 rescan_at = Instant::now() + RESCAN;
199 }
200 if now >= check_at {
201 let fresh = rewatch(&mut roots);
202 if !fresh.is_empty() {
203 scan("newly watched", &fresh, None);
204 }
205 check_at = Instant::now() + CHECK;
206 }
207 }
208 })
209 .ok()
210}
211
212pub fn spawn_auto_sync(
226 db_path: std::path::PathBuf,
227 on_state: impl Fn(bool) + Send + 'static,
228 on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
229) -> Option<std::thread::JoinHandle<()>> {
230 std::thread::Builder::new()
231 .name("koan-auto-sync".into())
232 .spawn(move || {
233 std::thread::sleep(std::time::Duration::from_secs(5));
234 loop {
235 let cfg = Config::load().unwrap_or_default();
236 if !cfg.remote.enabled || !cfg.remote.auto_sync {
237 std::thread::sleep(std::time::Duration::from_secs(60));
240 continue;
241 }
242
243 if let Some(client) = subsonic_client(&cfg)
244 && let Ok(db) = Database::open(&db_path)
245 {
246 on_state(true);
247 match sync_remote(
248 &db,
249 &client,
250 false,
251 &cfg.remote.url,
252 &cfg.remote.username,
253 &on_progress,
254 ) {
255 Ok(s) => log::info!(
256 "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
257 favourites {}↑ {}↓; playlists {}↓ {}↑",
258 s.library.artists_synced,
259 s.library.albums_synced,
260 s.library.tracks_synced,
261 s.library.albums_failed,
262 s.favourites.pushed,
263 s.favourites.imported,
264 s.playlists.pulled,
265 s.playlists.pushed,
266 ),
267 Err(e) => log::warn!("auto sync failed: {e}"),
268 }
269 on_state(false);
270 }
271
272 match cfg.remote.auto_sync_interval_mins {
273 0 => return,
275 mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
276 }
277 }
278 })
279 .ok()
280}
281
282#[derive(Debug, Clone, Copy, Default)]
284pub struct RebuildSummary {
285 pub tracks: u64,
286 pub albums: u64,
287 pub artists: u64,
288}
289
290pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
301 let count = |sql: &str| -> u64 {
302 db.conn
303 .query_row(sql, [], |r| r.get::<_, i64>(0))
304 .unwrap_or(0) as u64
305 };
306 let summary = RebuildSummary {
307 tracks: count("SELECT COUNT(*) FROM tracks"),
308 albums: count("SELECT COUNT(*) FROM albums"),
309 artists: count("SELECT COUNT(*) FROM artists"),
310 };
311
312 db.conn.execute_batch(
315 "BEGIN;
316 DELETE FROM track_vectors;
317 DELETE FROM lyrics_cache;
318 DELETE FROM play_history;
319 DELETE FROM scan_cache;
320 DELETE FROM tracks_fts;
321 DELETE FROM tracks;
322 DELETE FROM similar_artists;
323 DELETE FROM albums;
324 DELETE FROM artists;
325 COMMIT;",
326 )?;
327 let _ = db.conn.execute_batch("VACUUM");
328 Ok(summary)
329}
330
331pub fn evict_cache(
336 db: &Database,
337 cfg: &Config,
338 keep: &std::collections::HashSet<i64>,
339 verbose: bool,
340) -> u64 {
341 let Some(limit) = cfg.cache_limit_bytes().map(|l| l as i64) else {
342 return 0;
343 };
344 let mut current = match queries::total_cache_size(&db.conn) {
345 Ok(s) => s,
346 Err(e) => {
347 log::warn!("cache eviction: failed to query cache size: {e}");
348 return 0;
349 }
350 };
351 if current <= limit {
352 if verbose {
353 log::info!("cache within limit: {current} / {limit} bytes");
354 }
355 return 0;
356 }
357 let albums = match queries::cached_albums_lru(&db.conn) {
358 Ok(a) => a,
359 Err(e) => {
360 log::warn!("cache eviction: failed to query cached albums: {e}");
361 return 0;
362 }
363 };
364 let mut freed: i64 = 0;
365 for album in &albums {
366 if current <= limit {
367 break;
368 }
369 if album.track_ids.iter().any(|id| keep.contains(id)) {
370 continue;
371 }
372 for path in &album.cached_paths {
373 match std::fs::remove_file(path) {
374 Ok(()) => {}
375 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
376 Err(e) => log::warn!("cache eviction: failed to delete {path}: {e}"),
377 }
378 }
379 if let Err(e) = queries::clear_cache_for_tracks(&db.conn, &album.track_ids) {
380 log::warn!("cache eviction: failed to clear DB for album: {e}");
381 }
382 log::info!(
383 "evicted: {} — {} ({} bytes)",
384 album.artist_name,
385 album.album_title,
386 album.total_size
387 );
388 current -= album.total_size;
389 freed += album.total_size;
390 }
391 remove_empty_dirs(&cfg.cache_dir());
392 if freed > 0 {
393 log::info!("cache eviction freed {freed} bytes");
394 }
395 freed as u64
396}
397
398fn remove_empty_dirs(dir: &Path) {
400 if !dir.is_dir() {
401 return;
402 }
403 for entry in walkdir::WalkDir::new(dir)
404 .contents_first(true)
405 .into_iter()
406 .filter_map(Result::ok)
407 .filter(|e| e.file_type().is_dir() && e.path() != dir)
408 {
409 let _ = std::fs::remove_dir(entry.path());
410 }
411}
412
413pub fn cache_size_bytes(cfg: &Config) -> u64 {
415 walkdir::WalkDir::new(cfg.cache_dir())
416 .into_iter()
417 .filter_map(Result::ok)
418 .filter(|e| e.file_type().is_file())
419 .filter_map(|e| e.metadata().ok())
420 .map(|m| m.len())
421 .sum()
422}
423
424pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
429 let (lower, upper) = queries::folder_prefix_range(folder);
430 db.conn
431 .query_row(
432 "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
433 [&lower, &upper],
434 |r| r.get::<_, i64>(0),
435 )
436 .unwrap_or(0) as u64
437}
438
439pub fn tracks_from_server(db: &Database) -> u64 {
441 db.conn
442 .query_row(
443 "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
444 [],
445 |r| r.get::<_, i64>(0),
446 )
447 .unwrap_or(0) as u64
448}
449
450pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
463 let folder = &crate::index::spelling::on_disk(folder);
465 let (lower, upper) = queries::folder_prefix_range(folder);
466
467 let tx = db.conn.unchecked_transaction()?;
468 tx.execute(
470 "UPDATE tracks SET path = NULL, source = 'remote'
471 WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
472 [&lower, &upper],
473 )?;
474
475 let ids: Vec<i64> = {
476 let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
477 let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
478 rows.filter_map(Result::ok).collect()
479 };
480 for id in &ids {
481 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
482 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
483 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
484 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
485 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
486 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
487 }
488 prune_empty_albums_and_artists(&tx)?;
489 tx.commit()?;
490 Ok(ids.len() as u64)
491}
492
493pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
499 let tx = db.conn.unchecked_transaction()?;
500
501 let ids: Vec<i64> = {
502 let mut stmt =
503 tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
504 let rows = stmt.query_map([], |r| r.get(0))?;
505 rows.filter_map(Result::ok).collect()
506 };
507 for id in &ids {
508 tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
509 tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
510 tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
511 tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
512 tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
513 tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
514 }
515 tx.execute(
517 "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
518 WHERE remote_id IS NOT NULL",
519 [],
520 )?;
521 tx.execute("DELETE FROM similar_artists", [])?;
522 prune_empty_albums_and_artists(&tx)?;
523 tx.commit()?;
524 Ok(ids.len() as u64)
525}
526
527fn prune_empty_albums_and_artists(
529 tx: &rusqlite::Transaction<'_>,
530) -> Result<(), crate::db::connection::DbError> {
531 tx.execute(
532 "DELETE FROM albums WHERE NOT EXISTS
533 (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
534 [],
535 )?;
536 tx.execute(
537 "DELETE FROM similar_artists WHERE NOT EXISTS
538 (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
539 [],
540 )?;
541 tx.execute(
542 "DELETE FROM artists WHERE NOT EXISTS
543 (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
544 AND NOT EXISTS
545 (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
546 [],
547 )?;
548 Ok(())
549}
550
551#[derive(Debug, Clone, Copy, Default)]
553pub struct CacheCleared {
554 pub files: u64,
555 pub bytes: u64,
556}
557
558pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
563 let dir = cfg.cache_dir();
564 let mut cleared = CacheCleared::default();
565 for entry in walkdir::WalkDir::new(&dir)
566 .into_iter()
567 .filter_map(Result::ok)
568 .filter(|e| e.file_type().is_file())
569 {
570 if let Ok(meta) = entry.metadata() {
571 cleared.bytes += meta.len();
572 cleared.files += 1;
573 }
574 }
575 let _ = std::fs::remove_dir_all(&dir);
576 let _ = std::fs::create_dir_all(&dir);
577 let _ = queries::clear_cached_paths(&db.conn);
578 cleared
579}
580
581pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
588 let mut cleared = CacheCleared::default();
589 let paths = match queries::cached_paths_for(&db.conn, track_ids) {
590 Ok(paths) => paths,
591 Err(e) => {
592 log::warn!("could not read cached paths: {e}");
593 return cleared;
594 }
595 };
596 for path in &paths {
597 let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
598 match std::fs::remove_file(path) {
599 Ok(()) => {
600 cleared.files += 1;
601 cleared.bytes += size;
602 }
603 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
605 Err(e) => log::warn!("could not remove {path}: {e}"),
606 }
607 }
608 if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
609 log::warn!("removed downloads but failed to forget them ({e})");
610 }
611 cleared
612}
613
614pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
627 let mut swept = CacheCleared::default();
628 for entry in walkdir::WalkDir::new(cfg.cache_dir())
629 .into_iter()
630 .filter_map(Result::ok)
631 .filter(|e| e.file_type().is_file())
632 .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
633 {
634 let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
635 match std::fs::remove_file(entry.path()) {
636 Ok(()) => {
637 swept.files += 1;
638 swept.bytes += size;
639 }
640 Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
641 }
642 }
643 if swept.files > 0 {
644 log::info!(
645 "swept {} unfinished download(s), {} bytes",
646 swept.files,
647 swept.bytes
648 );
649 }
650 swept
651}
652
653pub fn requeue_cleared_downloads(
660 state: &Arc<SharedPlayerState>,
661 tx: &crossbeam_channel::Sender<PlayerCommand>,
662) {
663 let stale = state.reset_items_with_missing_files();
664 if stale.is_empty() {
665 return;
666 }
667 log::info!(
668 "{} queued tracks lost their copy — fetching again",
669 stale.len()
670 );
671 spawn_downloads(stale, tx.clone(), state.clone());
672}
673
674pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
686 let cfg = Config::load().unwrap_or_default();
687 if !cfg.remote.enabled {
688 return;
689 }
690 let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
691 log::warn!("not syncing favourite: {} has no remote id", path.display());
692 return;
693 };
694 let Some(client) = subsonic_client(&cfg) else {
695 log::warn!("not syncing favourite: no usable server credentials");
696 return;
697 };
698 std::thread::Builder::new()
699 .name("koan-fav-sync".into())
700 .spawn(move || {
701 let result = if star {
702 client.star(&remote_id)
703 } else {
704 client.unstar(&remote_id)
705 };
706 match result {
707 Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
708 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
709 }
710 })
711 .ok();
712}
713
714#[derive(Debug, Default)]
716pub struct FullSync {
717 pub library: crate::remote::sync::SyncResult,
718 pub favourites: FavouriteSync,
719 pub playlists: crate::playlists::PlaylistSync,
720}
721
722pub fn sync_remote(
733 db: &Database,
734 client: &SubsonicClient,
735 full: bool,
736 url: &str,
737 username: &str,
738 progress: &(dyn Fn(crate::remote::sync::SyncProgress) + Sync),
739) -> Result<FullSync, crate::remote::sync::SyncError> {
740 let library = crate::remote::sync::sync_library(db, client, full, url, username, progress)?;
741 Ok(FullSync {
742 library,
743 favourites: reconcile_favourites(db, client),
744 playlists: crate::playlists::reconcile_playlists(db, client, username),
745 })
746}
747
748#[derive(Debug, Default, Clone, Copy)]
750pub struct FavouriteSync {
751 pub pushed: usize,
752 pub imported: usize,
753}
754
755pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
766 let mut out = FavouriteSync::default();
767
768 let tracks =
769 queries::favourites_with_remote_id(&db.conn, queries::LOCAL_USER).unwrap_or_default();
770 for (_path, remote_id) in &tracks {
771 if client.star(remote_id).is_ok() {
772 out.pushed += 1;
773 }
774 }
775 for (_id, remote_id) in
776 queries::favourite_albums_with_remote_id(&db.conn, queries::LOCAL_USER).unwrap_or_default()
777 {
778 if client.star_album(&remote_id).is_ok() {
779 out.pushed += 1;
780 }
781 }
782 for (_id, remote_id) in
783 queries::favourite_artists_with_remote_id(&db.conn, queries::LOCAL_USER).unwrap_or_default()
784 {
785 if client.star_artist(&remote_id).is_ok() {
786 out.pushed += 1;
787 }
788 }
789
790 let starred = match client.get_starred_all() {
791 Ok(s) => s,
792 Err(e) => {
793 log::warn!("could not fetch starred items from the server: {e}");
794 return out;
795 }
796 };
797
798 let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
799 let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
800 let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
801 out.imported +=
802 queries::import_remote_favourites(&db.conn, queries::LOCAL_USER, &songs).unwrap_or(0);
803 out.imported += queries::import_remote_favourite_albums(&db.conn, queries::LOCAL_USER, &albums)
804 .unwrap_or(0);
805 out.imported +=
806 queries::import_remote_favourite_artists(&db.conn, queries::LOCAL_USER, &artists)
807 .unwrap_or(0);
808 out
809}
810
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
814pub enum FavouriteKind {
815 Track,
816 Album,
817 Artist,
818}
819
820pub fn sync_collection_favourite_to_remote(
825 db: &Database,
826 kind: FavouriteKind,
827 id: i64,
828 star: bool,
829) {
830 let cfg = Config::load().unwrap_or_default();
831 if !cfg.remote.enabled {
832 return;
833 }
834 let remote_id = match kind {
835 FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
836 FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
837 FavouriteKind::Track => return,
838 };
839 let Ok(Some(remote_id)) = remote_id else {
840 log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
841 return;
842 };
843 let Some(client) = subsonic_client(&cfg) else {
844 log::warn!("not syncing favourite: no usable server credentials");
845 return;
846 };
847 std::thread::Builder::new()
848 .name("koan-fav-sync".into())
849 .spawn(move || {
850 let result = match (kind, star) {
851 (FavouriteKind::Album, true) => client.star_album(&remote_id),
852 (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
853 (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
854 (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
855 (FavouriteKind::Track, _) => Ok(()),
856 };
857 match result {
858 Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
859 Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
860 }
861 })
862 .ok();
863}
864
865#[derive(Debug, thiserror::Error)]
867pub enum SignInError {
868 #[error("the server did not accept those credentials: {0}")]
869 Rejected(#[from] crate::remote::client::SubsonicError),
870 #[error("could not write the configuration: {0}")]
871 Config(#[from] crate::config::ConfigError),
872}
873
874pub fn set_remote_credentials(
887 url: &str,
888 username: &str,
889 password: &str,
890) -> Result<(), SignInError> {
891 let url = url.trim_end_matches('/');
892 SubsonicClient::new(url, username, password).ping()?;
893
894 Config::persist(|cfg| {
895 cfg.remote.enabled = true;
896 cfg.remote.url = url.to_string();
897 cfg.remote.username = username.to_string();
898 cfg.remote.password = password.to_string();
899 })?;
900 crate::remote::link::nudge();
903 Ok(())
904}
905
906pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
910 (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
911}
912
913pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
920 if !cfg.remote.enabled || cfg.remote.url.is_empty() {
921 return None;
922 }
923 let password = get_remote_password(cfg)?;
924 Some(SubsonicAuth::new(
925 &cfg.remote.url,
926 &cfg.remote.username,
927 &password,
928 ))
929}
930
931pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
943 let auth = subsonic_auth(cfg)?;
944
945 let mut slot = SUBSONIC_CLIENT.lock();
946 if let Some((cached, client)) = slot.as_ref()
947 && *cached == auth
948 {
949 return Some(client.clone());
950 }
951
952 let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
953 *slot = Some((auth, client.clone()));
954 Some(client)
955}
956
957type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
958
959static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
960 std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
961
962#[derive(Debug, thiserror::Error)]
971pub enum ShareError {
972 #[error("no remote server is configured")]
973 NoRemote,
974 #[error("sharing.public_url is not set, so there is no address to give out")]
975 NoPublicUrl,
976 #[error("none of these tracks are in the library")]
977 NothingToShare,
978 #[error("none of these tracks are on the server, so a link has nothing to point at")]
979 NothingRemote,
980 #[error("the server refused to share these: {0}")]
981 Server(#[from] crate::remote::client::SubsonicError),
982 #[error(transparent)]
983 Database(#[from] crate::db::connection::DbError),
984}
985
986#[derive(Debug, Clone)]
988pub struct ShareOutcome {
989 pub url: String,
990 pub id: String,
992 pub shared: usize,
994 pub skipped: usize,
996}
997
998#[derive(Debug, Clone, PartialEq, Eq)]
1000pub enum ShareTarget {
1001 Tracks(Vec<i64>),
1003 Album {
1005 album_id: i64,
1006 start_track_id: Option<i64>,
1007 },
1008 Artist(i64),
1010}
1011
1012pub fn resolve_share(
1018 conn: &rusqlite::Connection,
1019 target: &ShareTarget,
1020) -> Result<(Slice, Vec<i64>), ShareError> {
1021 let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
1022 Ok(queries::tracks_for_album(conn, album_id)?
1023 .into_iter()
1024 .map(|t| t.id)
1025 .collect())
1026 };
1027 let (slice, ids) = match target {
1028 ShareTarget::Tracks(ids) => {
1029 let rows = queries::tracks_by_ids(conn, ids)?;
1030 match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
1031 ([one], Some(album_id)) => {
1032 return resolve_share(
1033 conn,
1034 &ShareTarget::Album {
1035 album_id,
1036 start_track_id: Some(*one),
1037 },
1038 );
1039 }
1040 _ => {
1041 let ids = ids
1043 .iter()
1044 .copied()
1045 .filter(|id| rows.iter().any(|t| t.id == *id))
1046 .collect();
1047 (Slice::TRACKS, ids)
1048 }
1049 }
1050 }
1051 ShareTarget::Album {
1052 album_id,
1053 start_track_id,
1054 } => {
1055 let ids = album_tracks(*album_id)?;
1056 let slice = Slice {
1057 kind: ShareKind::Album,
1058 subject_id: Some(*album_id),
1059 start_track_id: start_track_id.filter(|s| ids.contains(s)),
1060 };
1061 (slice, ids)
1062 }
1063 ShareTarget::Artist(artist_id) => {
1064 let mut ids = Vec::new();
1065 for album in queries::albums_for_artist(conn, *artist_id)? {
1066 ids.extend(album_tracks(album.id)?);
1067 }
1068 let slice = Slice {
1069 kind: ShareKind::Artist,
1070 subject_id: Some(*artist_id),
1071 start_track_id: None,
1072 };
1073 (slice, ids)
1074 }
1075 };
1076 if ids.is_empty() {
1077 return Err(ShareError::NothingToShare);
1078 }
1079 Ok((slice, ids))
1080}
1081
1082pub fn create_share(
1097 db: &Database,
1098 user: i64,
1099 cfg: &Config,
1100 target: &ShareTarget,
1101 description: Option<&str>,
1102) -> Result<ShareOutcome, ShareError> {
1103 let Some(client) = subsonic_client(cfg) else {
1104 return create_native_share(db, user, cfg, target, description);
1105 };
1106 let resolved;
1109 let track_ids = match target {
1110 ShareTarget::Tracks(ids) => ids.as_slice(),
1111 _ => {
1112 resolved = resolve_share(&db.conn, target)?.1;
1113 resolved.as_slice()
1114 }
1115 };
1116
1117 let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
1119
1120 let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
1121 if shared == 0 {
1122 return Err(ShareError::NothingRemote);
1123 }
1124
1125 let one_album = rows
1129 .first()
1130 .and_then(|f| f.album_id)
1131 .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
1132 .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
1133
1134 let remote_ids: Vec<String> = match one_album {
1135 Some(rid) => vec![album_share_id(&client, rid)],
1136 None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
1137 };
1138
1139 let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
1140 let share = client.create_share(&refs, description)?;
1141
1142 let url = share
1145 .url
1146 .clone()
1147 .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
1148
1149 Ok(ShareOutcome {
1150 url,
1151 id: share.id,
1152 shared,
1153 skipped: track_ids.len().saturating_sub(shared),
1154 })
1155}
1156
1157fn create_native_share(
1159 db: &Database,
1160 user: i64,
1161 cfg: &Config,
1162 target: &ShareTarget,
1163 description: Option<&str>,
1164) -> Result<ShareOutcome, ShareError> {
1165 let base = cfg
1166 .sharing
1167 .public_url
1168 .as_deref()
1169 .filter(|u| !u.trim().is_empty())
1170 .ok_or(ShareError::NoPublicUrl)?;
1171 let (slice, ids) = resolve_share(&db.conn, target)?;
1172 let now = std::time::SystemTime::now()
1173 .duration_since(std::time::UNIX_EPOCH)
1174 .map_or(0, |d| d.as_secs() as i64);
1175 let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
1176 Ok(ShareOutcome {
1177 url: share_url(base, &share.id),
1178 id: share.id,
1179 shared: ids.len(),
1180 skipped: match (target, slice.kind) {
1182 (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
1183 _ => 0,
1184 },
1185 })
1186}
1187
1188pub fn share_url(public_url: &str, id: &str) -> String {
1190 format!("{}/share/{id}", public_url.trim_end_matches('/'))
1191}
1192
1193fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
1203 let koan = crate::remote::profile::is_koan(client.auth());
1204 album_share_id_for(koan, remote_id)
1205}
1206
1207fn album_share_id_for(koan: bool, remote_id: String) -> String {
1208 if koan && remote_id.parse::<i64>().is_ok() {
1209 format!("al-{remote_id}")
1210 } else {
1211 remote_id
1212 }
1213}
1214
1215fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
1216 let (remote_id, total): (Option<String>, i64) = conn
1217 .query_row(
1218 "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
1219 FROM albums al WHERE al.id = ?1",
1220 [album_id],
1221 |row| Ok((row.get(0)?, row.get(1)?)),
1222 )
1223 .ok()?;
1224 (total == selected as i64).then_some(remote_id).flatten()
1225}
1226
1227pub fn shuffle<T>(items: &mut [T]) {
1236 let mut seed = [0u8; 8];
1237 if getrandom::fill(&mut seed).is_err() {
1238 return; }
1240 let mut state = u64::from_le_bytes(seed) | 1;
1241 for i in (1..items.len()).rev() {
1242 state ^= state << 13;
1244 state ^= state >> 7;
1245 state ^= state << 17;
1246 items.swap(i, (state % (i as u64 + 1)) as usize);
1247 }
1248}
1249
1250pub fn truncate_bytes(s: &str, max: usize) -> &str {
1252 if s.len() <= max {
1253 return s;
1254 }
1255 let mut end = max;
1256 while end > 0 && !s.is_char_boundary(end) {
1257 end -= 1;
1258 }
1259 &s[..end]
1260}
1261
1262pub fn sanitise_filename(s: &str) -> String {
1265 let cleaned: String = s
1266 .chars()
1267 .map(|c| match c {
1268 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1269 _ => c,
1270 })
1271 .collect::<String>()
1272 .trim()
1273 .to_string();
1274
1275 truncate_bytes(&cleaned, 240).trim_end().to_string()
1276}
1277
1278pub fn year_of(date: &str) -> Option<&str> {
1281 date.get(..4)
1282}
1283
1284pub fn cache_path_for_track(
1287 cache_dir: &Path,
1288 track: &queries::TrackRow,
1289 album_date: Option<&str>,
1290) -> PathBuf {
1291 let artist_dir = sanitise_filename(&track.artist_name);
1292
1293 let year = album_date
1294 .and_then(year_of)
1295 .map(|y| format!("({}) ", y))
1296 .unwrap_or_default();
1297 let codec = track
1298 .codec
1299 .as_deref()
1300 .map(|c| format!(" [{}]", c))
1301 .unwrap_or_default();
1302 let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1303
1304 let disc_prefix = match track.disc {
1305 Some(d) if d > 1 => format!("{}-", d),
1306 _ => String::new(),
1307 };
1308 let track_num = track
1309 .track_number
1310 .map(|n| format!("{:02}. ", n))
1311 .unwrap_or_default();
1312
1313 let ext = track
1314 .codec
1315 .as_deref()
1316 .map(|c| c.to_lowercase())
1317 .unwrap_or_else(|| "flac".into());
1318
1319 let filename = sanitise_filename(&format!(
1320 "{}{}{} - {}",
1321 disc_prefix, track_num, track.artist_name, track.title
1322 ));
1323
1324 cache_dir
1325 .join(artist_dir)
1326 .join(album_dir)
1327 .join(format!("{}.{}", filename, ext))
1328}
1329
1330pub fn resolve_item_path(
1338 db: &Database,
1339 cfg: &Config,
1340 id: i64,
1341 track: &queries::TrackRow,
1342 album_date: Option<&str>,
1343) -> (PathBuf, ItemState) {
1344 match queries::resolve_playback_path(&db.conn, id) {
1345 Ok(Some(queries::PlaybackSource::Local(p))) => (p, ItemState::Ready),
1346 Ok(Some(queries::PlaybackSource::Cached(p))) => {
1351 let state = if is_cached_audio(&p) {
1352 ItemState::Ready
1353 } else {
1354 ItemState::Pending
1355 };
1356 (p, state)
1357 }
1358 Ok(Some(queries::PlaybackSource::Remote(_))) => {
1359 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1360 if dest.exists() && is_cached_audio(&dest) {
1361 (dest, ItemState::Ready)
1362 } else {
1363 (dest, ItemState::Pending)
1364 }
1365 }
1366 _ => {
1367 let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1369 (dest, ItemState::Pending)
1370 }
1371 }
1372}
1373
1374pub fn playlist_item_from_track(
1376 track: &queries::TrackRow,
1377 album_date: Option<&str>,
1378 dest: PathBuf,
1379 state: ItemState,
1380) -> PlaylistItem {
1381 let year = album_date.and_then(year_of).map(str::to_string);
1382 PlaylistItem {
1383 playlist_entry_id: None,
1384 id: QueueItemId::new(),
1385 db_id: Some(track.id),
1386 path: dest,
1387 title: track.title.clone(),
1388 artist: track.artist_name.clone(),
1389 album_artist: track.album_artist_name.clone(),
1390 album: track.album_title.clone(),
1391 year,
1392 codec: track.codec.clone(),
1393 track_number: track.track_number.map(|n| n as i64),
1394 disc: track.disc.map(|n| n as i64),
1395 duration_ms: track.duration_ms.map(|d| d as u64),
1396 state,
1397 }
1398}
1399
1400pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1407 use std::collections::HashMap;
1408
1409 let cfg = Config::load().unwrap_or_default();
1410 let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
1411
1412 tracks
1413 .iter()
1414 .map(|track| {
1415 let album_date = match track.album_id {
1416 Some(aid) => album_dates
1417 .entry(aid)
1418 .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
1419 .clone(),
1420 None => None,
1421 };
1422 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1423 playlist_item_from_track(track, album_date.as_deref(), path, state)
1424 })
1425 .collect()
1426}
1427
1428pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
1430 let album_date = track
1431 .album_id
1432 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1433
1434 let cfg = Config::load().unwrap_or_default();
1435 let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1436
1437 let year = album_date.as_deref().and_then(year_of).map(str::to_string);
1438
1439 PlaylistItem {
1440 playlist_entry_id: None,
1441 id: QueueItemId::new(),
1442 db_id: Some(track.id),
1443 path,
1444 title: track.title.clone(),
1445 artist: track.artist_name.clone(),
1446 album_artist: track.album_artist_name.clone(),
1447 album: track.album_title.clone(),
1448 year,
1449 codec: track.codec.clone(),
1450 track_number: track.track_number.map(|n| n as i64),
1451 disc: track.disc.map(|n| n as i64),
1452 duration_ms: track.duration_ms.map(|d| d as u64),
1453 state,
1454 }
1455}
1456
1457fn is_cached_audio(path: &std::path::Path) -> bool {
1467 const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1468 match std::fs::metadata(path) {
1469 Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1470 Ok(_) => {
1471 let mut first = [0u8; 1];
1472 match std::fs::File::open(path)
1473 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1474 {
1475 Ok(b) => b != b'{' && b != b'<',
1476 Err(_) => false,
1477 }
1478 }
1479 Err(_) => false,
1480 }
1481}
1482
1483pub fn download_track(
1490 db_id: i64,
1491 queue_id: QueueItemId,
1492 tx: &crossbeam_channel::Sender<PlayerCommand>,
1493 log_buf: &Arc<Mutex<Vec<String>>>,
1494 state: &Arc<SharedPlayerState>,
1495 cfg: &Config,
1496 client: &SubsonicClient,
1497) {
1498 let db = match crate::db::pool::shared().get() {
1503 Ok(db) => db,
1504 Err(e) => {
1505 fail_track(state, tx, queue_id, format!("db error: {}", e));
1506 return;
1507 }
1508 };
1509 let track = match queries::get_track_row(&db.conn, db_id) {
1510 Ok(Some(t)) => t,
1511 _ => {
1512 fail_track(state, tx, queue_id, "track not found".into());
1513 return;
1514 }
1515 };
1516
1517 let remote_id = match &track.remote_id {
1518 Some(rid) => rid.clone(),
1519 None => {
1520 if let Some(ref path) = track.path {
1522 let p = std::path::PathBuf::from(path);
1523 if p.exists() {
1524 state.update_paths(&[(queue_id, p)]);
1525 state.update_item_state(queue_id, ItemState::Ready);
1526 if state.is_cursor(queue_id) {
1527 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1528 }
1529 return;
1530 }
1531 }
1532 fail_track(
1533 state,
1534 tx,
1535 queue_id,
1536 "not in the library folder, and no remote copy to fetch".into(),
1537 );
1538 return;
1539 }
1540 };
1541
1542 if let Some(ref local_path) = track.path {
1544 let p = std::path::PathBuf::from(local_path);
1545 if p.exists() {
1546 log::info!("download_track: local file exists, using {}", p.display());
1547 state.update_paths(&[(queue_id, p)]);
1548 state.update_item_state(queue_id, ItemState::Ready);
1549 if state.is_cursor(queue_id) {
1550 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1551 }
1552 return;
1553 }
1554 }
1555
1556 let album_date: Option<String> = track
1557 .album_id
1558 .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1559
1560 let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1561
1562 if dest.exists() && !is_cached_audio(&dest) {
1568 log::warn!(
1569 "discarding non-audio cache entry {} (likely a stored server error)",
1570 dest.display()
1571 );
1572 let _ = std::fs::remove_file(&dest);
1573 }
1574 if dest.exists() {
1575 state.update_paths(&[(queue_id, dest)]);
1576 state.update_item_state(queue_id, ItemState::Ready);
1577 if state.is_cursor(queue_id) {
1578 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1579 }
1580 return;
1581 }
1582
1583 state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1586
1587 let bytes_written = crate::remote::downloads::ByteFeed::new();
1588
1589 let store = crate::remote::downloads::store();
1592 store.queued(crate::remote::downloads::Download {
1593 id: queue_id,
1594 track_id: db_id,
1595 title: track.title.clone(),
1596 artist: track.artist_name.clone(),
1597 source: crate::remote::download::part_path(&dest),
1598 dest: dest.clone(),
1599 total: 0,
1600 written: bytes_written.clone(),
1601 state: crate::remote::downloads::DownloadState::Queued,
1602 bytes_per_second: 0,
1603 });
1604
1605 let progress_qid = queue_id;
1606 let bytes_written_progress = bytes_written.clone();
1607 let progress_tx = tx.clone();
1608 let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1609 let stream_ready_flag = stream_ready_sent.clone();
1610 let announced_total = AtomicU64::new(u64::MAX);
1612 let gone = || state.get_item(queue_id).is_none();
1614 let result =
1615 client.download_with_progress(&remote_id, &dest, &gone, move |downloaded, total| {
1616 bytes_written_progress.set(downloaded);
1617 store.progressed();
1620 if announced_total.swap(total, Ordering::Relaxed) != total {
1621 store.started(progress_qid, total, bytes_written_progress.clone());
1624 }
1625 if !stream_ready_flag.load(Ordering::Relaxed)
1626 && downloaded >= crate::player::state::STREAM_THRESHOLD
1627 {
1628 stream_ready_flag.store(true, Ordering::Relaxed);
1629 progress_tx
1630 .send(PlayerCommand::TrackStreamReady(progress_qid))
1631 .ok();
1632 }
1633 });
1634
1635 if let Err(SubsonicError::Download(DownloadError::Cancelled)) = result {
1636 store.withdrawn(queue_id);
1637 bytes_written.done();
1638 return;
1639 }
1640
1641 if let Err(e) = result {
1646 store.failed(queue_id, e.to_string());
1647 fail_track(state, tx, queue_id, e.to_string());
1648 bytes_written.done();
1649 push_log(log_buf, format!("x {} — {}", track.title, e));
1650 return;
1651 }
1652 store.finished(queue_id);
1653
1654 state.update_paths(&[(queue_id, dest.clone())]);
1655 state.update_item_state(queue_id, ItemState::Ready);
1656 bytes_written.done();
1657 if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1659 log::warn!(
1660 "cached {} but failed to record it ({}) — it will not be evicted",
1661 dest.display(),
1662 e
1663 );
1664 }
1665
1666 push_log(
1667 log_buf,
1668 format!("+ {} — {}", track.title, track.artist_name),
1669 );
1670
1671 if state.is_cursor(queue_id) {
1672 tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1673 }
1674}
1675
1676pub(crate) fn fail_track(
1682 state: &Arc<SharedPlayerState>,
1683 tx: &crossbeam_channel::Sender<PlayerCommand>,
1684 queue_id: QueueItemId,
1685 reason: String,
1686) {
1687 state.update_item_state(queue_id, ItemState::Failed(reason));
1688 if state.is_cursor(queue_id) {
1689 tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1690 }
1691}
1692
1693fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1696 match log_buf.lock() {
1697 Ok(mut buf) => buf.push(msg),
1698 Err(_) => log::info!("{}", msg),
1699 }
1700}
1701
1702pub fn remote_unavailable(cfg: &Config) -> String {
1708 if !cfg.remote.enabled {
1709 return "no remote server is configured".into();
1710 }
1711 if cfg.remote.url.is_empty() {
1712 return "the remote server has no address".into();
1713 }
1714 if get_remote_password(cfg).is_none() {
1715 return "no password is stored for the remote server".into();
1716 }
1717 "the remote server could not be reached".into()
1720}
1721
1722pub fn spawn_downloads(
1732 pending: Vec<(i64, QueueItemId)>,
1733 tx: crossbeam_channel::Sender<PlayerCommand>,
1734 state: Arc<SharedPlayerState>,
1735) {
1736 if pending.is_empty() {
1737 return;
1738 }
1739 crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1740}
1741
1742#[cfg(test)]
1743mod year_tests {
1744 use super::year_of;
1745
1746 #[test]
1747 fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1748 assert_eq!(year_of("1997-05-21"), Some("1997"));
1749 assert_eq!(year_of("199"), None);
1750 assert_eq!(year_of("1997"), None);
1752 }
1753}
1754
1755#[cfg(test)]
1756mod rebuild_tests {
1757 use super::*;
1758 use crate::db::queries::sample_meta;
1759
1760 fn test_db() -> Database {
1761 let conn = rusqlite::Connection::open_in_memory().unwrap();
1762 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1763 crate::db::schema::create_tables(&conn).unwrap();
1764 Database { conn }
1765 }
1766
1767 #[test]
1768 fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1769 let dir = tempfile::tempdir().unwrap();
1770 let db = test_db();
1771
1772 let mut cached = Vec::new();
1773 for name in ["one", "two"] {
1774 let mut meta = sample_meta(name, "Artist", "Album");
1775 meta.source = "remote".into();
1776 meta.path = None;
1777 meta.remote_id = Some(name.into());
1778 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1779 let file = dir.path().join(format!("{name}.opus"));
1780 std::fs::write(&file, vec![0u8; 2048]).unwrap();
1781 queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1782 cached.push((id, file));
1783 }
1784
1785 let cleared = clear_downloads_for(&db, &[cached[0].0]);
1786 assert_eq!(cleared.files, 1);
1787 assert_eq!(cleared.bytes, 2048);
1788 assert!(!cached[0].1.exists(), "the copy asked for is gone");
1789 assert!(cached[1].1.exists(), "the other one is untouched");
1790
1791 assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1794 assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1795 assert!(
1796 queries::cached_paths_for(&db.conn, &[cached[0].0])
1797 .unwrap()
1798 .is_empty()
1799 );
1800 }
1801
1802 #[test]
1803 fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1804 let db = test_db();
1805 let mut meta = sample_meta("ghost", "Artist", "Album");
1806 meta.source = "remote".into();
1807 meta.path = None;
1808 meta.remote_id = Some("ghost".into());
1809 let id = queries::upsert_track(&db.conn, &meta).unwrap();
1810 queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1811
1812 let cleared = clear_downloads_for(&db, &[id]);
1813 assert_eq!(cleared.files, 0, "nothing was there to remove");
1814 assert!(
1816 queries::cached_paths_for(&db.conn, &[id])
1817 .unwrap()
1818 .is_empty()
1819 );
1820 }
1821
1822 #[test]
1823 fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1824 let dir = tempfile::tempdir().unwrap();
1825 let cache = dir.path().join("cache");
1826 std::fs::create_dir_all(cache.join("Artist")).unwrap();
1827
1828 let finished = cache.join("Artist/whole.opus");
1829 let half = cache.join("Artist/half.opus.part");
1830 std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1831 std::fs::write(&half, vec![0u8; 4096]).unwrap();
1832
1833 let cfg = Config {
1834 remote: crate::config::RemoteConfig {
1835 cache_dir: Some(cache.clone()),
1836 ..Default::default()
1837 },
1838 ..Default::default()
1839 };
1840
1841 let swept = sweep_partial_downloads(&cfg);
1842 assert_eq!(swept.files, 1);
1843 assert_eq!(swept.bytes, 4096);
1844 assert!(!half.exists(), "the unfinished one is gone");
1845 assert!(finished.exists(), "a downloaded track is not touched");
1846 }
1847
1848 #[test]
1849 fn sweeping_an_empty_cache_is_not_an_error() {
1850 let dir = tempfile::tempdir().unwrap();
1851 let cfg = Config {
1852 remote: crate::config::RemoteConfig {
1853 cache_dir: Some(dir.path().join("nothing-here")),
1854 ..Default::default()
1855 },
1856 ..Default::default()
1857 };
1858 assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1859 }
1860
1861 #[test]
1862 fn clearing_no_tracks_does_nothing() {
1863 let db = test_db();
1864 assert_eq!(clear_downloads_for(&db, &[]).files, 0);
1865 }
1866
1867 #[test]
1868 fn rebuild_drops_the_index_and_keeps_favourites() {
1869 let db = test_db();
1870 let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1871 meta.path = Some("/music/windowlicker.flac".into());
1872 let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1873
1874 queries::toggle_favourite(
1876 &db.conn,
1877 crate::db::queries::LOCAL_USER,
1878 Path::new("/music/windowlicker.flac"),
1879 )
1880 .unwrap();
1881 db.conn
1882 .execute(
1883 "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1884 VALUES (?1, 'test', 'la la la', 0)",
1885 [track_id],
1886 )
1887 .unwrap();
1888
1889 let summary = rebuild_index(&db).unwrap();
1890 assert_eq!(summary.tracks, 1);
1891 assert_eq!(summary.albums, 1);
1892
1893 let tracks: i64 = db
1894 .conn
1895 .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1896 .unwrap();
1897 assert_eq!(tracks, 0, "the index is gone");
1898
1899 let favourites: i64 = db
1900 .conn
1901 .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1902 .unwrap();
1903 assert_eq!(favourites, 1, "favourites survive — they key on the path");
1904
1905 let lyrics: i64 = db
1906 .conn
1907 .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1908 .unwrap();
1909 assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1910 }
1911
1912 #[test]
1913 fn rebuilding_an_empty_library_is_not_an_error() {
1914 let db = test_db();
1915 let summary = rebuild_index(&db).unwrap();
1916 assert_eq!(summary.tracks, 0);
1917 }
1918}
1919
1920#[cfg(test)]
1921mod share_tests {
1922 use super::*;
1923 use crate::db::queries::sample_meta;
1924
1925 fn test_db() -> Database {
1926 let conn = rusqlite::Connection::open_in_memory().unwrap();
1927 conn.pragma_update(None, "foreign_keys", "on").unwrap();
1928 crate::db::schema::create_tables(&conn).unwrap();
1929 Database { conn }
1930 }
1931
1932 fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1934 let ids: Vec<i64> = ["One", "Two", "Three"]
1935 .iter()
1936 .enumerate()
1937 .map(|(i, title)| {
1938 let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1939 meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1940 meta.track_number = Some(i as i32 + 1);
1941 queries::upsert_track(&db.conn, &meta).unwrap()
1942 })
1943 .collect();
1944 let album_id: i64 = db
1945 .conn
1946 .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1947 r.get(0)
1948 })
1949 .unwrap();
1950 db.conn
1951 .execute(
1952 "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1953 [album_id],
1954 )
1955 .unwrap();
1956 (album_id, ids)
1957 }
1958
1959 #[test]
1960 fn whole_album_collapses_to_the_album_link() {
1961 let db = test_db();
1962 let (album_id, ids) = album_of_three(&db);
1963 assert_eq!(
1964 album_remote_id(&db.conn, album_id, ids.len()),
1965 Some("al-1".into())
1966 );
1967 }
1968
1969 #[test]
1970 fn part_of_an_album_does_not() {
1971 let db = test_db();
1972 let (album_id, _) = album_of_three(&db);
1973 assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1976 }
1977
1978 #[test]
1979 fn a_local_only_album_has_no_link_to_collapse_to() {
1980 let db = test_db();
1981 let (album_id, ids) = album_of_three(&db);
1982 db.conn
1983 .execute(
1984 "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1985 [album_id],
1986 )
1987 .unwrap();
1988 assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1989 }
1990}
1991
1992#[cfg(test)]
1993mod client_cache_tests {
1994 use super::*;
1995
1996 #[test]
1997 fn one_subsonic_client_is_shared_per_credentials() {
1998 crate::config::isolate_config_for_tests();
1999 let mut cfg = Config::default();
2000 cfg.remote.enabled = true;
2001 cfg.remote.url = "https://shared-client.invalid".into();
2002 cfg.remote.username = "koan".into();
2003 cfg.remote.password = "first".into();
2004
2005 let first = subsonic_client(&cfg).expect("a configured remote yields a client");
2006 let again = subsonic_client(&cfg).expect("a configured remote yields a client");
2007 assert!(
2008 Arc::ptr_eq(&first, &again),
2009 "rebuilding drops the connection pool and re-handshakes TLS per request"
2010 );
2011
2012 cfg.remote.password = "second".into();
2013 let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
2014 assert!(
2015 !Arc::ptr_eq(&first, &relogged),
2016 "new credentials must not keep serving the client signed with the old ones"
2017 );
2018 }
2019}
2020
2021#[cfg(test)]
2022mod native_share_tests {
2023 use super::*;
2024 use crate::db::queries::{sample_meta, upsert_track};
2025
2026 #[test]
2027 fn a_standalone_server_shares_natively_in_the_order_asked() {
2028 let conn = rusqlite::Connection::open_in_memory().unwrap();
2029 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2030 crate::db::schema::create_tables(&conn).unwrap();
2031 let db = Database { conn };
2032 let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2033 let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
2034 let mut cfg = Config::default();
2035 assert!(matches!(
2036 create_share(
2037 &db,
2038 queries::LOCAL_USER,
2039 &cfg,
2040 &ShareTarget::Tracks(vec![a]),
2041 None
2042 ),
2043 Err(ShareError::NoPublicUrl)
2044 ));
2045 cfg.sharing.public_url = Some("https://koan.example/".into());
2046 let out = create_share(
2047 &db,
2048 queries::LOCAL_USER,
2049 &cfg,
2050 &ShareTarget::Tracks(vec![b, 9999, a]),
2051 Some("mix"),
2052 )
2053 .unwrap();
2054 assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2055 assert_eq!((out.shared, out.skipped), (2, 1));
2056 let share = queries::shares::get_share(&db.conn, &out.id)
2057 .unwrap()
2058 .unwrap();
2059 assert_eq!(share.track_ids, [b, a]);
2060 assert!(matches!(
2061 create_share(
2062 &db,
2063 queries::LOCAL_USER,
2064 &cfg,
2065 &ShareTarget::Tracks(vec![9999]),
2066 None
2067 ),
2068 Err(ShareError::NothingToShare)
2069 ));
2070 }
2071
2072 fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
2073 let mut meta = sample_meta(title, "Rrose", album);
2074 meta.track_number = Some(n);
2075 meta.date = Some(date.into());
2076 upsert_track(&db.conn, &meta).unwrap()
2077 }
2078
2079 #[test]
2080 fn shares_are_slices_fixed_when_made() {
2081 let conn = rusqlite::Connection::open_in_memory().unwrap();
2082 conn.pragma_update(None, "foreign_keys", "on").unwrap();
2083 crate::db::schema::create_tables(&conn).unwrap();
2084 let db = Database { conn };
2085 let later = album_track(&db, "L1", "Later", 1, "2021");
2086 let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
2087 let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
2088 let album_of = |t| {
2089 queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
2090 .album_id
2091 .unwrap()
2092 };
2093 let (earlier, later_album) = (album_of(a1), album_of(later));
2094 let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
2095 .artist_id
2096 .unwrap();
2097
2098 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
2100 assert_eq!(
2101 (slice.kind, slice.subject_id, slice.start_track_id),
2102 (ShareKind::Album, Some(earlier), Some(a2))
2103 );
2104 assert_eq!(ids, [a1, a2]);
2105
2106 let (slice, ids) = resolve_share(
2108 &db.conn,
2109 &ShareTarget::Album {
2110 album_id: later_album,
2111 start_track_id: Some(a1),
2112 },
2113 )
2114 .unwrap();
2115 assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
2116 assert_eq!(ids, [later]);
2117
2118 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
2120 assert_eq!(
2121 (slice.kind, slice.subject_id),
2122 (ShareKind::Artist, Some(artist))
2123 );
2124 assert_eq!(ids, [a1, a2, later]);
2125
2126 let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
2128 assert_eq!(slice, Slice::TRACKS);
2129 assert_eq!(ids, [later, a1]);
2130
2131 assert!(matches!(
2132 resolve_share(&db.conn, &ShareTarget::Artist(9999)),
2133 Err(ShareError::NothingToShare)
2134 ));
2135 }
2136}
2137
2138#[cfg(test)]
2139mod album_share_id_tests {
2140 use super::album_share_id_for;
2141
2142 #[test]
2143 fn a_koan_album_is_named_as_an_album() {
2144 assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
2145 assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
2147 assert_eq!(album_share_id_for(false, "46215".into()), "46215");
2148 assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
2149 }
2150}