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