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