Skip to main content

koan_core/
helpers.rs

1//! Helpers shared by every front end: koan-tui, koan-server, koan-ffi and koan-cli.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, Mutex};
6
7use crate::config::Config;
8use crate::db::connection::Database;
9use crate::db::queries;
10use crate::db::queries::shares::{ShareKind, Slice};
11use crate::player::commands::PlayerCommand;
12use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
13use crate::remote::client::{SubsonicAuth, SubsonicClient, SubsonicError};
14use crate::remote::download::DownloadError;
15
16// ---------------------------------------------------------------------------
17// Subsonic client builder
18// ---------------------------------------------------------------------------
19
20/// The remote password, from `config.local.toml` or a `KOAN_REMOTE__PASSWORD`
21/// layered over it.
22pub fn get_remote_password(cfg: &Config) -> Option<String> {
23    (!cfg.remote.password.is_empty()).then(|| cfg.remote.password.clone())
24}
25
26/// Index files that appear in the library folders while koan is running.
27///
28/// One incremental scan shortly after startup — the walk is a fraction of a
29/// second even across fifty thousand files, and everything unchanged is skipped
30/// on its mtime and size — then a scan of whatever the folders say changed.
31///
32/// Only the directories events name are scanned: walking the whole library for
33/// one new album costs a spinning disk minutes. Events that cannot change the
34/// index — access, metadata, Syncthing's bookkeeping, partial downloads — are
35/// dropped before they count (see `index::watch`). The whole library is scanned
36/// only when the watcher reports it lost events, or when so many directories
37/// changed at once that walking them one by one would cost more.
38///
39/// Changes are debounced: copying an album in produces a burst of events, and
40/// scanning once per file would be both slow and pointless. A scan that lands
41/// halfway through a move is corrected by the scan the rest of the move's
42/// events bring, since a directory scan removes what is no longer under it.
43///
44/// The folder list is re-read and each folder's identity checked every half
45/// minute, so a folder added in settings is watched without a restart, and a
46/// volume unmounted and mounted again is watched afresh and rescanned.
47///
48/// `on_state` reports whether a scan is running, so a UI can show it.
49pub fn spawn_library_watch(
50    db_path: std::path::PathBuf,
51    on_state: impl Fn(bool) + Send + Sync + 'static,
52) -> Option<std::thread::JoinHandle<()>> {
53    use std::collections::BTreeSet;
54    use std::time::{Duration, Instant};
55
56    use notify::{RecursiveMode, Watcher};
57
58    use crate::index::scanner::{self, ScanOptions};
59    use crate::index::watch::{WatchedRoot, scan_target};
60
61    // Copying an album in is a burst of events. Wait for it to stop before
62    // scanning, rather than scanning per file.
63    const SETTLE: Duration = Duration::from_secs(5);
64    // How often the folder list and each folder's identity are checked.
65    const CHECK: Duration = Duration::from_secs(30);
66    // Past this many directories one walk of the library is cheaper than many.
67    const MAX_DIRS: usize = 200;
68    // A full scan now and then, for the events a platform drops without
69    // asking for a rescan. Unchanged files are skipped on mtime and size, so an
70    // idle one is a second's work.
71    const RESCAN: Duration = Duration::from_secs(15 * 60);
72
73    std::thread::Builder::new()
74        .name("koan-library-watch".into())
75        .spawn(move || {
76            let scan = |reason: &str, folders: &[PathBuf], dirs: Option<&[PathBuf]>| {
77                if folders.is_empty() {
78                    return;
79                }
80                let Ok(db) = Database::open_existing(&db_path) else {
81                    return;
82                };
83                on_state(true);
84                let result = match dirs {
85                    Some(dirs) => {
86                        scanner::scan_dirs(&db, folders, dirs, ScanOptions::default(), None)
87                    }
88                    None => scanner::full_scan(&db, folders, ScanOptions::default(), None),
89                };
90                on_state(false);
91                log::info!(
92                    "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
93                    result.added,
94                    result.updated,
95                    result.removed,
96                    result.skipped
97                );
98            };
99            let folders = || Config::cached().library.folders.clone();
100
101            let (tx, rx) = std::sync::mpsc::channel();
102            let Ok(mut watcher) = notify::recommended_watcher(move |event| {
103                let _ = tx.send(event);
104            }) else {
105                log::warn!("could not watch the library folders");
106                return;
107            };
108
109            // Brings the watches in line with the configured folders as they
110            // are now, returning the ones newly watched — added in settings, or
111            // back from being unmounted — whose changes nobody heard.
112            let mut roots: Vec<WatchedRoot> = Vec::new();
113            let mut rewatch = |roots: &mut Vec<WatchedRoot>| {
114                let wanted: Vec<WatchedRoot> = folders()
115                    .iter()
116                    .filter_map(|f| WatchedRoot::resolve(f))
117                    .collect();
118                roots.retain(|root| {
119                    let keep = wanted.contains(root);
120                    if !keep {
121                        let _ = watcher.unwatch(&root.path);
122                    }
123                    keep
124                });
125                let mut fresh = Vec::new();
126                for root in wanted {
127                    if roots.contains(&root) {
128                        continue;
129                    }
130                    match watcher.watch(&root.path, RecursiveMode::Recursive) {
131                        Ok(()) => {
132                            fresh.push(root.path.clone());
133                            roots.push(root);
134                        }
135                        Err(e) => log::warn!("could not watch {}: {e}", root.path.display()),
136                    }
137                }
138                fresh
139            };
140
141            // After the first frame and the first track, not competing with them.
142            std::thread::sleep(Duration::from_secs(3));
143            rewatch(&mut roots);
144            scan("startup", &folders(), None);
145
146            let mut dirs = BTreeSet::new();
147            let mut everything = false;
148            let mut settle_at: Option<Instant> = None;
149            let mut check_at = Instant::now() + CHECK;
150            let mut rescan_at = Instant::now() + RESCAN;
151            loop {
152                let now = Instant::now();
153                let wake = settle_at
154                    .map_or(check_at, |at| at.min(check_at))
155                    .min(rescan_at);
156                match rx.recv_timeout(wake.saturating_duration_since(now)) {
157                    Ok(Ok(event)) if event.need_rescan() => {
158                        everything = true;
159                        settle_at = Some(Instant::now() + SETTLE);
160                    }
161                    Ok(Ok(event)) => {
162                        let mut heard = false;
163                        for dir in event
164                            .paths
165                            .iter()
166                            .filter_map(|p| scan_target(&event.kind, p, &roots))
167                        {
168                            dirs.insert(dir);
169                            heard = true;
170                        }
171                        if heard {
172                            settle_at = Some(Instant::now() + SETTLE);
173                        }
174                    }
175                    Ok(Err(e)) => log::debug!("library watch: {e}"),
176                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
177                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
178                }
179
180                let now = Instant::now();
181                if settle_at.is_some_and(|at| now >= at) {
182                    let changed =
183                        scanner::minimal_dirs(std::mem::take(&mut dirs).into_iter().collect());
184                    if everything || changed.len() > MAX_DIRS {
185                        scan("watched change", &folders(), None);
186                        rescan_at = Instant::now() + RESCAN;
187                    } else {
188                        scan("watched change", &folders(), Some(&changed));
189                    }
190                    everything = false;
191                    settle_at = None;
192                }
193                if now >= rescan_at {
194                    scan("periodic", &folders(), None);
195                    rescan_at = Instant::now() + RESCAN;
196                }
197                if now >= check_at {
198                    let fresh = rewatch(&mut roots);
199                    if !fresh.is_empty() {
200                        scan("newly watched", &fresh, None);
201                    }
202                    check_at = Instant::now() + CHECK;
203                }
204            }
205        })
206        .ok()
207}
208
209/// Keep the library in step with the server, without being asked.
210///
211/// One sync shortly after startup, then every `auto_sync_interval_mins`. Always
212/// incremental: it asks the server what changed rather than walking the whole
213/// library, which is what makes it cheap enough to run unattended. A full sync
214/// stays a deliberate action.
215///
216/// The startup run is delayed a few seconds so it is not competing with the
217/// first frame and the first track for the disk.
218///
219/// `on_state` reports whether a sync is running, so a UI can say so rather than
220/// appearing to do nothing, and `on_progress` how far it has got. The first
221/// sync against a server has no watermark and walks the whole library.
222pub fn spawn_auto_sync(
223    db_path: std::path::PathBuf,
224    on_state: impl Fn(bool) + Send + 'static,
225    on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
226) -> Option<std::thread::JoinHandle<()>> {
227    std::thread::Builder::new()
228        .name("koan-auto-sync".into())
229        .spawn(move || {
230            std::thread::sleep(std::time::Duration::from_secs(5));
231            loop {
232                let cfg = Config::load().unwrap_or_default();
233                if !cfg.remote.enabled || !cfg.remote.auto_sync {
234                    // Re-read rather than exit: the setting can be turned on
235                    // while the app is running.
236                    std::thread::sleep(std::time::Duration::from_secs(60));
237                    continue;
238                }
239
240                if let Some(client) = subsonic_client(&cfg)
241                    && let Ok(db) = Database::open_existing(&db_path)
242                {
243                    on_state(true);
244                    match sync_remote(
245                        &db,
246                        &client,
247                        false,
248                        &cfg.remote.url,
249                        &cfg.remote.username,
250                        &on_progress,
251                    ) {
252                        Ok(s) => log::info!(
253                            "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
254                             favourites {}↑ {}↓; playlists {}↓ {}↑",
255                            s.library.artists_synced,
256                            s.library.albums_synced,
257                            s.library.tracks_synced,
258                            s.library.albums_failed,
259                            s.favourites.pushed,
260                            s.favourites.imported,
261                            s.playlists.pulled,
262                            s.playlists.pushed,
263                        ),
264                        Err(e) => log::warn!("auto sync failed: {e}"),
265                    }
266                    on_state(false);
267                }
268
269                match cfg.remote.auto_sync_interval_mins {
270                    // Once at startup and no more.
271                    0 => return,
272                    mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
273                }
274            }
275        })
276        .ok()
277}
278
279/// What a library rebuild removed.
280#[derive(Debug, Clone, Copy, Default)]
281pub struct RebuildSummary {
282    pub tracks: u64,
283    pub albums: u64,
284    pub artists: u64,
285}
286
287/// Drop the index so the next scan rebuilds it from the files.
288///
289/// Favourites are keyed on the file path rather than a row id, so they survive
290/// this and re-attach when the paths come back. Everything keyed on a track id
291/// cannot: lyrics, play history and acoustic embeddings go, and the foreign keys
292/// would refuse the delete otherwise. Lyrics and embeddings are re-derivable;
293/// play counts are not, which is worth saying out loud wherever this is offered.
294///
295/// The remote half of the library comes back on the next sync, the local half on
296/// the next scan.
297pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
298    let count = |sql: &str| -> u64 {
299        db.conn
300            .query_row(sql, [], |r| r.get::<_, i64>(0))
301            .unwrap_or(0) as u64
302    };
303    let summary = RebuildSummary {
304        tracks: count("SELECT COUNT(*) FROM tracks"),
305        albums: count("SELECT COUNT(*) FROM albums"),
306        artists: count("SELECT COUNT(*) FROM artists"),
307    };
308
309    // Children before parents; the FTS index has no foreign keys but is derived
310    // from tracks and would otherwise keep answering for rows that are gone.
311    db.conn.execute_batch(
312        "BEGIN;
313         DELETE FROM track_vectors;
314         DELETE FROM lyrics_cache;
315         DELETE FROM play_history;
316         DELETE FROM scan_cache;
317         DELETE FROM tracks_fts;
318         DELETE FROM tracks;
319         DELETE FROM similar_artists;
320         DELETE FROM albums;
321         DELETE FROM artists;
322         COMMIT;",
323    )?;
324    let _ = db.conn.execute_batch("VACUUM");
325    Ok(summary)
326}
327
328/// Remove whole albums from the download cache, least recently played first,
329/// until it is under the configured limit. Never an album with a favourite
330/// in it, nor one with a track in `keep`: the queue, whose files the player
331/// may be reading. Returns the bytes freed.
332pub fn evict_cache(
333    db: &Database,
334    cfg: &Config,
335    keep: &std::collections::HashSet<i64>,
336    verbose: bool,
337) -> u64 {
338    let Some(limit) = cfg.cache_limit_bytes().map(|l| l as i64) else {
339        return 0;
340    };
341    let mut current = match queries::total_cache_size(&db.conn) {
342        Ok(s) => s,
343        Err(e) => {
344            log::warn!("cache eviction: failed to query cache size: {e}");
345            return 0;
346        }
347    };
348    if current <= limit {
349        if verbose {
350            log::info!("cache within limit: {current} / {limit} bytes");
351        }
352        return 0;
353    }
354    let albums = match queries::cached_albums_lru(&db.conn) {
355        Ok(a) => a,
356        Err(e) => {
357            log::warn!("cache eviction: failed to query cached albums: {e}");
358            return 0;
359        }
360    };
361    let mut freed: i64 = 0;
362    for album in &albums {
363        if current <= limit {
364            break;
365        }
366        if album.track_ids.iter().any(|id| keep.contains(id)) {
367            continue;
368        }
369        for path in &album.cached_paths {
370            match std::fs::remove_file(path) {
371                Ok(()) => {}
372                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
373                Err(e) => log::warn!("cache eviction: failed to delete {path}: {e}"),
374            }
375        }
376        if let Err(e) = queries::clear_cached_paths_for(&db.conn, &album.track_ids) {
377            log::warn!("cache eviction: failed to clear DB for album: {e}");
378        }
379        log::info!(
380            "evicted: {} — {} ({} bytes)",
381            album.artist_name,
382            album.album_title,
383            album.total_size
384        );
385        current -= album.total_size;
386        freed += album.total_size;
387    }
388    remove_empty_dirs(&cfg.cache_dir());
389    if freed > 0 {
390        log::info!("cache eviction freed {freed} bytes");
391    }
392    freed as u64
393}
394
395/// Remove empty directories under `dir`, leaving `dir` itself.
396fn remove_empty_dirs(dir: &Path) {
397    if !dir.is_dir() {
398        return;
399    }
400    for entry in walkdir::WalkDir::new(dir)
401        .contents_first(true)
402        .into_iter()
403        .filter_map(Result::ok)
404        .filter(|e| e.file_type().is_dir() && e.path() != dir)
405    {
406        let _ = std::fs::remove_dir(entry.path());
407    }
408}
409
410/// Bytes currently held in the download cache.
411pub fn cache_size_bytes(cfg: &Config) -> u64 {
412    walkdir::WalkDir::new(cfg.cache_dir())
413        .into_iter()
414        .filter_map(Result::ok)
415        .filter(|e| e.file_type().is_file())
416        .filter_map(|e| e.metadata().ok())
417        .map(|m| m.len())
418        .sum()
419}
420
421/// How many tracks came from this folder.
422///
423/// The trailing separator matters: without it `/Volumes/Music` also counts
424/// `/Volumes/Music Backup`.
425pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
426    let (lower, upper) = queries::folder_prefix_range(folder);
427    db.conn
428        .query_row(
429            "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
430            [&lower, &upper],
431            |r| r.get::<_, i64>(0),
432        )
433        .unwrap_or(0) as u64
434}
435
436/// How many tracks the server accounts for.
437pub fn tracks_from_server(db: &Database) -> u64 {
438    db.conn
439        .query_row(
440            "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
441            [],
442            |r| r.get::<_, i64>(0),
443        )
444        .unwrap_or(0) as u64
445}
446
447/// Forget every track under a folder.
448///
449/// Removing a folder from the library should remove what it put there —
450/// otherwise the library keeps showing records whose files it will never look
451/// at again, and there is no way back to an empty library short of clearing the
452/// whole index.
453///
454/// A track that also exists on the server keeps its row and loses only its local
455/// path: it is still playable, just by download rather than from disk.
456///
457/// Albums and artists left holding nothing go too, or the browser fills with
458/// empty shelves.
459pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
460    // Rows are keyed by the disk's spelling; a folder named the other way would forget nothing.
461    let folder = &crate::index::spelling::on_disk(folder);
462    let (lower, upper) = queries::folder_prefix_range(folder);
463
464    let tx = crate::db::queries::write_transaction(&db.conn)?;
465    // Still on the server: keep the row, drop the local file.
466    tx.execute(
467        "UPDATE tracks SET path = NULL, source = 'remote'
468          WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
469        [&lower, &upper],
470    )?;
471
472    let ids: Vec<i64> = {
473        let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
474        let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
475        rows.filter_map(Result::ok).collect()
476    };
477    delete_track_rows(&tx, &ids)?;
478    prune_empty_albums_and_artists(&tx)?;
479    tx.commit()?;
480    Ok(ids.len() as u64)
481}
482
483fn delete_track_rows(conn: &rusqlite::Connection, ids: &[i64]) -> rusqlite::Result<()> {
484    for id in ids {
485        conn.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
486        conn.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
487        conn.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
488        conn.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
489        conn.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
490        conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
491    }
492    Ok(())
493}
494
495/// Forget everything that only existed on the server.
496///
497/// Signing out should leave the library with what is actually on this machine.
498/// A track held both locally and remotely keeps its row and loses its remote id;
499/// one that only ever came from the server goes.
500pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
501    let tx = crate::db::queries::write_transaction(&db.conn)?;
502
503    let ids: Vec<i64> = {
504        let mut stmt =
505            tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
506        let rows = stmt.query_map([], |r| r.get(0))?;
507        rows.filter_map(Result::ok).collect()
508    };
509    delete_track_rows(&tx, &ids)?;
510    // Local copies stay, minus the server they were also on.
511    tx.execute(
512        "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
513          WHERE remote_id IS NOT NULL",
514        [],
515    )?;
516    tx.execute("DELETE FROM similar_artists", [])?;
517    prune_empty_albums_and_artists(&tx)?;
518    tx.commit()?;
519    Ok(ids.len() as u64)
520}
521
522/// Albums and artists with nothing left in them.
523fn prune_empty_albums_and_artists(
524    tx: &rusqlite::Transaction<'_>,
525) -> Result<(), crate::db::connection::DbError> {
526    tx.execute(
527        "DELETE FROM albums WHERE NOT EXISTS
528           (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
529        [],
530    )?;
531    tx.execute(
532        "DELETE FROM similar_artists WHERE NOT EXISTS
533           (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
534        [],
535    )?;
536    tx.execute(
537        "DELETE FROM artists WHERE NOT EXISTS
538             (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
539           AND NOT EXISTS
540             (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
541        [],
542    )?;
543    Ok(())
544}
545
546/// What clearing the download cache removed.
547#[derive(Debug, Clone, Copy, Default)]
548pub struct CacheCleared {
549    pub files: u64,
550    pub bytes: u64,
551}
552
553/// Delete every downloaded remote track and forget where they were.
554///
555/// The rows stay — a remote track is still in the library, it just has to be
556/// fetched again to play.
557pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
558    let dir = cfg.cache_dir();
559    let mut cleared = CacheCleared::default();
560    for entry in walkdir::WalkDir::new(&dir)
561        .into_iter()
562        .filter_map(Result::ok)
563        .filter(|e| e.file_type().is_file())
564    {
565        if let Ok(meta) = entry.metadata() {
566            cleared.bytes += meta.len();
567            cleared.files += 1;
568        }
569    }
570    let _ = std::fs::remove_dir_all(&dir);
571    let _ = std::fs::create_dir_all(&dir);
572    let _ = queries::clear_cached_paths(&db.conn);
573    cleared
574}
575
576/// Delete the downloaded copies of just these tracks.
577///
578/// The per-track counterpart of `clear_download_cache`, for throwing away one
579/// record rather than the lot. A track playing from a copy being removed keeps
580/// playing — the decoder holds the file open, and unlinking it only takes the
581/// name away — but the next play fetches it again.
582pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
583    let mut cleared = CacheCleared::default();
584    let paths = match queries::cached_paths_for(&db.conn, track_ids) {
585        Ok(paths) => paths,
586        Err(e) => {
587            log::warn!("could not read cached paths: {e}");
588            return cleared;
589        }
590    };
591    for path in &paths {
592        let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
593        match std::fs::remove_file(path) {
594            Ok(()) => {
595                cleared.files += 1;
596                cleared.bytes += size;
597            }
598            // Already gone is the outcome asked for, so it is not a failure.
599            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
600            Err(e) => log::warn!("could not remove {path}: {e}"),
601        }
602    }
603    if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
604        log::warn!("removed downloads but failed to forget them ({e})");
605    }
606    cleared
607}
608
609/// Throw away half-finished downloads left behind by a previous run.
610///
611/// A `.part` file only means something to the transfer writing it. koan does
612/// not resume — the file is written straight through and renamed at the end —
613/// so one still on disk at startup is from a run that did not finish, and it
614/// will be truncated and rewritten the next time that track is wanted anyway.
615/// Until then it is bytes nothing knows about: cache eviction only tracks what
616/// finished, so an interrupted download of a nine-hour recording is half a
617/// gigabyte that never gets reclaimed.
618///
619/// At startup rather than at exit, because a run that ends without getting to
620/// its own cleanup is exactly the run that leaves these behind.
621pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
622    let mut swept = CacheCleared::default();
623    for entry in walkdir::WalkDir::new(cfg.cache_dir())
624        .into_iter()
625        .filter_map(Result::ok)
626        .filter(|e| e.file_type().is_file())
627        .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
628    {
629        let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
630        match std::fs::remove_file(entry.path()) {
631            Ok(()) => {
632                swept.files += 1;
633                swept.bytes += size;
634            }
635            Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
636        }
637    }
638    if swept.files > 0 {
639        log::info!(
640            "swept {} unfinished download(s), {} bytes",
641            swept.files,
642            swept.bytes
643        );
644    }
645    swept
646}
647
648/// Re-root cached paths that name a cache directory other than `cache_dir`.
649///
650/// iOS gives an app a new container path when it is updated. The cache moves
651/// with it, but the absolute paths stored for its files do not, so every
652/// download looks missing: tracks are fetched again beside the copies already
653/// there, and eviction cannot find the old ones to delete. The cache lays
654/// files out as `<cache>/<artist>/<album>/<file>` (`cache_path_for_track`), so
655/// a stale path is re-rooted on its last three components when that file
656/// exists under `cache_dir`. Paths whose file is not there are left alone; the
657/// next play resolves them as it would any missing download.
658///
659/// Returns the number of paths rewritten.
660pub fn relocate_cached_paths(db: &Database, cache_dir: &Path) -> rusqlite::Result<usize> {
661    let prefix = format!("{}/", cache_dir.to_string_lossy().trim_end_matches('/'));
662    let stale: Vec<(i64, String)> = db
663        .conn
664        .prepare(
665            "SELECT id, cached_path FROM tracks
666             WHERE cached_path IS NOT NULL AND substr(cached_path, 1, ?2) != ?1",
667        )?
668        .query_map(
669            rusqlite::params![prefix, prefix.chars().count() as i64],
670            |r| Ok((r.get(0)?, r.get(1)?)),
671        )?
672        .collect::<rusqlite::Result<_>>()?;
673    if stale.is_empty() {
674        return Ok(0);
675    }
676
677    let tx = crate::db::queries::write_transaction(&db.conn)?;
678    let mut moved = 0;
679    for (id, old) in &stale {
680        let tail: Vec<_> = Path::new(old).components().rev().take(3).collect();
681        if tail.len() < 3 {
682            continue;
683        }
684        let new = tail
685            .iter()
686            .rev()
687            .fold(cache_dir.to_path_buf(), |p, c| p.join(c));
688        if new.is_file() {
689            tx.execute(
690                "UPDATE tracks SET cached_path = ?1 WHERE id = ?2",
691                rusqlite::params![new.to_string_lossy(), id],
692            )?;
693            moved += 1;
694        }
695    }
696    tx.commit()?;
697    if moved > 0 {
698        log::info!(
699            "re-rooted {moved} cached path(s) under {}",
700            cache_dir.display()
701        );
702    }
703    Ok(moved)
704}
705
706/// Fetch again anything in the queue whose downloaded copy has just been
707/// removed.
708///
709/// Clearing downloads deletes files the queue is still pointing at, and an item
710/// that goes on claiming to be ready plays nothing at all. Call this after
711/// either clearing function, from anywhere with a player attached.
712pub fn requeue_cleared_downloads(
713    state: &Arc<SharedPlayerState>,
714    tx: &crossbeam_channel::Sender<PlayerCommand>,
715) {
716    let stale = state.reset_items_with_missing_files();
717    if stale.is_empty() {
718        return;
719    }
720    log::info!(
721        "{} queued tracks lost their copy — fetching again",
722        stale.len()
723    );
724    spawn_downloads(stale, tx.clone(), state.clone());
725}
726
727/// Push a favourite to the remote server, if this track came from one.
728///
729/// Fire and forget on its own thread: starring is a courtesy to the server, and
730/// a slow or unreachable one should not hold up the click that caused it. The
731/// local favourite is already written by the time this runs.
732///
733/// Silently does nothing for a track with no `remote_id` — including a local
734/// file whose copy on the server failed to merge with it (#221), which is the
735/// one case where the silence is wrong.
736///
737/// Shared by the TUI, the server and the app.
738pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
739    let cfg = Config::load().unwrap_or_default();
740    if !cfg.remote.enabled {
741        return;
742    }
743    let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
744        log::warn!("not syncing favourite: {} has no remote id", path.display());
745        return;
746    };
747    let Some(client) = subsonic_client(&cfg) else {
748        log::warn!("not syncing favourite: no usable server credentials");
749        return;
750    };
751    std::thread::Builder::new()
752        .name("koan-fav-sync".into())
753        .spawn(move || {
754            let result = if star {
755                client.star(&remote_id)
756            } else {
757                client.unstar(&remote_id)
758            };
759            match result {
760                Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
761                Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
762            }
763        })
764        .ok();
765}
766
767/// Everything a sync is.
768#[derive(Debug, Default)]
769pub struct FullSync {
770    pub library: crate::remote::sync::SyncResult,
771    pub favourites: FavouriteSync,
772    pub playlists: crate::playlists::PlaylistSync,
773}
774
775/// Pull the library, then reconcile favourites and playlists.
776///
777/// One function because there are four callers — the app, the CLI, the GraphQL
778/// job and koan's own auto-sync — and each must sync the same things.
779///
780/// The library comes first: favourites and playlists both name tracks by the
781/// server's ids, and neither can find a track the library has not seen yet.
782pub fn sync_remote(
783    db: &Database,
784    client: &SubsonicClient,
785    full: bool,
786    url: &str,
787    username: &str,
788    progress: &(dyn Fn(crate::remote::sync::SyncProgress) + Sync),
789) -> Result<FullSync, crate::remote::sync::SyncError> {
790    // One at a time. An automatic sync still reconciling favourites when a
791    // full sync was asked for wrote under it, and each fought the other for
792    // the write lock. The second waits for the first, and then has little
793    // left to do.
794    static SYNCING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
795    let _one_at_a_time = SYNCING.lock();
796    let library = crate::remote::sync::sync_library(db, client, full, url, username, progress)?;
797    Ok(FullSync {
798        library,
799        favourites: reconcile_favourites(db, client),
800        playlists: crate::playlists::reconcile_playlists(db, client, url, username),
801    })
802}
803
804/// What a favourites reconciliation did.
805#[derive(Debug, Default, Clone, Copy)]
806pub struct FavouriteSync {
807    pub pushed: usize,
808    pub imported: usize,
809}
810
811/// Reconcile favourites with the server, both directions.
812///
813/// Stars every local favourite the server knows about but has not starred,
814/// then imports everything the server has starred. Union rather than mirror:
815/// neither side records an unstar, so treating one as authoritative would
816/// silently delete favourites made on the other. Reading the server's stars
817/// first keeps a sync from re-sending every favourite, one request each.
818///
819/// Covers albums and artists as well as tracks — `getStarred2` returns all
820/// three from one request, and reading only songs would leave a starred album
821/// invisible to koan.
822pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
823    let mut out = FavouriteSync::default();
824
825    let starred = match client.get_starred_all() {
826        Ok(s) => s,
827        Err(e) => {
828            log::warn!("could not fetch starred items from the server: {e}");
829            return out;
830        }
831    };
832    let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
833    let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
834    let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
835
836    let unstarred = |ids: Vec<String>, starred: &[String]| {
837        let starred: std::collections::HashSet<&String> = starred.iter().collect();
838        ids.into_iter()
839            .filter(|id| !starred.contains(id))
840            .collect::<Vec<_>>()
841    };
842    let tracks = queries::favourites_with_remote_id(&db.conn, queries::LOCAL_USER)
843        .unwrap_or_default()
844        .into_iter()
845        .map(|(_, id)| id)
846        .collect();
847    for remote_id in unstarred(tracks, &songs) {
848        if client.star(&remote_id).is_ok() {
849            out.pushed += 1;
850        }
851    }
852    let local_albums = queries::favourite_albums_with_remote_id(&db.conn, queries::LOCAL_USER)
853        .unwrap_or_default()
854        .into_iter()
855        .map(|(_, id)| id)
856        .collect();
857    for remote_id in unstarred(local_albums, &albums) {
858        if client.star_album(&remote_id).is_ok() {
859            out.pushed += 1;
860        }
861    }
862    let local_artists = queries::favourite_artists_with_remote_id(&db.conn, queries::LOCAL_USER)
863        .unwrap_or_default()
864        .into_iter()
865        .map(|(_, id)| id)
866        .collect();
867    for remote_id in unstarred(local_artists, &artists) {
868        if client.star_artist(&remote_id).is_ok() {
869            out.pushed += 1;
870        }
871    }
872
873    out.imported +=
874        queries::import_remote_favourites(&db.conn, queries::LOCAL_USER, &songs).unwrap_or(0);
875    out.imported += queries::import_remote_favourite_albums(&db.conn, queries::LOCAL_USER, &albums)
876        .unwrap_or(0);
877    out.imported +=
878        queries::import_remote_favourite_artists(&db.conn, queries::LOCAL_USER, &artists)
879            .unwrap_or(0);
880    out
881}
882
883/// What a favourite applies to. Subsonic stars all three, under different
884/// parameter names — passing an album id as `id` silently stars nothing.
885#[derive(Debug, Clone, Copy, PartialEq, Eq)]
886pub enum FavouriteKind {
887    Track,
888    Album,
889    Artist,
890}
891
892/// Push an album or artist favourite to the server.
893///
894/// Same shape as [`sync_favourite_to_remote`], but the remote id comes from the
895/// album or artist row rather than the track's path.
896pub fn sync_collection_favourite_to_remote(
897    db: &Database,
898    kind: FavouriteKind,
899    id: i64,
900    star: bool,
901) {
902    let cfg = Config::load().unwrap_or_default();
903    if !cfg.remote.enabled {
904        return;
905    }
906    let remote_id = match kind {
907        FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
908        FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
909        FavouriteKind::Track => return,
910    };
911    let Ok(Some(remote_id)) = remote_id else {
912        log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
913        return;
914    };
915    let Some(client) = subsonic_client(&cfg) else {
916        log::warn!("not syncing favourite: no usable server credentials");
917        return;
918    };
919    std::thread::Builder::new()
920        .name("koan-fav-sync".into())
921        .spawn(move || {
922            let result = match (kind, star) {
923                (FavouriteKind::Album, true) => client.star_album(&remote_id),
924                (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
925                (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
926                (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
927                (FavouriteKind::Track, _) => Ok(()),
928            };
929            match result {
930                Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
931                Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
932            }
933        })
934        .ok();
935}
936
937/// Why signing in to a remote server failed.
938#[derive(Debug, thiserror::Error)]
939pub enum SignInError {
940    #[error("the server did not accept those credentials: {0}")]
941    Rejected(#[from] crate::remote::client::SubsonicError),
942    #[error("could not write the configuration: {0}")]
943    Config(#[from] crate::config::ConfigError),
944}
945
946/// Sign in to a Subsonic/Navidrome server and remember it.
947///
948/// The password goes to `config.local.toml`, which is gitignored and written
949/// `0600`. Subsonic authenticates every request with the password or a salted
950/// MD5 of it, so there is no token to hold instead — whatever koan keeps is
951/// password-equivalent wherever it is kept.
952///
953/// The credentials are checked against the server before anything is written; a
954/// stored password that does not work is worse than none.
955///
956/// Shared by the CLI and the app so the two cannot disagree about where
957/// credentials live.
958pub fn set_remote_credentials(
959    url: &str,
960    username: &str,
961    password: &str,
962) -> Result<(), SignInError> {
963    let url = url.trim_end_matches('/');
964    SubsonicClient::new(url, username, password).ping()?;
965
966    Config::persist(|cfg| {
967        cfg.remote.enabled = true;
968        cfg.remote.url = url.to_string();
969        cfg.remote.username = username.to_string();
970        cfg.remote.password = password.to_string();
971    })?;
972    // The link rests for up to a minute while signed out; the profile Settings
973    // shows is probed when it wakes.
974    crate::remote::link::nudge();
975    Ok(())
976}
977
978/// Shared secret for koan's own Subsonic API.
979///
980/// Deliberately not the same secret as `get_remote_password` — see `SubsonicConfig`.
981pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
982    (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
983}
984
985/// Upstream Subsonic credentials from the merged config, returning `None` if
986/// remote is disabled or has no URL configured.
987///
988/// Prefer this over `subsonic_client` when only a signed URL is needed:
989/// building a client constructs blocking `reqwest` clients, which panics from
990/// inside a tokio runtime.
991pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
992    if !cfg.remote.enabled || cfg.remote.url.is_empty() {
993        return None;
994    }
995    let password = get_remote_password(cfg)?;
996    Some(SubsonicAuth::new(
997        &cfg.remote.url,
998        &cfg.remote.username,
999        &password,
1000    ))
1001}
1002
1003/// One `SubsonicClient` per set of credentials, shared process-wide.
1004///
1005/// Constructing one builds two blocking `reqwest` clients, each carrying its
1006/// own runtime on its own thread, and each starting with a cold connection
1007/// pool — so a client per call means a fresh TLS handshake for every cover art
1008/// request.
1009///
1010/// Keyed on the credentials, so logging in as someone else replaces the client
1011/// rather than serving the old one. Never call from async code: building the
1012/// inner clients panics inside a tokio runtime.
1013pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
1014    let auth = subsonic_auth(cfg)?;
1015
1016    let mut slot = SUBSONIC_CLIENT.lock();
1017    if let Some((cached, client)) = slot.as_ref()
1018        && *cached == auth
1019    {
1020        return Some(client.clone());
1021    }
1022
1023    let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
1024    *slot = Some((auth, client.clone()));
1025    Some(client)
1026}
1027
1028type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
1029
1030static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
1031    std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
1032
1033// ---------------------------------------------------------------------------
1034// Sharing
1035// ---------------------------------------------------------------------------
1036
1037/// Why a share link could not be made. Each variant is something the user can
1038/// act on.
1039#[derive(Debug, thiserror::Error)]
1040pub enum ShareError {
1041    #[error("sharing.public_url is not set, so there is no address to give out")]
1042    NoPublicUrl,
1043    #[error("none of these tracks are in the library")]
1044    NothingToShare,
1045    #[error("none of these tracks are on the server, so a link has nothing to point at")]
1046    NothingRemote,
1047    #[error("the server refused to share these: {0}")]
1048    Server(#[from] crate::remote::client::SubsonicError),
1049    #[error(transparent)]
1050    Database(#[from] crate::db::connection::DbError),
1051}
1052
1053/// A created share link, and how much of the request it covers.
1054#[derive(Debug, Clone)]
1055pub struct ShareOutcome {
1056    pub url: String,
1057    /// The server's own ID for the share, for callers that manage them.
1058    pub id: String,
1059    /// Tracks the server knows about, which went into the link.
1060    pub shared: usize,
1061    /// Tracks with no copy on the server, left out of it.
1062    pub skipped: usize,
1063}
1064
1065/// What a share link is asked to cover.
1066#[derive(Debug, Clone, PartialEq, Eq)]
1067pub enum ShareTarget {
1068    /// Loose tracks. A single track shares its album, cued to that track.
1069    Tracks(Vec<i64>),
1070    /// An album, optionally cued to one of its tracks.
1071    Album {
1072        album_id: i64,
1073        start_track_id: Option<i64>,
1074    },
1075    /// An artist's albums, in release order.
1076    Artist(i64),
1077}
1078
1079/// The slice a target makes and the tracks it covers, in play order. Only
1080/// tracks in the library are included; the list is fixed from here on.
1081///
1082/// A single track becomes its album cued to it: a song is heard in the
1083/// record it belongs to, the way the app shows it.
1084pub fn resolve_share(
1085    conn: &rusqlite::Connection,
1086    target: &ShareTarget,
1087) -> Result<(Slice, Vec<i64>), ShareError> {
1088    let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
1089        Ok(queries::tracks_for_album(conn, album_id)?
1090            .into_iter()
1091            .map(|t| t.id)
1092            .collect())
1093    };
1094    let (slice, ids) = match target {
1095        ShareTarget::Tracks(ids) => {
1096            let rows = queries::tracks_by_ids(conn, ids)?;
1097            match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
1098                ([one], Some(album_id)) => {
1099                    return resolve_share(
1100                        conn,
1101                        &ShareTarget::Album {
1102                            album_id,
1103                            start_track_id: Some(*one),
1104                        },
1105                    );
1106                }
1107                _ => {
1108                    // The order asked for, which is the order the page plays them in.
1109                    let ids = ids
1110                        .iter()
1111                        .copied()
1112                        .filter(|id| rows.iter().any(|t| t.id == *id))
1113                        .collect();
1114                    (Slice::TRACKS, ids)
1115                }
1116            }
1117        }
1118        ShareTarget::Album {
1119            album_id,
1120            start_track_id,
1121        } => {
1122            let ids = album_tracks(*album_id)?;
1123            let slice = Slice {
1124                kind: ShareKind::Album,
1125                subject_id: Some(*album_id),
1126                start_track_id: start_track_id.filter(|s| ids.contains(s)),
1127            };
1128            (slice, ids)
1129        }
1130        ShareTarget::Artist(artist_id) => {
1131            let mut ids = Vec::new();
1132            for album in queries::albums_for_artist(conn, *artist_id)? {
1133                ids.extend(album_tracks(album.id)?);
1134            }
1135            let slice = Slice {
1136                kind: ShareKind::Artist,
1137                subject_id: Some(*artist_id),
1138                start_track_id: None,
1139            };
1140            (slice, ids)
1141        }
1142    };
1143    if ids.is_empty() {
1144        return Err(ShareError::NothingToShare);
1145    }
1146    Ok((slice, ids))
1147}
1148
1149/// Create a public share link for a slice of the library.
1150///
1151/// With a remote Subsonic server configured, the link is made there: a laptop
1152/// or phone shares through the server it plays from, which may be another
1153/// koan. Without one this koan is the server, and makes the link itself.
1154///
1155/// A link points at the server, so only tracks the server knows about can go in
1156/// it. A mixed selection shares the part that can be shared and reports the
1157/// rest rather than failing whole — half a link beats none, as long as the
1158/// caller says which half.
1159///
1160/// `user` is who is sharing, recorded on a link this koan makes itself.
1161///
1162/// May be network-bound. Callers keep it off whatever thread draws.
1163pub fn create_share(
1164    db: &Database,
1165    user: i64,
1166    cfg: &Config,
1167    target: &ShareTarget,
1168    description: Option<&str>,
1169) -> Result<ShareOutcome, ShareError> {
1170    let Some(client) = subsonic_client(cfg) else {
1171        return create_native_share(db, user, cfg, target, description);
1172    };
1173    // A remote server makes its own kind of link from what it is given, so it
1174    // is given exactly what was picked.
1175    let resolved;
1176    let track_ids = match target {
1177        ShareTarget::Tracks(ids) => ids.as_slice(),
1178        _ => {
1179            resolved = resolve_share(&db.conn, target)?.1;
1180            resolved.as_slice()
1181        }
1182    };
1183
1184    // One query, not one per track: sharing an artist is thousands of tracks.
1185    let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
1186
1187    let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
1188    if shared == 0 {
1189        return Err(ShareError::NothingRemote);
1190    }
1191
1192    // A whole record shares as one album rather than as N tracks — the server
1193    // renders it as the album it is, and the link survives the user adding to
1194    // it. Only when the selection is the whole album.
1195    let one_album = rows
1196        .first()
1197        .and_then(|f| f.album_id)
1198        .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
1199        .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
1200
1201    let remote_ids: Vec<String> = match one_album {
1202        Some(rid) => vec![album_share_id(&client, rid)],
1203        None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
1204    };
1205
1206    let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
1207    let share = client.create_share(&refs, description)?;
1208
1209    // Navidrome does not always hand back a URL, and a share with no link is
1210    // useless to the caller — the ID is enough to build it.
1211    let url = share
1212        .url
1213        .clone()
1214        .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
1215
1216    Ok(ShareOutcome {
1217        url,
1218        id: share.id,
1219        shared,
1220        skipped: track_ids.len().saturating_sub(shared),
1221    })
1222}
1223
1224/// A share this koan serves at `{sharing.public_url}/share/{id}`.
1225///
1226/// What a server's own surfaces make whatever `[remote]` says: a link made
1227/// upstream would belong to the upstream's account, not the koan user who
1228/// asked, and could not be listed or revoked here.
1229pub fn create_native_share(
1230    db: &Database,
1231    user: i64,
1232    cfg: &Config,
1233    target: &ShareTarget,
1234    description: Option<&str>,
1235) -> Result<ShareOutcome, ShareError> {
1236    let base = cfg
1237        .sharing
1238        .public_url
1239        .as_deref()
1240        .filter(|u| !u.trim().is_empty())
1241        .ok_or(ShareError::NoPublicUrl)?;
1242    let (slice, ids) = resolve_share(&db.conn, target)?;
1243    let now = std::time::SystemTime::now()
1244        .duration_since(std::time::UNIX_EPOCH)
1245        .map_or(0, |d| d.as_secs() as i64);
1246    let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
1247    Ok(ShareOutcome {
1248        url: share_url(base, &share.id),
1249        id: share.id,
1250        shared: ids.len(),
1251        // Only loose tracks are named one by one, so only they can be missing.
1252        skipped: match (target, slice.kind) {
1253            (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
1254            _ => 0,
1255        },
1256    })
1257}
1258
1259/// A native share's public address.
1260pub fn share_url(public_url: &str, id: &str) -> String {
1261    format!("{}/share/{id}", public_url.trim_end_matches('/'))
1262}
1263
1264/// An album's id as `createShare` should be given it.
1265///
1266/// koan numbers albums and songs separately, publishes album ids bare, and
1267/// reads a bare id in `createShare` as a song, so album 5 would share song 5.
1268/// Its `al-` prefix says which is meant. Other servers get the id as they
1269/// issued it: some also number albums, and would not know the prefix.
1270fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
1271    let koan = crate::remote::profile::is_koan(client.auth());
1272    album_share_id_for(koan, remote_id)
1273}
1274
1275fn album_share_id_for(koan: bool, remote_id: String) -> String {
1276    if koan && remote_id.parse::<i64>().is_ok() {
1277        format!("al-{remote_id}")
1278    } else {
1279        remote_id
1280    }
1281}
1282
1283/// The album's own remote ID, but only when `selected` covers every track on
1284/// it. Sharing an album link for half an album would hand out more than the
1285/// user picked.
1286fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
1287    let (remote_id, total): (Option<String>, i64) = conn
1288        .query_row(
1289            "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
1290             FROM albums al WHERE al.id = ?1",
1291            [album_id],
1292            |row| Ok((row.get(0)?, row.get(1)?)),
1293        )
1294        .ok()?;
1295    (total == selected as i64).then_some(remote_id).flatten()
1296}
1297
1298// ---------------------------------------------------------------------------
1299// Path utilities
1300// ---------------------------------------------------------------------------
1301
1302/// Fisher-Yates over a fresh seed, so consecutive calls differ.
1303///
1304/// Deliberately not seeded from anything stable: "shuffle again" has to
1305/// actually produce a new order, which a process-lifetime seed wouldn't.
1306pub fn shuffle<T>(items: &mut [T]) {
1307    let mut seed = [0u8; 8];
1308    if getrandom::fill(&mut seed).is_err() {
1309        return; // Leave the order alone rather than pretending to shuffle.
1310    }
1311    let mut state = u64::from_le_bytes(seed) | 1;
1312    for i in (1..items.len()).rev() {
1313        // xorshift64 — plenty for shuffling a list nobody is betting on.
1314        state ^= state << 13;
1315        state ^= state >> 7;
1316        state ^= state << 17;
1317        items.swap(i, (state % (i as u64 + 1)) as usize);
1318    }
1319}
1320
1321/// Truncate a string to at most `max` bytes, cutting on a char boundary.
1322pub fn truncate_bytes(s: &str, max: usize) -> &str {
1323    if s.len() <= max {
1324        return s;
1325    }
1326    let mut end = max;
1327    while end > 0 && !s.is_char_boundary(end) {
1328        end -= 1;
1329    }
1330    &s[..end]
1331}
1332
1333/// Sanitise and truncate a string for use as a path component.
1334/// Strips illegal chars and caps at 240 bytes (macOS 255-byte filename limit minus room for ext).
1335/// `.` and `..` become `_`: tags and server metadata are untrusted, and either
1336/// would move the path out of the directory it is joined onto.
1337pub fn sanitise_filename(s: &str) -> String {
1338    let cleaned: String = s
1339        .chars()
1340        .map(|c| match c {
1341            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1342            _ => c,
1343        })
1344        .collect::<String>()
1345        .trim()
1346        .to_string();
1347
1348    let cleaned = truncate_bytes(&cleaned, 240).trim_end().to_string();
1349    match cleaned.as_str() {
1350        "." | ".." => "_".into(),
1351        _ => cleaned,
1352    }
1353}
1354
1355/// A file extension from a codec name, which for remote tracks is whatever
1356/// the server sent as `suffix`. ASCII alphanumerics only, so it can never
1357/// carry a separator or a `..`; `None` when nothing usable is left.
1358pub fn sanitise_extension(codec: &str) -> Option<String> {
1359    let ext: String = codec
1360        .chars()
1361        .filter(char::is_ascii_alphanumeric)
1362        .take(16)
1363        .collect::<String>()
1364        .to_lowercase();
1365    (!ext.is_empty()).then_some(ext)
1366}
1367
1368/// Whether `path` lies inside `dir` without leaving it on the way — every
1369/// component after the prefix is a plain name.
1370pub fn path_within(dir: &Path, path: &Path) -> bool {
1371    path.strip_prefix(dir).is_ok_and(|rest| {
1372        rest.components()
1373            .all(|c| matches!(c, std::path::Component::Normal(_)))
1374    })
1375}
1376
1377/// The year a tag date starts with. `get`, not a slice: a date is free text,
1378/// and a multibyte character in its first four bytes would panic a slice.
1379pub fn year_of(date: &str) -> Option<&str> {
1380    date.get(..4)
1381}
1382
1383/// Build a structured cache path for a track:
1384///   cache_dir/Album Artist/(Year) Album [Codec]/01. Track Artist - Title.ext
1385pub fn cache_path_for_track(
1386    cache_dir: &Path,
1387    track: &queries::TrackRow,
1388    album_date: Option<&str>,
1389) -> PathBuf {
1390    let artist_dir = sanitise_filename(&track.artist_name);
1391
1392    let year = album_date
1393        .and_then(year_of)
1394        .map(|y| format!("({}) ", y))
1395        .unwrap_or_default();
1396    let codec = track
1397        .codec
1398        .as_deref()
1399        .map(|c| format!(" [{}]", c))
1400        .unwrap_or_default();
1401    let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1402
1403    let disc_prefix = match track.disc {
1404        Some(d) if d > 1 => format!("{}-", d),
1405        _ => String::new(),
1406    };
1407    let track_num = track
1408        .track_number
1409        .map(|n| format!("{:02}. ", n))
1410        .unwrap_or_default();
1411
1412    let ext = track
1413        .codec
1414        .as_deref()
1415        .and_then(sanitise_extension)
1416        .unwrap_or_else(|| "flac".into());
1417
1418    let filename = sanitise_filename(&format!(
1419        "{}{}{} - {}",
1420        disc_prefix, track_num, track.artist_name, track.title
1421    ));
1422
1423    cache_dir
1424        .join(artist_dir)
1425        .join(album_dir)
1426        .join(format!("{}.{}", filename, ext))
1427}
1428
1429// ---------------------------------------------------------------------------
1430// Track resolution
1431// ---------------------------------------------------------------------------
1432
1433/// Resolve a track to its path + load state (without downloading).
1434/// Returns (path, `ItemState::Ready`) for local/cached, (cache path, `ItemState::Pending`)
1435/// for remote — a track with no copy here yet has to be fetched before it plays.
1436fn resolve_item_path(
1437    cfg: &Config,
1438    track: &queries::TrackRow,
1439    remote_url: Option<&str>,
1440    album_date: Option<&str>,
1441) -> (PathBuf, ItemState) {
1442    match queries::choose_playback_source(
1443        track.path.as_deref(),
1444        track.cached_path.as_deref(),
1445        remote_url,
1446    ) {
1447        Some(queries::PlaybackSource::Local(p)) => (p, ItemState::Ready),
1448        // A cache entry is only as good as its contents. Older builds could
1449        // store a Subsonic error body here, which reports Ready and then fails
1450        // to decode forever; treating it as Pending sends it back through the
1451        // download path, which discards it and re-fetches.
1452        Some(queries::PlaybackSource::Cached(p)) => {
1453            let state = if is_cached_audio(&p) {
1454                ItemState::Ready
1455            } else {
1456                ItemState::Pending
1457            };
1458            (p, state)
1459        }
1460        Some(queries::PlaybackSource::Remote(_)) => {
1461            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1462            if dest.exists() && is_cached_audio(&dest) {
1463                (dest, ItemState::Ready)
1464            } else {
1465                (dest, ItemState::Pending)
1466            }
1467        }
1468        _ => {
1469            // Fallback: construct a cache path and mark pending.
1470            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1471            (dest, ItemState::Pending)
1472        }
1473    }
1474}
1475
1476/// Build a PlaylistItem from a TrackRow + album date + resolved path + load state.
1477pub fn playlist_item_from_track(
1478    track: &queries::TrackRow,
1479    album_date: Option<&str>,
1480    dest: PathBuf,
1481    state: ItemState,
1482) -> PlaylistItem {
1483    let year = album_date.and_then(year_of).map(str::to_string);
1484    PlaylistItem {
1485        playlist_entry_id: None,
1486        id: QueueItemId::new(),
1487        db_id: Some(track.id),
1488        path: dest,
1489        title: track.title.clone(),
1490        artist: track.artist_name.clone(),
1491        album_artist: track.album_artist_name.clone(),
1492        album: track.album_title.clone(),
1493        year,
1494        codec: track.codec.clone(),
1495        track_number: track.track_number.map(|n| n as i64),
1496        disc: track.disc.map(|n| n as i64),
1497        duration_ms: track.duration_ms.map(|d| d as u64),
1498        state,
1499    }
1500}
1501
1502/// Build playlist items for many tracks at once.
1503///
1504/// One config read and one query for the whole batch, whatever its size: the
1505/// rows already say where each track's file and download are, and what they
1506/// leave out — the stream URL and the album's date — is read for all of them
1507/// together.
1508pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1509    let cfg = Config::load().unwrap_or_default();
1510    let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
1511    let extras = queries::queue_item_extras(&db.conn, &ids).unwrap_or_default();
1512
1513    tracks
1514        .iter()
1515        .map(|track| {
1516            let extra = extras.get(&track.id);
1517            let remote_url = extra.and_then(|e| e.remote_url.as_deref());
1518            let album_date = extra.and_then(|e| e.album_date.as_deref());
1519            let (path, state) = resolve_item_path(&cfg, track, remote_url, album_date);
1520            playlist_item_from_track(track, album_date, path, state)
1521        })
1522        .collect()
1523}
1524
1525// ---------------------------------------------------------------------------
1526// Download
1527// ---------------------------------------------------------------------------
1528
1529/// Whether a cached file plausibly holds audio.
1530///
1531/// A stored Subsonic error is a few hundred bytes of JSON or XML; no real
1532/// encoded track comes close to that, so the size check alone settles almost
1533/// every case and the leading byte covers the rest.
1534fn is_cached_audio(path: &std::path::Path) -> bool {
1535    const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1536    match std::fs::metadata(path) {
1537        Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1538        Ok(_) => {
1539            let mut first = [0u8; 1];
1540            match std::fs::File::open(path)
1541                .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1542            {
1543                Ok(b) => b != b'{' && b != b'<',
1544                Err(_) => false,
1545            }
1546        }
1547        Err(_) => false,
1548    }
1549}
1550
1551/// Resolve a track to a playable file, downloading from remote if needed.
1552///
1553/// Resolution order:
1554/// 1. Local library path (DB `path` field) -- use directly if file exists
1555/// 2. Cache path -- use if already downloaded
1556/// 3. Download from remote to cache -- stream while downloading
1557pub fn download_track(
1558    db_id: i64,
1559    queue_id: QueueItemId,
1560    tx: &crossbeam_channel::Sender<PlayerCommand>,
1561    log_buf: &Arc<Mutex<Vec<String>>>,
1562    state: &Arc<SharedPlayerState>,
1563    cfg: &Config,
1564    client: &SubsonicClient,
1565) {
1566    // From the pool. This runs once per track fetched, and opening a
1567    // connection runs the schema DDL and a WAL checkpoint — with several
1568    // transfers going, several init cycles would contend with each other and
1569    // with library reads.
1570    let db = match crate::db::pool::shared().get() {
1571        Ok(db) => db,
1572        Err(e) => {
1573            fail_track(state, tx, queue_id, format!("db error: {}", e));
1574            return;
1575        }
1576    };
1577    let track = match queries::get_track_row(&db.conn, db_id) {
1578        Ok(Some(t)) => t,
1579        _ => {
1580            fail_track(state, tx, queue_id, "track not found".into());
1581            return;
1582        }
1583    };
1584
1585    let remote_id = match &track.remote_id {
1586        Some(rid) => rid.clone(),
1587        None => {
1588            // No remote_id -- check if the local file exists.
1589            if let Some(ref path) = track.path {
1590                let p = std::path::PathBuf::from(path);
1591                if p.exists() {
1592                    state.update_paths(&[(queue_id, p)]);
1593                    state.update_item_state(queue_id, ItemState::Ready);
1594                    if state.is_cursor(queue_id) {
1595                        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1596                    }
1597                    return;
1598                }
1599            }
1600            fail_track(
1601                state,
1602                tx,
1603                queue_id,
1604                "not in the library folder, and no remote copy to fetch".into(),
1605            );
1606            return;
1607        }
1608    };
1609
1610    // 1. Check if the local library file exists.
1611    if let Some(ref local_path) = track.path {
1612        let p = std::path::PathBuf::from(local_path);
1613        if p.exists() {
1614            log::info!("download_track: local file exists, using {}", p.display());
1615            state.update_paths(&[(queue_id, p)]);
1616            state.update_item_state(queue_id, ItemState::Ready);
1617            if state.is_cursor(queue_id) {
1618                tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1619            }
1620            return;
1621        }
1622    }
1623
1624    let album_date: Option<String> = track
1625        .album_id
1626        .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1627
1628    let cache_dir = cfg.cache_dir();
1629    let dest = cache_path_for_track(&cache_dir, &track, album_date.as_deref());
1630    if !path_within(&cache_dir, &dest) {
1631        fail_track(
1632            state,
1633            tx,
1634            queue_id,
1635            format!("cache path escapes the cache: {}", dest.display()),
1636        );
1637        return;
1638    }
1639
1640    // 2. Already cached.
1641    //
1642    // Older builds could write a Subsonic error body here as if it were audio,
1643    // leaving a tiny JSON file that reports Ready and then fails to decode
1644    // forever. Treat those as absent so they get re-fetched.
1645    if dest.exists() && !is_cached_audio(&dest) {
1646        log::warn!(
1647            "discarding non-audio cache entry {} (likely a stored server error)",
1648            dest.display()
1649        );
1650        let _ = std::fs::remove_file(&dest);
1651    }
1652    if dest.exists() {
1653        state.update_paths(&[(queue_id, dest)]);
1654        state.update_item_state(queue_id, ItemState::Ready);
1655        if state.is_cursor(queue_id) {
1656            tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1657        }
1658        return;
1659    }
1660
1661    // 3. Download from remote. The queue item points at the in-progress file so
1662    // the decoder reads bytes as they land; it flips to `dest` on success.
1663    state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1664
1665    let bytes_written = crate::remote::downloads::ByteFeed::new();
1666
1667    // Announce it before a byte moves, so a queue of six shows six rows rather
1668    // than one row and five tracks that look like nothing is happening to them.
1669    let store = crate::remote::downloads::store();
1670    store.queued(crate::remote::downloads::Download {
1671        id: queue_id,
1672        track_id: db_id,
1673        title: track.title.clone(),
1674        artist: track.artist_name.clone(),
1675        source: crate::remote::download::part_path(&dest),
1676        dest: dest.clone(),
1677        total: 0,
1678        written: bytes_written.clone(),
1679        state: crate::remote::downloads::DownloadState::Queued,
1680        bytes_per_second: 0,
1681    });
1682
1683    let progress_qid = queue_id;
1684    let bytes_written_progress = bytes_written.clone();
1685    let progress_tx = tx.clone();
1686    let stream_ready_flag = std::sync::atomic::AtomicBool::new(false);
1687    // A retry restarts the byte count from zero, so a changed total re-announces.
1688    let announced_total = AtomicU64::new(u64::MAX);
1689    // Taken out of the queue, cleared or removed, while waiting out an outage.
1690    let gone = || state.get_item(queue_id).is_none();
1691    let result =
1692        client.download_with_progress(&remote_id, &dest, &gone, move |downloaded, total| {
1693            bytes_written_progress.set(downloaded);
1694            // What knows a transfer moved is the code moving it. Held to a reading
1695            // every 250ms inside, so a chunk landing costs an atomic and a compare.
1696            store.progressed();
1697            if announced_total.swap(total, Ordering::Relaxed) != total {
1698                // The store, and only the store. The item's state says whether its
1699                // file can be played, which a transfer in flight has not changed.
1700                store.started(progress_qid, total, bytes_written_progress.clone());
1701            }
1702            if !stream_ready_flag.load(Ordering::Relaxed)
1703                && downloaded >= crate::player::state::STREAM_THRESHOLD
1704            {
1705                stream_ready_flag.store(true, Ordering::Relaxed);
1706                progress_tx
1707                    .send(PlayerCommand::TrackStreamReady(progress_qid))
1708                    .ok();
1709            }
1710        });
1711
1712    if let Err(SubsonicError::Download(DownloadError::Cancelled)) = result {
1713        store.withdrawn(queue_id);
1714        bytes_written.done();
1715        return;
1716    }
1717
1718    // However it ended, a decoder reading the `.part` file may be parked at the
1719    // write head. It waits on the feed, so the feed has to wake it — and only
1720    // once the item says how it ended, or it looks, sees a download, and parks
1721    // again with nothing left to wake it.
1722    if let Err(e) = result {
1723        store.failed(queue_id, e.to_string());
1724        fail_track(state, tx, queue_id, e.to_string());
1725        bytes_written.done();
1726        push_log(log_buf, format!("x {} — {}", track.title, e));
1727        return;
1728    }
1729    store.finished(queue_id);
1730
1731    state.update_paths(&[(queue_id, dest.clone())]);
1732    state.update_item_state(queue_id, ItemState::Ready);
1733    bytes_written.done();
1734    // Without this row the file is invisible to cache eviction and never reclaimed.
1735    if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1736        log::warn!(
1737            "cached {} but failed to record it ({}) — it will not be evicted",
1738            dest.display(),
1739            e
1740        );
1741    }
1742
1743    push_log(
1744        log_buf,
1745        format!("+ {} — {}", track.title, track.artist_name),
1746    );
1747
1748    if state.is_cursor(queue_id) {
1749        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1750    }
1751}
1752
1753/// Mark a queue item unplayable and tell the player, if it is waiting on it.
1754///
1755/// Setting `ItemState::Failed` alone is not enough: the player only wakes for
1756/// `TrackReady`, so a cursor parked on the item would wait for a download that
1757/// has already given up.
1758pub(crate) fn fail_track(
1759    state: &Arc<SharedPlayerState>,
1760    tx: &crossbeam_channel::Sender<PlayerCommand>,
1761    queue_id: QueueItemId,
1762    reason: String,
1763) {
1764    state.update_item_state(queue_id, ItemState::Failed(reason));
1765    if state.is_cursor(queue_id) {
1766        tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1767    }
1768}
1769
1770/// Append to the TUI log pane, tolerating a poisoned lock — a download worker
1771/// must not die because some other thread panicked while holding it.
1772fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1773    match log_buf.lock() {
1774        Ok(mut buf) => buf.push(msg),
1775        Err(_) => log::info!("{}", msg),
1776    }
1777}
1778
1779/// Why there is no remote client, in words worth showing someone.
1780///
1781/// Every caller of `subsonic_client` gets `None` for three different reasons,
1782/// and reporting one for all of them makes "koan has no password", which sends
1783/// you to sign in, look like a server that is merely down.
1784pub fn remote_unavailable(cfg: &Config) -> String {
1785    if !cfg.remote.enabled {
1786        return "no remote server is configured".into();
1787    }
1788    if cfg.remote.url.is_empty() {
1789        return "the remote server has no address".into();
1790    }
1791    if get_remote_password(cfg).is_none() {
1792        return "no password is stored for the remote server".into();
1793    }
1794    // A password resolved, so the client should have built. Nothing else
1795    // returns `None`, but saying so beats claiming a cause that is wrong.
1796    "the remote server could not be reached".into()
1797}
1798
1799/// Submit tracks for download.
1800///
1801/// Everything that is not the TUI reaches downloads through here — the FFI, the
1802/// GraphQL server and radio's auto-extend. The batch goes to the shared queue:
1803/// the same pool, priority lane and cursor watcher the TUI uses.
1804pub fn spawn_downloads(
1805    pending: Vec<(i64, QueueItemId)>,
1806    tx: crossbeam_channel::Sender<PlayerCommand>,
1807    state: Arc<SharedPlayerState>,
1808) {
1809    if pending.is_empty() {
1810        return;
1811    }
1812    crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1813}
1814
1815#[cfg(test)]
1816mod year_tests {
1817    use super::year_of;
1818
1819    #[test]
1820    fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1821        assert_eq!(year_of("1997-05-21"), Some("1997"));
1822        assert_eq!(year_of("199"), None);
1823        // Full-width digits: four bytes in is mid-character.
1824        assert_eq!(year_of("1997"), None);
1825    }
1826}
1827
1828#[cfg(test)]
1829mod rebuild_tests {
1830    use super::*;
1831    use crate::db::queries::sample_meta;
1832
1833    fn test_db() -> Database {
1834        let conn = rusqlite::Connection::open_in_memory().unwrap();
1835        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1836        crate::db::schema::create_tables(&conn).unwrap();
1837        Database { conn }
1838    }
1839
1840    #[test]
1841    fn cached_paths_follow_a_moved_cache_directory() {
1842        let old = tempfile::tempdir().unwrap();
1843        let new = tempfile::tempdir().unwrap();
1844        let db = test_db();
1845
1846        let mut rows = Vec::new();
1847        for name in ["moved", "gone", "current"] {
1848            let mut meta = sample_meta(name, "Artist", "Album");
1849            meta.source = "remote".into();
1850            meta.path = None;
1851            meta.remote_id = Some(name.into());
1852            let id = queries::upsert_track(&db.conn, &meta).unwrap();
1853            let tail = format!("Artist/Album/{name}.flac");
1854            // Every copy now lives under the new directory except "gone".
1855            if name != "gone" {
1856                let file = new.path().join(&tail);
1857                std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1858                std::fs::write(&file, b"audio").unwrap();
1859            }
1860            let stored = if name == "current" {
1861                new.path()
1862            } else {
1863                old.path()
1864            }
1865            .join(&tail);
1866            queries::set_cached_path(&db.conn, id, &stored.to_string_lossy()).unwrap();
1867            rows.push((id, tail));
1868        }
1869
1870        assert_eq!(relocate_cached_paths(&db, new.path()).unwrap(), 1);
1871
1872        let cached = |id: i64| -> String {
1873            db.conn
1874                .query_row("SELECT cached_path FROM tracks WHERE id = ?1", [id], |r| {
1875                    r.get(0)
1876                })
1877                .unwrap()
1878        };
1879        let expect = |root: &Path, tail: &str| root.join(tail).to_string_lossy().into_owned();
1880        assert_eq!(
1881            cached(rows[0].0),
1882            expect(new.path(), &rows[0].1),
1883            "re-rooted"
1884        );
1885        assert_eq!(
1886            cached(rows[1].0),
1887            expect(old.path(), &rows[1].1),
1888            "no file, left alone"
1889        );
1890        assert_eq!(
1891            cached(rows[2].0),
1892            expect(new.path(), &rows[2].1),
1893            "already current"
1894        );
1895        assert_eq!(
1896            relocate_cached_paths(&db, new.path()).unwrap(),
1897            0,
1898            "idempotent"
1899        );
1900    }
1901
1902    #[test]
1903    fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1904        let dir = tempfile::tempdir().unwrap();
1905        let db = test_db();
1906
1907        let mut cached = Vec::new();
1908        for name in ["one", "two"] {
1909            let mut meta = sample_meta(name, "Artist", "Album");
1910            meta.source = "remote".into();
1911            meta.path = None;
1912            meta.remote_id = Some(name.into());
1913            let id = queries::upsert_track(&db.conn, &meta).unwrap();
1914            let file = dir.path().join(format!("{name}.opus"));
1915            std::fs::write(&file, vec![0u8; 2048]).unwrap();
1916            queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1917            cached.push((id, file));
1918        }
1919
1920        let cleared = clear_downloads_for(&db, &[cached[0].0]);
1921        assert_eq!(cleared.files, 1);
1922        assert_eq!(cleared.bytes, 2048);
1923        assert!(!cached[0].1.exists(), "the copy asked for is gone");
1924        assert!(cached[1].1.exists(), "the other one is untouched");
1925
1926        // The row survives — a remote track is still in the library, it just
1927        // has to be fetched again.
1928        assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1929        assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1930        assert!(
1931            queries::cached_paths_for(&db.conn, &[cached[0].0])
1932                .unwrap()
1933                .is_empty()
1934        );
1935    }
1936
1937    #[test]
1938    fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1939        let db = test_db();
1940        let mut meta = sample_meta("ghost", "Artist", "Album");
1941        meta.source = "remote".into();
1942        meta.path = None;
1943        meta.remote_id = Some("ghost".into());
1944        let id = queries::upsert_track(&db.conn, &meta).unwrap();
1945        queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1946
1947        let cleared = clear_downloads_for(&db, &[id]);
1948        assert_eq!(cleared.files, 0, "nothing was there to remove");
1949        // Forgotten regardless: the row claimed a copy that does not exist.
1950        assert!(
1951            queries::cached_paths_for(&db.conn, &[id])
1952                .unwrap()
1953                .is_empty()
1954        );
1955    }
1956
1957    #[test]
1958    fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1959        let dir = tempfile::tempdir().unwrap();
1960        let cache = dir.path().join("cache");
1961        std::fs::create_dir_all(cache.join("Artist")).unwrap();
1962
1963        let finished = cache.join("Artist/whole.opus");
1964        let half = cache.join("Artist/half.opus.part");
1965        std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1966        std::fs::write(&half, vec![0u8; 4096]).unwrap();
1967
1968        let cfg = Config {
1969            remote: crate::config::RemoteConfig {
1970                cache_dir: Some(cache.clone()),
1971                ..Default::default()
1972            },
1973            ..Default::default()
1974        };
1975
1976        let swept = sweep_partial_downloads(&cfg);
1977        assert_eq!(swept.files, 1);
1978        assert_eq!(swept.bytes, 4096);
1979        assert!(!half.exists(), "the unfinished one is gone");
1980        assert!(finished.exists(), "a downloaded track is not touched");
1981    }
1982
1983    #[test]
1984    fn sweeping_an_empty_cache_is_not_an_error() {
1985        let dir = tempfile::tempdir().unwrap();
1986        let cfg = Config {
1987            remote: crate::config::RemoteConfig {
1988                cache_dir: Some(dir.path().join("nothing-here")),
1989                ..Default::default()
1990            },
1991            ..Default::default()
1992        };
1993        assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1994    }
1995
1996    #[test]
1997    fn clearing_no_tracks_does_nothing() {
1998        let db = test_db();
1999        assert_eq!(clear_downloads_for(&db, &[]).files, 0);
2000    }
2001
2002    #[test]
2003    fn rebuild_drops_the_index_and_keeps_favourites() {
2004        let db = test_db();
2005        let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2006        meta.path = Some("/music/windowlicker.flac".into());
2007        let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
2008
2009        // Favourites key on the path; lyrics key on the row id.
2010        queries::toggle_favourite(
2011            &db.conn,
2012            crate::db::queries::LOCAL_USER,
2013            Path::new("/music/windowlicker.flac"),
2014        )
2015        .unwrap();
2016        db.conn
2017            .execute(
2018                "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
2019                 VALUES (?1, 'test', 'la la la', 0)",
2020                [track_id],
2021            )
2022            .unwrap();
2023
2024        let summary = rebuild_index(&db).unwrap();
2025        assert_eq!(summary.tracks, 1);
2026        assert_eq!(summary.albums, 1);
2027
2028        let tracks: i64 = db
2029            .conn
2030            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2031            .unwrap();
2032        assert_eq!(tracks, 0, "the index is gone");
2033
2034        let favourites: i64 = db
2035            .conn
2036            .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
2037            .unwrap();
2038        assert_eq!(favourites, 1, "favourites survive — they key on the path");
2039
2040        let lyrics: i64 = db
2041            .conn
2042            .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
2043            .unwrap();
2044        assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
2045    }
2046
2047    #[test]
2048    fn rebuilding_an_empty_library_is_not_an_error() {
2049        let db = test_db();
2050        let summary = rebuild_index(&db).unwrap();
2051        assert_eq!(summary.tracks, 0);
2052    }
2053}
2054
2055#[cfg(test)]
2056mod share_tests {
2057    use super::*;
2058    use crate::db::queries::sample_meta;
2059
2060    fn test_db() -> Database {
2061        let conn = rusqlite::Connection::open_in_memory().unwrap();
2062        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2063        crate::db::schema::create_tables(&conn).unwrap();
2064        Database { conn }
2065    }
2066
2067    /// Three tracks on one album; the album carries a remote ID.
2068    fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
2069        let ids: Vec<i64> = ["One", "Two", "Three"]
2070            .iter()
2071            .enumerate()
2072            .map(|(i, title)| {
2073                let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
2074                meta.path = Some(format!("/music/geogaddi/{i}.flac"));
2075                meta.track_number = Some(i as i32 + 1);
2076                queries::upsert_track(&db.conn, &meta).unwrap()
2077            })
2078            .collect();
2079        let album_id: i64 = db
2080            .conn
2081            .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
2082                r.get(0)
2083            })
2084            .unwrap();
2085        db.conn
2086            .execute(
2087                "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
2088                [album_id],
2089            )
2090            .unwrap();
2091        (album_id, ids)
2092    }
2093
2094    #[test]
2095    fn whole_album_collapses_to_the_album_link() {
2096        let db = test_db();
2097        let (album_id, ids) = album_of_three(&db);
2098        assert_eq!(
2099            album_remote_id(&db.conn, album_id, ids.len()),
2100            Some("al-1".into())
2101        );
2102    }
2103
2104    #[test]
2105    fn part_of_an_album_does_not() {
2106        let db = test_db();
2107        let (album_id, _) = album_of_three(&db);
2108        // Sharing an album link for two of three tracks would hand out a track
2109        // the user did not pick.
2110        assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
2111    }
2112
2113    #[test]
2114    fn a_local_only_album_has_no_link_to_collapse_to() {
2115        let db = test_db();
2116        let (album_id, ids) = album_of_three(&db);
2117        db.conn
2118            .execute(
2119                "UPDATE albums SET remote_id = NULL WHERE id = ?1",
2120                [album_id],
2121            )
2122            .unwrap();
2123        assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
2124    }
2125}
2126
2127#[cfg(test)]
2128mod client_cache_tests {
2129    use super::*;
2130
2131    #[test]
2132    fn one_subsonic_client_is_shared_per_credentials() {
2133        crate::config::isolate_config_for_tests();
2134        let mut cfg = Config::default();
2135        cfg.remote.enabled = true;
2136        cfg.remote.url = "https://shared-client.invalid".into();
2137        cfg.remote.username = "koan".into();
2138        cfg.remote.password = "first".into();
2139
2140        let first = subsonic_client(&cfg).expect("a configured remote yields a client");
2141        let again = subsonic_client(&cfg).expect("a configured remote yields a client");
2142        assert!(
2143            Arc::ptr_eq(&first, &again),
2144            "rebuilding drops the connection pool and re-handshakes TLS per request"
2145        );
2146
2147        cfg.remote.password = "second".into();
2148        let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
2149        assert!(
2150            !Arc::ptr_eq(&first, &relogged),
2151            "new credentials must not keep serving the client signed with the old ones"
2152        );
2153    }
2154}
2155
2156#[cfg(test)]
2157mod native_share_tests {
2158    use super::*;
2159    use crate::db::queries::{sample_meta, upsert_track};
2160
2161    #[test]
2162    fn a_standalone_server_shares_natively_in_the_order_asked() {
2163        let conn = rusqlite::Connection::open_in_memory().unwrap();
2164        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2165        crate::db::schema::create_tables(&conn).unwrap();
2166        let db = Database { conn };
2167        let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2168        let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
2169        let mut cfg = Config::default();
2170        assert!(matches!(
2171            create_share(
2172                &db,
2173                queries::LOCAL_USER,
2174                &cfg,
2175                &ShareTarget::Tracks(vec![a]),
2176                None
2177            ),
2178            Err(ShareError::NoPublicUrl)
2179        ));
2180        cfg.sharing.public_url = Some("https://koan.example/".into());
2181        let out = create_share(
2182            &db,
2183            queries::LOCAL_USER,
2184            &cfg,
2185            &ShareTarget::Tracks(vec![b, 9999, a]),
2186            Some("mix"),
2187        )
2188        .unwrap();
2189        assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2190        assert_eq!((out.shared, out.skipped), (2, 1));
2191        let share = queries::shares::get_share(&db.conn, &out.id)
2192            .unwrap()
2193            .unwrap();
2194        assert_eq!(share.track_ids, [b, a]);
2195        assert!(matches!(
2196            create_share(
2197                &db,
2198                queries::LOCAL_USER,
2199                &cfg,
2200                &ShareTarget::Tracks(vec![9999]),
2201                None
2202            ),
2203            Err(ShareError::NothingToShare)
2204        ));
2205    }
2206
2207    #[test]
2208    fn a_server_with_an_upstream_still_shares_natively() {
2209        // Local-only tracks, which the upstream path refuses as NothingRemote.
2210        let conn = rusqlite::Connection::open_in_memory().unwrap();
2211        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2212        crate::db::schema::create_tables(&conn).unwrap();
2213        let db = Database { conn };
2214        let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2215        let mut cfg = Config::default();
2216        cfg.remote.enabled = true;
2217        cfg.remote.url = "https://upstream.invalid".into();
2218        cfg.remote.username = "someone".into();
2219        cfg.remote.password = "secret".into();
2220        cfg.sharing.public_url = Some("https://koan.example".into());
2221        let out = create_native_share(
2222            &db,
2223            queries::LOCAL_USER,
2224            &cfg,
2225            &ShareTarget::Tracks(vec![a]),
2226            None,
2227        )
2228        .unwrap();
2229        assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2230        assert!(
2231            queries::shares::get_share(&db.conn, &out.id)
2232                .unwrap()
2233                .is_some()
2234        );
2235    }
2236
2237    fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
2238        let mut meta = sample_meta(title, "Rrose", album);
2239        meta.track_number = Some(n);
2240        meta.date = Some(date.into());
2241        upsert_track(&db.conn, &meta).unwrap()
2242    }
2243
2244    #[test]
2245    fn shares_are_slices_fixed_when_made() {
2246        let conn = rusqlite::Connection::open_in_memory().unwrap();
2247        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2248        crate::db::schema::create_tables(&conn).unwrap();
2249        let db = Database { conn };
2250        let later = album_track(&db, "L1", "Later", 1, "2021");
2251        let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
2252        let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
2253        let album_of = |t| {
2254            queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
2255                .album_id
2256                .unwrap()
2257        };
2258        let (earlier, later_album) = (album_of(a1), album_of(later));
2259        let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
2260            .artist_id
2261            .unwrap();
2262
2263        // One track: its album, cued to it.
2264        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
2265        assert_eq!(
2266            (slice.kind, slice.subject_id, slice.start_track_id),
2267            (ShareKind::Album, Some(earlier), Some(a2))
2268        );
2269        assert_eq!(ids, [a1, a2]);
2270
2271        // An album, with a cue that is not on it dropped.
2272        let (slice, ids) = resolve_share(
2273            &db.conn,
2274            &ShareTarget::Album {
2275                album_id: later_album,
2276                start_track_id: Some(a1),
2277            },
2278        )
2279        .unwrap();
2280        assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
2281        assert_eq!(ids, [later]);
2282
2283        // An artist: every album, in release order.
2284        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
2285        assert_eq!(
2286            (slice.kind, slice.subject_id),
2287            (ShareKind::Artist, Some(artist))
2288        );
2289        assert_eq!(ids, [a1, a2, later]);
2290
2291        // Several tracks stay a list, in the order given.
2292        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
2293        assert_eq!(slice, Slice::TRACKS);
2294        assert_eq!(ids, [later, a1]);
2295
2296        assert!(matches!(
2297            resolve_share(&db.conn, &ShareTarget::Artist(9999)),
2298            Err(ShareError::NothingToShare)
2299        ));
2300    }
2301}
2302
2303#[cfg(test)]
2304mod album_share_id_tests {
2305    use super::album_share_id_for;
2306
2307    #[test]
2308    fn a_koan_album_is_named_as_an_album() {
2309        assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
2310        // Already prefixed, or not koan's numbering: left as issued.
2311        assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
2312        assert_eq!(album_share_id_for(false, "46215".into()), "46215");
2313        assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
2314    }
2315}
2316
2317#[cfg(test)]
2318mod cache_path_tests {
2319    use super::*;
2320
2321    fn track(artist: &str, album: &str, codec: &str) -> queries::TrackRow {
2322        queries::TrackRow {
2323            id: 1,
2324            album_id: None,
2325            artist_id: None,
2326            artist_name: artist.into(),
2327            album_artist_name: artist.into(),
2328            album_title: album.into(),
2329            disc: None,
2330            track_number: Some(1),
2331            title: "Song".into(),
2332            duration_ms: None,
2333            path: None,
2334            codec: Some(codec.into()),
2335            sample_rate: None,
2336            bit_depth: None,
2337            channels: None,
2338            bitrate: None,
2339            genre: None,
2340            source: "remote".into(),
2341            remote_id: Some("r1".into()),
2342            cached_path: None,
2343        }
2344    }
2345
2346    #[test]
2347    fn a_server_suffix_cannot_leave_the_cache() {
2348        let cache = Path::new("/cache");
2349        for codec in [
2350            "flac/../../../../x",
2351            "..",
2352            "../..",
2353            "/etc/passwd",
2354            "\\..\\..",
2355        ] {
2356            let path = cache_path_for_track(cache, &track("A", "B", codec), None);
2357            assert!(path_within(cache, &path), "{codec}: {}", path.display());
2358        }
2359        let path = cache_path_for_track(cache, &track("A", "B", "flac/../../../../x"), None);
2360        assert_eq!(path.extension().unwrap(), "flacx");
2361    }
2362
2363    #[test]
2364    fn dot_names_cannot_climb_out() {
2365        let cache = Path::new("/cache");
2366        let path = cache_path_for_track(cache, &track("..", ".", ".."), None);
2367        assert!(path_within(cache, &path), "{}", path.display());
2368        assert_eq!(sanitise_filename(".."), "_");
2369        assert_eq!(sanitise_filename(" . "), "_");
2370        assert_eq!(sanitise_filename("..."), "...");
2371    }
2372
2373    #[test]
2374    fn an_empty_suffix_falls_back_to_flac() {
2375        assert_eq!(sanitise_extension("../"), None);
2376        assert_eq!(sanitise_extension("FLAC"), Some("flac".into()));
2377        let path = cache_path_for_track(Path::new("/c"), &track("A", "B", "./"), None);
2378        assert_eq!(path.extension().unwrap(), "flac");
2379    }
2380
2381    #[test]
2382    fn path_within_rejects_parent_components() {
2383        let dir = Path::new("/cache");
2384        assert!(path_within(dir, Path::new("/cache/a/b.flac")));
2385        assert!(!path_within(dir, Path::new("/cache/a/../../x")));
2386        assert!(!path_within(dir, Path::new("/elsewhere/x")));
2387    }
2388}
2389
2390#[cfg(test)]
2391mod favourite_sync_tests {
2392    use super::*;
2393    use crate::db::queries::sample_meta;
2394
2395    /// A server with song `s1` starred, recording every id it is asked to star.
2396    fn serve(stars: Arc<Mutex<Vec<String>>>) -> String {
2397        use std::io::{BufRead, Write};
2398        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2399        let url = format!("http://{}", listener.local_addr().unwrap());
2400        std::thread::spawn(move || {
2401            for mut stream in listener.incoming().flatten() {
2402                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
2403                let mut request = String::new();
2404                reader.read_line(&mut request).unwrap();
2405                let mut line = String::new();
2406                while reader.read_line(&mut line).unwrap_or(0) > 2 {
2407                    line.clear();
2408                }
2409                let target = request.split_whitespace().nth(1).unwrap_or("");
2410                let (path, query) = target.split_once('?').unwrap_or((target, ""));
2411                let body = match path.rsplit('/').next().unwrap() {
2412                    "getStarred2" => {
2413                        r#"{"subsonic-response":{"status":"ok","starred2":{"song":[{"id":"s1","title":"One"}]}}}"#
2414                    }
2415                    "star" => {
2416                        if let Some((_, id)) = query
2417                            .split('&')
2418                            .filter_map(|kv| kv.split_once('='))
2419                            .find(|(k, _)| *k == "id")
2420                        {
2421                            stars.lock().unwrap().push(id.to_string());
2422                        }
2423                        r#"{"subsonic-response":{"status":"ok"}}"#
2424                    }
2425                    _ => r#"{"subsonic-response":{"status":"ok"}}"#,
2426                };
2427                let _ = write!(
2428                    stream,
2429                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2430                    body.len()
2431                );
2432            }
2433        });
2434        url
2435    }
2436
2437    #[test]
2438    fn only_favourites_the_server_lacks_are_starred() {
2439        let dir = tempfile::tempdir().unwrap();
2440        let db = Database::open(&dir.path().join("koan.db")).unwrap();
2441        for (title, remote_id) in [("One", "s1"), ("Two", "s2")] {
2442            let mut meta = sample_meta(title, "Artist", "Album");
2443            meta.path = Some(format!("/music/{title}.flac"));
2444            meta.remote_id = Some(remote_id.into());
2445            queries::upsert_track(&db.conn, &meta).unwrap();
2446            queries::add_favourite(
2447                &db.conn,
2448                queries::LOCAL_USER,
2449                Path::new(&format!("/music/{title}.flac")),
2450            )
2451            .unwrap();
2452        }
2453
2454        let stars = Arc::new(Mutex::new(Vec::new()));
2455        let url = serve(stars.clone());
2456        let sync = reconcile_favourites(&db, &SubsonicClient::new(&url, "u", "pw"));
2457        assert_eq!(sync.pushed, 1);
2458        assert_eq!(*stars.lock().unwrap(), ["s2"]);
2459    }
2460}