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