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::player::commands::PlayerCommand;
14use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
15use crate::remote::client::{SubsonicAuth, SubsonicClient};
16
17// ---------------------------------------------------------------------------
18// Subsonic client builder
19// ---------------------------------------------------------------------------
20
21/// The remote password, from `config.local.toml` or a `KOAN_REMOTE__PASSWORD`
22/// layered over it.
23pub fn get_remote_password(cfg: &Config) -> Option<String> {
24    (!cfg.remote.password.is_empty()).then(|| cfg.remote.password.clone())
25}
26
27/// Index files that appear in the library folders while koan is running.
28///
29/// One incremental scan shortly after startup — the walk is a fraction of a
30/// second even across fifty thousand files, and everything unchanged is skipped
31/// on its mtime and size — then a rescan whenever the folders change.
32///
33/// Changes are debounced: copying an album in produces a burst of events, and
34/// scanning once per file would be both slow and pointless. The scan is
35/// incremental in every case, so the cost is proportional to what actually
36/// changed rather than to the size of the library.
37///
38/// `on_state` reports whether a scan is running, so a UI can show it.
39pub fn spawn_library_watch(
40    db_path: std::path::PathBuf,
41    on_state: impl Fn(bool) + Send + Sync + 'static,
42) -> Option<std::thread::JoinHandle<()>> {
43    use notify::{RecursiveMode, Watcher};
44
45    std::thread::Builder::new()
46        .name("koan-library-watch".into())
47        .spawn(move || {
48            let scan_now = |reason: &str| {
49                let cfg = Config::load().unwrap_or_default();
50                if cfg.library.folders.is_empty() {
51                    return;
52                }
53                let Ok(db) = Database::open(&db_path) else {
54                    return;
55                };
56                on_state(true);
57                let result = crate::index::scanner::full_scan(
58                    &db,
59                    &cfg.library.folders,
60                    crate::index::scanner::ScanOptions::default(),
61                    None,
62                );
63                on_state(false);
64                log::info!(
65                    "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
66                    result.added,
67                    result.updated,
68                    result.removed,
69                    result.skipped
70                );
71            };
72
73            // After the first frame and the first track, not competing with them.
74            std::thread::sleep(std::time::Duration::from_secs(3));
75            scan_now("startup");
76
77            let (tx, rx) = std::sync::mpsc::channel();
78            let Ok(mut watcher) = notify::recommended_watcher(move |event| {
79                let _ = tx.send(event);
80            }) else {
81                log::warn!("could not watch the library folders");
82                return;
83            };
84
85            let cfg = Config::load().unwrap_or_default();
86            for folder in &cfg.library.folders {
87                if let Err(e) = watcher.watch(folder, RecursiveMode::Recursive) {
88                    log::warn!("could not watch {}: {e}", folder.display());
89                }
90            }
91
92            // Copying an album in is a burst of events. Wait for it to stop
93            // before scanning, rather than scanning per file.
94            const SETTLE: std::time::Duration = std::time::Duration::from_secs(5);
95            while let Ok(first) = rx.recv() {
96                if first.is_err() {
97                    continue;
98                }
99                while rx.recv_timeout(SETTLE).is_ok() {}
100                scan_now("watched change");
101            }
102        })
103        .ok()
104}
105
106/// Keep the library in step with the server, without being asked.
107///
108/// One sync shortly after startup, then every `auto_sync_interval_mins`. Always
109/// incremental: it asks the server what changed rather than walking the whole
110/// library, which is what makes it cheap enough to run unattended. A full sync
111/// stays a deliberate action.
112///
113/// The startup run is delayed a few seconds so it is not competing with the
114/// first frame and the first track for the disk.
115///
116/// `on_state` reports whether a sync is running, so a UI can say so rather than
117/// appearing to do nothing.
118pub fn spawn_auto_sync(
119    db_path: std::path::PathBuf,
120    on_state: impl Fn(bool) + Send + 'static,
121) -> Option<std::thread::JoinHandle<()>> {
122    std::thread::Builder::new()
123        .name("koan-auto-sync".into())
124        .spawn(move || {
125            std::thread::sleep(std::time::Duration::from_secs(5));
126            loop {
127                let cfg = Config::load().unwrap_or_default();
128                if !cfg.remote.enabled || !cfg.remote.auto_sync {
129                    // Re-read rather than exit: the setting can be turned on
130                    // while the app is running.
131                    std::thread::sleep(std::time::Duration::from_secs(60));
132                    continue;
133                }
134
135                if let Some(client) = subsonic_client(&cfg)
136                    && let Ok(db) = Database::open(&db_path)
137                {
138                    on_state(true);
139                    match sync_remote(&db, &client, false, &cfg.remote.url, &cfg.remote.username) {
140                        Ok(s) => log::info!(
141                            "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
142                             favourites {}↑ {}↓; playlists {}↓ {}↑",
143                            s.library.artists_synced,
144                            s.library.albums_synced,
145                            s.library.tracks_synced,
146                            s.library.albums_failed,
147                            s.favourites.pushed,
148                            s.favourites.imported,
149                            s.playlists.pulled,
150                            s.playlists.pushed,
151                        ),
152                        Err(e) => log::warn!("auto sync failed: {e}"),
153                    }
154                    on_state(false);
155                }
156
157                match cfg.remote.auto_sync_interval_mins {
158                    // Once at startup and no more.
159                    0 => return,
160                    mins => std::thread::sleep(std::time::Duration::from_secs(mins * 60)),
161                }
162            }
163        })
164        .ok()
165}
166
167/// What a library rebuild removed.
168#[derive(Debug, Clone, Copy, Default)]
169pub struct RebuildSummary {
170    pub tracks: u64,
171    pub albums: u64,
172    pub artists: u64,
173}
174
175/// Drop the index so the next scan rebuilds it from the files.
176///
177/// Favourites are keyed on the file path rather than a row id, so they survive
178/// this and re-attach when the paths come back. Everything keyed on a track id
179/// cannot: lyrics, play history and acoustic embeddings go, and the foreign keys
180/// would refuse the delete otherwise. Lyrics and embeddings are re-derivable;
181/// play counts are not, which is worth saying out loud wherever this is offered.
182///
183/// The remote half of the library comes back on the next sync, the local half on
184/// the next scan.
185pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
186    let count = |sql: &str| -> u64 {
187        db.conn
188            .query_row(sql, [], |r| r.get::<_, i64>(0))
189            .unwrap_or(0) as u64
190    };
191    let summary = RebuildSummary {
192        tracks: count("SELECT COUNT(*) FROM tracks"),
193        albums: count("SELECT COUNT(*) FROM albums"),
194        artists: count("SELECT COUNT(*) FROM artists"),
195    };
196
197    // Children before parents; the FTS index has no foreign keys but is derived
198    // from tracks and would otherwise keep answering for rows that are gone.
199    db.conn.execute_batch(
200        "BEGIN;
201         DELETE FROM track_vectors;
202         DELETE FROM lyrics_cache;
203         DELETE FROM play_history;
204         DELETE FROM scan_cache;
205         DELETE FROM tracks_fts;
206         DELETE FROM tracks;
207         DELETE FROM similar_artists;
208         DELETE FROM albums;
209         DELETE FROM artists;
210         COMMIT;",
211    )?;
212    let _ = db.conn.execute_batch("VACUUM");
213    Ok(summary)
214}
215
216/// Bytes currently held in the download cache.
217pub fn cache_size_bytes(cfg: &Config) -> u64 {
218    walkdir::WalkDir::new(cfg.cache_dir())
219        .into_iter()
220        .filter_map(Result::ok)
221        .filter(|e| e.file_type().is_file())
222        .filter_map(|e| e.metadata().ok())
223        .map(|m| m.len())
224        .sum()
225}
226
227/// How many tracks came from this folder.
228///
229/// The trailing separator matters: without it `/Volumes/Music` also counts
230/// `/Volumes/Music Backup`.
231pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
232    let (lower, upper) = queries::folder_prefix_range(folder);
233    db.conn
234        .query_row(
235            "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
236            [&lower, &upper],
237            |r| r.get::<_, i64>(0),
238        )
239        .unwrap_or(0) as u64
240}
241
242/// How many tracks the server accounts for.
243pub fn tracks_from_server(db: &Database) -> u64 {
244    db.conn
245        .query_row(
246            "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
247            [],
248            |r| r.get::<_, i64>(0),
249        )
250        .unwrap_or(0) as u64
251}
252
253/// Forget every track under a folder.
254///
255/// Removing a folder from the library should remove what it put there —
256/// otherwise the library keeps showing records whose files it will never look
257/// at again, and there is no way back to an empty library short of clearing the
258/// whole index.
259///
260/// A track that also exists on the server keeps its row and loses only its local
261/// path: it is still playable, just by download rather than from disk.
262///
263/// Albums and artists left holding nothing go too, or the browser fills with
264/// empty shelves.
265pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
266    // Rows are keyed by the disk's spelling; a folder named the other way would forget nothing.
267    let folder = &crate::index::spelling::on_disk(folder);
268    let (lower, upper) = queries::folder_prefix_range(folder);
269
270    let tx = db.conn.unchecked_transaction()?;
271    // Still on the server: keep the row, drop the local file.
272    tx.execute(
273        "UPDATE tracks SET path = NULL, source = 'remote'
274          WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
275        [&lower, &upper],
276    )?;
277
278    let ids: Vec<i64> = {
279        let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
280        let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
281        rows.filter_map(Result::ok).collect()
282    };
283    for id in &ids {
284        tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
285        tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
286        tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
287        tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
288        tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
289        tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
290    }
291    prune_empty_albums_and_artists(&tx)?;
292    tx.commit()?;
293    Ok(ids.len() as u64)
294}
295
296/// Forget everything that only existed on the server.
297///
298/// Signing out should leave the library with what is actually on this machine.
299/// A track held both locally and remotely keeps its row and loses its remote id;
300/// one that only ever came from the server goes.
301pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
302    let tx = db.conn.unchecked_transaction()?;
303
304    let ids: Vec<i64> = {
305        let mut stmt =
306            tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
307        let rows = stmt.query_map([], |r| r.get(0))?;
308        rows.filter_map(Result::ok).collect()
309    };
310    for id in &ids {
311        tx.execute("DELETE FROM track_vectors WHERE track_id = ?1", [id])?;
312        tx.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
313        tx.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
314        tx.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
315        tx.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
316        tx.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
317    }
318    // Local copies stay, minus the server they were also on.
319    tx.execute(
320        "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
321          WHERE remote_id IS NOT NULL",
322        [],
323    )?;
324    tx.execute("DELETE FROM similar_artists", [])?;
325    prune_empty_albums_and_artists(&tx)?;
326    tx.commit()?;
327    Ok(ids.len() as u64)
328}
329
330/// Albums and artists with nothing left in them.
331fn prune_empty_albums_and_artists(
332    tx: &rusqlite::Transaction<'_>,
333) -> Result<(), crate::db::connection::DbError> {
334    tx.execute(
335        "DELETE FROM albums WHERE NOT EXISTS
336           (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
337        [],
338    )?;
339    tx.execute(
340        "DELETE FROM similar_artists WHERE NOT EXISTS
341           (SELECT 1 FROM albums WHERE albums.artist_id = similar_artists.artist_id)",
342        [],
343    )?;
344    tx.execute(
345        "DELETE FROM artists WHERE NOT EXISTS
346             (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
347           AND NOT EXISTS
348             (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
349        [],
350    )?;
351    Ok(())
352}
353
354/// What clearing the download cache removed.
355#[derive(Debug, Clone, Copy, Default)]
356pub struct CacheCleared {
357    pub files: u64,
358    pub bytes: u64,
359}
360
361/// Delete every downloaded remote track and forget where they were.
362///
363/// The rows stay — a remote track is still in the library, it just has to be
364/// fetched again to play.
365pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
366    let dir = cfg.cache_dir();
367    let mut cleared = CacheCleared::default();
368    for entry in walkdir::WalkDir::new(&dir)
369        .into_iter()
370        .filter_map(Result::ok)
371        .filter(|e| e.file_type().is_file())
372    {
373        if let Ok(meta) = entry.metadata() {
374            cleared.bytes += meta.len();
375            cleared.files += 1;
376        }
377    }
378    let _ = std::fs::remove_dir_all(&dir);
379    let _ = std::fs::create_dir_all(&dir);
380    let _ = queries::clear_cached_paths(&db.conn);
381    cleared
382}
383
384/// Delete the downloaded copies of just these tracks.
385///
386/// The per-track counterpart of `clear_download_cache`, for throwing away one
387/// record rather than the lot. A track playing from a copy being removed keeps
388/// playing — the decoder holds the file open, and unlinking it only takes the
389/// name away — but the next play fetches it again.
390pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
391    let mut cleared = CacheCleared::default();
392    let paths = match queries::cached_paths_for(&db.conn, track_ids) {
393        Ok(paths) => paths,
394        Err(e) => {
395            log::warn!("could not read cached paths: {e}");
396            return cleared;
397        }
398    };
399    for path in &paths {
400        let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
401        match std::fs::remove_file(path) {
402            Ok(()) => {
403                cleared.files += 1;
404                cleared.bytes += size;
405            }
406            // Already gone is the outcome asked for, so it is not a failure.
407            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
408            Err(e) => log::warn!("could not remove {path}: {e}"),
409        }
410    }
411    if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
412        log::warn!("removed downloads but failed to forget them ({e})");
413    }
414    cleared
415}
416
417/// Throw away half-finished downloads left behind by a previous run.
418///
419/// A `.part` file only means something to the transfer writing it. koan does
420/// not resume — the file is written straight through and renamed at the end —
421/// so one still on disk at startup is from a run that did not finish, and it
422/// will be truncated and rewritten the next time that track is wanted anyway.
423/// Until then it is bytes nothing knows about: cache eviction only tracks what
424/// finished, so an interrupted download of a nine-hour recording is half a
425/// gigabyte that never gets reclaimed.
426///
427/// At startup rather than at exit, because a run that ends without getting to
428/// its own cleanup is exactly the run that leaves these behind.
429pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
430    let mut swept = CacheCleared::default();
431    for entry in walkdir::WalkDir::new(cfg.cache_dir())
432        .into_iter()
433        .filter_map(Result::ok)
434        .filter(|e| e.file_type().is_file())
435        .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
436    {
437        let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
438        match std::fs::remove_file(entry.path()) {
439            Ok(()) => {
440                swept.files += 1;
441                swept.bytes += size;
442            }
443            Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
444        }
445    }
446    if swept.files > 0 {
447        log::info!(
448            "swept {} unfinished download(s), {} bytes",
449            swept.files,
450            swept.bytes
451        );
452    }
453    swept
454}
455
456/// Fetch again anything in the queue whose downloaded copy has just been
457/// removed.
458///
459/// Clearing downloads deletes files the queue is still pointing at, and an item
460/// that goes on claiming to be ready plays nothing at all. Call this after
461/// either clearing function, from anywhere with a player attached.
462pub fn requeue_cleared_downloads(
463    state: &Arc<SharedPlayerState>,
464    tx: &crossbeam_channel::Sender<PlayerCommand>,
465) {
466    let stale = state.reset_items_with_missing_files();
467    if stale.is_empty() {
468        return;
469    }
470    log::info!(
471        "{} queued tracks lost their copy — fetching again",
472        stale.len()
473    );
474    spawn_downloads(stale, tx.clone(), state.clone());
475}
476
477/// Push a favourite to the remote server, if this track came from one.
478///
479/// Fire and forget on its own thread: starring is a courtesy to the server, and
480/// a slow or unreachable one should not hold up the click that caused it. The
481/// local favourite is already written by the time this runs.
482///
483/// Silently does nothing for a track with no `remote_id` — including a local
484/// file whose copy on the server failed to merge with it (#221), which is the
485/// one case where the silence is wrong.
486///
487/// Shared by the TUI, the server and the app, which each had their own copy.
488pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
489    let cfg = Config::load().unwrap_or_default();
490    if !cfg.remote.enabled {
491        return;
492    }
493    let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
494        log::warn!("not syncing favourite: {} has no remote id", path.display());
495        return;
496    };
497    let Some(client) = subsonic_client(&cfg) else {
498        log::warn!("not syncing favourite: no usable server credentials");
499        return;
500    };
501    std::thread::Builder::new()
502        .name("koan-fav-sync".into())
503        .spawn(move || {
504            let result = if star {
505                client.star(&remote_id)
506            } else {
507                client.unstar(&remote_id)
508            };
509            match result {
510                Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
511                Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
512            }
513        })
514        .ok();
515}
516
517/// Everything a sync is.
518#[derive(Debug, Default)]
519pub struct FullSync {
520    pub library: crate::remote::sync::SyncResult,
521    pub favourites: FavouriteSync,
522    pub playlists: crate::playlists::PlaylistSync,
523}
524
525/// Pull the library, then reconcile favourites and playlists.
526///
527/// One function because there are four callers — the app, the CLI, the GraphQL
528/// job and koan's own auto-sync — and they had each been told separately what a
529/// sync consists of. Two of them never heard about playlists, and the auto-sync
530/// had never heard about favourites either, so a star made on the server only
531/// arrived if you happened to press the button yourself.
532///
533/// The library comes first: favourites and playlists both name tracks by the
534/// server's ids, and neither can find a track the library has not seen yet.
535pub fn sync_remote(
536    db: &Database,
537    client: &SubsonicClient,
538    full: bool,
539    url: &str,
540    username: &str,
541) -> Result<FullSync, crate::remote::sync::SyncError> {
542    let library = crate::remote::sync::sync_library(db, client, full, url, username)?;
543    Ok(FullSync {
544        library,
545        favourites: reconcile_favourites(db, client),
546        playlists: crate::playlists::reconcile_playlists(db, client, username),
547    })
548}
549
550/// What a favourites reconciliation did.
551#[derive(Debug, Default, Clone, Copy)]
552pub struct FavouriteSync {
553    pub pushed: usize,
554    pub imported: usize,
555}
556
557/// Reconcile favourites with the server, both directions.
558///
559/// Pushes every local favourite that the server knows about, then imports
560/// everything the server has starred. Union rather than mirror: neither side
561/// records an unstar, so treating one as authoritative would silently delete
562/// favourites made on the other.
563///
564/// Covers albums and artists as well as tracks — `getStarred2` returns all
565/// three from one request, and reading only songs left a starred album
566/// invisible to koan.
567pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
568    let mut out = FavouriteSync::default();
569
570    let tracks = queries::favourites_with_remote_id(&db.conn).unwrap_or_default();
571    for (_path, remote_id) in &tracks {
572        if client.star(remote_id).is_ok() {
573            out.pushed += 1;
574        }
575    }
576    for (_id, remote_id) in queries::favourite_albums_with_remote_id(&db.conn).unwrap_or_default() {
577        if client.star_album(&remote_id).is_ok() {
578            out.pushed += 1;
579        }
580    }
581    for (_id, remote_id) in queries::favourite_artists_with_remote_id(&db.conn).unwrap_or_default()
582    {
583        if client.star_artist(&remote_id).is_ok() {
584            out.pushed += 1;
585        }
586    }
587
588    let starred = match client.get_starred_all() {
589        Ok(s) => s,
590        Err(e) => {
591            log::warn!("could not fetch starred items from the server: {e}");
592            return out;
593        }
594    };
595
596    let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
597    let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
598    let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
599    out.imported += queries::import_remote_favourites(&db.conn, &songs).unwrap_or(0);
600    out.imported += queries::import_remote_favourite_albums(&db.conn, &albums).unwrap_or(0);
601    out.imported += queries::import_remote_favourite_artists(&db.conn, &artists).unwrap_or(0);
602    out
603}
604
605/// What a favourite applies to. Subsonic stars all three, under different
606/// parameter names — passing an album id as `id` silently stars nothing.
607#[derive(Debug, Clone, Copy, PartialEq, Eq)]
608pub enum FavouriteKind {
609    Track,
610    Album,
611    Artist,
612}
613
614/// Push an album or artist favourite to the server.
615///
616/// Same shape as [`sync_favourite_to_remote`], but the remote id comes from the
617/// album or artist row rather than the track's path.
618pub fn sync_collection_favourite_to_remote(
619    db: &Database,
620    kind: FavouriteKind,
621    id: i64,
622    star: bool,
623) {
624    let cfg = Config::load().unwrap_or_default();
625    if !cfg.remote.enabled {
626        return;
627    }
628    let remote_id = match kind {
629        FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
630        FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
631        FavouriteKind::Track => return,
632    };
633    let Ok(Some(remote_id)) = remote_id else {
634        log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
635        return;
636    };
637    let Some(client) = subsonic_client(&cfg) else {
638        log::warn!("not syncing favourite: no usable server credentials");
639        return;
640    };
641    std::thread::Builder::new()
642        .name("koan-fav-sync".into())
643        .spawn(move || {
644            let result = match (kind, star) {
645                (FavouriteKind::Album, true) => client.star_album(&remote_id),
646                (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
647                (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
648                (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
649                (FavouriteKind::Track, _) => Ok(()),
650            };
651            match result {
652                Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
653                Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
654            }
655        })
656        .ok();
657}
658
659/// Why signing in to a remote server failed.
660#[derive(Debug, thiserror::Error)]
661pub enum SignInError {
662    #[error("the server did not accept those credentials: {0}")]
663    Rejected(#[from] crate::remote::client::SubsonicError),
664    #[error("could not write the configuration: {0}")]
665    Config(#[from] crate::config::ConfigError),
666}
667
668/// Sign in to a Subsonic/Navidrome server and remember it.
669///
670/// The password goes to `config.local.toml`, which is gitignored and written
671/// `0600`. Subsonic authenticates every request with the password or a salted
672/// MD5 of it, so there is no token to hold instead — whatever koan keeps is
673/// password-equivalent wherever it is kept.
674///
675/// The credentials are checked against the server before anything is written; a
676/// stored password that does not work is worse than none.
677///
678/// Shared by the CLI and the app so the two cannot disagree about where
679/// credentials live.
680pub fn set_remote_credentials(
681    url: &str,
682    username: &str,
683    password: &str,
684) -> Result<(), SignInError> {
685    let url = url.trim_end_matches('/');
686    SubsonicClient::new(url, username, password).ping()?;
687
688    Config::persist(|cfg| {
689        cfg.remote.enabled = true;
690        cfg.remote.url = url.to_string();
691        cfg.remote.username = username.to_string();
692        cfg.remote.password = password.to_string();
693    })?;
694    Ok(())
695}
696
697/// Shared secret for koan's own Subsonic API.
698///
699/// Deliberately not the same secret as `get_remote_password` — see `SubsonicConfig`.
700pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
701    (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
702}
703
704/// Upstream Subsonic credentials from the merged config, returning `None` if
705/// remote is disabled or has no URL configured.
706///
707/// Prefer this over `subsonic_client` when only a signed URL is needed:
708/// building a client constructs blocking `reqwest` clients, which panics from
709/// inside a tokio runtime.
710pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
711    if !cfg.remote.enabled || cfg.remote.url.is_empty() {
712        return None;
713    }
714    let password = get_remote_password(cfg)?;
715    Some(SubsonicAuth::new(
716        &cfg.remote.url,
717        &cfg.remote.username,
718        &password,
719    ))
720}
721
722/// One `SubsonicClient` per set of credentials, shared process-wide.
723///
724/// Constructing one builds two blocking `reqwest` clients, each carrying its
725/// own runtime on its own thread, and each starting with a cold connection
726/// pool — so a client per call means a fresh TLS handshake for every cover art
727/// request. The download queue had already worked this out and kept a client
728/// of its own for the app's lifetime; this is that, for everyone.
729///
730/// Keyed on the credentials, so logging in as someone else replaces the client
731/// rather than serving the old one. Never call from async code: building the
732/// inner clients panics inside a tokio runtime.
733pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
734    let auth = subsonic_auth(cfg)?;
735
736    let mut slot = SUBSONIC_CLIENT.lock();
737    if let Some((cached, client)) = slot.as_ref()
738        && *cached == auth
739    {
740        return Some(client.clone());
741    }
742
743    let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
744    *slot = Some((auth, client.clone()));
745    Some(client)
746}
747
748type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
749
750static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
751    std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
752
753// ---------------------------------------------------------------------------
754// Sharing
755// ---------------------------------------------------------------------------
756
757/// Why a share link could not be made. Each variant is something the user can
758/// act on, which is the point — every caller used to collapse these into
759/// "local-only tracks can't be shared" and send people looking in the wrong
760/// place.
761#[derive(Debug, thiserror::Error)]
762pub enum ShareError {
763    #[error("no remote server is configured")]
764    NoRemote,
765    #[error("none of these tracks are on the server, so a link has nothing to point at")]
766    NothingRemote,
767    #[error("the server refused to share these: {0}")]
768    Server(#[from] crate::remote::client::SubsonicError),
769    #[error(transparent)]
770    Database(#[from] crate::db::connection::DbError),
771}
772
773/// A created share link, and how much of the request it covers.
774#[derive(Debug, Clone)]
775pub struct ShareOutcome {
776    pub url: String,
777    /// The server's own ID for the share, for callers that manage them.
778    pub id: String,
779    /// Tracks the server knows about, which went into the link.
780    pub shared: usize,
781    /// Tracks with no copy on the server, left out of it.
782    pub skipped: usize,
783}
784
785/// Create a public share link on the remote server for these tracks.
786///
787/// A link points at the server, so only tracks the server knows about can go in
788/// it. A mixed selection shares the part that can be shared and reports the
789/// rest rather than failing whole — half a link beats none, as long as the
790/// caller says which half.
791///
792/// Network-bound. Callers keep it off whatever thread draws.
793pub fn create_share(
794    db: &Database,
795    cfg: &Config,
796    track_ids: &[i64],
797    description: Option<&str>,
798) -> Result<ShareOutcome, ShareError> {
799    let client = subsonic_client(cfg).ok_or(ShareError::NoRemote)?;
800
801    // One query, not one per track: sharing an artist is thousands of tracks.
802    let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
803
804    let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
805    if shared == 0 {
806        return Err(ShareError::NothingRemote);
807    }
808
809    // A whole record shares as one album rather than as N tracks — the server
810    // renders it as the album it is, and the link survives the user adding to
811    // it. Only when the selection is genuinely the whole thing.
812    let one_album = rows
813        .first()
814        .and_then(|f| f.album_id)
815        .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
816        .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
817
818    let remote_ids: Vec<String> = match one_album {
819        Some(rid) => vec![rid],
820        None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
821    };
822
823    let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
824    let share = client.create_share(&refs, description)?;
825
826    // Navidrome does not always hand back a URL, and a share with no link is
827    // useless to the caller — the ID is enough to build it.
828    let url = share
829        .url
830        .clone()
831        .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
832
833    Ok(ShareOutcome {
834        url,
835        id: share.id,
836        shared,
837        skipped: track_ids.len().saturating_sub(shared),
838    })
839}
840
841/// The album's own remote ID, but only when `selected` covers every track on
842/// it. Sharing an album link for half an album would hand out more than the
843/// user picked.
844fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
845    let (remote_id, total): (Option<String>, i64) = conn
846        .query_row(
847            "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
848             FROM albums al WHERE al.id = ?1",
849            [album_id],
850            |row| Ok((row.get(0)?, row.get(1)?)),
851        )
852        .ok()?;
853    (total == selected as i64).then_some(remote_id).flatten()
854}
855
856// ---------------------------------------------------------------------------
857// Path utilities
858// ---------------------------------------------------------------------------
859
860/// Fisher-Yates over a fresh seed, so consecutive calls differ.
861///
862/// Deliberately not seeded from anything stable: "shuffle again" has to
863/// actually produce a new order, which a process-lifetime seed wouldn't.
864pub fn shuffle<T>(items: &mut [T]) {
865    let mut seed = [0u8; 8];
866    if getrandom::fill(&mut seed).is_err() {
867        return; // Leave the order alone rather than pretending to shuffle.
868    }
869    let mut state = u64::from_le_bytes(seed) | 1;
870    for i in (1..items.len()).rev() {
871        // xorshift64 — plenty for shuffling a list nobody is betting on.
872        state ^= state << 13;
873        state ^= state >> 7;
874        state ^= state << 17;
875        items.swap(i, (state % (i as u64 + 1)) as usize);
876    }
877}
878
879/// Truncate a string to at most `max` bytes, cutting on a char boundary.
880pub fn truncate_bytes(s: &str, max: usize) -> &str {
881    if s.len() <= max {
882        return s;
883    }
884    let mut end = max;
885    while end > 0 && !s.is_char_boundary(end) {
886        end -= 1;
887    }
888    &s[..end]
889}
890
891/// Sanitise and truncate a string for use as a path component.
892/// Strips illegal chars and caps at 240 bytes (macOS 255-byte filename limit minus room for ext).
893pub fn sanitise_filename(s: &str) -> String {
894    let cleaned: String = s
895        .chars()
896        .map(|c| match c {
897            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
898            _ => c,
899        })
900        .collect::<String>()
901        .trim()
902        .to_string();
903
904    truncate_bytes(&cleaned, 240).trim_end().to_string()
905}
906
907/// The year a tag date starts with. `get`, not a slice: a date is free text,
908/// and a multibyte character in its first four bytes would panic a slice.
909pub fn year_of(date: &str) -> Option<&str> {
910    date.get(..4)
911}
912
913/// Build a structured cache path for a track:
914///   cache_dir/Album Artist/(Year) Album [Codec]/01. Track Artist - Title.ext
915pub fn cache_path_for_track(
916    cache_dir: &Path,
917    track: &queries::TrackRow,
918    album_date: Option<&str>,
919) -> PathBuf {
920    let artist_dir = sanitise_filename(&track.artist_name);
921
922    let year = album_date
923        .and_then(year_of)
924        .map(|y| format!("({}) ", y))
925        .unwrap_or_default();
926    let codec = track
927        .codec
928        .as_deref()
929        .map(|c| format!(" [{}]", c))
930        .unwrap_or_default();
931    let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
932
933    let disc_prefix = match track.disc {
934        Some(d) if d > 1 => format!("{}-", d),
935        _ => String::new(),
936    };
937    let track_num = track
938        .track_number
939        .map(|n| format!("{:02}. ", n))
940        .unwrap_or_default();
941
942    let ext = track
943        .codec
944        .as_deref()
945        .map(|c| c.to_lowercase())
946        .unwrap_or_else(|| "flac".into());
947
948    let filename = sanitise_filename(&format!(
949        "{}{}{} - {}",
950        disc_prefix, track_num, track.artist_name, track.title
951    ));
952
953    cache_dir
954        .join(artist_dir)
955        .join(album_dir)
956        .join(format!("{}.{}", filename, ext))
957}
958
959// ---------------------------------------------------------------------------
960// Track resolution
961// ---------------------------------------------------------------------------
962
963/// Resolve a track to its path + load state (without downloading).
964/// Returns (path, `ItemState::Ready`) for local/cached, (cache path, `ItemState::Pending`)
965/// for remote — a track with no copy here yet has to be fetched before it plays.
966pub fn resolve_item_path(
967    db: &Database,
968    cfg: &Config,
969    id: i64,
970    track: &queries::TrackRow,
971    album_date: Option<&str>,
972) -> (PathBuf, ItemState) {
973    match queries::resolve_playback_path(&db.conn, id) {
974        Ok(Some(queries::PlaybackSource::Local(p))) => (p, ItemState::Ready),
975        // A cache entry is only as good as its contents. Older builds could
976        // store a Subsonic error body here, which reports Ready and then fails
977        // to decode forever; treating it as Pending sends it back through the
978        // download path, which discards it and re-fetches.
979        Ok(Some(queries::PlaybackSource::Cached(p))) => {
980            let state = if is_cached_audio(&p) {
981                ItemState::Ready
982            } else {
983                ItemState::Pending
984            };
985            (p, state)
986        }
987        Ok(Some(queries::PlaybackSource::Remote(_))) => {
988            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
989            if dest.exists() && is_cached_audio(&dest) {
990                (dest, ItemState::Ready)
991            } else {
992                (dest, ItemState::Pending)
993            }
994        }
995        _ => {
996            // Fallback: construct a cache path and mark pending.
997            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
998            (dest, ItemState::Pending)
999        }
1000    }
1001}
1002
1003/// Build a PlaylistItem from a TrackRow + album date + resolved path + load state.
1004pub fn playlist_item_from_track(
1005    track: &queries::TrackRow,
1006    album_date: Option<&str>,
1007    dest: PathBuf,
1008    state: ItemState,
1009) -> PlaylistItem {
1010    let year = album_date.and_then(year_of).map(str::to_string);
1011    PlaylistItem {
1012        playlist_entry_id: None,
1013        id: QueueItemId::new(),
1014        db_id: Some(track.id),
1015        path: dest,
1016        title: track.title.clone(),
1017        artist: track.artist_name.clone(),
1018        album_artist: track.album_artist_name.clone(),
1019        album: track.album_title.clone(),
1020        year,
1021        codec: track.codec.clone(),
1022        track_number: track.track_number.map(|n| n as i64),
1023        disc: track.disc.map(|n| n as i64),
1024        duration_ms: track.duration_ms.map(|d| d as u64),
1025        state,
1026    }
1027}
1028
1029/// Build playlist items for many tracks at once.
1030///
1031/// `track_to_playlist_item` loads the config on every call, which means
1032/// reading and parsing `config.toml` and `config.local.toml` once per track —
1033/// the reason a large add crawled. This loads it once and memoises album dates,
1034/// so a thousand-track add costs one config read instead of a thousand.
1035pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1036    use std::collections::HashMap;
1037
1038    let cfg = Config::load().unwrap_or_default();
1039    let mut album_dates: HashMap<i64, Option<String>> = HashMap::new();
1040
1041    tracks
1042        .iter()
1043        .map(|track| {
1044            let album_date = match track.album_id {
1045                Some(aid) => album_dates
1046                    .entry(aid)
1047                    .or_insert_with(|| queries::album_date(&db.conn, aid).ok().flatten())
1048                    .clone(),
1049                None => None,
1050            };
1051            let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1052            playlist_item_from_track(track, album_date.as_deref(), path, state)
1053        })
1054        .collect()
1055}
1056
1057/// Build a PlaylistItem from a TrackRow, resolving its path automatically.
1058pub fn track_to_playlist_item(track: &queries::TrackRow, db: &Database) -> PlaylistItem {
1059    let album_date = track
1060        .album_id
1061        .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1062
1063    let cfg = Config::load().unwrap_or_default();
1064    let (path, state) = resolve_item_path(db, &cfg, track.id, track, album_date.as_deref());
1065
1066    let year = album_date.as_deref().and_then(year_of).map(str::to_string);
1067
1068    PlaylistItem {
1069        playlist_entry_id: None,
1070        id: QueueItemId::new(),
1071        db_id: Some(track.id),
1072        path,
1073        title: track.title.clone(),
1074        artist: track.artist_name.clone(),
1075        album_artist: track.album_artist_name.clone(),
1076        album: track.album_title.clone(),
1077        year,
1078        codec: track.codec.clone(),
1079        track_number: track.track_number.map(|n| n as i64),
1080        disc: track.disc.map(|n| n as i64),
1081        duration_ms: track.duration_ms.map(|d| d as u64),
1082        state,
1083    }
1084}
1085
1086// ---------------------------------------------------------------------------
1087// Download
1088// ---------------------------------------------------------------------------
1089
1090/// Whether a cached file plausibly holds audio.
1091///
1092/// A stored Subsonic error is a few hundred bytes of JSON or XML; no real
1093/// encoded track comes close to that, so the size check alone settles almost
1094/// every case and the leading byte covers the rest.
1095fn is_cached_audio(path: &std::path::Path) -> bool {
1096    const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1097    match std::fs::metadata(path) {
1098        Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1099        Ok(_) => {
1100            let mut first = [0u8; 1];
1101            match std::fs::File::open(path)
1102                .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1103            {
1104                Ok(b) => b != b'{' && b != b'<',
1105                Err(_) => false,
1106            }
1107        }
1108        Err(_) => false,
1109    }
1110}
1111
1112/// Resolve a track to a playable file, downloading from remote if needed.
1113///
1114/// Resolution order:
1115/// 1. Local library path (DB `path` field) -- use directly if file exists
1116/// 2. Cache path -- use if already downloaded
1117/// 3. Download from remote to cache -- stream while downloading
1118pub fn download_track(
1119    db_id: i64,
1120    queue_id: QueueItemId,
1121    tx: &crossbeam_channel::Sender<PlayerCommand>,
1122    log_buf: &Arc<Mutex<Vec<String>>>,
1123    state: &Arc<SharedPlayerState>,
1124    cfg: &Config,
1125    client: &SubsonicClient,
1126) {
1127    // From the pool. This runs once per track fetched, and opening a
1128    // connection here re-ran the schema DDL and attempted a WAL checkpoint —
1129    // with several transfers going, several init cycles contending with each
1130    // other and with whatever the library was trying to read.
1131    let db = match crate::db::pool::shared().get() {
1132        Ok(db) => db,
1133        Err(e) => {
1134            fail_track(state, tx, queue_id, format!("db error: {}", e));
1135            return;
1136        }
1137    };
1138    let track = match queries::get_track_row(&db.conn, db_id) {
1139        Ok(Some(t)) => t,
1140        _ => {
1141            fail_track(state, tx, queue_id, "track not found".into());
1142            return;
1143        }
1144    };
1145
1146    let remote_id = match &track.remote_id {
1147        Some(rid) => rid.clone(),
1148        None => {
1149            // No remote_id -- check if the local file exists.
1150            if let Some(ref path) = track.path {
1151                let p = std::path::PathBuf::from(path);
1152                if p.exists() {
1153                    state.update_paths(&[(queue_id, p)]);
1154                    state.update_item_state(queue_id, ItemState::Ready);
1155                    if state.is_cursor(queue_id) {
1156                        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1157                    }
1158                    return;
1159                }
1160            }
1161            fail_track(
1162                state,
1163                tx,
1164                queue_id,
1165                "not in the library folder, and no remote copy to fetch".into(),
1166            );
1167            return;
1168        }
1169    };
1170
1171    // 1. Check if the local library file exists.
1172    if let Some(ref local_path) = track.path {
1173        let p = std::path::PathBuf::from(local_path);
1174        if p.exists() {
1175            log::info!("download_track: local file exists, using {}", p.display());
1176            state.update_paths(&[(queue_id, p)]);
1177            state.update_item_state(queue_id, ItemState::Ready);
1178            if state.is_cursor(queue_id) {
1179                tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1180            }
1181            return;
1182        }
1183    }
1184
1185    let album_date: Option<String> = track
1186        .album_id
1187        .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1188
1189    let dest = cache_path_for_track(&cfg.cache_dir(), &track, album_date.as_deref());
1190
1191    // 2. Already cached.
1192    //
1193    // Older builds could write a Subsonic error body here as if it were audio,
1194    // leaving a tiny JSON file that reports Ready and then fails to decode
1195    // forever. Treat those as absent so they get re-fetched.
1196    if dest.exists() && !is_cached_audio(&dest) {
1197        log::warn!(
1198            "discarding non-audio cache entry {} (likely a stored server error)",
1199            dest.display()
1200        );
1201        let _ = std::fs::remove_file(&dest);
1202    }
1203    if dest.exists() {
1204        state.update_paths(&[(queue_id, dest)]);
1205        state.update_item_state(queue_id, ItemState::Ready);
1206        if state.is_cursor(queue_id) {
1207            tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1208        }
1209        return;
1210    }
1211
1212    // 3. Download from remote. The queue item points at the in-progress file so
1213    // the decoder reads bytes as they land; it flips to `dest` on success.
1214    state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1215
1216    let bytes_written = crate::remote::downloads::ByteFeed::new();
1217
1218    // Announce it before a byte moves, so a queue of six shows six rows rather
1219    // than one row and five tracks that look like nothing is happening to them.
1220    let store = crate::remote::downloads::store();
1221    store.queued(crate::remote::downloads::Download {
1222        id: queue_id,
1223        track_id: db_id,
1224        title: track.title.clone(),
1225        artist: track.artist_name.clone(),
1226        source: crate::remote::download::part_path(&dest),
1227        dest: dest.clone(),
1228        total: 0,
1229        written: bytes_written.clone(),
1230        state: crate::remote::downloads::DownloadState::Queued,
1231        bytes_per_second: 0,
1232    });
1233
1234    let progress_qid = queue_id;
1235    let bytes_written_progress = bytes_written.clone();
1236    let progress_tx = tx.clone();
1237    let stream_ready_sent = Arc::new(std::sync::atomic::AtomicBool::new(false));
1238    let stream_ready_flag = stream_ready_sent.clone();
1239    // A retry restarts the byte count from zero, so a changed total re-announces.
1240    let announced_total = AtomicU64::new(u64::MAX);
1241    let result = client.download_with_progress(&remote_id, &dest, move |downloaded, total| {
1242        bytes_written_progress.set(downloaded);
1243        // What knows a transfer moved is the code moving it. Held to a reading
1244        // every 250ms inside, so a chunk landing costs an atomic and a compare.
1245        store.progressed();
1246        if announced_total.swap(total, Ordering::Relaxed) != total {
1247            // The store, and only the store. The item's state says whether its
1248            // file can be played, which a transfer in flight has not changed.
1249            store.started(progress_qid, total, bytes_written_progress.clone());
1250        }
1251        if !stream_ready_flag.load(Ordering::Relaxed)
1252            && downloaded >= crate::player::state::STREAM_THRESHOLD
1253        {
1254            stream_ready_flag.store(true, Ordering::Relaxed);
1255            progress_tx
1256                .send(PlayerCommand::TrackStreamReady(progress_qid))
1257                .ok();
1258        }
1259    });
1260
1261    // However it ended, a decoder reading the `.part` file may be parked at the
1262    // write head. It waits on the feed, so the feed has to wake it — and only
1263    // once the item says how it ended, or it looks, sees a download, and parks
1264    // again with nothing left to wake it.
1265    if let Err(e) = result {
1266        store.failed(queue_id, e.to_string());
1267        fail_track(state, tx, queue_id, e.to_string());
1268        bytes_written.done();
1269        push_log(log_buf, format!("x {} — {}", track.title, e));
1270        return;
1271    }
1272    store.finished(queue_id);
1273
1274    state.update_paths(&[(queue_id, dest.clone())]);
1275    state.update_item_state(queue_id, ItemState::Ready);
1276    bytes_written.done();
1277    // Without this row the file is invisible to cache eviction and never reclaimed.
1278    if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1279        log::warn!(
1280            "cached {} but failed to record it ({}) — it will not be evicted",
1281            dest.display(),
1282            e
1283        );
1284    }
1285
1286    push_log(
1287        log_buf,
1288        format!("+ {} — {}", track.title, track.artist_name),
1289    );
1290
1291    if state.is_cursor(queue_id) {
1292        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1293    }
1294}
1295
1296/// Mark a queue item unplayable and tell the player, if it is waiting on it.
1297///
1298/// Setting `ItemState::Failed` alone is not enough: the player only wakes for
1299/// `TrackReady`, so a cursor parked on the item would wait for a download that
1300/// has already given up.
1301pub(crate) fn fail_track(
1302    state: &Arc<SharedPlayerState>,
1303    tx: &crossbeam_channel::Sender<PlayerCommand>,
1304    queue_id: QueueItemId,
1305    reason: String,
1306) {
1307    state.update_item_state(queue_id, ItemState::Failed(reason));
1308    if state.is_cursor(queue_id) {
1309        tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1310    }
1311}
1312
1313/// Append to the TUI log pane, tolerating a poisoned lock — a download worker
1314/// must not die because some other thread panicked while holding it.
1315fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1316    match log_buf.lock() {
1317        Ok(mut buf) => buf.push(msg),
1318        Err(_) => log::info!("{}", msg),
1319    }
1320}
1321
1322/// Why there is no remote client, in words worth showing someone.
1323///
1324/// Every caller of `subsonic_client` gets `None` for three different reasons and
1325/// used to report the same one — so "koan has no password", which sends you to
1326/// sign in, arrived looking like a server that was merely down.
1327pub fn remote_unavailable(cfg: &Config) -> String {
1328    if !cfg.remote.enabled {
1329        return "no remote server is configured".into();
1330    }
1331    if cfg.remote.url.is_empty() {
1332        return "the remote server has no address".into();
1333    }
1334    if get_remote_password(cfg).is_none() {
1335        return "no password is stored for the remote server".into();
1336    }
1337    // A password resolved, so the client should have built. Nothing else
1338    // returns `None`, but saying so beats claiming a cause that is wrong.
1339    "the remote server could not be reached".into()
1340}
1341
1342/// Spawn background downloads for remote tracks with ItemState::Pending.
1343/// Submit tracks for download.
1344///
1345/// Everything that is not the TUI reaches downloads through here — the FFI, the
1346/// GraphQL server and radio's auto-extend. It used to spawn a thread per batch
1347/// and walk it with a `for` loop, which meant one track at a time no matter
1348/// what `download_workers` said, and no reordering when the cursor moved. It
1349/// hands the batch to the shared queue now, which is the same pool, priority
1350/// lane and cursor watcher the TUI has always used.
1351pub fn spawn_downloads(
1352    pending: Vec<(i64, QueueItemId)>,
1353    tx: crossbeam_channel::Sender<PlayerCommand>,
1354    state: Arc<SharedPlayerState>,
1355) {
1356    if pending.is_empty() {
1357        return;
1358    }
1359    crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1360}
1361
1362#[cfg(test)]
1363mod year_tests {
1364    use super::year_of;
1365
1366    #[test]
1367    fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1368        assert_eq!(year_of("1997-05-21"), Some("1997"));
1369        assert_eq!(year_of("199"), None);
1370        // Full-width digits: four bytes in is mid-character.
1371        assert_eq!(year_of("1997"), None);
1372    }
1373}
1374
1375#[cfg(test)]
1376mod rebuild_tests {
1377    use super::*;
1378    use crate::db::queries::sample_meta;
1379
1380    fn test_db() -> Database {
1381        let conn = rusqlite::Connection::open_in_memory().unwrap();
1382        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1383        crate::db::schema::create_tables(&conn).unwrap();
1384        Database { conn }
1385    }
1386
1387    #[test]
1388    fn clearing_one_download_leaves_the_others_and_the_library_alone() {
1389        let dir = tempfile::tempdir().unwrap();
1390        let db = test_db();
1391
1392        let mut cached = Vec::new();
1393        for name in ["one", "two"] {
1394            let mut meta = sample_meta(name, "Artist", "Album");
1395            meta.source = "remote".into();
1396            meta.path = None;
1397            meta.remote_id = Some(name.into());
1398            let id = queries::upsert_track(&db.conn, &meta).unwrap();
1399            let file = dir.path().join(format!("{name}.opus"));
1400            std::fs::write(&file, vec![0u8; 2048]).unwrap();
1401            queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
1402            cached.push((id, file));
1403        }
1404
1405        let cleared = clear_downloads_for(&db, &[cached[0].0]);
1406        assert_eq!(cleared.files, 1);
1407        assert_eq!(cleared.bytes, 2048);
1408        assert!(!cached[0].1.exists(), "the copy asked for is gone");
1409        assert!(cached[1].1.exists(), "the other one is untouched");
1410
1411        // The row survives — a remote track is still in the library, it just
1412        // has to be fetched again.
1413        assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
1414        assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
1415        assert!(
1416            queries::cached_paths_for(&db.conn, &[cached[0].0])
1417                .unwrap()
1418                .is_empty()
1419        );
1420    }
1421
1422    #[test]
1423    fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
1424        let db = test_db();
1425        let mut meta = sample_meta("ghost", "Artist", "Album");
1426        meta.source = "remote".into();
1427        meta.path = None;
1428        meta.remote_id = Some("ghost".into());
1429        let id = queries::upsert_track(&db.conn, &meta).unwrap();
1430        queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
1431
1432        let cleared = clear_downloads_for(&db, &[id]);
1433        assert_eq!(cleared.files, 0, "nothing was there to remove");
1434        // Forgotten regardless: the row claimed a copy that does not exist.
1435        assert!(
1436            queries::cached_paths_for(&db.conn, &[id])
1437                .unwrap()
1438                .is_empty()
1439        );
1440    }
1441
1442    #[test]
1443    fn sweeping_removes_half_finished_downloads_and_nothing_else() {
1444        let dir = tempfile::tempdir().unwrap();
1445        let cache = dir.path().join("cache");
1446        std::fs::create_dir_all(cache.join("Artist")).unwrap();
1447
1448        let finished = cache.join("Artist/whole.opus");
1449        let half = cache.join("Artist/half.opus.part");
1450        std::fs::write(&finished, vec![0u8; 1024]).unwrap();
1451        std::fs::write(&half, vec![0u8; 4096]).unwrap();
1452
1453        let cfg = Config {
1454            remote: crate::config::RemoteConfig {
1455                cache_dir: Some(cache.clone()),
1456                ..Default::default()
1457            },
1458            ..Default::default()
1459        };
1460
1461        let swept = sweep_partial_downloads(&cfg);
1462        assert_eq!(swept.files, 1);
1463        assert_eq!(swept.bytes, 4096);
1464        assert!(!half.exists(), "the unfinished one is gone");
1465        assert!(finished.exists(), "a downloaded track is not touched");
1466    }
1467
1468    #[test]
1469    fn sweeping_an_empty_cache_is_not_an_error() {
1470        let dir = tempfile::tempdir().unwrap();
1471        let cfg = Config {
1472            remote: crate::config::RemoteConfig {
1473                cache_dir: Some(dir.path().join("nothing-here")),
1474                ..Default::default()
1475            },
1476            ..Default::default()
1477        };
1478        assert_eq!(sweep_partial_downloads(&cfg).files, 0);
1479    }
1480
1481    #[test]
1482    fn clearing_no_tracks_does_nothing() {
1483        let db = test_db();
1484        assert_eq!(clear_downloads_for(&db, &[]).files, 0);
1485    }
1486
1487    #[test]
1488    fn rebuild_drops_the_index_and_keeps_favourites() {
1489        let db = test_db();
1490        let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1491        meta.path = Some("/music/windowlicker.flac".into());
1492        let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
1493
1494        // Favourites key on the path; lyrics key on the row id.
1495        queries::toggle_favourite(&db.conn, Path::new("/music/windowlicker.flac")).unwrap();
1496        db.conn
1497            .execute(
1498                "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
1499                 VALUES (?1, 'test', 'la la la', 0)",
1500                [track_id],
1501            )
1502            .unwrap();
1503
1504        let summary = rebuild_index(&db).unwrap();
1505        assert_eq!(summary.tracks, 1);
1506        assert_eq!(summary.albums, 1);
1507
1508        let tracks: i64 = db
1509            .conn
1510            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1511            .unwrap();
1512        assert_eq!(tracks, 0, "the index is gone");
1513
1514        let favourites: i64 = db
1515            .conn
1516            .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
1517            .unwrap();
1518        assert_eq!(favourites, 1, "favourites survive — they key on the path");
1519
1520        let lyrics: i64 = db
1521            .conn
1522            .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
1523            .unwrap();
1524        assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
1525    }
1526
1527    #[test]
1528    fn rebuilding_an_empty_library_is_not_an_error() {
1529        let db = test_db();
1530        let summary = rebuild_index(&db).unwrap();
1531        assert_eq!(summary.tracks, 0);
1532    }
1533}
1534
1535#[cfg(test)]
1536mod share_tests {
1537    use super::*;
1538    use crate::db::queries::sample_meta;
1539
1540    fn test_db() -> Database {
1541        let conn = rusqlite::Connection::open_in_memory().unwrap();
1542        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1543        crate::db::schema::create_tables(&conn).unwrap();
1544        Database { conn }
1545    }
1546
1547    /// Three tracks on one album; the album carries a remote ID.
1548    fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
1549        let ids: Vec<i64> = ["One", "Two", "Three"]
1550            .iter()
1551            .enumerate()
1552            .map(|(i, title)| {
1553                let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
1554                meta.path = Some(format!("/music/geogaddi/{i}.flac"));
1555                meta.track_number = Some(i as i32 + 1);
1556                queries::upsert_track(&db.conn, &meta).unwrap()
1557            })
1558            .collect();
1559        let album_id: i64 = db
1560            .conn
1561            .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
1562                r.get(0)
1563            })
1564            .unwrap();
1565        db.conn
1566            .execute(
1567                "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
1568                [album_id],
1569            )
1570            .unwrap();
1571        (album_id, ids)
1572    }
1573
1574    #[test]
1575    fn whole_album_collapses_to_the_album_link() {
1576        let db = test_db();
1577        let (album_id, ids) = album_of_three(&db);
1578        assert_eq!(
1579            album_remote_id(&db.conn, album_id, ids.len()),
1580            Some("al-1".into())
1581        );
1582    }
1583
1584    #[test]
1585    fn part_of_an_album_does_not() {
1586        let db = test_db();
1587        let (album_id, _) = album_of_three(&db);
1588        // Sharing an album link for two of three tracks would hand out a track
1589        // the user did not pick.
1590        assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
1591    }
1592
1593    #[test]
1594    fn a_local_only_album_has_no_link_to_collapse_to() {
1595        let db = test_db();
1596        let (album_id, ids) = album_of_three(&db);
1597        db.conn
1598            .execute(
1599                "UPDATE albums SET remote_id = NULL WHERE id = ?1",
1600                [album_id],
1601            )
1602            .unwrap();
1603        assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
1604    }
1605}
1606
1607#[cfg(test)]
1608mod client_cache_tests {
1609    use super::*;
1610
1611    #[test]
1612    fn one_subsonic_client_is_shared_per_credentials() {
1613        crate::config::isolate_config_for_tests();
1614        let mut cfg = Config::default();
1615        cfg.remote.enabled = true;
1616        cfg.remote.url = "https://shared-client.invalid".into();
1617        cfg.remote.username = "koan".into();
1618        cfg.remote.password = "first".into();
1619
1620        let first = subsonic_client(&cfg).expect("a configured remote yields a client");
1621        let again = subsonic_client(&cfg).expect("a configured remote yields a client");
1622        assert!(
1623            Arc::ptr_eq(&first, &again),
1624            "rebuilding drops the connection pool and re-handshakes TLS per request"
1625        );
1626
1627        cfg.remote.password = "second".into();
1628        let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
1629        assert!(
1630            !Arc::ptr_eq(&first, &relogged),
1631            "new credentials must not keep serving the client signed with the old ones"
1632        );
1633    }
1634}