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(&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(&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_cache_for_tracks(&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 = db.conn.unchecked_transaction()?;
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 = db.conn.unchecked_transaction()?;
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 = db.conn.unchecked_transaction()?;
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    let library = crate::remote::sync::sync_library(db, client, full, url, username, progress)?;
791    Ok(FullSync {
792        library,
793        favourites: reconcile_favourites(db, client),
794        playlists: crate::playlists::reconcile_playlists(db, client, url, username),
795    })
796}
797
798/// What a favourites reconciliation did.
799#[derive(Debug, Default, Clone, Copy)]
800pub struct FavouriteSync {
801    pub pushed: usize,
802    pub imported: usize,
803}
804
805/// Reconcile favourites with the server, both directions.
806///
807/// Stars every local favourite the server knows about but has not starred,
808/// then imports everything the server has starred. Union rather than mirror:
809/// neither side records an unstar, so treating one as authoritative would
810/// silently delete favourites made on the other. Reading the server's stars
811/// first keeps a sync from re-sending every favourite, one request each.
812///
813/// Covers albums and artists as well as tracks — `getStarred2` returns all
814/// three from one request, and reading only songs would leave a starred album
815/// invisible to koan.
816pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
817    let mut out = FavouriteSync::default();
818
819    let starred = match client.get_starred_all() {
820        Ok(s) => s,
821        Err(e) => {
822            log::warn!("could not fetch starred items from the server: {e}");
823            return out;
824        }
825    };
826    let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
827    let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
828    let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
829
830    let unstarred = |ids: Vec<String>, starred: &[String]| {
831        let starred: std::collections::HashSet<&String> = starred.iter().collect();
832        ids.into_iter()
833            .filter(|id| !starred.contains(id))
834            .collect::<Vec<_>>()
835    };
836    let tracks = queries::favourites_with_remote_id(&db.conn, queries::LOCAL_USER)
837        .unwrap_or_default()
838        .into_iter()
839        .map(|(_, id)| id)
840        .collect();
841    for remote_id in unstarred(tracks, &songs) {
842        if client.star(&remote_id).is_ok() {
843            out.pushed += 1;
844        }
845    }
846    let local_albums = queries::favourite_albums_with_remote_id(&db.conn, queries::LOCAL_USER)
847        .unwrap_or_default()
848        .into_iter()
849        .map(|(_, id)| id)
850        .collect();
851    for remote_id in unstarred(local_albums, &albums) {
852        if client.star_album(&remote_id).is_ok() {
853            out.pushed += 1;
854        }
855    }
856    let local_artists = queries::favourite_artists_with_remote_id(&db.conn, queries::LOCAL_USER)
857        .unwrap_or_default()
858        .into_iter()
859        .map(|(_, id)| id)
860        .collect();
861    for remote_id in unstarred(local_artists, &artists) {
862        if client.star_artist(&remote_id).is_ok() {
863            out.pushed += 1;
864        }
865    }
866
867    out.imported +=
868        queries::import_remote_favourites(&db.conn, queries::LOCAL_USER, &songs).unwrap_or(0);
869    out.imported += queries::import_remote_favourite_albums(&db.conn, queries::LOCAL_USER, &albums)
870        .unwrap_or(0);
871    out.imported +=
872        queries::import_remote_favourite_artists(&db.conn, queries::LOCAL_USER, &artists)
873            .unwrap_or(0);
874    out
875}
876
877/// What a favourite applies to. Subsonic stars all three, under different
878/// parameter names — passing an album id as `id` silently stars nothing.
879#[derive(Debug, Clone, Copy, PartialEq, Eq)]
880pub enum FavouriteKind {
881    Track,
882    Album,
883    Artist,
884}
885
886/// Push an album or artist favourite to the server.
887///
888/// Same shape as [`sync_favourite_to_remote`], but the remote id comes from the
889/// album or artist row rather than the track's path.
890pub fn sync_collection_favourite_to_remote(
891    db: &Database,
892    kind: FavouriteKind,
893    id: i64,
894    star: bool,
895) {
896    let cfg = Config::load().unwrap_or_default();
897    if !cfg.remote.enabled {
898        return;
899    }
900    let remote_id = match kind {
901        FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
902        FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
903        FavouriteKind::Track => return,
904    };
905    let Ok(Some(remote_id)) = remote_id else {
906        log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
907        return;
908    };
909    let Some(client) = subsonic_client(&cfg) else {
910        log::warn!("not syncing favourite: no usable server credentials");
911        return;
912    };
913    std::thread::Builder::new()
914        .name("koan-fav-sync".into())
915        .spawn(move || {
916            let result = match (kind, star) {
917                (FavouriteKind::Album, true) => client.star_album(&remote_id),
918                (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
919                (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
920                (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
921                (FavouriteKind::Track, _) => Ok(()),
922            };
923            match result {
924                Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
925                Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
926            }
927        })
928        .ok();
929}
930
931/// Why signing in to a remote server failed.
932#[derive(Debug, thiserror::Error)]
933pub enum SignInError {
934    #[error("the server did not accept those credentials: {0}")]
935    Rejected(#[from] crate::remote::client::SubsonicError),
936    #[error("could not write the configuration: {0}")]
937    Config(#[from] crate::config::ConfigError),
938}
939
940/// Sign in to a Subsonic/Navidrome server and remember it.
941///
942/// The password goes to `config.local.toml`, which is gitignored and written
943/// `0600`. Subsonic authenticates every request with the password or a salted
944/// MD5 of it, so there is no token to hold instead — whatever koan keeps is
945/// password-equivalent wherever it is kept.
946///
947/// The credentials are checked against the server before anything is written; a
948/// stored password that does not work is worse than none.
949///
950/// Shared by the CLI and the app so the two cannot disagree about where
951/// credentials live.
952pub fn set_remote_credentials(
953    url: &str,
954    username: &str,
955    password: &str,
956) -> Result<(), SignInError> {
957    let url = url.trim_end_matches('/');
958    SubsonicClient::new(url, username, password).ping()?;
959
960    Config::persist(|cfg| {
961        cfg.remote.enabled = true;
962        cfg.remote.url = url.to_string();
963        cfg.remote.username = username.to_string();
964        cfg.remote.password = password.to_string();
965    })?;
966    // The link rests for up to a minute while signed out; the profile Settings
967    // shows is probed when it wakes.
968    crate::remote::link::nudge();
969    Ok(())
970}
971
972/// Shared secret for koan's own Subsonic API.
973///
974/// Deliberately not the same secret as `get_remote_password` — see `SubsonicConfig`.
975pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
976    (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
977}
978
979/// Upstream Subsonic credentials from the merged config, returning `None` if
980/// remote is disabled or has no URL configured.
981///
982/// Prefer this over `subsonic_client` when only a signed URL is needed:
983/// building a client constructs blocking `reqwest` clients, which panics from
984/// inside a tokio runtime.
985pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
986    if !cfg.remote.enabled || cfg.remote.url.is_empty() {
987        return None;
988    }
989    let password = get_remote_password(cfg)?;
990    Some(SubsonicAuth::new(
991        &cfg.remote.url,
992        &cfg.remote.username,
993        &password,
994    ))
995}
996
997/// One `SubsonicClient` per set of credentials, shared process-wide.
998///
999/// Constructing one builds two blocking `reqwest` clients, each carrying its
1000/// own runtime on its own thread, and each starting with a cold connection
1001/// pool — so a client per call means a fresh TLS handshake for every cover art
1002/// request.
1003///
1004/// Keyed on the credentials, so logging in as someone else replaces the client
1005/// rather than serving the old one. Never call from async code: building the
1006/// inner clients panics inside a tokio runtime.
1007pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
1008    let auth = subsonic_auth(cfg)?;
1009
1010    let mut slot = SUBSONIC_CLIENT.lock();
1011    if let Some((cached, client)) = slot.as_ref()
1012        && *cached == auth
1013    {
1014        return Some(client.clone());
1015    }
1016
1017    let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
1018    *slot = Some((auth, client.clone()));
1019    Some(client)
1020}
1021
1022type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
1023
1024static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
1025    std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
1026
1027// ---------------------------------------------------------------------------
1028// Sharing
1029// ---------------------------------------------------------------------------
1030
1031/// Why a share link could not be made. Each variant is something the user can
1032/// act on.
1033#[derive(Debug, thiserror::Error)]
1034pub enum ShareError {
1035    #[error("sharing.public_url is not set, so there is no address to give out")]
1036    NoPublicUrl,
1037    #[error("none of these tracks are in the library")]
1038    NothingToShare,
1039    #[error("none of these tracks are on the server, so a link has nothing to point at")]
1040    NothingRemote,
1041    #[error("the server refused to share these: {0}")]
1042    Server(#[from] crate::remote::client::SubsonicError),
1043    #[error(transparent)]
1044    Database(#[from] crate::db::connection::DbError),
1045}
1046
1047/// A created share link, and how much of the request it covers.
1048#[derive(Debug, Clone)]
1049pub struct ShareOutcome {
1050    pub url: String,
1051    /// The server's own ID for the share, for callers that manage them.
1052    pub id: String,
1053    /// Tracks the server knows about, which went into the link.
1054    pub shared: usize,
1055    /// Tracks with no copy on the server, left out of it.
1056    pub skipped: usize,
1057}
1058
1059/// What a share link is asked to cover.
1060#[derive(Debug, Clone, PartialEq, Eq)]
1061pub enum ShareTarget {
1062    /// Loose tracks. A single track shares its album, cued to that track.
1063    Tracks(Vec<i64>),
1064    /// An album, optionally cued to one of its tracks.
1065    Album {
1066        album_id: i64,
1067        start_track_id: Option<i64>,
1068    },
1069    /// An artist's albums, in release order.
1070    Artist(i64),
1071}
1072
1073/// The slice a target makes and the tracks it covers, in play order. Only
1074/// tracks in the library are included; the list is fixed from here on.
1075///
1076/// A single track becomes its album cued to it: a song is heard in the
1077/// record it belongs to, the way the app shows it.
1078pub fn resolve_share(
1079    conn: &rusqlite::Connection,
1080    target: &ShareTarget,
1081) -> Result<(Slice, Vec<i64>), ShareError> {
1082    let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
1083        Ok(queries::tracks_for_album(conn, album_id)?
1084            .into_iter()
1085            .map(|t| t.id)
1086            .collect())
1087    };
1088    let (slice, ids) = match target {
1089        ShareTarget::Tracks(ids) => {
1090            let rows = queries::tracks_by_ids(conn, ids)?;
1091            match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
1092                ([one], Some(album_id)) => {
1093                    return resolve_share(
1094                        conn,
1095                        &ShareTarget::Album {
1096                            album_id,
1097                            start_track_id: Some(*one),
1098                        },
1099                    );
1100                }
1101                _ => {
1102                    // The order asked for, which is the order the page plays them in.
1103                    let ids = ids
1104                        .iter()
1105                        .copied()
1106                        .filter(|id| rows.iter().any(|t| t.id == *id))
1107                        .collect();
1108                    (Slice::TRACKS, ids)
1109                }
1110            }
1111        }
1112        ShareTarget::Album {
1113            album_id,
1114            start_track_id,
1115        } => {
1116            let ids = album_tracks(*album_id)?;
1117            let slice = Slice {
1118                kind: ShareKind::Album,
1119                subject_id: Some(*album_id),
1120                start_track_id: start_track_id.filter(|s| ids.contains(s)),
1121            };
1122            (slice, ids)
1123        }
1124        ShareTarget::Artist(artist_id) => {
1125            let mut ids = Vec::new();
1126            for album in queries::albums_for_artist(conn, *artist_id)? {
1127                ids.extend(album_tracks(album.id)?);
1128            }
1129            let slice = Slice {
1130                kind: ShareKind::Artist,
1131                subject_id: Some(*artist_id),
1132                start_track_id: None,
1133            };
1134            (slice, ids)
1135        }
1136    };
1137    if ids.is_empty() {
1138        return Err(ShareError::NothingToShare);
1139    }
1140    Ok((slice, ids))
1141}
1142
1143/// Create a public share link for a slice of the library.
1144///
1145/// With a remote Subsonic server configured, the link is made there: a laptop
1146/// or phone shares through the server it plays from, which may be another
1147/// koan. Without one this koan is the server, and makes the link itself.
1148///
1149/// A link points at the server, so only tracks the server knows about can go in
1150/// it. A mixed selection shares the part that can be shared and reports the
1151/// rest rather than failing whole — half a link beats none, as long as the
1152/// caller says which half.
1153///
1154/// `user` is who is sharing, recorded on a link this koan makes itself.
1155///
1156/// May be network-bound. Callers keep it off whatever thread draws.
1157pub fn create_share(
1158    db: &Database,
1159    user: i64,
1160    cfg: &Config,
1161    target: &ShareTarget,
1162    description: Option<&str>,
1163) -> Result<ShareOutcome, ShareError> {
1164    let Some(client) = subsonic_client(cfg) else {
1165        return create_native_share(db, user, cfg, target, description);
1166    };
1167    // A remote server makes its own kind of link from what it is given, so it
1168    // is given exactly what was picked.
1169    let resolved;
1170    let track_ids = match target {
1171        ShareTarget::Tracks(ids) => ids.as_slice(),
1172        _ => {
1173            resolved = resolve_share(&db.conn, target)?.1;
1174            resolved.as_slice()
1175        }
1176    };
1177
1178    // One query, not one per track: sharing an artist is thousands of tracks.
1179    let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
1180
1181    let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
1182    if shared == 0 {
1183        return Err(ShareError::NothingRemote);
1184    }
1185
1186    // A whole record shares as one album rather than as N tracks — the server
1187    // renders it as the album it is, and the link survives the user adding to
1188    // it. Only when the selection is the whole album.
1189    let one_album = rows
1190        .first()
1191        .and_then(|f| f.album_id)
1192        .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
1193        .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
1194
1195    let remote_ids: Vec<String> = match one_album {
1196        Some(rid) => vec![album_share_id(&client, rid)],
1197        None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
1198    };
1199
1200    let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
1201    let share = client.create_share(&refs, description)?;
1202
1203    // Navidrome does not always hand back a URL, and a share with no link is
1204    // useless to the caller — the ID is enough to build it.
1205    let url = share
1206        .url
1207        .clone()
1208        .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
1209
1210    Ok(ShareOutcome {
1211        url,
1212        id: share.id,
1213        shared,
1214        skipped: track_ids.len().saturating_sub(shared),
1215    })
1216}
1217
1218/// A share this koan serves at `{sharing.public_url}/share/{id}`.
1219///
1220/// What a server's own surfaces make whatever `[remote]` says: a link made
1221/// upstream would belong to the upstream's account, not the koan user who
1222/// asked, and could not be listed or revoked here.
1223pub fn create_native_share(
1224    db: &Database,
1225    user: i64,
1226    cfg: &Config,
1227    target: &ShareTarget,
1228    description: Option<&str>,
1229) -> Result<ShareOutcome, ShareError> {
1230    let base = cfg
1231        .sharing
1232        .public_url
1233        .as_deref()
1234        .filter(|u| !u.trim().is_empty())
1235        .ok_or(ShareError::NoPublicUrl)?;
1236    let (slice, ids) = resolve_share(&db.conn, target)?;
1237    let now = std::time::SystemTime::now()
1238        .duration_since(std::time::UNIX_EPOCH)
1239        .map_or(0, |d| d.as_secs() as i64);
1240    let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
1241    Ok(ShareOutcome {
1242        url: share_url(base, &share.id),
1243        id: share.id,
1244        shared: ids.len(),
1245        // Only loose tracks are named one by one, so only they can be missing.
1246        skipped: match (target, slice.kind) {
1247            (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
1248            _ => 0,
1249        },
1250    })
1251}
1252
1253/// A native share's public address.
1254pub fn share_url(public_url: &str, id: &str) -> String {
1255    format!("{}/share/{id}", public_url.trim_end_matches('/'))
1256}
1257
1258/// An album's id as `createShare` should be given it.
1259///
1260/// koan numbers albums and songs separately, publishes album ids bare, and
1261/// reads a bare id in `createShare` as a song, so album 5 would share song 5.
1262/// Its `al-` prefix says which is meant. Other servers get the id as they
1263/// issued it: some also number albums, and would not know the prefix.
1264fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
1265    let koan = crate::remote::profile::is_koan(client.auth());
1266    album_share_id_for(koan, remote_id)
1267}
1268
1269fn album_share_id_for(koan: bool, remote_id: String) -> String {
1270    if koan && remote_id.parse::<i64>().is_ok() {
1271        format!("al-{remote_id}")
1272    } else {
1273        remote_id
1274    }
1275}
1276
1277/// The album's own remote ID, but only when `selected` covers every track on
1278/// it. Sharing an album link for half an album would hand out more than the
1279/// user picked.
1280fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
1281    let (remote_id, total): (Option<String>, i64) = conn
1282        .query_row(
1283            "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
1284             FROM albums al WHERE al.id = ?1",
1285            [album_id],
1286            |row| Ok((row.get(0)?, row.get(1)?)),
1287        )
1288        .ok()?;
1289    (total == selected as i64).then_some(remote_id).flatten()
1290}
1291
1292// ---------------------------------------------------------------------------
1293// Path utilities
1294// ---------------------------------------------------------------------------
1295
1296/// Fisher-Yates over a fresh seed, so consecutive calls differ.
1297///
1298/// Deliberately not seeded from anything stable: "shuffle again" has to
1299/// actually produce a new order, which a process-lifetime seed wouldn't.
1300pub fn shuffle<T>(items: &mut [T]) {
1301    let mut seed = [0u8; 8];
1302    if getrandom::fill(&mut seed).is_err() {
1303        return; // Leave the order alone rather than pretending to shuffle.
1304    }
1305    let mut state = u64::from_le_bytes(seed) | 1;
1306    for i in (1..items.len()).rev() {
1307        // xorshift64 — plenty for shuffling a list nobody is betting on.
1308        state ^= state << 13;
1309        state ^= state >> 7;
1310        state ^= state << 17;
1311        items.swap(i, (state % (i as u64 + 1)) as usize);
1312    }
1313}
1314
1315/// Truncate a string to at most `max` bytes, cutting on a char boundary.
1316pub fn truncate_bytes(s: &str, max: usize) -> &str {
1317    if s.len() <= max {
1318        return s;
1319    }
1320    let mut end = max;
1321    while end > 0 && !s.is_char_boundary(end) {
1322        end -= 1;
1323    }
1324    &s[..end]
1325}
1326
1327/// Sanitise and truncate a string for use as a path component.
1328/// Strips illegal chars and caps at 240 bytes (macOS 255-byte filename limit minus room for ext).
1329/// `.` and `..` become `_`: tags and server metadata are untrusted, and either
1330/// would move the path out of the directory it is joined onto.
1331pub fn sanitise_filename(s: &str) -> String {
1332    let cleaned: String = s
1333        .chars()
1334        .map(|c| match c {
1335            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1336            _ => c,
1337        })
1338        .collect::<String>()
1339        .trim()
1340        .to_string();
1341
1342    let cleaned = truncate_bytes(&cleaned, 240).trim_end().to_string();
1343    match cleaned.as_str() {
1344        "." | ".." => "_".into(),
1345        _ => cleaned,
1346    }
1347}
1348
1349/// A file extension from a codec name, which for remote tracks is whatever
1350/// the server sent as `suffix`. ASCII alphanumerics only, so it can never
1351/// carry a separator or a `..`; `None` when nothing usable is left.
1352pub fn sanitise_extension(codec: &str) -> Option<String> {
1353    let ext: String = codec
1354        .chars()
1355        .filter(char::is_ascii_alphanumeric)
1356        .take(16)
1357        .collect::<String>()
1358        .to_lowercase();
1359    (!ext.is_empty()).then_some(ext)
1360}
1361
1362/// Whether `path` lies inside `dir` without leaving it on the way — every
1363/// component after the prefix is a plain name.
1364pub fn path_within(dir: &Path, path: &Path) -> bool {
1365    path.strip_prefix(dir).is_ok_and(|rest| {
1366        rest.components()
1367            .all(|c| matches!(c, std::path::Component::Normal(_)))
1368    })
1369}
1370
1371/// The year a tag date starts with. `get`, not a slice: a date is free text,
1372/// and a multibyte character in its first four bytes would panic a slice.
1373pub fn year_of(date: &str) -> Option<&str> {
1374    date.get(..4)
1375}
1376
1377/// Build a structured cache path for a track:
1378///   cache_dir/Album Artist/(Year) Album [Codec]/01. Track Artist - Title.ext
1379pub fn cache_path_for_track(
1380    cache_dir: &Path,
1381    track: &queries::TrackRow,
1382    album_date: Option<&str>,
1383) -> PathBuf {
1384    let artist_dir = sanitise_filename(&track.artist_name);
1385
1386    let year = album_date
1387        .and_then(year_of)
1388        .map(|y| format!("({}) ", y))
1389        .unwrap_or_default();
1390    let codec = track
1391        .codec
1392        .as_deref()
1393        .map(|c| format!(" [{}]", c))
1394        .unwrap_or_default();
1395    let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1396
1397    let disc_prefix = match track.disc {
1398        Some(d) if d > 1 => format!("{}-", d),
1399        _ => String::new(),
1400    };
1401    let track_num = track
1402        .track_number
1403        .map(|n| format!("{:02}. ", n))
1404        .unwrap_or_default();
1405
1406    let ext = track
1407        .codec
1408        .as_deref()
1409        .and_then(sanitise_extension)
1410        .unwrap_or_else(|| "flac".into());
1411
1412    let filename = sanitise_filename(&format!(
1413        "{}{}{} - {}",
1414        disc_prefix, track_num, track.artist_name, track.title
1415    ));
1416
1417    cache_dir
1418        .join(artist_dir)
1419        .join(album_dir)
1420        .join(format!("{}.{}", filename, ext))
1421}
1422
1423// ---------------------------------------------------------------------------
1424// Track resolution
1425// ---------------------------------------------------------------------------
1426
1427/// Resolve a track to its path + load state (without downloading).
1428/// Returns (path, `ItemState::Ready`) for local/cached, (cache path, `ItemState::Pending`)
1429/// for remote — a track with no copy here yet has to be fetched before it plays.
1430pub fn resolve_item_path(
1431    db: &Database,
1432    cfg: &Config,
1433    id: i64,
1434    track: &queries::TrackRow,
1435    album_date: Option<&str>,
1436) -> (PathBuf, ItemState) {
1437    match queries::resolve_playback_path(&db.conn, id) {
1438        Ok(Some(queries::PlaybackSource::Local(p))) => (p, ItemState::Ready),
1439        // A cache entry is only as good as its contents. Older builds could
1440        // store a Subsonic error body here, which reports Ready and then fails
1441        // to decode forever; treating it as Pending sends it back through the
1442        // download path, which discards it and re-fetches.
1443        Ok(Some(queries::PlaybackSource::Cached(p))) => {
1444            let state = if is_cached_audio(&p) {
1445                ItemState::Ready
1446            } else {
1447                ItemState::Pending
1448            };
1449            (p, state)
1450        }
1451        Ok(Some(queries::PlaybackSource::Remote(_))) => {
1452            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1453            if dest.exists() && is_cached_audio(&dest) {
1454                (dest, ItemState::Ready)
1455            } else {
1456                (dest, ItemState::Pending)
1457            }
1458        }
1459        _ => {
1460            // Fallback: construct a cache path and mark pending.
1461            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1462            (dest, ItemState::Pending)
1463        }
1464    }
1465}
1466
1467/// Build a PlaylistItem from a TrackRow + album date + resolved path + load state.
1468pub fn playlist_item_from_track(
1469    track: &queries::TrackRow,
1470    album_date: Option<&str>,
1471    dest: PathBuf,
1472    state: ItemState,
1473) -> PlaylistItem {
1474    let year = album_date.and_then(year_of).map(str::to_string);
1475    PlaylistItem {
1476        playlist_entry_id: None,
1477        id: QueueItemId::new(),
1478        db_id: Some(track.id),
1479        path: dest,
1480        title: track.title.clone(),
1481        artist: track.artist_name.clone(),
1482        album_artist: track.album_artist_name.clone(),
1483        album: track.album_title.clone(),
1484        year,
1485        codec: track.codec.clone(),
1486        track_number: track.track_number.map(|n| n as i64),
1487        disc: track.disc.map(|n| n as i64),
1488        duration_ms: track.duration_ms.map(|d| d as u64),
1489        state,
1490    }
1491}
1492
1493/// Build playlist items for many tracks at once.
1494///
1495/// `track_to_playlist_item` loads the config on every call, which means
1496/// reading and parsing `config.toml` and `config.local.toml` once per track.
1497/// This loads it once and memoises album dates,
1498/// so a thousand-track add costs one config read instead of a thousand.
1499pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1500    use std::collections::HashMap;
1501
1502    let cfg = Config::load().unwrap_or_default();
1503    let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
1504
1505    tracks
1506        .iter()
1507        .map(|track| {
1508            let album_date = match track.album_id {
1509                Some(aid) => album_dates
1510                    .entry(aid)
1511                    .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
1512                    .clone(),
1513                None => None,
1514            };
1515            let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1516            playlist_item_from_track(track, album_date.as_deref(), path, state)
1517        })
1518        .collect()
1519}
1520
1521/// Build a PlaylistItem from a TrackRow, resolving its path automatically.
1522pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
1523    let album_date = track
1524        .album_id
1525        .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1526
1527    let cfg = Config::load().unwrap_or_default();
1528    let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1529
1530    playlist_item_from_track(track, album_date.as_deref(), path, state)
1531}
1532
1533// ---------------------------------------------------------------------------
1534// Download
1535// ---------------------------------------------------------------------------
1536
1537/// Whether a cached file plausibly holds audio.
1538///
1539/// A stored Subsonic error is a few hundred bytes of JSON or XML; no real
1540/// encoded track comes close to that, so the size check alone settles almost
1541/// every case and the leading byte covers the rest.
1542fn is_cached_audio(path: &std::path::Path) -> bool {
1543    const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1544    match std::fs::metadata(path) {
1545        Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1546        Ok(_) => {
1547            let mut first = [0u8; 1];
1548            match std::fs::File::open(path)
1549                .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1550            {
1551                Ok(b) => b != b'{' && b != b'<',
1552                Err(_) => false,
1553            }
1554        }
1555        Err(_) => false,
1556    }
1557}
1558
1559/// Resolve a track to a playable file, downloading from remote if needed.
1560///
1561/// Resolution order:
1562/// 1. Local library path (DB `path` field) -- use directly if file exists
1563/// 2. Cache path -- use if already downloaded
1564/// 3. Download from remote to cache -- stream while downloading
1565pub fn download_track(
1566    db_id: i64,
1567    queue_id: QueueItemId,
1568    tx: &crossbeam_channel::Sender<PlayerCommand>,
1569    log_buf: &Arc<Mutex<Vec<String>>>,
1570    state: &Arc<SharedPlayerState>,
1571    cfg: &Config,
1572    client: &SubsonicClient,
1573) {
1574    // From the pool. This runs once per track fetched, and opening a
1575    // connection runs the schema DDL and a WAL checkpoint — with several
1576    // transfers going, several init cycles would contend with each other and
1577    // with library reads.
1578    let db = match crate::db::pool::shared().get() {
1579        Ok(db) => db,
1580        Err(e) => {
1581            fail_track(state, tx, queue_id, format!("db error: {}", e));
1582            return;
1583        }
1584    };
1585    let track = match queries::get_track_row(&db.conn, db_id) {
1586        Ok(Some(t)) => t,
1587        _ => {
1588            fail_track(state, tx, queue_id, "track not found".into());
1589            return;
1590        }
1591    };
1592
1593    let remote_id = match &track.remote_id {
1594        Some(rid) => rid.clone(),
1595        None => {
1596            // No remote_id -- check if the local file exists.
1597            if let Some(ref path) = track.path {
1598                let p = std::path::PathBuf::from(path);
1599                if p.exists() {
1600                    state.update_paths(&[(queue_id, p)]);
1601                    state.update_item_state(queue_id, ItemState::Ready);
1602                    if state.is_cursor(queue_id) {
1603                        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1604                    }
1605                    return;
1606                }
1607            }
1608            fail_track(
1609                state,
1610                tx,
1611                queue_id,
1612                "not in the library folder, and no remote copy to fetch".into(),
1613            );
1614            return;
1615        }
1616    };
1617
1618    // 1. Check if the local library file exists.
1619    if let Some(ref local_path) = track.path {
1620        let p = std::path::PathBuf::from(local_path);
1621        if p.exists() {
1622            log::info!("download_track: local file exists, using {}", p.display());
1623            state.update_paths(&[(queue_id, p)]);
1624            state.update_item_state(queue_id, ItemState::Ready);
1625            if state.is_cursor(queue_id) {
1626                tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1627            }
1628            return;
1629        }
1630    }
1631
1632    let album_date: Option<String> = track
1633        .album_id
1634        .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1635
1636    let cache_dir = cfg.cache_dir();
1637    let dest = cache_path_for_track(&cache_dir, &track, album_date.as_deref());
1638    if !path_within(&cache_dir, &dest) {
1639        fail_track(
1640            state,
1641            tx,
1642            queue_id,
1643            format!("cache path escapes the cache: {}", dest.display()),
1644        );
1645        return;
1646    }
1647
1648    // 2. Already cached.
1649    //
1650    // Older builds could write a Subsonic error body here as if it were audio,
1651    // leaving a tiny JSON file that reports Ready and then fails to decode
1652    // forever. Treat those as absent so they get re-fetched.
1653    if dest.exists() && !is_cached_audio(&dest) {
1654        log::warn!(
1655            "discarding non-audio cache entry {} (likely a stored server error)",
1656            dest.display()
1657        );
1658        let _ = std::fs::remove_file(&dest);
1659    }
1660    if dest.exists() {
1661        state.update_paths(&[(queue_id, dest)]);
1662        state.update_item_state(queue_id, ItemState::Ready);
1663        if state.is_cursor(queue_id) {
1664            tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1665        }
1666        return;
1667    }
1668
1669    // 3. Download from remote. The queue item points at the in-progress file so
1670    // the decoder reads bytes as they land; it flips to `dest` on success.
1671    state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1672
1673    let bytes_written = crate::remote::downloads::ByteFeed::new();
1674
1675    // Announce it before a byte moves, so a queue of six shows six rows rather
1676    // than one row and five tracks that look like nothing is happening to them.
1677    let store = crate::remote::downloads::store();
1678    store.queued(crate::remote::downloads::Download {
1679        id: queue_id,
1680        track_id: db_id,
1681        title: track.title.clone(),
1682        artist: track.artist_name.clone(),
1683        source: crate::remote::download::part_path(&dest),
1684        dest: dest.clone(),
1685        total: 0,
1686        written: bytes_written.clone(),
1687        state: crate::remote::downloads::DownloadState::Queued,
1688        bytes_per_second: 0,
1689    });
1690
1691    let progress_qid = queue_id;
1692    let bytes_written_progress = bytes_written.clone();
1693    let progress_tx = tx.clone();
1694    let stream_ready_flag = std::sync::atomic::AtomicBool::new(false);
1695    // A retry restarts the byte count from zero, so a changed total re-announces.
1696    let announced_total = AtomicU64::new(u64::MAX);
1697    // Taken out of the queue, cleared or removed, while waiting out an outage.
1698    let gone = || state.get_item(queue_id).is_none();
1699    let result =
1700        client.download_with_progress(&remote_id, &dest, &gone, move |downloaded, total| {
1701            bytes_written_progress.set(downloaded);
1702            // What knows a transfer moved is the code moving it. Held to a reading
1703            // every 250ms inside, so a chunk landing costs an atomic and a compare.
1704            store.progressed();
1705            if announced_total.swap(total, Ordering::Relaxed) != total {
1706                // The store, and only the store. The item's state says whether its
1707                // file can be played, which a transfer in flight has not changed.
1708                store.started(progress_qid, total, bytes_written_progress.clone());
1709            }
1710            if !stream_ready_flag.load(Ordering::Relaxed)
1711                && downloaded >= crate::player::state::STREAM_THRESHOLD
1712            {
1713                stream_ready_flag.store(true, Ordering::Relaxed);
1714                progress_tx
1715                    .send(PlayerCommand::TrackStreamReady(progress_qid))
1716                    .ok();
1717            }
1718        });
1719
1720    if let Err(SubsonicError::Download(DownloadError::Cancelled)) = result {
1721        store.withdrawn(queue_id);
1722        bytes_written.done();
1723        return;
1724    }
1725
1726    // However it ended, a decoder reading the `.part` file may be parked at the
1727    // write head. It waits on the feed, so the feed has to wake it — and only
1728    // once the item says how it ended, or it looks, sees a download, and parks
1729    // again with nothing left to wake it.
1730    if let Err(e) = result {
1731        store.failed(queue_id, e.to_string());
1732        fail_track(state, tx, queue_id, e.to_string());
1733        bytes_written.done();
1734        push_log(log_buf, format!("x {} — {}", track.title, e));
1735        return;
1736    }
1737    store.finished(queue_id);
1738
1739    state.update_paths(&[(queue_id, dest.clone())]);
1740    state.update_item_state(queue_id, ItemState::Ready);
1741    bytes_written.done();
1742    // Without this row the file is invisible to cache eviction and never reclaimed.
1743    if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1744        log::warn!(
1745            "cached {} but failed to record it ({}) — it will not be evicted",
1746            dest.display(),
1747            e
1748        );
1749    }
1750
1751    push_log(
1752        log_buf,
1753        format!("+ {} — {}", track.title, track.artist_name),
1754    );
1755
1756    if state.is_cursor(queue_id) {
1757        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1758    }
1759}
1760
1761/// Mark a queue item unplayable and tell the player, if it is waiting on it.
1762///
1763/// Setting `ItemState::Failed` alone is not enough: the player only wakes for
1764/// `TrackReady`, so a cursor parked on the item would wait for a download that
1765/// has already given up.
1766pub(crate) fn fail_track(
1767    state: &Arc<SharedPlayerState>,
1768    tx: &crossbeam_channel::Sender<PlayerCommand>,
1769    queue_id: QueueItemId,
1770    reason: String,
1771) {
1772    state.update_item_state(queue_id, ItemState::Failed(reason));
1773    if state.is_cursor(queue_id) {
1774        tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1775    }
1776}
1777
1778/// Append to the TUI log pane, tolerating a poisoned lock — a download worker
1779/// must not die because some other thread panicked while holding it.
1780fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1781    match log_buf.lock() {
1782        Ok(mut buf) => buf.push(msg),
1783        Err(_) => log::info!("{}", msg),
1784    }
1785}
1786
1787/// Why there is no remote client, in words worth showing someone.
1788///
1789/// Every caller of `subsonic_client` gets `None` for three different reasons,
1790/// and reporting one for all of them makes "koan has no password", which sends
1791/// you to sign in, look like a server that is merely down.
1792pub fn remote_unavailable(cfg: &Config) -> String {
1793    if !cfg.remote.enabled {
1794        return "no remote server is configured".into();
1795    }
1796    if cfg.remote.url.is_empty() {
1797        return "the remote server has no address".into();
1798    }
1799    if get_remote_password(cfg).is_none() {
1800        return "no password is stored for the remote server".into();
1801    }
1802    // A password resolved, so the client should have built. Nothing else
1803    // returns `None`, but saying so beats claiming a cause that is wrong.
1804    "the remote server could not be reached".into()
1805}
1806
1807/// Submit tracks for download.
1808///
1809/// Everything that is not the TUI reaches downloads through here — the FFI, the
1810/// GraphQL server and radio's auto-extend. The batch goes to the shared queue:
1811/// the same pool, priority lane and cursor watcher the TUI uses.
1812pub fn spawn_downloads(
1813    pending: Vec<(i64, QueueItemId)>,
1814    tx: crossbeam_channel::Sender<PlayerCommand>,
1815    state: Arc<SharedPlayerState>,
1816) {
1817    if pending.is_empty() {
1818        return;
1819    }
1820    crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1821}
1822
1823#[cfg(test)]
1824mod year_tests {
1825    use super::year_of;
1826
1827    #[test]
1828    fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1829        assert_eq!(year_of("1997-05-21"), Some("1997"));
1830        assert_eq!(year_of("199"), None);
1831        // Full-width digits: four bytes in is mid-character.
1832        assert_eq!(year_of("1997"), None);
1833    }
1834}
1835
1836#[cfg(test)]
1837mod rebuild_tests {
1838    use super::*;
1839    use crate::db::queries::sample_meta;
1840
1841    fn test_db() -> Database {
1842        let conn = rusqlite::Connection::open_in_memory().unwrap();
1843        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1844        crate::db::schema::create_tables(&conn).unwrap();
1845        Database { conn }
1846    }
1847
1848    #[test]
1849    fn cached_paths_follow_a_moved_cache_directory() {
1850        let old = tempfile::tempdir().unwrap();
1851        let new = tempfile::tempdir().unwrap();
1852        let db = test_db();
1853
1854        let mut rows = Vec::new();
1855        for name in ["moved", "gone", "current"] {
1856            let mut meta = sample_meta(name, "Artist", "Album");
1857            meta.source = "remote".into();
1858            meta.path = None;
1859            meta.remote_id = Some(name.into());
1860            let id = queries::upsert_track(&db.conn, &meta).unwrap();
1861            let tail = format!("Artist/Album/{name}.flac");
1862            // Every copy now lives under the new directory except "gone".
1863            if name != "gone" {
1864                let file = new.path().join(&tail);
1865                std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1866                std::fs::write(&file, b"audio").unwrap();
1867            }
1868            let stored = if name == "current" {
1869                new.path()
1870            } else {
1871                old.path()
1872            }
1873            .join(&tail);
1874            queries::set_cached_path(&db.conn, id, &stored.to_string_lossy()).unwrap();
1875            rows.push((id, tail));
1876        }
1877
1878        assert_eq!(relocate_cached_paths(&db, new.path()).unwrap(), 1);
1879
1880        let cached = |id: i64| -> String {
1881            db.conn
1882                .query_row("SELECT cached_path FROM tracks WHERE id = ?1", [id], |r| {
1883                    r.get(0)
1884                })
1885                .unwrap()
1886        };
1887        let expect = |root: &Path, tail: &str| root.join(tail).to_string_lossy().into_owned();
1888        assert_eq!(
1889            cached(rows[0].0),
1890            expect(new.path(), &rows[0].1),
1891            "re-rooted"
1892        );
1893        assert_eq!(
1894            cached(rows[1].0),
1895            expect(old.path(), &rows[1].1),
1896            "no file, left alone"
1897        );
1898        assert_eq!(
1899            cached(rows[2].0),
1900            expect(new.path(), &rows[2].1),
1901            "already current"
1902        );
1903        assert_eq!(
1904            relocate_cached_paths(&db, new.path()).unwrap(),
1905            0,
1906            "idempotent"
1907        );
1908    }
1909
1910    #[test]
1911    fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1912        let dir = tempfile::tempdir().unwrap();
1913        let db = test_db();
1914
1915        let mut cached = Vec::new();
1916        for name in ["one", "two"] {
1917            let mut meta = sample_meta(name, "Artist", "Album");
1918            meta.source = "remote".into();
1919            meta.path = None;
1920            meta.remote_id = Some(name.into());
1921            let id = queries::upsert_track(&db.conn, &meta).unwrap();
1922            let file = dir.path().join(format!("{name}.opus"));
1923            std::fs::write(&file, vec![0u8; 2048]).unwrap();
1924            queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1925            cached.push((id, file));
1926        }
1927
1928        let cleared = clear_downloads_for(&db, &[cached[0].0]);
1929        assert_eq!(cleared.files, 1);
1930        assert_eq!(cleared.bytes, 2048);
1931        assert!(!cached[0].1.exists(), "the copy asked for is gone");
1932        assert!(cached[1].1.exists(), "the other one is untouched");
1933
1934        // The row survives — a remote track is still in the library, it just
1935        // has to be fetched again.
1936        assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1937        assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1938        assert!(
1939            queries::cached_paths_for(&db.conn, &[cached[0].0])
1940                .unwrap()
1941                .is_empty()
1942        );
1943    }
1944
1945    #[test]
1946    fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1947        let db = test_db();
1948        let mut meta = sample_meta("ghost", "Artist", "Album");
1949        meta.source = "remote".into();
1950        meta.path = None;
1951        meta.remote_id = Some("ghost".into());
1952        let id = queries::upsert_track(&db.conn, &meta).unwrap();
1953        queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1954
1955        let cleared = clear_downloads_for(&db, &[id]);
1956        assert_eq!(cleared.files, 0, "nothing was there to remove");
1957        // Forgotten regardless: the row claimed a copy that does not exist.
1958        assert!(
1959            queries::cached_paths_for(&db.conn, &[id])
1960                .unwrap()
1961                .is_empty()
1962        );
1963    }
1964
1965    #[test]
1966    fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1967        let dir = tempfile::tempdir().unwrap();
1968        let cache = dir.path().join("cache");
1969        std::fs::create_dir_all(cache.join("Artist")).unwrap();
1970
1971        let finished = cache.join("Artist/whole.opus");
1972        let half = cache.join("Artist/half.opus.part");
1973        std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1974        std::fs::write(&half, vec![0u8; 4096]).unwrap();
1975
1976        let cfg = Config {
1977            remote: crate::config::RemoteConfig {
1978                cache_dir: Some(cache.clone()),
1979                ..Default::default()
1980            },
1981            ..Default::default()
1982        };
1983
1984        let swept = sweep_partial_downloads(&cfg);
1985        assert_eq!(swept.files, 1);
1986        assert_eq!(swept.bytes, 4096);
1987        assert!(!half.exists(), "the unfinished one is gone");
1988        assert!(finished.exists(), "a downloaded track is not touched");
1989    }
1990
1991    #[test]
1992    fn sweeping_an_empty_cache_is_not_an_error() {
1993        let dir = tempfile::tempdir().unwrap();
1994        let cfg = Config {
1995            remote: crate::config::RemoteConfig {
1996                cache_dir: Some(dir.path().join("nothing-here")),
1997                ..Default::default()
1998            },
1999            ..Default::default()
2000        };
2001        assert_eq!(sweep_partial_downloads(&cfg).files, 0);
2002    }
2003
2004    #[test]
2005    fn clearing_no_tracks_does_nothing() {
2006        let db = test_db();
2007        assert_eq!(clear_downloads_for(&db, &[]).files, 0);
2008    }
2009
2010    #[test]
2011    fn rebuild_drops_the_index_and_keeps_favourites() {
2012        let db = test_db();
2013        let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2014        meta.path = Some("/music/windowlicker.flac".into());
2015        let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
2016
2017        // Favourites key on the path; lyrics key on the row id.
2018        queries::toggle_favourite(
2019            &db.conn,
2020            crate::db::queries::LOCAL_USER,
2021            Path::new("/music/windowlicker.flac"),
2022        )
2023        .unwrap();
2024        db.conn
2025            .execute(
2026                "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
2027                 VALUES (?1, 'test', 'la la la', 0)",
2028                [track_id],
2029            )
2030            .unwrap();
2031
2032        let summary = rebuild_index(&db).unwrap();
2033        assert_eq!(summary.tracks, 1);
2034        assert_eq!(summary.albums, 1);
2035
2036        let tracks: i64 = db
2037            .conn
2038            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2039            .unwrap();
2040        assert_eq!(tracks, 0, "the index is gone");
2041
2042        let favourites: i64 = db
2043            .conn
2044            .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
2045            .unwrap();
2046        assert_eq!(favourites, 1, "favourites survive — they key on the path");
2047
2048        let lyrics: i64 = db
2049            .conn
2050            .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
2051            .unwrap();
2052        assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
2053    }
2054
2055    #[test]
2056    fn rebuilding_an_empty_library_is_not_an_error() {
2057        let db = test_db();
2058        let summary = rebuild_index(&db).unwrap();
2059        assert_eq!(summary.tracks, 0);
2060    }
2061}
2062
2063#[cfg(test)]
2064mod share_tests {
2065    use super::*;
2066    use crate::db::queries::sample_meta;
2067
2068    fn test_db() -> Database {
2069        let conn = rusqlite::Connection::open_in_memory().unwrap();
2070        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2071        crate::db::schema::create_tables(&conn).unwrap();
2072        Database { conn }
2073    }
2074
2075    /// Three tracks on one album; the album carries a remote ID.
2076    fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
2077        let ids: Vec<i64> = ["One", "Two", "Three"]
2078            .iter()
2079            .enumerate()
2080            .map(|(i, title)| {
2081                let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
2082                meta.path = Some(format!("/music/geogaddi/{i}.flac"));
2083                meta.track_number = Some(i as i32 + 1);
2084                queries::upsert_track(&db.conn, &meta).unwrap()
2085            })
2086            .collect();
2087        let album_id: i64 = db
2088            .conn
2089            .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
2090                r.get(0)
2091            })
2092            .unwrap();
2093        db.conn
2094            .execute(
2095                "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
2096                [album_id],
2097            )
2098            .unwrap();
2099        (album_id, ids)
2100    }
2101
2102    #[test]
2103    fn whole_album_collapses_to_the_album_link() {
2104        let db = test_db();
2105        let (album_id, ids) = album_of_three(&db);
2106        assert_eq!(
2107            album_remote_id(&db.conn, album_id, ids.len()),
2108            Some("al-1".into())
2109        );
2110    }
2111
2112    #[test]
2113    fn part_of_an_album_does_not() {
2114        let db = test_db();
2115        let (album_id, _) = album_of_three(&db);
2116        // Sharing an album link for two of three tracks would hand out a track
2117        // the user did not pick.
2118        assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
2119    }
2120
2121    #[test]
2122    fn a_local_only_album_has_no_link_to_collapse_to() {
2123        let db = test_db();
2124        let (album_id, ids) = album_of_three(&db);
2125        db.conn
2126            .execute(
2127                "UPDATE albums SET remote_id = NULL WHERE id = ?1",
2128                [album_id],
2129            )
2130            .unwrap();
2131        assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
2132    }
2133}
2134
2135#[cfg(test)]
2136mod client_cache_tests {
2137    use super::*;
2138
2139    #[test]
2140    fn one_subsonic_client_is_shared_per_credentials() {
2141        crate::config::isolate_config_for_tests();
2142        let mut cfg = Config::default();
2143        cfg.remote.enabled = true;
2144        cfg.remote.url = "https://shared-client.invalid".into();
2145        cfg.remote.username = "koan".into();
2146        cfg.remote.password = "first".into();
2147
2148        let first = subsonic_client(&cfg).expect("a configured remote yields a client");
2149        let again = subsonic_client(&cfg).expect("a configured remote yields a client");
2150        assert!(
2151            Arc::ptr_eq(&first, &again),
2152            "rebuilding drops the connection pool and re-handshakes TLS per request"
2153        );
2154
2155        cfg.remote.password = "second".into();
2156        let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
2157        assert!(
2158            !Arc::ptr_eq(&first, &relogged),
2159            "new credentials must not keep serving the client signed with the old ones"
2160        );
2161    }
2162}
2163
2164#[cfg(test)]
2165mod native_share_tests {
2166    use super::*;
2167    use crate::db::queries::{sample_meta, upsert_track};
2168
2169    #[test]
2170    fn a_standalone_server_shares_natively_in_the_order_asked() {
2171        let conn = rusqlite::Connection::open_in_memory().unwrap();
2172        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2173        crate::db::schema::create_tables(&conn).unwrap();
2174        let db = Database { conn };
2175        let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2176        let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
2177        let mut cfg = Config::default();
2178        assert!(matches!(
2179            create_share(
2180                &db,
2181                queries::LOCAL_USER,
2182                &cfg,
2183                &ShareTarget::Tracks(vec![a]),
2184                None
2185            ),
2186            Err(ShareError::NoPublicUrl)
2187        ));
2188        cfg.sharing.public_url = Some("https://koan.example/".into());
2189        let out = create_share(
2190            &db,
2191            queries::LOCAL_USER,
2192            &cfg,
2193            &ShareTarget::Tracks(vec![b, 9999, a]),
2194            Some("mix"),
2195        )
2196        .unwrap();
2197        assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2198        assert_eq!((out.shared, out.skipped), (2, 1));
2199        let share = queries::shares::get_share(&db.conn, &out.id)
2200            .unwrap()
2201            .unwrap();
2202        assert_eq!(share.track_ids, [b, a]);
2203        assert!(matches!(
2204            create_share(
2205                &db,
2206                queries::LOCAL_USER,
2207                &cfg,
2208                &ShareTarget::Tracks(vec![9999]),
2209                None
2210            ),
2211            Err(ShareError::NothingToShare)
2212        ));
2213    }
2214
2215    #[test]
2216    fn a_server_with_an_upstream_still_shares_natively() {
2217        // Local-only tracks, which the upstream path refuses as NothingRemote.
2218        let conn = rusqlite::Connection::open_in_memory().unwrap();
2219        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2220        crate::db::schema::create_tables(&conn).unwrap();
2221        let db = Database { conn };
2222        let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2223        let mut cfg = Config::default();
2224        cfg.remote.enabled = true;
2225        cfg.remote.url = "https://upstream.invalid".into();
2226        cfg.remote.username = "someone".into();
2227        cfg.remote.password = "secret".into();
2228        cfg.sharing.public_url = Some("https://koan.example".into());
2229        let out = create_native_share(
2230            &db,
2231            queries::LOCAL_USER,
2232            &cfg,
2233            &ShareTarget::Tracks(vec![a]),
2234            None,
2235        )
2236        .unwrap();
2237        assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2238        assert!(
2239            queries::shares::get_share(&db.conn, &out.id)
2240                .unwrap()
2241                .is_some()
2242        );
2243    }
2244
2245    fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
2246        let mut meta = sample_meta(title, "Rrose", album);
2247        meta.track_number = Some(n);
2248        meta.date = Some(date.into());
2249        upsert_track(&db.conn, &meta).unwrap()
2250    }
2251
2252    #[test]
2253    fn shares_are_slices_fixed_when_made() {
2254        let conn = rusqlite::Connection::open_in_memory().unwrap();
2255        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2256        crate::db::schema::create_tables(&conn).unwrap();
2257        let db = Database { conn };
2258        let later = album_track(&db, "L1", "Later", 1, "2021");
2259        let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
2260        let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
2261        let album_of = |t| {
2262            queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
2263                .album_id
2264                .unwrap()
2265        };
2266        let (earlier, later_album) = (album_of(a1), album_of(later));
2267        let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
2268            .artist_id
2269            .unwrap();
2270
2271        // One track: its album, cued to it.
2272        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
2273        assert_eq!(
2274            (slice.kind, slice.subject_id, slice.start_track_id),
2275            (ShareKind::Album, Some(earlier), Some(a2))
2276        );
2277        assert_eq!(ids, [a1, a2]);
2278
2279        // An album, with a cue that is not on it dropped.
2280        let (slice, ids) = resolve_share(
2281            &db.conn,
2282            &ShareTarget::Album {
2283                album_id: later_album,
2284                start_track_id: Some(a1),
2285            },
2286        )
2287        .unwrap();
2288        assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
2289        assert_eq!(ids, [later]);
2290
2291        // An artist: every album, in release order.
2292        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
2293        assert_eq!(
2294            (slice.kind, slice.subject_id),
2295            (ShareKind::Artist, Some(artist))
2296        );
2297        assert_eq!(ids, [a1, a2, later]);
2298
2299        // Several tracks stay a list, in the order given.
2300        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
2301        assert_eq!(slice, Slice::TRACKS);
2302        assert_eq!(ids, [later, a1]);
2303
2304        assert!(matches!(
2305            resolve_share(&db.conn, &ShareTarget::Artist(9999)),
2306            Err(ShareError::NothingToShare)
2307        ));
2308    }
2309}
2310
2311#[cfg(test)]
2312mod album_share_id_tests {
2313    use super::album_share_id_for;
2314
2315    #[test]
2316    fn a_koan_album_is_named_as_an_album() {
2317        assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
2318        // Already prefixed, or not koan's numbering: left as issued.
2319        assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
2320        assert_eq!(album_share_id_for(false, "46215".into()), "46215");
2321        assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
2322    }
2323}
2324
2325#[cfg(test)]
2326mod cache_path_tests {
2327    use super::*;
2328
2329    fn track(artist: &str, album: &str, codec: &str) -> queries::TrackRow {
2330        queries::TrackRow {
2331            id: 1,
2332            album_id: None,
2333            artist_id: None,
2334            artist_name: artist.into(),
2335            album_artist_name: artist.into(),
2336            album_title: album.into(),
2337            disc: None,
2338            track_number: Some(1),
2339            title: "Song".into(),
2340            duration_ms: None,
2341            path: None,
2342            codec: Some(codec.into()),
2343            sample_rate: None,
2344            bit_depth: None,
2345            channels: None,
2346            bitrate: None,
2347            genre: None,
2348            source: "remote".into(),
2349            remote_id: Some("r1".into()),
2350            cached_path: None,
2351        }
2352    }
2353
2354    #[test]
2355    fn a_server_suffix_cannot_leave_the_cache() {
2356        let cache = Path::new("/cache");
2357        for codec in [
2358            "flac/../../../../x",
2359            "..",
2360            "../..",
2361            "/etc/passwd",
2362            "\\..\\..",
2363        ] {
2364            let path = cache_path_for_track(cache, &track("A", "B", codec), None);
2365            assert!(path_within(cache, &path), "{codec}: {}", path.display());
2366        }
2367        let path = cache_path_for_track(cache, &track("A", "B", "flac/../../../../x"), None);
2368        assert_eq!(path.extension().unwrap(), "flacx");
2369    }
2370
2371    #[test]
2372    fn dot_names_cannot_climb_out() {
2373        let cache = Path::new("/cache");
2374        let path = cache_path_for_track(cache, &track("..", ".", ".."), None);
2375        assert!(path_within(cache, &path), "{}", path.display());
2376        assert_eq!(sanitise_filename(".."), "_");
2377        assert_eq!(sanitise_filename(" . "), "_");
2378        assert_eq!(sanitise_filename("..."), "...");
2379    }
2380
2381    #[test]
2382    fn an_empty_suffix_falls_back_to_flac() {
2383        assert_eq!(sanitise_extension("../"), None);
2384        assert_eq!(sanitise_extension("FLAC"), Some("flac".into()));
2385        let path = cache_path_for_track(Path::new("/c"), &track("A", "B", "./"), None);
2386        assert_eq!(path.extension().unwrap(), "flac");
2387    }
2388
2389    #[test]
2390    fn path_within_rejects_parent_components() {
2391        let dir = Path::new("/cache");
2392        assert!(path_within(dir, Path::new("/cache/a/b.flac")));
2393        assert!(!path_within(dir, Path::new("/cache/a/../../x")));
2394        assert!(!path_within(dir, Path::new("/elsewhere/x")));
2395    }
2396}
2397
2398#[cfg(test)]
2399mod favourite_sync_tests {
2400    use super::*;
2401    use crate::db::queries::sample_meta;
2402
2403    /// A server with song `s1` starred, recording every id it is asked to star.
2404    fn serve(stars: Arc<Mutex<Vec<String>>>) -> String {
2405        use std::io::{BufRead, Write};
2406        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2407        let url = format!("http://{}", listener.local_addr().unwrap());
2408        std::thread::spawn(move || {
2409            for mut stream in listener.incoming().flatten() {
2410                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
2411                let mut request = String::new();
2412                reader.read_line(&mut request).unwrap();
2413                let mut line = String::new();
2414                while reader.read_line(&mut line).unwrap_or(0) > 2 {
2415                    line.clear();
2416                }
2417                let target = request.split_whitespace().nth(1).unwrap_or("");
2418                let (path, query) = target.split_once('?').unwrap_or((target, ""));
2419                let body = match path.rsplit('/').next().unwrap() {
2420                    "getStarred2" => {
2421                        r#"{"subsonic-response":{"status":"ok","starred2":{"song":[{"id":"s1","title":"One"}]}}}"#
2422                    }
2423                    "star" => {
2424                        if let Some((_, id)) = query
2425                            .split('&')
2426                            .filter_map(|kv| kv.split_once('='))
2427                            .find(|(k, _)| *k == "id")
2428                        {
2429                            stars.lock().unwrap().push(id.to_string());
2430                        }
2431                        r#"{"subsonic-response":{"status":"ok"}}"#
2432                    }
2433                    _ => r#"{"subsonic-response":{"status":"ok"}}"#,
2434                };
2435                let _ = write!(
2436                    stream,
2437                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2438                    body.len()
2439                );
2440            }
2441        });
2442        url
2443    }
2444
2445    #[test]
2446    fn only_favourites_the_server_lacks_are_starred() {
2447        let dir = tempfile::tempdir().unwrap();
2448        let db = Database::open(&dir.path().join("koan.db")).unwrap();
2449        for (title, remote_id) in [("One", "s1"), ("Two", "s2")] {
2450            let mut meta = sample_meta(title, "Artist", "Album");
2451            meta.path = Some(format!("/music/{title}.flac"));
2452            meta.remote_id = Some(remote_id.into());
2453            queries::upsert_track(&db.conn, &meta).unwrap();
2454            queries::add_favourite(
2455                &db.conn,
2456                queries::LOCAL_USER,
2457                Path::new(&format!("/music/{title}.flac")),
2458            )
2459            .unwrap();
2460        }
2461
2462        let stars = Arc::new(Mutex::new(Vec::new()));
2463        let url = serve(stars.clone());
2464        let sync = reconcile_favourites(&db, &SubsonicClient::new(&url, "u", "pw"));
2465        assert_eq!(sync.pushed, 1);
2466        assert_eq!(*stars.lock().unwrap(), ["s2"]);
2467    }
2468}