Skip to main content

koan_core/
helpers.rs

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