Skip to main content

koan_core/
helpers.rs

1//! Helpers shared by every front end: koan-tui, koan-server, koan-ffi and koan-cli.
2
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::Duration;
7
8use crate::config::Config;
9use crate::db::connection::Database;
10use crate::db::queries;
11use crate::db::queries::shares::{ShareKind, Slice};
12use crate::player::commands::PlayerCommand;
13use crate::player::state::{ItemState, PlaylistItem, QueueItemId, SharedPlayerState};
14use crate::remote::client::{SubsonicAuth, SubsonicClient, SubsonicError};
15use crate::remote::download::DownloadError;
16
17// ---------------------------------------------------------------------------
18// Subsonic client builder
19// ---------------------------------------------------------------------------
20
21/// The remote password, from `config.local.toml` or a `KOAN_REMOTE__PASSWORD`
22/// layered over it.
23pub fn get_remote_password(cfg: &Config) -> Option<String> {
24    (!cfg.remote.password.is_empty()).then(|| cfg.remote.password.clone())
25}
26
27/// Index files that appear in the library folders while koan is running.
28///
29/// One incremental scan shortly after startup — the walk is a fraction of a
30/// second even across fifty thousand files, and everything unchanged is skipped
31/// on its mtime and size — then a scan of whatever the folders say changed.
32///
33/// Only the directories events name are scanned: walking the whole library for
34/// one new album costs a spinning disk minutes. Events that cannot change the
35/// index — access, metadata, Syncthing's bookkeeping, partial downloads — are
36/// dropped before they count (see `index::watch`). The whole library is scanned
37/// only when the watcher reports it lost events, or when so many directories
38/// changed at once that walking them one by one would cost more.
39///
40/// Changes are debounced: copying an album in produces a burst of events, and
41/// scanning once per file would be both slow and pointless. A scan that lands
42/// halfway through a move is corrected by the scan the rest of the move's
43/// events bring, since a directory scan removes what is no longer under it.
44///
45/// The folder list is re-read and each folder's identity checked every half
46/// minute, so a folder added in settings is watched without a restart, and a
47/// volume unmounted and mounted again is watched afresh and rescanned.
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 std::collections::BTreeSet;
55    use std::time::{Duration, Instant};
56
57    use notify::{RecursiveMode, Watcher};
58
59    use crate::index::scanner::{self, ScanOptions};
60    use crate::index::watch::{WatchedRoot, scan_target};
61
62    // Copying an album in is a burst of events. Wait for it to stop before
63    // scanning, rather than scanning per file.
64    const SETTLE: Duration = Duration::from_secs(5);
65    // How often the folder list and each folder's identity are checked.
66    const CHECK: Duration = Duration::from_secs(30);
67    // Past this many directories one walk of the library is cheaper than many.
68    const MAX_DIRS: usize = 200;
69    // A full scan now and then, for the events a platform drops without
70    // asking for a rescan. Unchanged files are skipped on mtime and size, so an
71    // idle one is a second's work.
72    const RESCAN: Duration = Duration::from_secs(15 * 60);
73
74    std::thread::Builder::new()
75        .name("koan-library-watch".into())
76        .spawn(move || {
77            let scan = |reason: &str, folders: &[PathBuf], dirs: Option<&[PathBuf]>| {
78                if folders.is_empty() {
79                    return;
80                }
81                let Ok(db) = Database::open_existing(&db_path) else {
82                    return;
83                };
84                on_state(true);
85                let result = match dirs {
86                    Some(dirs) => {
87                        scanner::scan_dirs(&db, folders, dirs, ScanOptions::default(), None)
88                    }
89                    None => scanner::full_scan(&db, folders, ScanOptions::default(), None),
90                };
91                on_state(false);
92                log::info!(
93                    "{reason} scan: {} added, {} updated, {} removed, {} unchanged",
94                    result.added,
95                    result.updated,
96                    result.removed,
97                    result.skipped
98                );
99            };
100            let folders = || Config::cached().library.folders.clone();
101
102            let (tx, rx) = std::sync::mpsc::channel();
103            let Ok(mut watcher) = notify::recommended_watcher(move |event| {
104                let _ = tx.send(event);
105            }) else {
106                log::warn!("could not watch the library folders");
107                return;
108            };
109
110            // Brings the watches in line with the configured folders as they
111            // are now, returning the ones newly watched — added in settings, or
112            // back from being unmounted — whose changes nobody heard.
113            let mut roots: Vec<WatchedRoot> = Vec::new();
114            let mut rewatch = |roots: &mut Vec<WatchedRoot>| {
115                let wanted: Vec<WatchedRoot> = folders()
116                    .iter()
117                    .filter_map(|f| WatchedRoot::resolve(f))
118                    .collect();
119                roots.retain(|root| {
120                    let keep = wanted.contains(root);
121                    if !keep {
122                        let _ = watcher.unwatch(&root.path);
123                    }
124                    keep
125                });
126                let mut fresh = Vec::new();
127                for root in wanted {
128                    if roots.contains(&root) {
129                        continue;
130                    }
131                    match watcher.watch(&root.path, RecursiveMode::Recursive) {
132                        Ok(()) => {
133                            fresh.push(root.path.clone());
134                            roots.push(root);
135                        }
136                        Err(e) => log::warn!("could not watch {}: {e}", root.path.display()),
137                    }
138                }
139                fresh
140            };
141
142            // After the first frame and the first track, not competing with them.
143            std::thread::sleep(Duration::from_secs(3));
144            rewatch(&mut roots);
145            scan("startup", &folders(), None);
146
147            let mut dirs = BTreeSet::new();
148            let mut everything = false;
149            let mut settle_at: Option<Instant> = None;
150            let mut check_at = Instant::now() + CHECK;
151            let mut rescan_at = Instant::now() + RESCAN;
152            loop {
153                // Changes heard meanwhile wait in the channel; a rescan that
154                // fell due runs as soon as this returns.
155                crate::quiet::wait_until_awake();
156                let now = Instant::now();
157                let wake = settle_at
158                    .map_or(check_at, |at| at.min(check_at))
159                    .min(rescan_at);
160                match rx.recv_timeout(wake.saturating_duration_since(now)) {
161                    Ok(Ok(event)) if event.need_rescan() => {
162                        everything = true;
163                        settle_at = Some(Instant::now() + SETTLE);
164                    }
165                    Ok(Ok(event)) => {
166                        let mut heard = false;
167                        for dir in event
168                            .paths
169                            .iter()
170                            .filter_map(|p| scan_target(&event.kind, p, &roots))
171                        {
172                            dirs.insert(dir);
173                            heard = true;
174                        }
175                        if heard {
176                            settle_at = Some(Instant::now() + SETTLE);
177                        }
178                    }
179                    Ok(Err(e)) => log::debug!("library watch: {e}"),
180                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
181                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
182                }
183
184                let now = Instant::now();
185                if settle_at.is_some_and(|at| now >= at) {
186                    let changed =
187                        scanner::minimal_dirs(std::mem::take(&mut dirs).into_iter().collect());
188                    if everything || changed.len() > MAX_DIRS {
189                        scan("watched change", &folders(), None);
190                        rescan_at = Instant::now() + RESCAN;
191                    } else {
192                        scan("watched change", &folders(), Some(&changed));
193                    }
194                    everything = false;
195                    settle_at = None;
196                }
197                if now >= rescan_at {
198                    scan("periodic", &folders(), None);
199                    rescan_at = Instant::now() + RESCAN;
200                }
201                if now >= check_at {
202                    let fresh = rewatch(&mut roots);
203                    if !fresh.is_empty() {
204                        scan("newly watched", &fresh, None);
205                    }
206                    check_at = Instant::now() + CHECK;
207                }
208            }
209        })
210        .ok()
211}
212
213/// Keep the library in step with the server, without being asked.
214///
215/// One sync shortly after startup, then every `auto_sync_interval_mins` for a
216/// server that cannot say when it changes. A koan server can: it sends every
217/// linked app a sync when its library or a playlist moves, and queues one for
218/// an app that was away, so a timer would only ask a question already
219/// answered. Each run walks the library only if the server says it moved —
220/// see `Walk::IfChanged` — which is what makes it cheap enough to run
221/// unattended.
222///
223/// The startup run is delayed a few seconds so it is not competing with the
224/// first frame and the first track for the disk.
225///
226/// `on_state` reports whether a sync is running, so a UI can say so rather than
227/// appearing to do nothing, and `on_progress` how far it has got.
228pub fn spawn_auto_sync(
229    db_path: std::path::PathBuf,
230    on_state: impl Fn(bool) + Send + 'static,
231    on_progress: impl Fn(crate::remote::sync::SyncProgress) + Send + Sync + 'static,
232) -> Option<std::thread::JoinHandle<()>> {
233    std::thread::Builder::new()
234        .name("koan-auto-sync".into())
235        .spawn(move || {
236            std::thread::sleep(std::time::Duration::from_secs(5));
237            loop {
238                // Due while the app was in the background: runs when it is not.
239                crate::quiet::wait_until_awake();
240                let cfg = Config::load().unwrap_or_default();
241                if !cfg.remote.enabled || !cfg.remote.auto_sync {
242                    // Re-read rather than exit: the setting can be turned on
243                    // while the app is running.
244                    std::thread::sleep(std::time::Duration::from_secs(60));
245                    continue;
246                }
247
248                if let Some(client) = subsonic_client(&cfg)
249                    && let Ok(db) = Database::open_existing(&db_path)
250                {
251                    on_state(true);
252                    match sync_remote(
253                        &db,
254                        &client,
255                        Walk::IfChanged,
256                        &cfg.remote.url,
257                        &cfg.remote.username,
258                        &on_progress,
259                    ) {
260                        Ok(s) => log::info!(
261                            "auto sync: {} artists, {} albums, {} tracks ({} albums failed); \
262                             favourites {}↑ {}↓; playlists {}↓ {}↑",
263                            s.library.artists_synced,
264                            s.library.albums_synced,
265                            s.library.tracks_synced,
266                            s.library.albums_failed,
267                            s.favourites.pushed,
268                            s.favourites.imported,
269                            s.playlists.pulled,
270                            s.playlists.pushed,
271                        ),
272                        Err(e) => log::warn!("auto sync failed: {e}"),
273                    }
274                    on_state(false);
275                }
276
277                // Once at startup and no more, or on the interval. A koan
278                // server's own syncs make the interval redundant; it is still
279                // slept, since the server signed in to can change.
280                let mins = cfg.remote.auto_sync_interval_mins;
281                if mins == 0 {
282                    return;
283                }
284                loop {
285                    std::thread::sleep(std::time::Duration::from_secs(mins * 60));
286                    if !crate::remote::profile::current().is_some_and(|p| p.links()) {
287                        break;
288                    }
289                }
290            }
291        })
292        .ok()
293}
294
295/// What a library rebuild removed.
296#[derive(Debug, Clone, Copy, Default)]
297pub struct RebuildSummary {
298    pub tracks: u64,
299    pub albums: u64,
300    pub artists: u64,
301}
302
303/// Drop the index so the next scan rebuilds it from the files.
304///
305/// Favourites are keyed on the file path rather than a row id, so they survive
306/// this and re-attach when the paths come back. Everything keyed on a track id
307/// cannot: lyrics, play history and acoustic embeddings go, and the foreign keys
308/// would refuse the delete otherwise. Lyrics and embeddings are re-derivable;
309/// play counts are not, which is worth saying out loud wherever this is offered.
310///
311/// The remote half of the library comes back on the next sync, the local half on
312/// the next scan.
313pub fn rebuild_index(db: &Database) -> Result<RebuildSummary, crate::db::connection::DbError> {
314    let count = |sql: &str| -> u64 {
315        db.conn
316            .query_row(sql, [], |r| r.get::<_, i64>(0))
317            .unwrap_or(0) as u64
318    };
319    let summary = RebuildSummary {
320        tracks: count("SELECT COUNT(*) FROM tracks"),
321        albums: count("SELECT COUNT(*) FROM albums"),
322        artists: count("SELECT COUNT(*) FROM artists"),
323    };
324
325    // Children before parents; the FTS index has no foreign keys but is derived
326    // from tracks and would otherwise keep answering for rows that are gone.
327    db.conn.execute_batch(
328        "BEGIN;
329         DELETE FROM lyrics_cache;
330         DELETE FROM play_history;
331         DELETE FROM scan_cache;
332         DELETE FROM tracks_fts;
333         DELETE FROM tracks;
334         DELETE FROM albums;
335         DELETE FROM artists;
336         COMMIT;",
337    )?;
338    let _ = db.conn.execute_batch("VACUUM");
339    Ok(summary)
340}
341
342/// Remove whole albums from the download cache, least recently played first,
343/// until it is under the configured limit. Never an album with a favourite
344/// in it, nor one with a track in `keep`: the queue, whose files the player
345/// may be reading. Returns the bytes freed.
346pub fn evict_cache(
347    db: &Database,
348    cfg: &Config,
349    keep: &std::collections::HashSet<i64>,
350    verbose: bool,
351) -> u64 {
352    let Some(limit) = cfg.cache_limit_bytes().map(|l| l as i64) else {
353        return 0;
354    };
355    let mut current = match queries::total_cache_size(&db.conn) {
356        Ok(s) => s,
357        Err(e) => {
358            log::warn!("cache eviction: failed to query cache size: {e}");
359            return 0;
360        }
361    };
362    if current <= limit {
363        if verbose {
364            log::info!("cache within limit: {current} / {limit} bytes");
365        }
366        return 0;
367    }
368    let albums = match queries::cached_albums_lru(&db.conn) {
369        Ok(a) => a,
370        Err(e) => {
371            log::warn!("cache eviction: failed to query cached albums: {e}");
372            return 0;
373        }
374    };
375    let mut freed: i64 = 0;
376    for album in &albums {
377        if current <= limit {
378            break;
379        }
380        if album.track_ids.iter().any(|id| keep.contains(id)) {
381            continue;
382        }
383        for path in &album.cached_paths {
384            match std::fs::remove_file(path) {
385                Ok(()) => {}
386                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
387                Err(e) => log::warn!("cache eviction: failed to delete {path}: {e}"),
388            }
389        }
390        if let Err(e) = queries::clear_cached_paths_for(&db.conn, &album.track_ids) {
391            log::warn!("cache eviction: failed to clear DB for album: {e}");
392        }
393        log::info!(
394            "evicted: {} — {} ({} bytes)",
395            album.artist_name,
396            album.album_title,
397            album.total_size
398        );
399        current -= album.total_size;
400        freed += album.total_size;
401    }
402    remove_empty_dirs(&cfg.cache_dir());
403    if freed > 0 {
404        log::info!("cache eviction freed {freed} bytes");
405    }
406    freed as u64
407}
408
409/// Remove empty directories under `dir`, leaving `dir` itself.
410fn remove_empty_dirs(dir: &Path) {
411    if !dir.is_dir() {
412        return;
413    }
414    for entry in walkdir::WalkDir::new(dir)
415        .contents_first(true)
416        .into_iter()
417        .filter_map(Result::ok)
418        .filter(|e| e.file_type().is_dir() && e.path() != dir)
419    {
420        let _ = std::fs::remove_dir(entry.path());
421    }
422}
423
424/// Bytes currently held in the download cache.
425pub fn cache_size_bytes(cfg: &Config) -> u64 {
426    walkdir::WalkDir::new(cfg.cache_dir())
427        .into_iter()
428        .filter_map(Result::ok)
429        .filter(|e| e.file_type().is_file())
430        .filter_map(|e| e.metadata().ok())
431        .map(|m| m.len())
432        .sum()
433}
434
435/// How many tracks came from this folder.
436///
437/// The trailing separator matters: without it `/Volumes/Music` also counts
438/// `/Volumes/Music Backup`.
439pub fn tracks_under(db: &Database, folder: &Path) -> u64 {
440    let (lower, upper) = queries::folder_prefix_range(folder);
441    db.conn
442        .query_row(
443            "SELECT COUNT(*) FROM tracks WHERE path >= ?1 AND path < ?2",
444            [&lower, &upper],
445            |r| r.get::<_, i64>(0),
446        )
447        .unwrap_or(0) as u64
448}
449
450/// How many tracks the server accounts for.
451pub fn tracks_from_server(db: &Database) -> u64 {
452    db.conn
453        .query_row(
454            "SELECT COUNT(*) FROM tracks WHERE remote_id IS NOT NULL",
455            [],
456            |r| r.get::<_, i64>(0),
457        )
458        .unwrap_or(0) as u64
459}
460
461/// Forget every track under a folder.
462///
463/// Removing a folder from the library should remove what it put there —
464/// otherwise the library keeps showing records whose files it will never look
465/// at again, and there is no way back to an empty library short of clearing the
466/// whole index.
467///
468/// A track that also exists on the server keeps its row and loses only its local
469/// path: it is still playable, just by download rather than from disk.
470///
471/// Albums and artists left holding nothing go too, or the browser fills with
472/// empty shelves.
473pub fn forget_folder(db: &Database, folder: &Path) -> Result<u64, crate::db::connection::DbError> {
474    // Rows are keyed by the disk's spelling; a folder named the other way would forget nothing.
475    let folder = &crate::index::spelling::on_disk(folder);
476    let (lower, upper) = queries::folder_prefix_range(folder);
477
478    let tx = crate::db::queries::write_transaction(&db.conn)?;
479    // Still on the server: keep the row, drop the local file.
480    tx.execute(
481        "UPDATE tracks SET path = NULL, source = 'remote'
482          WHERE path >= ?1 AND path < ?2 AND remote_id IS NOT NULL",
483        [&lower, &upper],
484    )?;
485
486    let ids: Vec<i64> = {
487        let mut stmt = tx.prepare("SELECT id FROM tracks WHERE path >= ?1 AND path < ?2")?;
488        let rows = stmt.query_map([&lower, &upper], |r| r.get(0))?;
489        rows.filter_map(Result::ok).collect()
490    };
491    delete_track_rows(&tx, &ids)?;
492    prune_empty_albums_and_artists(&tx)?;
493    tx.commit()?;
494    Ok(ids.len() as u64)
495}
496
497fn delete_track_rows(conn: &rusqlite::Connection, ids: &[i64]) -> rusqlite::Result<()> {
498    for id in ids {
499        conn.execute("DELETE FROM lyrics_cache WHERE track_id = ?1", [id])?;
500        conn.execute("DELETE FROM play_history WHERE track_id = ?1", [id])?;
501        conn.execute("DELETE FROM scan_cache WHERE track_id = ?1", [id])?;
502        conn.execute("DELETE FROM tracks_fts WHERE rowid = ?1", [id])?;
503        conn.execute("DELETE FROM tracks WHERE id = ?1", [id])?;
504    }
505    Ok(())
506}
507
508/// Forget everything that only existed on the server.
509///
510/// Signing out should leave the library with what is actually on this machine.
511/// A track held both locally and remotely keeps its row and loses its remote id;
512/// one that only ever came from the server goes.
513pub fn forget_remote(db: &Database) -> Result<u64, crate::db::connection::DbError> {
514    let tx = crate::db::queries::write_transaction(&db.conn)?;
515
516    let ids: Vec<i64> = {
517        let mut stmt =
518            tx.prepare("SELECT id FROM tracks WHERE remote_id IS NOT NULL AND path IS NULL")?;
519        let rows = stmt.query_map([], |r| r.get(0))?;
520        rows.filter_map(Result::ok).collect()
521    };
522    delete_track_rows(&tx, &ids)?;
523    // Local copies stay, minus the server they were also on.
524    tx.execute(
525        "UPDATE tracks SET remote_id = NULL, remote_url = NULL, source = 'local'
526          WHERE remote_id IS NOT NULL",
527        [],
528    )?;
529    prune_empty_albums_and_artists(&tx)?;
530    tx.commit()?;
531    Ok(ids.len() as u64)
532}
533
534/// Albums and artists with nothing left in them.
535fn prune_empty_albums_and_artists(
536    tx: &rusqlite::Transaction<'_>,
537) -> Result<(), crate::db::connection::DbError> {
538    tx.execute(
539        "DELETE FROM albums WHERE NOT EXISTS
540           (SELECT 1 FROM tracks WHERE tracks.album_id = albums.id)",
541        [],
542    )?;
543    tx.execute(
544        "DELETE FROM artists WHERE NOT EXISTS
545             (SELECT 1 FROM albums WHERE albums.artist_id = artists.id)
546           AND NOT EXISTS
547             (SELECT 1 FROM tracks WHERE tracks.artist_id = artists.id)",
548        [],
549    )?;
550    Ok(())
551}
552
553/// What clearing the download cache removed.
554#[derive(Debug, Clone, Copy, Default)]
555pub struct CacheCleared {
556    pub files: u64,
557    pub bytes: u64,
558}
559
560/// Delete every downloaded remote track and forget where they were.
561///
562/// The rows stay — a remote track is still in the library, it just has to be
563/// fetched again to play.
564pub fn clear_download_cache(db: &Database, cfg: &Config) -> CacheCleared {
565    let dir = cfg.cache_dir();
566    let mut cleared = CacheCleared::default();
567    for entry in walkdir::WalkDir::new(&dir)
568        .into_iter()
569        .filter_map(Result::ok)
570        .filter(|e| e.file_type().is_file())
571    {
572        if let Ok(meta) = entry.metadata() {
573            cleared.bytes += meta.len();
574            cleared.files += 1;
575        }
576    }
577    let _ = std::fs::remove_dir_all(&dir);
578    let _ = std::fs::create_dir_all(&dir);
579    let _ = queries::clear_cached_paths(&db.conn);
580    cleared
581}
582
583/// Delete the downloaded copies of just these tracks.
584///
585/// The per-track counterpart of `clear_download_cache`, for throwing away one
586/// record rather than the lot. A track playing from a copy being removed keeps
587/// playing — the decoder holds the file open, and unlinking it only takes the
588/// name away — but the next play fetches it again.
589pub fn clear_downloads_for(db: &Database, track_ids: &[i64]) -> CacheCleared {
590    let mut cleared = CacheCleared::default();
591    let paths = match queries::cached_paths_for(&db.conn, track_ids) {
592        Ok(paths) => paths,
593        Err(e) => {
594            log::warn!("could not read cached paths: {e}");
595            return cleared;
596        }
597    };
598    for path in &paths {
599        let size = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
600        match std::fs::remove_file(path) {
601            Ok(()) => {
602                cleared.files += 1;
603                cleared.bytes += size;
604            }
605            // Already gone is the outcome asked for, so it is not a failure.
606            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
607            Err(e) => log::warn!("could not remove {path}: {e}"),
608        }
609    }
610    if let Err(e) = queries::clear_cached_paths_for(&db.conn, track_ids) {
611        log::warn!("removed downloads but failed to forget them ({e})");
612    }
613    cleared
614}
615
616/// Throw away half-finished downloads left behind by a previous run.
617///
618/// A `.part` file only means something to the transfer writing it. koan does
619/// not resume — the file is written straight through and renamed at the end —
620/// so one still on disk at startup is from a run that did not finish, and it
621/// will be truncated and rewritten the next time that track is wanted anyway.
622/// Until then it is bytes nothing knows about: cache eviction only tracks what
623/// finished, so an interrupted download of a nine-hour recording is half a
624/// gigabyte that never gets reclaimed.
625///
626/// At startup rather than at exit, because a run that ends without getting to
627/// its own cleanup is exactly the run that leaves these behind.
628pub fn sweep_partial_downloads(cfg: &Config) -> CacheCleared {
629    let mut swept = CacheCleared::default();
630    for entry in walkdir::WalkDir::new(cfg.cache_dir())
631        .into_iter()
632        .filter_map(Result::ok)
633        .filter(|e| e.file_type().is_file())
634        .filter(|e| e.path().extension().is_some_and(|ext| ext == "part"))
635    {
636        let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
637        match std::fs::remove_file(entry.path()) {
638            Ok(()) => {
639                swept.files += 1;
640                swept.bytes += size;
641            }
642            Err(e) => log::warn!("could not remove {}: {e}", entry.path().display()),
643        }
644    }
645    if swept.files > 0 {
646        log::info!(
647            "swept {} unfinished download(s), {} bytes",
648            swept.files,
649            swept.bytes
650        );
651    }
652    swept
653}
654
655/// Re-root cached paths that name a cache directory other than `cache_dir`.
656///
657/// iOS gives an app a new container path when it is updated. The cache moves
658/// with it, but the absolute paths stored for its files do not, so every
659/// download looks missing: tracks are fetched again beside the copies already
660/// there, and eviction cannot find the old ones to delete. The cache lays
661/// files out as `<cache>/<artist>/<album>/<file>` (`cache_path_for_track`), so
662/// a stale path is re-rooted on its last three components when that file
663/// exists under `cache_dir`. Paths whose file is not there are left alone; the
664/// next play resolves them as it would any missing download.
665///
666/// Returns the number of paths rewritten.
667pub fn relocate_cached_paths(db: &Database, cache_dir: &Path) -> rusqlite::Result<usize> {
668    let prefix = format!("{}/", cache_dir.to_string_lossy().trim_end_matches('/'));
669    let stale: Vec<(i64, String)> = db
670        .conn
671        .prepare(
672            "SELECT id, cached_path FROM tracks
673             WHERE cached_path IS NOT NULL AND substr(cached_path, 1, ?2) != ?1",
674        )?
675        .query_map(
676            rusqlite::params![prefix, prefix.chars().count() as i64],
677            |r| Ok((r.get(0)?, r.get(1)?)),
678        )?
679        .collect::<rusqlite::Result<_>>()?;
680    if stale.is_empty() {
681        return Ok(0);
682    }
683
684    let tx = crate::db::queries::write_transaction(&db.conn)?;
685    let mut moved = 0;
686    for (id, old) in &stale {
687        let tail: Vec<_> = Path::new(old).components().rev().take(3).collect();
688        if tail.len() < 3 {
689            continue;
690        }
691        let new = tail
692            .iter()
693            .rev()
694            .fold(cache_dir.to_path_buf(), |p, c| p.join(c));
695        if new.is_file() {
696            tx.execute(
697                "UPDATE tracks SET cached_path = ?1 WHERE id = ?2",
698                rusqlite::params![new.to_string_lossy(), id],
699            )?;
700            moved += 1;
701        }
702    }
703    tx.commit()?;
704    if moved > 0 {
705        log::info!(
706            "re-rooted {moved} cached path(s) under {}",
707            cache_dir.display()
708        );
709    }
710    Ok(moved)
711}
712
713/// Fetch again anything in the queue whose downloaded copy has just been
714/// removed.
715///
716/// Clearing downloads deletes files the queue is still pointing at, and an item
717/// that goes on claiming to be ready plays nothing at all. Call this after
718/// either clearing function, from anywhere with a player attached.
719pub fn requeue_cleared_downloads(
720    state: &Arc<SharedPlayerState>,
721    tx: &crossbeam_channel::Sender<PlayerCommand>,
722) {
723    let stale = state.reset_items_with_missing_files();
724    if stale.is_empty() {
725        return;
726    }
727    log::info!(
728        "{} queued tracks lost their copy — fetching again",
729        stale.len()
730    );
731    spawn_downloads(stale, tx.clone(), state.clone());
732}
733
734/// Push a favourite to the remote server, if this track came from one.
735///
736/// Fire and forget on its own thread: starring is a courtesy to the server, and
737/// a slow or unreachable one should not hold up the click that caused it. The
738/// local favourite is already written by the time this runs.
739///
740/// Silently does nothing for a track with no `remote_id` — including a local
741/// file whose copy on the server failed to merge with it (#221), which is the
742/// one case where the silence is wrong.
743///
744/// Shared by the TUI, the server and the app.
745pub fn sync_favourite_to_remote(db: &Database, path: &Path, star: bool) {
746    let cfg = Config::load().unwrap_or_default();
747    if !cfg.remote.enabled {
748        return;
749    }
750    let Ok(Some(remote_id)) = queries::remote_id_for_path(&db.conn, path) else {
751        log::warn!("not syncing favourite: {} has no remote id", path.display());
752        return;
753    };
754    let Some(client) = subsonic_client(&cfg) else {
755        log::warn!("not syncing favourite: no usable server credentials");
756        return;
757    };
758    std::thread::Builder::new()
759        .name("koan-fav-sync".into())
760        .spawn(move || {
761            let result = if star {
762                client.star(&remote_id)
763            } else {
764                client.unstar(&remote_id)
765            };
766            match result {
767                Ok(()) => log::info!("synced favourite to remote: {remote_id} = {star}"),
768                Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
769            }
770        })
771        .ok();
772}
773
774/// Everything a sync is.
775#[derive(Debug, Default)]
776pub struct Synced {
777    pub library: crate::remote::sync::SyncResult,
778    pub favourites: FavouriteSync,
779    pub playlists: crate::playlists::PlaylistSync,
780}
781
782/// Whether a sync walks the server's library.
783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub enum Walk {
785    /// Somebody asked: walk it, whatever the server says.
786    Always,
787    /// On koan's own account — a server saying something changed, a timer, a
788    /// favourite on another device: walk it only if the server's library has
789    /// moved since the last complete walk.
790    IfChanged,
791}
792
793/// Pull the library, then reconcile favourites and playlists.
794///
795/// One function because there are four callers — the app, the CLI, the GraphQL
796/// job and koan's own auto-sync — and each must sync the same things.
797///
798/// The library comes first: favourites and playlists both name tracks by the
799/// server's ids, and neither can find a track the library has not seen yet.
800/// Walking it is most of a sync's cost — fifty thousand tracks written for
801/// every walk — so `Walk::IfChanged` asks the server first. Its version is
802/// read before the walk, so a change landing mid-walk leaves it newer than
803/// what is recorded, and the next sync walks again. Favourites and playlists
804/// are a request or two each, and are reconciled every time.
805pub fn sync_remote(
806    db: &Database,
807    client: &SubsonicClient,
808    walk: Walk,
809    url: &str,
810    username: &str,
811    progress: &(dyn Fn(crate::remote::sync::SyncProgress) + Sync),
812) -> Result<Synced, crate::remote::sync::SyncError> {
813    use crate::remote::sync;
814    // One at a time. An automatic sync still reconciling favourites when a
815    // sync was asked for wrote under it, and each fought the other for the
816    // write lock. The second waits for the first, and then has little left
817    // to do.
818    static SYNCING: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
819    let _one_at_a_time = SYNCING.lock();
820
821    let walked = sync::library_version(db, url);
822    let version = client
823        .library_modified(walked)
824        .inspect_err(|e| log::debug!("library version unavailable: {e}"))
825        .ok()
826        .flatten();
827    let library = if walk == Walk::IfChanged && version.is_some() && version == walked {
828        log::info!("library unchanged on the server; not walked");
829        sync::SyncResult::default()
830    } else {
831        let library = sync::sync_library(db, client, url, username, progress)?;
832        if library.is_complete()
833            && let Some(version) = version
834        {
835            sync::set_library_version(db, url, username, version)?;
836        }
837        library
838    };
839    Ok(Synced {
840        library,
841        favourites: reconcile_favourites(db, client),
842        playlists: crate::playlists::reconcile_playlists(db, client, url, username),
843    })
844}
845
846/// What a favourites reconciliation did.
847#[derive(Debug, Default, Clone, Copy)]
848pub struct FavouriteSync {
849    pub pushed: usize,
850    pub imported: usize,
851}
852
853/// Reconcile favourites with the server, both directions.
854///
855/// Stars every local favourite the server knows about but has not starred,
856/// then imports everything the server has starred. Union rather than mirror:
857/// neither side records an unstar, so treating one as authoritative would
858/// silently delete favourites made on the other. Reading the server's stars
859/// first keeps a sync from re-sending every favourite, one request each.
860///
861/// Covers albums and artists as well as tracks — `getStarred2` returns all
862/// three from one request, and reading only songs would leave a starred album
863/// invisible to koan.
864pub fn reconcile_favourites(db: &Database, client: &SubsonicClient) -> FavouriteSync {
865    let mut out = FavouriteSync::default();
866
867    let starred = match client.get_starred_all() {
868        Ok(s) => s,
869        Err(e) => {
870            log::warn!("could not fetch starred items from the server: {e}");
871            return out;
872        }
873    };
874    let songs: Vec<String> = starred.song.into_iter().map(|s| s.id).collect();
875    let albums: Vec<String> = starred.album.into_iter().map(|a| a.id).collect();
876    let artists: Vec<String> = starred.artist.into_iter().map(|a| a.id).collect();
877
878    let unstarred = |ids: Vec<String>, starred: &[String]| {
879        let starred: std::collections::HashSet<&String> = starred.iter().collect();
880        ids.into_iter()
881            .filter(|id| !starred.contains(id))
882            .collect::<Vec<_>>()
883    };
884    let tracks = queries::favourites_with_remote_id(&db.conn, queries::LOCAL_USER)
885        .unwrap_or_default()
886        .into_iter()
887        .map(|(_, id)| id)
888        .collect();
889    for remote_id in unstarred(tracks, &songs) {
890        if client.star(&remote_id).is_ok() {
891            out.pushed += 1;
892        }
893    }
894    let local_albums = queries::favourite_albums_with_remote_id(&db.conn, queries::LOCAL_USER)
895        .unwrap_or_default()
896        .into_iter()
897        .map(|(_, id)| id)
898        .collect();
899    for remote_id in unstarred(local_albums, &albums) {
900        if client.star_album(&remote_id).is_ok() {
901            out.pushed += 1;
902        }
903    }
904    let local_artists = queries::favourite_artists_with_remote_id(&db.conn, queries::LOCAL_USER)
905        .unwrap_or_default()
906        .into_iter()
907        .map(|(_, id)| id)
908        .collect();
909    for remote_id in unstarred(local_artists, &artists) {
910        if client.star_artist(&remote_id).is_ok() {
911            out.pushed += 1;
912        }
913    }
914
915    out.imported +=
916        queries::import_remote_favourites(&db.conn, queries::LOCAL_USER, &songs).unwrap_or(0);
917    out.imported += queries::import_remote_favourite_albums(&db.conn, queries::LOCAL_USER, &albums)
918        .unwrap_or(0);
919    out.imported +=
920        queries::import_remote_favourite_artists(&db.conn, queries::LOCAL_USER, &artists)
921            .unwrap_or(0);
922    out
923}
924
925/// What a favourite applies to. Subsonic stars all three, under different
926/// parameter names — passing an album id as `id` silently stars nothing.
927#[derive(Debug, Clone, Copy, PartialEq, Eq)]
928pub enum FavouriteKind {
929    Track,
930    Album,
931    Artist,
932}
933
934/// Push an album or artist favourite to the server.
935///
936/// Same shape as [`sync_favourite_to_remote`], but the remote id comes from the
937/// album or artist row rather than the track's path.
938pub fn sync_collection_favourite_to_remote(
939    db: &Database,
940    kind: FavouriteKind,
941    id: i64,
942    star: bool,
943) {
944    let cfg = Config::load().unwrap_or_default();
945    if !cfg.remote.enabled {
946        return;
947    }
948    let remote_id = match kind {
949        FavouriteKind::Album => queries::album_remote_id(&db.conn, id),
950        FavouriteKind::Artist => queries::artist_remote_id(&db.conn, id),
951        FavouriteKind::Track => return,
952    };
953    let Ok(Some(remote_id)) = remote_id else {
954        log::warn!("not syncing favourite: {kind:?} {id} has no remote id");
955        return;
956    };
957    let Some(client) = subsonic_client(&cfg) else {
958        log::warn!("not syncing favourite: no usable server credentials");
959        return;
960    };
961    std::thread::Builder::new()
962        .name("koan-fav-sync".into())
963        .spawn(move || {
964            let result = match (kind, star) {
965                (FavouriteKind::Album, true) => client.star_album(&remote_id),
966                (FavouriteKind::Album, false) => client.unstar_album(&remote_id),
967                (FavouriteKind::Artist, true) => client.star_artist(&remote_id),
968                (FavouriteKind::Artist, false) => client.unstar_artist(&remote_id),
969                (FavouriteKind::Track, _) => Ok(()),
970            };
971            match result {
972                Ok(()) => log::info!("synced favourite to remote: {kind:?} {remote_id} = {star}"),
973                Err(e) => log::warn!("failed to sync favourite to remote: {e}"),
974            }
975        })
976        .ok();
977}
978
979/// Why signing in to a remote server failed.
980#[derive(Debug, thiserror::Error)]
981pub enum SignInError {
982    #[error("the server did not accept those credentials: {0}")]
983    Rejected(#[from] crate::remote::client::SubsonicError),
984    #[error("could not write the configuration: {0}")]
985    Config(#[from] crate::config::ConfigError),
986}
987
988/// Sign in to a Subsonic/Navidrome server and remember it.
989///
990/// The password goes to `config.local.toml`, which is gitignored and written
991/// `0600`. Subsonic authenticates every request with the password or a salted
992/// MD5 of it, so there is no token to hold instead — whatever koan keeps is
993/// password-equivalent wherever it is kept.
994///
995/// The credentials are checked against the server before anything is written; a
996/// stored password that does not work is worse than none.
997///
998/// Shared by the CLI and the app so the two cannot disagree about where
999/// credentials live.
1000pub fn set_remote_credentials(
1001    url: &str,
1002    username: &str,
1003    password: &str,
1004) -> Result<(), SignInError> {
1005    let url = url.trim_end_matches('/');
1006    SubsonicClient::new(url, username, password).ping()?;
1007
1008    Config::persist(|cfg| {
1009        cfg.remote.enabled = true;
1010        cfg.remote.url = url.to_string();
1011        cfg.remote.username = username.to_string();
1012        cfg.remote.password = password.to_string();
1013    })?;
1014    // The link rests for up to a minute while signed out; the profile Settings
1015    // shows is probed when it wakes.
1016    crate::remote::link::nudge();
1017    Ok(())
1018}
1019
1020/// Shared secret for koan's own Subsonic API.
1021///
1022/// Deliberately not the same secret as `get_remote_password` — see `SubsonicConfig`.
1023pub fn get_subsonic_password(cfg: &Config) -> Option<String> {
1024    (!cfg.subsonic.password.is_empty()).then(|| cfg.subsonic.password.clone())
1025}
1026
1027/// Upstream Subsonic credentials from the merged config, returning `None` if
1028/// remote is disabled or has no URL configured.
1029///
1030/// Prefer this over `subsonic_client` when only a signed URL is needed:
1031/// building a client constructs blocking `reqwest` clients, which panics from
1032/// inside a tokio runtime.
1033pub fn subsonic_auth(cfg: &Config) -> Option<SubsonicAuth> {
1034    if !cfg.remote.enabled || cfg.remote.url.is_empty() {
1035        return None;
1036    }
1037    let password = get_remote_password(cfg)?;
1038    Some(SubsonicAuth::new(
1039        &cfg.remote.url,
1040        &cfg.remote.username,
1041        &password,
1042    ))
1043}
1044
1045/// One `SubsonicClient` per set of credentials, shared process-wide.
1046///
1047/// Constructing one builds two blocking `reqwest` clients, each carrying its
1048/// own runtime on its own thread, and each starting with a cold connection
1049/// pool — so a client per call means a fresh TLS handshake for every cover art
1050/// request.
1051///
1052/// Keyed on the credentials, so logging in as someone else replaces the client
1053/// rather than serving the old one. Never call from async code: building the
1054/// inner clients panics inside a tokio runtime.
1055pub fn subsonic_client(cfg: &Config) -> Option<Arc<SubsonicClient>> {
1056    let auth = subsonic_auth(cfg)?;
1057
1058    let mut slot = SUBSONIC_CLIENT.lock();
1059    if let Some((cached, client)) = slot.as_ref()
1060        && *cached == auth
1061    {
1062        return Some(client.clone());
1063    }
1064
1065    let client = Arc::new(SubsonicClient::from_auth(auth.clone()));
1066    *slot = Some((auth, client.clone()));
1067    Some(client)
1068}
1069
1070type CachedClient = Option<(SubsonicAuth, Arc<SubsonicClient>)>;
1071
1072static SUBSONIC_CLIENT: std::sync::LazyLock<parking_lot::Mutex<CachedClient>> =
1073    std::sync::LazyLock::new(|| parking_lot::Mutex::new(None));
1074
1075// ---------------------------------------------------------------------------
1076// Sharing
1077// ---------------------------------------------------------------------------
1078
1079/// Why a share link could not be made. Each variant is something the user can
1080/// act on.
1081#[derive(Debug, thiserror::Error)]
1082pub enum ShareError {
1083    #[error("sharing.public_url is not set, so there is no address to give out")]
1084    NoPublicUrl,
1085    #[error("none of these tracks are in the library")]
1086    NothingToShare,
1087    #[error("none of these tracks are on the server, so a link has nothing to point at")]
1088    NothingRemote,
1089    #[error("the server refused to share these: {0}")]
1090    Server(#[from] crate::remote::client::SubsonicError),
1091    #[error(transparent)]
1092    Database(#[from] crate::db::connection::DbError),
1093}
1094
1095/// A created share link, and how much of the request it covers.
1096#[derive(Debug, Clone)]
1097pub struct ShareOutcome {
1098    pub url: String,
1099    /// The server's own ID for the share, for callers that manage them.
1100    pub id: String,
1101    /// Tracks the server knows about, which went into the link.
1102    pub shared: usize,
1103    /// Tracks with no copy on the server, left out of it.
1104    pub skipped: usize,
1105}
1106
1107/// What a share link is asked to cover.
1108#[derive(Debug, Clone, PartialEq, Eq)]
1109pub enum ShareTarget {
1110    /// Loose tracks. A single track shares its album, cued to that track.
1111    Tracks(Vec<i64>),
1112    /// An album, optionally cued to one of its tracks.
1113    Album {
1114        album_id: i64,
1115        start_track_id: Option<i64>,
1116    },
1117    /// An artist's albums, in release order.
1118    Artist(i64),
1119}
1120
1121/// The slice a target makes and the tracks it covers, in play order. Only
1122/// tracks in the library are included; the list is fixed from here on.
1123///
1124/// A single track becomes its album cued to it: a song is heard in the
1125/// record it belongs to, the way the app shows it.
1126pub fn resolve_share(
1127    conn: &rusqlite::Connection,
1128    target: &ShareTarget,
1129) -> Result<(Slice, Vec<i64>), ShareError> {
1130    let album_tracks = |album_id| -> Result<Vec<i64>, ShareError> {
1131        Ok(queries::tracks_for_album(conn, album_id)?
1132            .into_iter()
1133            .map(|t| t.id)
1134            .collect())
1135    };
1136    let (slice, ids) = match target {
1137        ShareTarget::Tracks(ids) => {
1138            let rows = queries::tracks_by_ids(conn, ids)?;
1139            match (ids.as_slice(), rows.first().and_then(|t| t.album_id)) {
1140                ([one], Some(album_id)) => {
1141                    return resolve_share(
1142                        conn,
1143                        &ShareTarget::Album {
1144                            album_id,
1145                            start_track_id: Some(*one),
1146                        },
1147                    );
1148                }
1149                _ => {
1150                    // The order asked for, which is the order the page plays them in.
1151                    let ids = ids
1152                        .iter()
1153                        .copied()
1154                        .filter(|id| rows.iter().any(|t| t.id == *id))
1155                        .collect();
1156                    (Slice::TRACKS, ids)
1157                }
1158            }
1159        }
1160        ShareTarget::Album {
1161            album_id,
1162            start_track_id,
1163        } => {
1164            let ids = album_tracks(*album_id)?;
1165            let slice = Slice {
1166                kind: ShareKind::Album,
1167                subject_id: Some(*album_id),
1168                start_track_id: start_track_id.filter(|s| ids.contains(s)),
1169            };
1170            (slice, ids)
1171        }
1172        ShareTarget::Artist(artist_id) => {
1173            let mut ids = Vec::new();
1174            for album in queries::albums_for_artist(conn, *artist_id)? {
1175                ids.extend(album_tracks(album.id)?);
1176            }
1177            let slice = Slice {
1178                kind: ShareKind::Artist,
1179                subject_id: Some(*artist_id),
1180                start_track_id: None,
1181            };
1182            (slice, ids)
1183        }
1184    };
1185    if ids.is_empty() {
1186        return Err(ShareError::NothingToShare);
1187    }
1188    Ok((slice, ids))
1189}
1190
1191/// Create a public share link for a slice of the library.
1192///
1193/// With a remote Subsonic server configured, the link is made there: a laptop
1194/// or phone shares through the server it plays from, which may be another
1195/// koan. Without one this koan is the server, and makes the link itself.
1196///
1197/// A link points at the server, so only tracks the server knows about can go in
1198/// it. A mixed selection shares the part that can be shared and reports the
1199/// rest rather than failing whole — half a link beats none, as long as the
1200/// caller says which half.
1201///
1202/// `user` is who is sharing, recorded on a link this koan makes itself.
1203///
1204/// May be network-bound. Callers keep it off whatever thread draws.
1205pub fn create_share(
1206    db: &Database,
1207    user: i64,
1208    cfg: &Config,
1209    target: &ShareTarget,
1210    description: Option<&str>,
1211) -> Result<ShareOutcome, ShareError> {
1212    let Some(client) = subsonic_client(cfg) else {
1213        return create_native_share(db, user, cfg, target, description);
1214    };
1215    // A remote server makes its own kind of link from what it is given, so it
1216    // is given exactly what was picked.
1217    let resolved;
1218    let track_ids = match target {
1219        ShareTarget::Tracks(ids) => ids.as_slice(),
1220        _ => {
1221            resolved = resolve_share(&db.conn, target)?.1;
1222            resolved.as_slice()
1223        }
1224    };
1225
1226    // One query, not one per track: sharing an artist is thousands of tracks.
1227    let rows = queries::tracks_by_ids(&db.conn, track_ids)?;
1228
1229    let shared = rows.iter().filter(|t| t.remote_id.is_some()).count();
1230    if shared == 0 {
1231        return Err(ShareError::NothingRemote);
1232    }
1233
1234    // A whole record shares as one album rather than as N tracks — the server
1235    // renders it as the album it is, and the link survives the user adding to
1236    // it. Only when the selection is the whole album.
1237    let one_album = rows
1238        .first()
1239        .and_then(|f| f.album_id)
1240        .filter(|first| rows.iter().all(|t| t.album_id == Some(*first)))
1241        .and_then(|album_id| album_remote_id(&db.conn, album_id, rows.len()));
1242
1243    let remote_ids: Vec<String> = match one_album {
1244        Some(rid) => vec![album_share_id(&client, rid)],
1245        None => rows.into_iter().filter_map(|t| t.remote_id).collect(),
1246    };
1247
1248    let refs: Vec<&str> = remote_ids.iter().map(String::as_str).collect();
1249    let share = client.create_share(&refs, description)?;
1250
1251    // Navidrome does not always hand back a URL, and a share with no link is
1252    // useless to the caller — the ID is enough to build it.
1253    let url = share
1254        .url
1255        .clone()
1256        .unwrap_or_else(|| format!("{}/s/{}", client.base_url(), share.id));
1257
1258    Ok(ShareOutcome {
1259        url,
1260        id: share.id,
1261        shared,
1262        skipped: track_ids.len().saturating_sub(shared),
1263    })
1264}
1265
1266/// A share this koan serves at `{sharing.public_url}/share/{id}`.
1267///
1268/// What a server's own surfaces make whatever `[remote]` says: a link made
1269/// upstream would belong to the upstream's account, not the koan user who
1270/// asked, and could not be listed or revoked here.
1271pub fn create_native_share(
1272    db: &Database,
1273    user: i64,
1274    cfg: &Config,
1275    target: &ShareTarget,
1276    description: Option<&str>,
1277) -> Result<ShareOutcome, ShareError> {
1278    let base = cfg
1279        .sharing
1280        .public_url
1281        .as_deref()
1282        .filter(|u| !u.trim().is_empty())
1283        .ok_or(ShareError::NoPublicUrl)?;
1284    let (slice, ids) = resolve_share(&db.conn, target)?;
1285    let now = std::time::SystemTime::now()
1286        .duration_since(std::time::UNIX_EPOCH)
1287        .map_or(0, |d| d.as_secs() as i64);
1288    let share = queries::shares::create_share(&db.conn, user, slice, &ids, description, now, None)?;
1289    Ok(ShareOutcome {
1290        url: share_url(base, &share.id),
1291        id: share.id,
1292        shared: ids.len(),
1293        // Only loose tracks are named one by one, so only they can be missing.
1294        skipped: match (target, slice.kind) {
1295            (ShareTarget::Tracks(asked), ShareKind::Tracks) => asked.len() - ids.len(),
1296            _ => 0,
1297        },
1298    })
1299}
1300
1301/// A native share's public address.
1302pub fn share_url(public_url: &str, id: &str) -> String {
1303    format!("{}/share/{id}", public_url.trim_end_matches('/'))
1304}
1305
1306/// An album's id as `createShare` should be given it.
1307///
1308/// koan numbers albums and songs separately, publishes album ids bare, and
1309/// reads a bare id in `createShare` as a song, so album 5 would share song 5.
1310/// Its `al-` prefix says which is meant. Other servers get the id as they
1311/// issued it: some also number albums, and would not know the prefix.
1312fn album_share_id(client: &crate::remote::client::SubsonicClient, remote_id: String) -> String {
1313    let koan = crate::remote::profile::is_koan(client.auth());
1314    album_share_id_for(koan, remote_id)
1315}
1316
1317fn album_share_id_for(koan: bool, remote_id: String) -> String {
1318    if koan && remote_id.parse::<i64>().is_ok() {
1319        format!("al-{remote_id}")
1320    } else {
1321        remote_id
1322    }
1323}
1324
1325/// The album's own remote ID, but only when `selected` covers every track on
1326/// it. Sharing an album link for half an album would hand out more than the
1327/// user picked.
1328fn album_remote_id(conn: &rusqlite::Connection, album_id: i64, selected: usize) -> Option<String> {
1329    let (remote_id, total): (Option<String>, i64) = conn
1330        .query_row(
1331            "SELECT al.remote_id, (SELECT COUNT(*) FROM tracks WHERE album_id = al.id)
1332             FROM albums al WHERE al.id = ?1",
1333            [album_id],
1334            |row| Ok((row.get(0)?, row.get(1)?)),
1335        )
1336        .ok()?;
1337    (total == selected as i64).then_some(remote_id).flatten()
1338}
1339
1340// ---------------------------------------------------------------------------
1341// Path utilities
1342// ---------------------------------------------------------------------------
1343
1344/// Fisher-Yates over a fresh seed, so consecutive calls differ.
1345///
1346/// Deliberately not seeded from anything stable: "shuffle again" has to
1347/// actually produce a new order, which a process-lifetime seed wouldn't.
1348pub fn shuffle<T>(items: &mut [T]) {
1349    let mut seed = [0u8; 8];
1350    if getrandom::fill(&mut seed).is_err() {
1351        return; // Leave the order alone rather than pretending to shuffle.
1352    }
1353    let mut state = u64::from_le_bytes(seed) | 1;
1354    for i in (1..items.len()).rev() {
1355        // xorshift64 — plenty for shuffling a list nobody is betting on.
1356        state ^= state << 13;
1357        state ^= state >> 7;
1358        state ^= state << 17;
1359        items.swap(i, (state % (i as u64 + 1)) as usize);
1360    }
1361}
1362
1363/// Truncate a string to at most `max` bytes, cutting on a char boundary.
1364pub fn truncate_bytes(s: &str, max: usize) -> &str {
1365    if s.len() <= max {
1366        return s;
1367    }
1368    let mut end = max;
1369    while end > 0 && !s.is_char_boundary(end) {
1370        end -= 1;
1371    }
1372    &s[..end]
1373}
1374
1375/// Sanitise and truncate a string for use as a path component.
1376/// Strips illegal chars and caps at 240 bytes (macOS 255-byte filename limit minus room for ext).
1377/// `.` and `..` become `_`: tags and server metadata are untrusted, and either
1378/// would move the path out of the directory it is joined onto.
1379pub fn sanitise_filename(s: &str) -> String {
1380    let cleaned: String = s
1381        .chars()
1382        .map(|c| match c {
1383            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
1384            _ => c,
1385        })
1386        .collect::<String>()
1387        .trim()
1388        .to_string();
1389
1390    let cleaned = truncate_bytes(&cleaned, 240).trim_end().to_string();
1391    match cleaned.as_str() {
1392        "." | ".." => "_".into(),
1393        _ => cleaned,
1394    }
1395}
1396
1397/// A file extension from a codec name, which for remote tracks is whatever
1398/// the server sent as `suffix`. ASCII alphanumerics only, so it can never
1399/// carry a separator or a `..`; `None` when nothing usable is left.
1400pub fn sanitise_extension(codec: &str) -> Option<String> {
1401    let ext: String = codec
1402        .chars()
1403        .filter(char::is_ascii_alphanumeric)
1404        .take(16)
1405        .collect::<String>()
1406        .to_lowercase();
1407    (!ext.is_empty()).then_some(ext)
1408}
1409
1410/// Whether `path` lies inside `dir` without leaving it on the way — every
1411/// component after the prefix is a plain name.
1412pub fn path_within(dir: &Path, path: &Path) -> bool {
1413    path.strip_prefix(dir).is_ok_and(|rest| {
1414        rest.components()
1415            .all(|c| matches!(c, std::path::Component::Normal(_)))
1416    })
1417}
1418
1419/// The year a tag date starts with. `get`, not a slice: a date is free text,
1420/// and a multibyte character in its first four bytes would panic a slice.
1421pub fn year_of(date: &str) -> Option<&str> {
1422    date.get(..4)
1423}
1424
1425/// Build a structured cache path for a track:
1426///   cache_dir/Album Artist/(Year) Album [Codec]/01. Track Artist - Title.ext
1427pub fn cache_path_for_track(
1428    cache_dir: &Path,
1429    track: &queries::TrackRow,
1430    album_date: Option<&str>,
1431) -> PathBuf {
1432    let artist_dir = sanitise_filename(&track.artist_name);
1433
1434    let year = album_date
1435        .and_then(year_of)
1436        .map(|y| format!("({}) ", y))
1437        .unwrap_or_default();
1438    let codec = track
1439        .codec
1440        .as_deref()
1441        .map(|c| format!(" [{}]", c))
1442        .unwrap_or_default();
1443    let album_dir = sanitise_filename(&format!("{}{}{}", year, track.album_title, codec));
1444
1445    let disc_prefix = match track.disc {
1446        Some(d) if d > 1 => format!("{}-", d),
1447        _ => String::new(),
1448    };
1449    let track_num = track
1450        .track_number
1451        .map(|n| format!("{:02}. ", n))
1452        .unwrap_or_default();
1453
1454    let ext = track
1455        .codec
1456        .as_deref()
1457        .and_then(sanitise_extension)
1458        .unwrap_or_else(|| "flac".into());
1459
1460    let filename = sanitise_filename(&format!(
1461        "{}{}{} - {}",
1462        disc_prefix, track_num, track.artist_name, track.title
1463    ));
1464
1465    cache_dir
1466        .join(artist_dir)
1467        .join(album_dir)
1468        .join(format!("{}.{}", filename, ext))
1469}
1470
1471// ---------------------------------------------------------------------------
1472// Track resolution
1473// ---------------------------------------------------------------------------
1474
1475/// Resolve a track to its path + load state (without downloading).
1476/// Returns (path, `ItemState::Ready`) for local/cached, (cache path, `ItemState::Pending`)
1477/// for remote — a track with no copy here yet has to be fetched before it plays.
1478fn resolve_item_path(
1479    cfg: &Config,
1480    track: &queries::TrackRow,
1481    remote_url: Option<&str>,
1482    album_date: Option<&str>,
1483) -> (PathBuf, ItemState) {
1484    match queries::choose_playback_source(
1485        track.path.as_deref(),
1486        track.cached_path.as_deref(),
1487        remote_url,
1488    ) {
1489        Some(queries::PlaybackSource::Local(p)) => (p, ItemState::Ready),
1490        // A cache entry is only as good as its contents. Older builds could
1491        // store a Subsonic error body here, which reports Ready and then fails
1492        // to decode forever; treating it as Pending sends it back through the
1493        // download path, which discards it and re-fetches.
1494        Some(queries::PlaybackSource::Cached(p)) => {
1495            let state = if is_cached_audio(&p) {
1496                ItemState::Ready
1497            } else {
1498                ItemState::Pending
1499            };
1500            (p, state)
1501        }
1502        Some(queries::PlaybackSource::Remote(_)) => {
1503            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1504            if dest.exists() && is_cached_audio(&dest) {
1505                (dest, ItemState::Ready)
1506            } else {
1507                (dest, ItemState::Pending)
1508            }
1509        }
1510        _ => {
1511            // Fallback: construct a cache path and mark pending.
1512            let dest = cache_path_for_track(&cfg.cache_dir(), track, album_date);
1513            (dest, ItemState::Pending)
1514        }
1515    }
1516}
1517
1518/// Build a PlaylistItem from a TrackRow + album date + resolved path + load state.
1519pub fn playlist_item_from_track(
1520    track: &queries::TrackRow,
1521    album_date: Option<&str>,
1522    dest: PathBuf,
1523    state: ItemState,
1524) -> PlaylistItem {
1525    let year = album_date.and_then(year_of).map(str::to_string);
1526    PlaylistItem {
1527        playlist_entry_id: None,
1528        id: QueueItemId::new(),
1529        db_id: Some(track.id),
1530        path: dest,
1531        title: track.title.clone(),
1532        artist: track.artist_name.clone(),
1533        album_artist: track.album_artist_name.clone(),
1534        album: track.album_title.clone(),
1535        year,
1536        codec: track.codec.clone(),
1537        track_number: track.track_number.map(|n| n as i64),
1538        disc: track.disc.map(|n| n as i64),
1539        duration_ms: track.duration_ms.map(|d| d as u64),
1540        state,
1541    }
1542}
1543
1544/// Build playlist items for many tracks at once.
1545///
1546/// One config read and one query for the whole batch, whatever its size: the
1547/// rows already say where each track's file and download are, and what they
1548/// leave out — the stream URL and the album's date — is read for all of them
1549/// together.
1550pub fn playlist_items_for_tracks(db: &Database, tracks: &[queries::TrackRow]) -> Vec<PlaylistItem> {
1551    let cfg = Config::load().unwrap_or_default();
1552    let ids: Vec<i64> = tracks.iter().map(|t| t.id).collect();
1553    let extras = queries::queue_item_extras(&db.conn, &ids).unwrap_or_default();
1554
1555    tracks
1556        .iter()
1557        .map(|track| {
1558            let extra = extras.get(&track.id);
1559            let remote_url = extra.and_then(|e| e.remote_url.as_deref());
1560            let album_date = extra.and_then(|e| e.album_date.as_deref());
1561            let (path, state) = resolve_item_path(&cfg, track, remote_url, album_date);
1562            playlist_item_from_track(track, album_date, path, state)
1563        })
1564        .collect()
1565}
1566
1567// ---------------------------------------------------------------------------
1568// Download
1569// ---------------------------------------------------------------------------
1570
1571/// Whether a cached file plausibly holds audio.
1572///
1573/// A stored Subsonic error is a few hundred bytes of JSON or XML; no real
1574/// encoded track comes close to that, so the size check alone settles almost
1575/// every case and the leading byte covers the rest.
1576fn is_cached_audio(path: &std::path::Path) -> bool {
1577    const MIN_PLAUSIBLE_BYTES: u64 = 4096;
1578    match std::fs::metadata(path) {
1579        Ok(meta) if meta.len() >= MIN_PLAUSIBLE_BYTES => true,
1580        Ok(_) => {
1581            let mut first = [0u8; 1];
1582            match std::fs::File::open(path)
1583                .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut first).map(|_| first[0]))
1584            {
1585                Ok(b) => b != b'{' && b != b'<',
1586                Err(_) => false,
1587            }
1588        }
1589        Err(_) => false,
1590    }
1591}
1592
1593/// Resolve a track to a playable file, downloading from remote if needed.
1594///
1595/// Resolution order:
1596/// 1. Local library path (DB `path` field) -- use directly if file exists
1597/// 2. Cache path -- use if already downloaded
1598/// 3. Download from remote to cache -- stream while downloading
1599pub fn download_track(
1600    db_id: i64,
1601    queue_id: QueueItemId,
1602    tx: &crossbeam_channel::Sender<PlayerCommand>,
1603    log_buf: &Arc<Mutex<Vec<String>>>,
1604    state: &Arc<SharedPlayerState>,
1605    cfg: &Config,
1606    client: &SubsonicClient,
1607) {
1608    if !in_queue_soon(state, queue_id, Duration::from_secs(5)) {
1609        return;
1610    }
1611
1612    // From the pool. This runs once per track fetched, and opening a
1613    // connection runs the schema DDL and a WAL checkpoint — with several
1614    // transfers going, several init cycles would contend with each other and
1615    // with library reads.
1616    let db = match crate::db::pool::shared().get() {
1617        Ok(db) => db,
1618        Err(e) => {
1619            fail_track(state, tx, queue_id, format!("db error: {}", e));
1620            return;
1621        }
1622    };
1623    let track = match queries::get_track_row(&db.conn, db_id) {
1624        Ok(Some(t)) => t,
1625        _ => {
1626            fail_track(state, tx, queue_id, "track not found".into());
1627            return;
1628        }
1629    };
1630
1631    let remote_id = match &track.remote_id {
1632        Some(rid) => rid.clone(),
1633        None => {
1634            // No remote_id -- check if the local file exists.
1635            if let Some(ref path) = track.path {
1636                let p = std::path::PathBuf::from(path);
1637                if p.exists() {
1638                    state.update_paths(&[(queue_id, p)]);
1639                    state.update_item_state(queue_id, ItemState::Ready);
1640                    if state.is_cursor(queue_id) {
1641                        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1642                    }
1643                    return;
1644                }
1645            }
1646            fail_track(
1647                state,
1648                tx,
1649                queue_id,
1650                "not in the library folder, and no remote copy to fetch".into(),
1651            );
1652            return;
1653        }
1654    };
1655
1656    // 1. Check if the local library file exists.
1657    if let Some(ref local_path) = track.path {
1658        let p = std::path::PathBuf::from(local_path);
1659        if p.exists() {
1660            log::info!("download_track: local file exists, using {}", p.display());
1661            state.update_paths(&[(queue_id, p)]);
1662            state.update_item_state(queue_id, ItemState::Ready);
1663            if state.is_cursor(queue_id) {
1664                tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1665            }
1666            return;
1667        }
1668    }
1669
1670    let album_date: Option<String> = track
1671        .album_id
1672        .and_then(|aid| queries::album_date(&db.conn, aid).ok().flatten());
1673
1674    let cache_dir = cfg.cache_dir();
1675    let dest = cache_path_for_track(&cache_dir, &track, album_date.as_deref());
1676    if !path_within(&cache_dir, &dest) {
1677        fail_track(
1678            state,
1679            tx,
1680            queue_id,
1681            format!("cache path escapes the cache: {}", dest.display()),
1682        );
1683        return;
1684    }
1685
1686    // 2. Already cached.
1687    //
1688    // Older builds could write a Subsonic error body here as if it were audio,
1689    // leaving a tiny JSON file that reports Ready and then fails to decode
1690    // forever. Treat those as absent so they get re-fetched.
1691    if dest.exists() && !is_cached_audio(&dest) {
1692        log::warn!(
1693            "discarding non-audio cache entry {} (likely a stored server error)",
1694            dest.display()
1695        );
1696        let _ = std::fs::remove_file(&dest);
1697    }
1698    if dest.exists() {
1699        state.update_paths(&[(queue_id, dest)]);
1700        state.update_item_state(queue_id, ItemState::Ready);
1701        if state.is_cursor(queue_id) {
1702            tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1703        }
1704        return;
1705    }
1706
1707    // 3. Download from remote. The queue item points at the in-progress file so
1708    // the decoder reads bytes as they land; it flips to `dest` on success.
1709    state.update_paths(&[(queue_id, crate::remote::download::part_path(&dest))]);
1710
1711    let bytes_written = crate::remote::downloads::ByteFeed::new();
1712
1713    // Announce it before a byte moves, so a queue of six shows six rows rather
1714    // than one row and five tracks that look like nothing is happening to them.
1715    let store = crate::remote::downloads::store();
1716    store.queued(crate::remote::downloads::Download {
1717        id: queue_id,
1718        track_id: db_id,
1719        title: track.title.clone(),
1720        artist: track.artist_name.clone(),
1721        source: crate::remote::download::part_path(&dest),
1722        dest: dest.clone(),
1723        total: 0,
1724        written: bytes_written.clone(),
1725        state: crate::remote::downloads::DownloadState::Queued,
1726        bytes_per_second: 0,
1727    });
1728
1729    let progress_qid = queue_id;
1730    let bytes_written_progress = bytes_written.clone();
1731    let progress_tx = tx.clone();
1732    let stream_ready_flag = std::sync::atomic::AtomicBool::new(false);
1733    // A retry restarts the byte count from zero, so a changed total re-announces.
1734    let announced_total = AtomicU64::new(u64::MAX);
1735    // Taken out of the queue, cleared or removed, while waiting out an outage.
1736    let gone = || state.get_item(queue_id).is_none();
1737    let result =
1738        client.download_with_progress(&remote_id, &dest, &gone, move |downloaded, total| {
1739            bytes_written_progress.set(downloaded);
1740            // What knows a transfer moved is the code moving it. Held to a reading
1741            // every 250ms inside, so a chunk landing costs an atomic and a compare.
1742            store.progressed();
1743            if announced_total.swap(total, Ordering::Relaxed) != total {
1744                // The store, and only the store. The item's state says whether its
1745                // file can be played, which a transfer in flight has not changed.
1746                store.started(progress_qid, total, bytes_written_progress.clone());
1747            }
1748            if !stream_ready_flag.load(Ordering::Relaxed)
1749                && downloaded >= crate::player::state::STREAM_THRESHOLD
1750            {
1751                stream_ready_flag.store(true, Ordering::Relaxed);
1752                progress_tx
1753                    .send(PlayerCommand::TrackStreamReady(progress_qid))
1754                    .ok();
1755            }
1756        });
1757
1758    if let Err(SubsonicError::Download(DownloadError::Cancelled)) = result {
1759        store.withdrawn(queue_id);
1760        bytes_written.done();
1761        return;
1762    }
1763
1764    // However it ended, a decoder reading the `.part` file may be parked at the
1765    // write head. It waits on the feed, so the feed has to wake it — and only
1766    // once the item says how it ended, or it looks, sees a download, and parks
1767    // again with nothing left to wake it.
1768    if let Err(e) = result {
1769        store.failed(queue_id, e.to_string());
1770        fail_track(state, tx, queue_id, e.to_string());
1771        bytes_written.done();
1772        push_log(log_buf, format!("x {} — {}", track.title, e));
1773        return;
1774    }
1775    store.finished(queue_id);
1776
1777    state.update_paths(&[(queue_id, dest.clone())]);
1778    state.update_item_state(queue_id, ItemState::Ready);
1779    bytes_written.done();
1780    // Without this row the file is invisible to cache eviction and never reclaimed.
1781    if let Err(e) = queries::set_cached_path(&db.conn, db_id, &dest.to_string_lossy()) {
1782        log::warn!(
1783            "cached {} but failed to record it ({}) — it will not be evicted",
1784            dest.display(),
1785            e
1786        );
1787    }
1788
1789    push_log(
1790        log_buf,
1791        format!("+ {} — {}", track.title, track.artist_name),
1792    );
1793
1794    if state.is_cursor(queue_id) {
1795        tx.send(PlayerCommand::TrackReady(queue_id)).ok();
1796    }
1797}
1798
1799/// Wait for the player to hold `id`, at most `timeout`. Whether it does.
1800///
1801/// Queueing a track sends the player the command that adds it and starts its
1802/// download together, and a download worker already running can begin before
1803/// the player has applied the command. Until then the track reads as taken
1804/// out of the queue, which is what cancels a download, so the download
1805/// cancelled itself before a byte moved and nothing tried it again. Woken
1806/// when the queue changes rather than polled; a track that never arrives — a
1807/// queue replaced again before the player took it — is not fetched.
1808fn in_queue_soon(state: &SharedPlayerState, id: QueueItemId, timeout: Duration) -> bool {
1809    let changed = crate::signal::engine_changed();
1810    let deadline = std::time::Instant::now() + timeout;
1811    let mut seen = changed.generation();
1812    loop {
1813        if state.get_item(id).is_some() {
1814            return true;
1815        }
1816        let now = std::time::Instant::now();
1817        if now >= deadline {
1818            return false;
1819        }
1820        seen = changed.wait_until(seen, deadline - now);
1821    }
1822}
1823
1824/// Mark a queue item unplayable and tell the player, if it is waiting on it.
1825///
1826/// Setting `ItemState::Failed` alone is not enough: the player only wakes for
1827/// `TrackReady`, so a cursor parked on the item would wait for a download that
1828/// has already given up.
1829pub(crate) fn fail_track(
1830    state: &Arc<SharedPlayerState>,
1831    tx: &crossbeam_channel::Sender<PlayerCommand>,
1832    queue_id: QueueItemId,
1833    reason: String,
1834) {
1835    state.update_item_state(queue_id, ItemState::Failed(reason));
1836    if state.is_cursor(queue_id) {
1837        tx.send(PlayerCommand::TrackFailed(queue_id)).ok();
1838    }
1839}
1840
1841/// Append to the TUI log pane, tolerating a poisoned lock — a download worker
1842/// must not die because some other thread panicked while holding it.
1843fn push_log(log_buf: &Arc<Mutex<Vec<String>>>, msg: String) {
1844    match log_buf.lock() {
1845        Ok(mut buf) => buf.push(msg),
1846        Err(_) => log::info!("{}", msg),
1847    }
1848}
1849
1850/// Why there is no remote client, in words worth showing someone.
1851///
1852/// Every caller of `subsonic_client` gets `None` for three different reasons,
1853/// and reporting one for all of them makes "koan has no password", which sends
1854/// you to sign in, look like a server that is merely down.
1855pub fn remote_unavailable(cfg: &Config) -> String {
1856    if !cfg.remote.enabled {
1857        return "no remote server is configured".into();
1858    }
1859    if cfg.remote.url.is_empty() {
1860        return "the remote server has no address".into();
1861    }
1862    if get_remote_password(cfg).is_none() {
1863        return "no password is stored for the remote server".into();
1864    }
1865    // A password resolved, so the client should have built. Nothing else
1866    // returns `None`, but saying so beats claiming a cause that is wrong.
1867    "the remote server could not be reached".into()
1868}
1869
1870/// Submit tracks for download.
1871///
1872/// Everything that is not the TUI reaches downloads through here — the FFI and the
1873/// GraphQL server. The batch goes to the shared queue:
1874/// the same pool, priority lane and cursor watcher the TUI uses.
1875pub fn spawn_downloads(
1876    pending: Vec<(i64, QueueItemId)>,
1877    tx: crossbeam_channel::Sender<PlayerCommand>,
1878    state: Arc<SharedPlayerState>,
1879) {
1880    if pending.is_empty() {
1881        return;
1882    }
1883    crate::remote::queue::shared(&tx, &state, None).enqueue(pending);
1884}
1885
1886#[cfg(test)]
1887mod queue_arrival_tests {
1888    use super::*;
1889    use crate::player::state::{ItemState, PlaylistItem};
1890
1891    fn item() -> PlaylistItem {
1892        PlaylistItem {
1893            playlist_entry_id: None,
1894            id: QueueItemId::new(),
1895            db_id: Some(1),
1896            path: std::path::PathBuf::from("/cache/one.flac"),
1897            title: "One".into(),
1898            artist: "Artist".into(),
1899            album_artist: "Artist".into(),
1900            album: "Album".into(),
1901            year: None,
1902            codec: None,
1903            track_number: None,
1904            disc: None,
1905            duration_ms: None,
1906            state: ItemState::Pending,
1907        }
1908    }
1909
1910    /// The download starts before the player has the track, which arrives a
1911    /// moment later: the download waits for it rather than cancelling.
1912    #[test]
1913    fn a_download_waits_for_its_track_to_reach_the_queue() {
1914        let state = Arc::new(SharedPlayerState::new());
1915        let track = item();
1916        let id = track.id;
1917        let player = state.clone();
1918        std::thread::spawn(move || {
1919            std::thread::sleep(Duration::from_millis(50));
1920            player.add_items(vec![track]);
1921        });
1922        assert!(in_queue_soon(&state, id, Duration::from_secs(2)));
1923    }
1924
1925    #[test]
1926    fn a_track_that_never_arrives_is_not_waited_for_long() {
1927        let state = SharedPlayerState::new();
1928        let started = std::time::Instant::now();
1929        assert!(!in_queue_soon(
1930            &state,
1931            QueueItemId::new(),
1932            Duration::from_millis(100)
1933        ));
1934        assert!(started.elapsed() < Duration::from_secs(1));
1935    }
1936}
1937
1938#[cfg(test)]
1939mod year_tests {
1940    use super::year_of;
1941
1942    #[test]
1943    fn a_year_is_the_first_four_characters_when_they_are_bytes_too() {
1944        assert_eq!(year_of("1997-05-21"), Some("1997"));
1945        assert_eq!(year_of("199"), None);
1946        // Full-width digits: four bytes in is mid-character.
1947        assert_eq!(year_of("1997"), None);
1948    }
1949}
1950
1951#[cfg(test)]
1952mod rebuild_tests {
1953    use super::*;
1954    use crate::db::queries::sample_meta;
1955
1956    fn test_db() -> Database {
1957        let conn = rusqlite::Connection::open_in_memory().unwrap();
1958        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1959        crate::db::schema::create_tables(&conn).unwrap();
1960        Database { conn }
1961    }
1962
1963    #[test]
1964    fn cached_paths_follow_a_moved_cache_directory() {
1965        let old = tempfile::tempdir().unwrap();
1966        let new = tempfile::tempdir().unwrap();
1967        let db = test_db();
1968
1969        let mut rows = Vec::new();
1970        for name in ["moved", "gone", "current"] {
1971            let mut meta = sample_meta(name, "Artist", "Album");
1972            meta.source = "remote".into();
1973            meta.path = None;
1974            meta.remote_id = Some(name.into());
1975            let id = queries::upsert_track(&db.conn, &meta).unwrap();
1976            let tail = format!("Artist/Album/{name}.flac");
1977            // Every copy now lives under the new directory except "gone".
1978            if name != "gone" {
1979                let file = new.path().join(&tail);
1980                std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1981                std::fs::write(&file, b"audio").unwrap();
1982            }
1983            let stored = if name == "current" {
1984                new.path()
1985            } else {
1986                old.path()
1987            }
1988            .join(&tail);
1989            queries::set_cached_path(&db.conn, id, &stored.to_string_lossy()).unwrap();
1990            rows.push((id, tail));
1991        }
1992
1993        assert_eq!(relocate_cached_paths(&db, new.path()).unwrap(), 1);
1994
1995        let cached = |id: i64| -> String {
1996            db.conn
1997                .query_row("SELECT cached_path FROM tracks WHERE id = ?1", [id], |r| {
1998                    r.get(0)
1999                })
2000                .unwrap()
2001        };
2002        let expect = |root: &Path, tail: &str| root.join(tail).to_string_lossy().into_owned();
2003        assert_eq!(
2004            cached(rows[0].0),
2005            expect(new.path(), &rows[0].1),
2006            "re-rooted"
2007        );
2008        assert_eq!(
2009            cached(rows[1].0),
2010            expect(old.path(), &rows[1].1),
2011            "no file, left alone"
2012        );
2013        assert_eq!(
2014            cached(rows[2].0),
2015            expect(new.path(), &rows[2].1),
2016            "already current"
2017        );
2018        assert_eq!(
2019            relocate_cached_paths(&db, new.path()).unwrap(),
2020            0,
2021            "idempotent"
2022        );
2023    }
2024
2025    #[test]
2026    fn clearing_one_download_leaves_the_others_and_the_library_alone() {
2027        let dir = tempfile::tempdir().unwrap();
2028        let db = test_db();
2029
2030        let mut cached = Vec::new();
2031        for name in ["one", "two"] {
2032            let mut meta = sample_meta(name, "Artist", "Album");
2033            meta.source = "remote".into();
2034            meta.path = None;
2035            meta.remote_id = Some(name.into());
2036            let id = queries::upsert_track(&db.conn, &meta).unwrap();
2037            let file = dir.path().join(format!("{name}.opus"));
2038            std::fs::write(&file, vec![0u8; 2048]).unwrap();
2039            queries::set_cached_path(&db.conn, id, &file.to_string_lossy()).unwrap();
2040            cached.push((id, file));
2041        }
2042
2043        let cleared = clear_downloads_for(&db, &[cached[0].0]);
2044        assert_eq!(cleared.files, 1);
2045        assert_eq!(cleared.bytes, 2048);
2046        assert!(!cached[0].1.exists(), "the copy asked for is gone");
2047        assert!(cached[1].1.exists(), "the other one is untouched");
2048
2049        // The row survives — a remote track is still in the library, it just
2050        // has to be fetched again.
2051        assert_eq!(queries::library_stats(&db.conn).unwrap().remote_tracks, 2);
2052        assert_eq!(queries::library_stats(&db.conn).unwrap().cached_tracks, 1);
2053        assert!(
2054            queries::cached_paths_for(&db.conn, &[cached[0].0])
2055                .unwrap()
2056                .is_empty()
2057        );
2058    }
2059
2060    #[test]
2061    fn clearing_a_download_that_is_already_gone_is_not_a_failure() {
2062        let db = test_db();
2063        let mut meta = sample_meta("ghost", "Artist", "Album");
2064        meta.source = "remote".into();
2065        meta.path = None;
2066        meta.remote_id = Some("ghost".into());
2067        let id = queries::upsert_track(&db.conn, &meta).unwrap();
2068        queries::set_cached_path(&db.conn, id, "/nowhere/at/all.opus").unwrap();
2069
2070        let cleared = clear_downloads_for(&db, &[id]);
2071        assert_eq!(cleared.files, 0, "nothing was there to remove");
2072        // Forgotten regardless: the row claimed a copy that does not exist.
2073        assert!(
2074            queries::cached_paths_for(&db.conn, &[id])
2075                .unwrap()
2076                .is_empty()
2077        );
2078    }
2079
2080    #[test]
2081    fn sweeping_removes_half_finished_downloads_and_nothing_else() {
2082        let dir = tempfile::tempdir().unwrap();
2083        let cache = dir.path().join("cache");
2084        std::fs::create_dir_all(cache.join("Artist")).unwrap();
2085
2086        let finished = cache.join("Artist/whole.opus");
2087        let half = cache.join("Artist/half.opus.part");
2088        std::fs::write(&finished, vec![0u8; 1024]).unwrap();
2089        std::fs::write(&half, vec![0u8; 4096]).unwrap();
2090
2091        let cfg = Config {
2092            remote: crate::config::RemoteConfig {
2093                cache_dir: Some(cache.clone()),
2094                ..Default::default()
2095            },
2096            ..Default::default()
2097        };
2098
2099        let swept = sweep_partial_downloads(&cfg);
2100        assert_eq!(swept.files, 1);
2101        assert_eq!(swept.bytes, 4096);
2102        assert!(!half.exists(), "the unfinished one is gone");
2103        assert!(finished.exists(), "a downloaded track is not touched");
2104    }
2105
2106    #[test]
2107    fn sweeping_an_empty_cache_is_not_an_error() {
2108        let dir = tempfile::tempdir().unwrap();
2109        let cfg = Config {
2110            remote: crate::config::RemoteConfig {
2111                cache_dir: Some(dir.path().join("nothing-here")),
2112                ..Default::default()
2113            },
2114            ..Default::default()
2115        };
2116        assert_eq!(sweep_partial_downloads(&cfg).files, 0);
2117    }
2118
2119    #[test]
2120    fn clearing_no_tracks_does_nothing() {
2121        let db = test_db();
2122        assert_eq!(clear_downloads_for(&db, &[]).files, 0);
2123    }
2124
2125    #[test]
2126    fn rebuild_drops_the_index_and_keeps_favourites() {
2127        let db = test_db();
2128        let mut meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2129        meta.path = Some("/music/windowlicker.flac".into());
2130        let track_id = queries::upsert_track(&db.conn, &meta).unwrap();
2131
2132        // Favourites key on the path; lyrics key on the row id.
2133        queries::toggle_favourite(
2134            &db.conn,
2135            crate::db::queries::LOCAL_USER,
2136            Path::new("/music/windowlicker.flac"),
2137        )
2138        .unwrap();
2139        db.conn
2140            .execute(
2141                "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
2142                 VALUES (?1, 'test', 'la la la', 0)",
2143                [track_id],
2144            )
2145            .unwrap();
2146
2147        let summary = rebuild_index(&db).unwrap();
2148        assert_eq!(summary.tracks, 1);
2149        assert_eq!(summary.albums, 1);
2150
2151        let tracks: i64 = db
2152            .conn
2153            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2154            .unwrap();
2155        assert_eq!(tracks, 0, "the index is gone");
2156
2157        let favourites: i64 = db
2158            .conn
2159            .query_row("SELECT COUNT(*) FROM favourites", [], |r| r.get(0))
2160            .unwrap();
2161        assert_eq!(favourites, 1, "favourites survive — they key on the path");
2162
2163        let lyrics: i64 = db
2164            .conn
2165            .query_row("SELECT COUNT(*) FROM lyrics_cache", [], |r| r.get(0))
2166            .unwrap();
2167        assert_eq!(lyrics, 0, "anything keyed on a track id cannot survive");
2168    }
2169
2170    #[test]
2171    fn rebuilding_an_empty_library_is_not_an_error() {
2172        let db = test_db();
2173        let summary = rebuild_index(&db).unwrap();
2174        assert_eq!(summary.tracks, 0);
2175    }
2176}
2177
2178#[cfg(test)]
2179mod share_tests {
2180    use super::*;
2181    use crate::db::queries::sample_meta;
2182
2183    fn test_db() -> Database {
2184        let conn = rusqlite::Connection::open_in_memory().unwrap();
2185        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2186        crate::db::schema::create_tables(&conn).unwrap();
2187        Database { conn }
2188    }
2189
2190    /// Three tracks on one album; the album carries a remote ID.
2191    fn album_of_three(db: &Database) -> (i64, Vec<i64>) {
2192        let ids: Vec<i64> = ["One", "Two", "Three"]
2193            .iter()
2194            .enumerate()
2195            .map(|(i, title)| {
2196                let mut meta = sample_meta(title, "Boards of Canada", "Geogaddi");
2197                meta.path = Some(format!("/music/geogaddi/{i}.flac"));
2198                meta.track_number = Some(i as i32 + 1);
2199                queries::upsert_track(&db.conn, &meta).unwrap()
2200            })
2201            .collect();
2202        let album_id: i64 = db
2203            .conn
2204            .query_row("SELECT album_id FROM tracks WHERE id = ?1", [ids[0]], |r| {
2205                r.get(0)
2206            })
2207            .unwrap();
2208        db.conn
2209            .execute(
2210                "UPDATE albums SET remote_id = 'al-1' WHERE id = ?1",
2211                [album_id],
2212            )
2213            .unwrap();
2214        (album_id, ids)
2215    }
2216
2217    #[test]
2218    fn whole_album_collapses_to_the_album_link() {
2219        let db = test_db();
2220        let (album_id, ids) = album_of_three(&db);
2221        assert_eq!(
2222            album_remote_id(&db.conn, album_id, ids.len()),
2223            Some("al-1".into())
2224        );
2225    }
2226
2227    #[test]
2228    fn part_of_an_album_does_not() {
2229        let db = test_db();
2230        let (album_id, _) = album_of_three(&db);
2231        // Sharing an album link for two of three tracks would hand out a track
2232        // the user did not pick.
2233        assert_eq!(album_remote_id(&db.conn, album_id, 2), None);
2234    }
2235
2236    #[test]
2237    fn a_local_only_album_has_no_link_to_collapse_to() {
2238        let db = test_db();
2239        let (album_id, ids) = album_of_three(&db);
2240        db.conn
2241            .execute(
2242                "UPDATE albums SET remote_id = NULL WHERE id = ?1",
2243                [album_id],
2244            )
2245            .unwrap();
2246        assert_eq!(album_remote_id(&db.conn, album_id, ids.len()), None);
2247    }
2248}
2249
2250#[cfg(test)]
2251mod client_cache_tests {
2252    use super::*;
2253
2254    #[test]
2255    fn one_subsonic_client_is_shared_per_credentials() {
2256        crate::config::isolate_config_for_tests();
2257        let mut cfg = Config::default();
2258        cfg.remote.enabled = true;
2259        cfg.remote.url = "https://shared-client.invalid".into();
2260        cfg.remote.username = "koan".into();
2261        cfg.remote.password = "first".into();
2262
2263        let first = subsonic_client(&cfg).expect("a configured remote yields a client");
2264        let again = subsonic_client(&cfg).expect("a configured remote yields a client");
2265        assert!(
2266            Arc::ptr_eq(&first, &again),
2267            "rebuilding drops the connection pool and re-handshakes TLS per request"
2268        );
2269
2270        cfg.remote.password = "second".into();
2271        let relogged = subsonic_client(&cfg).expect("a configured remote yields a client");
2272        assert!(
2273            !Arc::ptr_eq(&first, &relogged),
2274            "new credentials must not keep serving the client signed with the old ones"
2275        );
2276    }
2277}
2278
2279#[cfg(test)]
2280mod native_share_tests {
2281    use super::*;
2282    use crate::db::queries::{sample_meta, upsert_track};
2283
2284    #[test]
2285    fn a_standalone_server_shares_natively_in_the_order_asked() {
2286        let conn = rusqlite::Connection::open_in_memory().unwrap();
2287        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2288        crate::db::schema::create_tables(&conn).unwrap();
2289        let db = Database { conn };
2290        let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2291        let b = upsert_track(&db.conn, &sample_meta("B", "X", "Y")).unwrap();
2292        let mut cfg = Config::default();
2293        assert!(matches!(
2294            create_share(
2295                &db,
2296                queries::LOCAL_USER,
2297                &cfg,
2298                &ShareTarget::Tracks(vec![a]),
2299                None
2300            ),
2301            Err(ShareError::NoPublicUrl)
2302        ));
2303        cfg.sharing.public_url = Some("https://koan.example/".into());
2304        let out = create_share(
2305            &db,
2306            queries::LOCAL_USER,
2307            &cfg,
2308            &ShareTarget::Tracks(vec![b, 9999, a]),
2309            Some("mix"),
2310        )
2311        .unwrap();
2312        assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2313        assert_eq!((out.shared, out.skipped), (2, 1));
2314        let share = queries::shares::get_share(&db.conn, &out.id)
2315            .unwrap()
2316            .unwrap();
2317        assert_eq!(share.track_ids, [b, a]);
2318        assert!(matches!(
2319            create_share(
2320                &db,
2321                queries::LOCAL_USER,
2322                &cfg,
2323                &ShareTarget::Tracks(vec![9999]),
2324                None
2325            ),
2326            Err(ShareError::NothingToShare)
2327        ));
2328    }
2329
2330    #[test]
2331    fn a_server_with_an_upstream_still_shares_natively() {
2332        // Local-only tracks, which the upstream path refuses as NothingRemote.
2333        let conn = rusqlite::Connection::open_in_memory().unwrap();
2334        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2335        crate::db::schema::create_tables(&conn).unwrap();
2336        let db = Database { conn };
2337        let a = upsert_track(&db.conn, &sample_meta("A", "X", "Y")).unwrap();
2338        let mut cfg = Config::default();
2339        cfg.remote.enabled = true;
2340        cfg.remote.url = "https://upstream.invalid".into();
2341        cfg.remote.username = "someone".into();
2342        cfg.remote.password = "secret".into();
2343        cfg.sharing.public_url = Some("https://koan.example".into());
2344        let out = create_native_share(
2345            &db,
2346            queries::LOCAL_USER,
2347            &cfg,
2348            &ShareTarget::Tracks(vec![a]),
2349            None,
2350        )
2351        .unwrap();
2352        assert_eq!(out.url, format!("https://koan.example/share/{}", out.id));
2353        assert!(
2354            queries::shares::get_share(&db.conn, &out.id)
2355                .unwrap()
2356                .is_some()
2357        );
2358    }
2359
2360    fn album_track(db: &Database, title: &str, album: &str, n: i32, date: &str) -> i64 {
2361        let mut meta = sample_meta(title, "Rrose", album);
2362        meta.track_number = Some(n);
2363        meta.date = Some(date.into());
2364        upsert_track(&db.conn, &meta).unwrap()
2365    }
2366
2367    #[test]
2368    fn shares_are_slices_fixed_when_made() {
2369        let conn = rusqlite::Connection::open_in_memory().unwrap();
2370        conn.pragma_update(None, "foreign_keys", "on").unwrap();
2371        crate::db::schema::create_tables(&conn).unwrap();
2372        let db = Database { conn };
2373        let later = album_track(&db, "L1", "Later", 1, "2021");
2374        let a1 = album_track(&db, "E1", "Earlier", 1, "2015");
2375        let a2 = album_track(&db, "E2", "Earlier", 2, "2015");
2376        let album_of = |t| {
2377            queries::tracks_by_ids(&db.conn, &[t]).unwrap()[0]
2378                .album_id
2379                .unwrap()
2380        };
2381        let (earlier, later_album) = (album_of(a1), album_of(later));
2382        let artist = queries::tracks_by_ids(&db.conn, &[a1]).unwrap()[0]
2383            .artist_id
2384            .unwrap();
2385
2386        // One track: its album, cued to it.
2387        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![a2])).unwrap();
2388        assert_eq!(
2389            (slice.kind, slice.subject_id, slice.start_track_id),
2390            (ShareKind::Album, Some(earlier), Some(a2))
2391        );
2392        assert_eq!(ids, [a1, a2]);
2393
2394        // An album, with a cue that is not on it dropped.
2395        let (slice, ids) = resolve_share(
2396            &db.conn,
2397            &ShareTarget::Album {
2398                album_id: later_album,
2399                start_track_id: Some(a1),
2400            },
2401        )
2402        .unwrap();
2403        assert_eq!((slice.kind, slice.start_track_id), (ShareKind::Album, None));
2404        assert_eq!(ids, [later]);
2405
2406        // An artist: every album, in release order.
2407        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Artist(artist)).unwrap();
2408        assert_eq!(
2409            (slice.kind, slice.subject_id),
2410            (ShareKind::Artist, Some(artist))
2411        );
2412        assert_eq!(ids, [a1, a2, later]);
2413
2414        // Several tracks stay a list, in the order given.
2415        let (slice, ids) = resolve_share(&db.conn, &ShareTarget::Tracks(vec![later, a1])).unwrap();
2416        assert_eq!(slice, Slice::TRACKS);
2417        assert_eq!(ids, [later, a1]);
2418
2419        assert!(matches!(
2420            resolve_share(&db.conn, &ShareTarget::Artist(9999)),
2421            Err(ShareError::NothingToShare)
2422        ));
2423    }
2424}
2425
2426#[cfg(test)]
2427mod album_share_id_tests {
2428    use super::album_share_id_for;
2429
2430    #[test]
2431    fn a_koan_album_is_named_as_an_album() {
2432        assert_eq!(album_share_id_for(true, "46215".into()), "al-46215");
2433        // Already prefixed, or not koan's numbering: left as issued.
2434        assert_eq!(album_share_id_for(true, "al-7".into()), "al-7");
2435        assert_eq!(album_share_id_for(false, "46215".into()), "46215");
2436        assert_eq!(album_share_id_for(false, "3xJ9kQ2pZ".into()), "3xJ9kQ2pZ");
2437    }
2438}
2439
2440#[cfg(test)]
2441mod cache_path_tests {
2442    use super::*;
2443
2444    fn track(artist: &str, album: &str, codec: &str) -> queries::TrackRow {
2445        queries::TrackRow {
2446            id: 1,
2447            album_id: None,
2448            artist_id: None,
2449            artist_name: artist.into(),
2450            album_artist_name: artist.into(),
2451            album_title: album.into(),
2452            disc: None,
2453            track_number: Some(1),
2454            title: "Song".into(),
2455            duration_ms: None,
2456            path: None,
2457            codec: Some(codec.into()),
2458            sample_rate: None,
2459            bit_depth: None,
2460            channels: None,
2461            bitrate: None,
2462            genre: None,
2463            source: "remote".into(),
2464            remote_id: Some("r1".into()),
2465            cached_path: None,
2466        }
2467    }
2468
2469    #[test]
2470    fn a_server_suffix_cannot_leave_the_cache() {
2471        let cache = Path::new("/cache");
2472        for codec in [
2473            "flac/../../../../x",
2474            "..",
2475            "../..",
2476            "/etc/passwd",
2477            "\\..\\..",
2478        ] {
2479            let path = cache_path_for_track(cache, &track("A", "B", codec), None);
2480            assert!(path_within(cache, &path), "{codec}: {}", path.display());
2481        }
2482        let path = cache_path_for_track(cache, &track("A", "B", "flac/../../../../x"), None);
2483        assert_eq!(path.extension().unwrap(), "flacx");
2484    }
2485
2486    #[test]
2487    fn dot_names_cannot_climb_out() {
2488        let cache = Path::new("/cache");
2489        let path = cache_path_for_track(cache, &track("..", ".", ".."), None);
2490        assert!(path_within(cache, &path), "{}", path.display());
2491        assert_eq!(sanitise_filename(".."), "_");
2492        assert_eq!(sanitise_filename(" . "), "_");
2493        assert_eq!(sanitise_filename("..."), "...");
2494    }
2495
2496    #[test]
2497    fn an_empty_suffix_falls_back_to_flac() {
2498        assert_eq!(sanitise_extension("../"), None);
2499        assert_eq!(sanitise_extension("FLAC"), Some("flac".into()));
2500        let path = cache_path_for_track(Path::new("/c"), &track("A", "B", "./"), None);
2501        assert_eq!(path.extension().unwrap(), "flac");
2502    }
2503
2504    #[test]
2505    fn path_within_rejects_parent_components() {
2506        let dir = Path::new("/cache");
2507        assert!(path_within(dir, Path::new("/cache/a/b.flac")));
2508        assert!(!path_within(dir, Path::new("/cache/a/../../x")));
2509        assert!(!path_within(dir, Path::new("/elsewhere/x")));
2510    }
2511}
2512
2513#[cfg(test)]
2514mod favourite_sync_tests {
2515    use super::*;
2516    use crate::db::queries::sample_meta;
2517
2518    /// A server with song `s1` starred, recording every id it is asked to star.
2519    fn serve(stars: Arc<Mutex<Vec<String>>>) -> String {
2520        use std::io::{BufRead, Write};
2521        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2522        let url = format!("http://{}", listener.local_addr().unwrap());
2523        std::thread::spawn(move || {
2524            for mut stream in listener.incoming().flatten() {
2525                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
2526                let mut request = String::new();
2527                reader.read_line(&mut request).unwrap();
2528                let mut line = String::new();
2529                while reader.read_line(&mut line).unwrap_or(0) > 2 {
2530                    line.clear();
2531                }
2532                let target = request.split_whitespace().nth(1).unwrap_or("");
2533                let (path, query) = target.split_once('?').unwrap_or((target, ""));
2534                let body = match path.rsplit('/').next().unwrap() {
2535                    "getStarred2" => {
2536                        r#"{"subsonic-response":{"status":"ok","starred2":{"song":[{"id":"s1","title":"One"}]}}}"#
2537                    }
2538                    "star" => {
2539                        if let Some((_, id)) = query
2540                            .split('&')
2541                            .filter_map(|kv| kv.split_once('='))
2542                            .find(|(k, _)| *k == "id")
2543                        {
2544                            stars.lock().unwrap().push(id.to_string());
2545                        }
2546                        r#"{"subsonic-response":{"status":"ok"}}"#
2547                    }
2548                    _ => r#"{"subsonic-response":{"status":"ok"}}"#,
2549                };
2550                let _ = write!(
2551                    stream,
2552                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{body}",
2553                    body.len()
2554                );
2555            }
2556        });
2557        url
2558    }
2559
2560    #[test]
2561    fn only_favourites_the_server_lacks_are_starred() {
2562        let dir = tempfile::tempdir().unwrap();
2563        let db = Database::open(&dir.path().join("koan.db")).unwrap();
2564        for (title, remote_id) in [("One", "s1"), ("Two", "s2")] {
2565            let mut meta = sample_meta(title, "Artist", "Album");
2566            meta.path = Some(format!("/music/{title}.flac"));
2567            meta.remote_id = Some(remote_id.into());
2568            queries::upsert_track(&db.conn, &meta).unwrap();
2569            queries::add_favourite(
2570                &db.conn,
2571                queries::LOCAL_USER,
2572                Path::new(&format!("/music/{title}.flac")),
2573            )
2574            .unwrap();
2575        }
2576
2577        let stars = Arc::new(Mutex::new(Vec::new()));
2578        let url = serve(stars.clone());
2579        let sync = reconcile_favourites(&db, &SubsonicClient::new(&url, "u", "pw"));
2580        assert_eq!(sync.pushed, 1);
2581        assert_eq!(*stars.lock().unwrap(), ["s2"]);
2582    }
2583}