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