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