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