Skip to main content

koan_core/
helpers.rs

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