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