Skip to main content

koan_core/db/queries/
tracks.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use rusqlite::{Connection, OptionalExtension, params};
5
6use crate::db::connection::DbError;
7
8use super::sources;
9use super::{PlaybackSource, TrackMeta, TrackRow};
10
11/// Map a rusqlite Row to a TrackRow. Expects the standard column order:
12/// id, album_id, artist_id, artist_name, album_artist_name, album_title,
13/// disc, track_number, title, duration_ms, path,
14/// codec, sample_rate, bit_depth, channels, bitrate,
15/// genre, source, remote_id, cached_path
16pub(crate) fn row_to_track_row(row: &rusqlite::Row) -> rusqlite::Result<TrackRow> {
17    row_to_track_row_at(row, 0)
18}
19
20/// The same, for a query that selects something of its own before the track's
21/// columns — a playlist entry selects its id and position first.
22pub(crate) fn row_to_track_row_at(row: &rusqlite::Row, at: usize) -> rusqlite::Result<TrackRow> {
23    let artist_name: String = row.get::<_, Option<String>>(at + 3)?.unwrap_or_default();
24    Ok(TrackRow {
25        id: row.get(at)?,
26        album_id: row.get(at + 1)?,
27        artist_id: row.get(at + 2)?,
28        artist_name: artist_name.clone(),
29        album_artist_name: row.get::<_, Option<String>>(at + 4)?.unwrap_or(artist_name),
30        album_title: row.get::<_, Option<String>>(at + 5)?.unwrap_or_default(),
31        disc: row.get(at + 6)?,
32        track_number: row.get(at + 7)?,
33        title: row.get(at + 8)?,
34        duration_ms: row.get(at + 9)?,
35        path: row.get(at + 10)?,
36        codec: row.get(at + 11)?,
37        sample_rate: row.get(at + 12)?,
38        bit_depth: row.get(at + 13)?,
39        channels: row.get(at + 14)?,
40        bitrate: row.get(at + 15)?,
41        genre: row.get(at + 16)?,
42        source: row.get(at + 17)?,
43        remote_id: row.get(at + 18)?,
44        cached_path: row.get(at + 19)?,
45    })
46}
47
48/// Insert or update a track from what one source says about it.
49///
50/// A `TrackMeta` with a path is a file and one with a server id an entry on
51/// the server; one with both is both. Each source keeps its own tags, and the
52/// track's columns are derived from its sources, the file's first — see
53/// `sources`. Which track a source belongs to is decided in one place,
54/// `sources::link`: the same MusicBrainz recording on the same release, or the
55/// same album, album artist, disc, number and title with the artist as a
56/// tie-break. Two sources of the same kind are never one track, and a match
57/// that is ambiguous is declined.
58pub fn upsert_track(conn: &Connection, meta: &TrackMeta) -> Result<i64, DbError> {
59    upsert_track_status(conn, meta).map(|(id, _)| id)
60}
61
62/// `upsert_track`, additionally reporting whether a new row was inserted (`true`)
63/// or an existing one updated (`false`).
64pub fn upsert_track_status(conn: &Connection, meta: &TrackMeta) -> Result<(i64, bool), DbError> {
65    upsert_track_with(conn, meta, None)
66}
67
68/// `upsert_track` for a remote sync, which knows the server ids it has seen so
69/// far. An entry in the same slot whose id the sync has not seen is taken to be
70/// this one under the id it had before: a server that renumbers its library
71/// keeps its rows, history and favourites rather than gaining a second copy of
72/// every track. An id already seen belongs to an entry of its own, so two
73/// entries the server lists with identical tags stay two rows.
74pub fn upsert_synced_track(
75    conn: &Connection,
76    meta: &TrackMeta,
77    seen: &HashSet<String>,
78) -> Result<i64, DbError> {
79    upsert_track_with(conn, meta, Some(seen)).map(|(id, _)| id)
80}
81
82fn upsert_track_with(
83    conn: &Connection,
84    meta: &TrackMeta,
85    seen: Option<&HashSet<String>>,
86) -> Result<(i64, bool), DbError> {
87    // Use a savepoint so this works both standalone and inside an existing
88    // transaction (e.g. the chunk transactions in scan_folder).
89    conn.execute_batch("SAVEPOINT upsert_track")?;
90    let result = (|| {
91        let mut recorded = None;
92        if meta.path.is_some() {
93            recorded = Some(sources::record(conn, sources::Kind::Local, meta, None)?);
94        }
95        if meta.remote_id.is_some() {
96            let (track, inserted) = sources::record(conn, sources::Kind::Remote, meta, seen)?;
97            recorded = Some((track, recorded.is_some_and(|(_, i)| i) || inserted));
98        }
99        recorded.ok_or(DbError::NoSource)
100    })();
101    match &result {
102        Ok(_) => conn.execute_batch("RELEASE upsert_track")?,
103        Err(_) => conn.execute_batch("ROLLBACK TO upsert_track; RELEASE upsert_track")?,
104    }
105    result
106}
107
108/// Clear the disc numbers stored as 0, which matching reads as none. Run
109/// before the source rows are built, so their keys agree.
110pub(crate) fn clear_zero_discs(conn: &Connection) -> rusqlite::Result<()> {
111    conn.execute("UPDATE tracks SET disc = NULL WHERE disc = 0", [])?;
112    Ok(())
113}
114
115/// Drop the remote entries the server no longer lists, keeping each track for
116/// whatever else it has: a file keeps its row and only loses the server's copy,
117/// and a server-only track whose entry the server renumbered goes to the live
118/// entry in its slot, history and favourites with it. The rest are deleted
119/// with their downloads.
120///
121/// An entry is gone when its id is not in `live_tracks`, or its album's id is
122/// not in `live_albums`. Either may be `None` when the sync cannot vouch for
123/// it: only a sync that listed everything has seen every id the server knows.
124pub fn remove_vanished_remote(
125    conn: &Connection,
126    live_tracks: Option<&HashSet<String>>,
127    live_albums: Option<&HashSet<String>>,
128) -> Result<usize, DbError> {
129    conn.execute_batch(
130        "SAVEPOINT remove_vanished;
131         CREATE TEMP TABLE IF NOT EXISTS live_ids (kind TEXT, id TEXT, PRIMARY KEY (kind, id));
132         DELETE FROM temp.live_ids;",
133    )?;
134    let removed = (|| {
135        let mut insert =
136            conn.prepare("INSERT OR IGNORE INTO temp.live_ids (kind, id) VALUES (?1, ?2)")?;
137        for (kind, ids) in [("track", live_tracks), ("album", live_albums)] {
138            for id in ids.into_iter().flatten() {
139                insert.execute(params![kind, id])?;
140            }
141        }
142        let track_gone = if live_tracks.is_some() {
143            "remote_id NOT IN (SELECT id FROM temp.live_ids WHERE kind = 'track')"
144        } else {
145            "0"
146        };
147        let album_gone = if live_albums.is_some() {
148            "album_remote_id IS NOT NULL
149             AND album_remote_id NOT IN (SELECT id FROM temp.live_ids WHERE kind = 'album')"
150        } else {
151            "0"
152        };
153        let gone: Vec<String> = conn
154            .prepare(&format!(
155                "SELECT remote_id FROM remote_entries WHERE {track_gone} OR {album_gone}"
156            ))?
157            .query_map([], |r| r.get(0))?
158            .collect::<rusqlite::Result<_>>()?;
159        let mut downloads = sources::remove_vanished(conn, &gone, live_tracks.is_some())?;
160        // Tracks a rebuilt index has not yet re-read, whose entry this sync
161        // did not claim again.
162        if live_tracks.is_some() {
163            let unread: Vec<String> = conn
164                .prepare(
165                    "SELECT t.remote_id FROM tracks t WHERE t.remote_id IS NOT NULL
166                        AND NOT EXISTS (SELECT 1 FROM remote_entries r WHERE r.track_id = t.id)
167                        AND t.remote_id NOT IN (SELECT id FROM temp.live_ids WHERE kind = 'track')",
168                )?
169                .query_map([], |r| r.get(0))?
170                .collect::<rusqlite::Result<_>>()?;
171            for key in &unread {
172                downloads.extend(sources::forget_unread(conn, sources::Kind::Remote, key)?);
173            }
174        }
175        Ok::<_, DbError>((gone.len(), downloads))
176    })();
177    match removed {
178        Ok((n, downloads)) => {
179            conn.execute_batch("RELEASE remove_vanished")?;
180            // A downloaded copy goes with its track; nothing would ever play
181            // or clean it up otherwise.
182            let mut freed = 0;
183            for path in downloads {
184                let size = std::fs::metadata(&path).map_or(0, |m| m.len());
185                if std::fs::remove_file(&path).is_ok() {
186                    freed += size;
187                }
188            }
189            if freed > 0 {
190                crate::helpers::cache_shrank(freed);
191            }
192            Ok(n)
193        }
194        Err(e) => {
195            conn.execute_batch("ROLLBACK TO remove_vanished; RELEASE remove_vanished")?;
196            Err(e)
197        }
198    }
199}
200
201/// Fold together the rows one file got by being spelled two ways.
202///
203/// A drop from Finder used to index a file under the precomposed spelling
204/// Foundation hands over, and the next scan stored the same file again under
205/// the directory entry's own, decomposed bytes — the two open the same file on
206/// a Mac, and `tracks.path` is compared bytewise. Paths are resolved against
207/// the directory on the way in now, so only this can bring the pairs it left
208/// back together.
209///
210/// The older row wins: it carries the play history, and the sync link if a
211/// server has the recording. It takes the decomposed spelling, which is the one
212/// a scan wrote — Foundation never produces a decomposed path, so that row is
213/// the one that came from the directory. A pair with no such spelling, or a
214/// path with more than two, is left visible rather than guessed at. The scan
215/// cache is keyed by path, so it moves by name.
216pub(crate) fn merge_spelling_twins(conn: &Connection) -> rusqlite::Result<()> {
217    use unicode_normalization::{UnicodeNormalization, is_nfd};
218
219    let rows: Vec<(i64, String)> = {
220        let mut stmt = conn.prepare("SELECT id, path FROM tracks WHERE path IS NOT NULL")?;
221        let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
222        rows.collect::<rusqlite::Result<Vec<_>>>()?
223    };
224    let mut by_spelling: HashMap<String, Vec<(i64, String)>> = HashMap::new();
225    for (id, path) in rows.into_iter().filter(|(_, path)| !path.is_ascii()) {
226        by_spelling
227            .entry(path.nfc().collect())
228            .or_default()
229            .push((id, path));
230    }
231
232    for mut pair in by_spelling.into_values().filter(|group| group.len() == 2) {
233        pair.sort_by_key(|(id, _)| *id);
234        let (winner, winner_path) = &pair[0];
235        let (loser, loser_path) = &pair[1];
236        let Some(disk) = [winner_path, loser_path]
237            .into_iter()
238            .find(|path| is_nfd(path))
239        else {
240            continue;
241        };
242        let disk = disk.clone();
243        let stale = if winner_path == &disk {
244            loser_path
245        } else {
246            winner_path
247        }
248        .clone();
249
250        conn.execute(
251            "UPDATE tracks SET
252                 remote_id = COALESCE(remote_id, (SELECT remote_id FROM tracks WHERE id = ?2)),
253                 remote_url = COALESCE(remote_url, (SELECT remote_url FROM tracks WHERE id = ?2)),
254                 cached_path = COALESCE(cached_path, (SELECT cached_path FROM tracks WHERE id = ?2)),
255                 cache_size_bytes = COALESCE(cache_size_bytes, (SELECT cache_size_bytes FROM tracks WHERE id = ?2)),
256                 cache_download_date = COALESCE(cache_download_date, (SELECT cache_download_date FROM tracks WHERE id = ?2)),
257                 genre = COALESCE(genre, (SELECT genre FROM tracks WHERE id = ?2)),
258                 mbid = COALESCE(mbid, (SELECT mbid FROM tracks WHERE id = ?2))
259               WHERE id = ?1",
260            params![winner, loser],
261        )?;
262        sources::fold_rows(conn, *winner, *loser)?;
263        conn.execute("DELETE FROM scan_cache WHERE path = ?1", params![stale])?;
264        conn.execute(
265            "UPDATE tracks SET path = ?1 WHERE id = ?2",
266            params![disk, winner],
267        )?;
268    }
269
270    Ok(())
271}
272
273/// Drop an album or artist the last track just left. Correcting a tag moves a row
274/// to a different album, and the one it came from is usually a misreading nobody
275/// wants left in the browser looking like a record with nothing on it.
276pub(crate) fn prune_if_empty(
277    conn: &Connection,
278    album_id: Option<i64>,
279    artist_id: Option<i64>,
280) -> rusqlite::Result<()> {
281    if let Some(album_id) = album_id {
282        let emptied = conn.execute(
283            "DELETE FROM albums WHERE id = ?1
284               AND NOT EXISTS (SELECT 1 FROM tracks WHERE album_id = ?1)",
285            params![album_id],
286        )?;
287        if emptied > 0 {
288            conn.execute(
289                "DELETE FROM favourite_albums WHERE album_id = ?1",
290                params![album_id],
291            )?;
292            conn.execute(
293                "DELETE FROM album_ratings WHERE album_id = ?1",
294                params![album_id],
295            )?;
296        }
297    }
298
299    if let Some(artist_id) = artist_id {
300        let stranded: bool = conn.query_row(
301            "SELECT NOT EXISTS (SELECT 1 FROM tracks WHERE artist_id = ?1)
302                AND NOT EXISTS (SELECT 1 FROM albums WHERE artist_id = ?1)",
303            params![artist_id],
304            |row| row.get(0),
305        )?;
306        if stranded {
307            conn.execute(
308                "DELETE FROM favourite_artists WHERE artist_id = ?1",
309                params![artist_id],
310            )?;
311            conn.execute(
312                "DELETE FROM artist_ratings WHERE artist_id = ?1",
313                params![artist_id],
314            )?;
315            conn.execute("DELETE FROM artists WHERE id = ?1", params![artist_id])?;
316        }
317    }
318
319    Ok(())
320}
321
322/// A folder holding fewer tracks than this is exempt from the removal-fraction
323/// check, where one deleted track out of three is already 33%.
324const STALE_CHECK_MIN_ROWS: i64 = 100;
325
326/// Share of a folder's tracks that may vanish in a single scan before the removal
327/// is treated as a mount failure rather than a deletion.
328const MAX_STALE_FRACTION: f64 = 0.2;
329
330/// Tell the apps' cover caches to forget what they hold for the albums and
331/// tracks under `folder`, whose files a rescan has just looked at: a cover
332/// image beside them may have been added, replaced or removed, and nothing
333/// in their rows says so. Returns how many records were named.
334pub fn evict_art_under(conn: &Connection, folder: &Path) -> Result<usize, DbError> {
335    let (lower, upper) = super::folder_prefix_range(folder);
336    Ok(conn.execute(
337        "INSERT INTO art_evictions (kind, id)
338         SELECT 'album', album_id FROM tracks
339          WHERE path >= ?1 AND path < ?2 AND album_id IS NOT NULL
340          GROUP BY album_id
341         UNION ALL
342         SELECT 'track', id FROM tracks WHERE path >= ?1 AND path < ?2",
343        params![lower, upper],
344    )?)
345}
346
347/// Forget the files under `folder` that no longer exist.
348///
349/// A track the server also has keeps its row and streams from there; one that
350/// was only the file is deleted, with its play history and lyrics. A server
351/// entry freed this way pairs with a file that turns up under another path —
352/// the same file moved. A folder that is present but unreadable must never look
353/// like a folder whose files were deleted, and two brakes enforce that: an IO
354/// error is not read as "gone", and a run that would clear more than
355/// [`MAX_STALE_FRACTION`] of a folder holding at least [`STALE_CHECK_MIN_ROWS`]
356/// tracks is refused with [`DbError::UnsafeBulkDelete`].
357///
358/// `force_remove` lifts the second brake only, for the case where the files really
359/// were deleted. The IO-error and dangling-symlink checks still apply (see
360/// [`crate::index::known_missing`]), and the caller is still
361/// responsible for not calling this at all when the folder yielded no files.
362///
363/// Returns the paths removed or demoted, so a caller can show what it did.
364pub fn remove_stale_tracks(
365    conn: &Connection,
366    folder: &Path,
367    force_remove: bool,
368) -> Result<Vec<String>, DbError> {
369    remove_stale_tracks_walked(conn, folder, force_remove, None)
370}
371
372/// [`remove_stale_tracks`], after a walk of `folder` that found `walked`: a
373/// row at one of those paths is there, and only the rest are asked of the
374/// filesystem. A walk sees everything a stat would, so on a network mount
375/// this saves a round trip per file in the library; what the walk did not
376/// find is still confirmed missing before it goes, so an unmounted share is
377/// still kept.
378pub fn remove_stale_tracks_walked(
379    conn: &Connection,
380    folder: &Path,
381    force_remove: bool,
382    walked: Option<&std::collections::HashSet<String>>,
383) -> Result<Vec<String>, DbError> {
384    let (lower, upper) = super::folder_prefix_range(folder);
385
386    // Files, and the tracks a rebuilt index has not yet re-read: a track
387    // whose file the scan did not claim again.
388    let paths: Vec<String> = conn
389        .prepare(
390            "SELECT path FROM local_files WHERE path >= ?1 AND path < ?2
391             UNION
392             SELECT t.path FROM tracks t WHERE t.path >= ?1 AND t.path < ?2
393                AND NOT EXISTS (SELECT 1 FROM local_files f WHERE f.track_id = t.id)",
394        )?
395        .query_map(params![lower, upper], |row| row.get(0))?
396        .collect::<rusqlite::Result<_>>()?;
397    let total = paths.len() as i64;
398    let unwalked = paths
399        .into_iter()
400        .filter(|path| !walked.is_some_and(|w| w.contains(path)));
401    // A row the walk did not find under its own spelling but did under the
402    // directory's is the same file stored twice; see `sources::fold_twin`.
403    let mut spelling = crate::index::spelling::Spelling::default();
404    let mut unclaimed = Vec::new();
405    for path in unwalked {
406        let spelled = walked.and_then(|w| {
407            let on_disk = spelling.on_disk(Path::new(&path));
408            let on_disk = on_disk.to_string_lossy();
409            (on_disk != path.as_str() && w.contains(on_disk.as_ref())).then(|| on_disk.into_owned())
410        });
411        match spelled {
412            Some(on_disk) if sources::fold_twin(conn, &path, &on_disk)? => {}
413            _ => unclaimed.push(path),
414        }
415    }
416    let stale: Vec<String> = unclaimed
417        .into_iter()
418        // A permission error, an ailing mount or a symlink whose target has
419        // gone away is "cannot tell", not "deleted".
420        .filter(|path| crate::index::known_missing(Path::new(path)))
421        .collect();
422
423    let count = stale.len();
424    if !force_remove
425        && total >= STALE_CHECK_MIN_ROWS
426        && count as f64 > total as f64 * MAX_STALE_FRACTION
427    {
428        return Err(DbError::UnsafeBulkDelete(format!(
429            "{} of {} tracks under {} are missing ({:.0}% of the folder) — that reads as an \
430             unmounted or unreadable folder rather than a deletion, so nothing was removed. \
431             If the files really are gone, re-run with `koan scan --force-remove`.",
432            count,
433            total,
434            folder.display(),
435            count as f64 / total as f64 * 100.0
436        )));
437    }
438
439    if force_remove && count > 0 {
440        log::warn!(
441            "--force-remove: deleting {} of {} tracks under {} along with their play history",
442            count,
443            total,
444            folder.display()
445        );
446    }
447
448    let mut tracks = Vec::with_capacity(stale.len());
449    for path in &stale {
450        tracks.extend(sources::track_of_file(conn, path)?);
451    }
452    sources::forget_tracks(conn, &tracks, sources::Forget::Demote)?;
453
454    Ok(stale)
455}
456
457/// Give files gone from under `folder` their tracks back where a scan has
458/// just found them at new paths: `arrived` is the tracks it made. See
459/// `sources::adopt_moved`. Returns how many were given back.
460pub fn adopt_moved_files(
461    conn: &Connection,
462    folder: &Path,
463    arrived: &[i64],
464    walked: Option<&std::collections::HashSet<String>>,
465) -> Result<usize, DbError> {
466    if arrived.is_empty() {
467        return Ok(0);
468    }
469    let (lower, upper) = super::folder_prefix_range(folder);
470    let paths: Vec<String> = conn
471        .prepare("SELECT path FROM local_files WHERE path >= ?1 AND path < ?2")?
472        .query_map(params![lower, upper], |row| row.get(0))?
473        .collect::<rusqlite::Result<_>>()?;
474    let mut adopted = 0;
475    // A file the walk found has not moved.
476    for path in paths
477        .into_iter()
478        .filter(|path| !walked.is_some_and(|w| w.contains(path)))
479    {
480        if crate::index::known_missing(Path::new(&path))
481            && sources::adopt_moved(conn, &path, arrived)?
482        {
483            adopted += 1;
484        }
485    }
486    Ok(adopted)
487}
488
489/// Get all tracks for an artist, ordered chronologically (album date, disc, track#).
490///
491/// The album-artist half is a subquery on `albums` rather than `al.artist_id =
492/// ?1` on the join: SQLite can only use an index for an `OR` when both sides
493/// name the same table, so the join form read every track in the library to
494/// find one artist's.
495pub fn tracks_for_artist(conn: &Connection, artist_id: i64) -> Result<Vec<TrackRow>, DbError> {
496    let mut stmt = conn.prepare(
497        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
498                t.disc, t.track_number, t.title, t.duration_ms, t.path,
499                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
500                t.genre, t.source, t.remote_id, t.cached_path
501         FROM tracks t
502         LEFT JOIN artists a ON t.artist_id = a.id
503         LEFT JOIN albums al ON t.album_id = al.id
504         LEFT JOIN artists aa ON al.artist_id = aa.id
505         WHERE t.artist_id = ?1
506                OR t.album_id IN (SELECT id FROM albums WHERE artist_id = ?1)
507         ORDER BY al.date, al.title COLLATE LIBRARY, t.disc, t.track_number",
508    )?;
509    let rows = stmt
510        .query_map(params![artist_id], row_to_track_row)?
511        .collect::<Result<Vec<_>, _>>()?;
512    Ok(rows)
513}
514
515/// Load all tracks that have a local path into a HashMap keyed by path.
516/// Used by the playlist builder to skip expensive lofty reads for known files.
517///
518/// For large libraries, prefer `tracks_by_paths()` which only fetches the
519/// tracks you actually need.
520pub fn all_tracks_by_path(
521    conn: &Connection,
522) -> Result<std::collections::HashMap<String, TrackRow>, DbError> {
523    let mut stmt = conn.prepare(
524        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
525                t.disc, t.track_number, t.title, t.duration_ms, t.path,
526                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
527                t.genre, t.source, t.remote_id, t.cached_path
528         FROM tracks t
529         LEFT JOIN artists a ON t.artist_id = a.id
530         LEFT JOIN albums al ON t.album_id = al.id
531         LEFT JOIN artists aa ON al.artist_id = aa.id
532         WHERE t.path IS NOT NULL",
533    )?;
534
535    let rows = stmt
536        .query_map(params![], row_to_track_row)?
537        .collect::<Result<Vec<_>, _>>()?;
538
539    let mut map = std::collections::HashMap::with_capacity(rows.len());
540    for row in rows {
541        if let Some(ref path) = row.path {
542            map.insert(path.clone(), row);
543        }
544    }
545    Ok(map)
546}
547
548/// Load tracks matching a specific set of paths into a HashMap.
549/// Processes in batches of 500 to stay within SQLite variable limits.
550/// For small path sets this is dramatically cheaper than `all_tracks_by_path`.
551pub fn tracks_by_paths(
552    conn: &Connection,
553    paths: &[String],
554) -> Result<std::collections::HashMap<String, TrackRow>, DbError> {
555    const BATCH_SIZE: usize = 500;
556    let mut map = std::collections::HashMap::with_capacity(paths.len());
557
558    for chunk in paths.chunks(BATCH_SIZE) {
559        let placeholders: String = chunk
560            .iter()
561            .enumerate()
562            .map(|(i, _)| {
563                if i == 0 {
564                    "?".to_string()
565                } else {
566                    ",?".to_string()
567                }
568            })
569            .collect();
570
571        let sql = format!(
572            "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
573                    t.disc, t.track_number, t.title, t.duration_ms, t.path,
574                    t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
575                    t.genre, t.source, t.remote_id, t.cached_path
576             FROM tracks t
577             LEFT JOIN artists a ON t.artist_id = a.id
578             LEFT JOIN albums al ON t.album_id = al.id
579             LEFT JOIN artists aa ON al.artist_id = aa.id
580             WHERE t.path IN ({placeholders})"
581        );
582
583        let mut stmt = conn.prepare(&sql)?;
584        let params: Vec<&dyn rusqlite::types::ToSql> = chunk
585            .iter()
586            .map(|s| s as &dyn rusqlite::types::ToSql)
587            .collect();
588        let rows = stmt
589            .query_map(params.as_slice(), row_to_track_row)?
590            .collect::<Result<Vec<_>, _>>()?;
591
592        for row in rows {
593            if let Some(ref path) = row.path {
594                map.insert(path.clone(), row);
595            }
596        }
597    }
598
599    Ok(map)
600}
601
602/// Get all tracks in the library, ordered by artist/album/disc/track.
603pub fn all_tracks(conn: &Connection) -> Result<Vec<TrackRow>, DbError> {
604    let mut stmt = conn.prepare(
605        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
606                t.disc, t.track_number, t.title, t.duration_ms, t.path,
607                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
608                t.genre, t.source, t.remote_id, t.cached_path
609         FROM tracks t
610         LEFT JOIN artists a ON t.artist_id = a.id
611         LEFT JOIN albums al ON t.album_id = al.id
612         LEFT JOIN artists aa ON al.artist_id = aa.id
613         ORDER BY a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number",
614    )?;
615
616    let rows = stmt
617        .query_map(params![], row_to_track_row)?
618        .collect::<Result<Vec<_>, _>>()?;
619
620    Ok(rows)
621}
622
623/// Get random tracks from the library, optionally filtered by artist.
624pub fn random_tracks(
625    conn: &Connection,
626    count: u32,
627    artist_id: Option<i64>,
628) -> Result<Vec<TrackRow>, DbError> {
629    random_tracks_where(
630        conn,
631        count,
632        &RandomFilter {
633            artist_id,
634            ..Default::default()
635        },
636    )
637}
638
639/// What `random_tracks_where` draws from.
640#[derive(Debug, Clone, Copy, Default)]
641pub struct RandomFilter<'a> {
642    /// Tracks credited to this artist or on their albums.
643    pub artist_id: Option<i64>,
644    /// Matched case-insensitively against the track's own tag.
645    pub genre: Option<&'a str>,
646    /// Release year bounds of the track's album, inclusive. Tracks without a
647    /// dated album are left out when either is set.
648    pub year_from: Option<i32>,
649    pub year_to: Option<i32>,
650    /// Track ids never drawn.
651    pub exclude: &'a [i64],
652}
653
654/// `count` random tracks matching `filter`.
655///
656/// The draw orders bare ids and the joins run for the picked rows only, so a
657/// handful from a large library does not join every track to sort it.
658pub fn random_tracks_where(
659    conn: &Connection,
660    count: u32,
661    filter: &RandomFilter,
662) -> Result<Vec<TrackRow>, DbError> {
663    let mut wheres: Vec<String> = Vec::new();
664    let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
665    if let Some(aid) = filter.artist_id {
666        wheres.push(
667            "(r.artist_id = ? OR r.album_id IN (SELECT id FROM albums WHERE artist_id = ?))".into(),
668        );
669        params.push(Box::new(aid));
670        params.push(Box::new(aid));
671    }
672    if let Some(genre) = filter.genre {
673        wheres.push("r.genre = ? COLLATE NOCASE".into());
674        params.push(Box::new(genre.to_owned()));
675    }
676    let year =
677        "(SELECT CAST(substr(ra.date, 1, 4) AS INTEGER) FROM albums ra WHERE ra.id = r.album_id)";
678    if let Some(from) = filter.year_from {
679        wheres.push(format!("{year} >= ?"));
680        params.push(Box::new(from));
681    }
682    if let Some(to) = filter.year_to {
683        wheres.push(format!("{year} <= ?"));
684        params.push(Box::new(to));
685    }
686    if !filter.exclude.is_empty() {
687        wheres.push("r.id NOT IN (SELECT value FROM json_each(?))".into());
688        params.push(Box::new(super::json_list(filter.exclude)));
689    }
690    params.push(Box::new(count));
691    let draw = if wheres.is_empty() {
692        String::new()
693    } else {
694        format!("WHERE {}", wheres.join(" AND "))
695    };
696    let sql = format!(
697        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
698                t.disc, t.track_number, t.title, t.duration_ms, t.path,
699                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
700                t.genre, t.source, t.remote_id, t.cached_path
701         FROM tracks t
702         LEFT JOIN artists a ON t.artist_id = a.id
703         LEFT JOIN albums al ON t.album_id = al.id
704         LEFT JOIN artists aa ON al.artist_id = aa.id
705         WHERE t.id IN (SELECT r.id FROM tracks r {draw} ORDER BY RANDOM() LIMIT ?)
706         ORDER BY RANDOM()"
707    );
708    let mut stmt = conn.prepare(&sql)?;
709    let rows = stmt
710        .query_map(rusqlite::params_from_iter(params.iter()), row_to_track_row)?
711        .collect::<Result<Vec<_>, _>>()?;
712    Ok(rows)
713}
714
715/// Get all tracks with pagination.
716pub fn all_tracks_paged(
717    conn: &Connection,
718    limit: u32,
719    offset: u32,
720) -> Result<Vec<TrackRow>, DbError> {
721    let mut stmt = conn.prepare(
722        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
723                t.disc, t.track_number, t.title, t.duration_ms, t.path,
724                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
725                t.genre, t.source, t.remote_id, t.cached_path
726         FROM tracks t
727         LEFT JOIN artists a ON t.artist_id = a.id
728         LEFT JOIN albums al ON t.album_id = al.id
729         LEFT JOIN artists aa ON al.artist_id = aa.id
730         ORDER BY a.name COLLATE LIBRARY, al.date, al.title COLLATE LIBRARY, t.disc, t.track_number
731         LIMIT ?1 OFFSET ?2",
732    )?;
733    let rows = stmt
734        .query_map(params![limit, offset], row_to_track_row)?
735        .collect::<Result<Vec<_>, _>>()?;
736    Ok(rows)
737}
738
739/// Fetch many tracks in one query, in the order the ids were given, rather
740/// than one round trip per track.
741pub fn tracks_by_ids(conn: &Connection, ids: &[i64]) -> Result<Vec<TrackRow>, DbError> {
742    if ids.is_empty() {
743        return Ok(Vec::new());
744    }
745    let mut stmt = conn.prepare_cached(
746        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
747                t.disc, t.track_number, t.title, t.duration_ms, t.path,
748                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
749                t.genre, t.source, t.remote_id, t.cached_path
750         FROM tracks t
751         LEFT JOIN artists a ON t.artist_id = a.id
752         LEFT JOIN albums al ON t.album_id = al.id
753         LEFT JOIN artists aa ON al.artist_id = aa.id
754         WHERE t.id IN (SELECT value FROM json_each(?1))",
755    )?;
756    let rows = stmt
757        .query_map([super::json_list(ids)], row_to_track_row)?
758        .collect::<Result<Vec<_>, _>>()?;
759
760    // SQL returns them in whatever order it likes; callers care about the order
761    // they asked for, because that is the order they will be queued in.
762    //
763    // Looked up rather than taken: an id asked for twice must come back twice,
764    // or a track queued again, or a playlist holding the same song twice, loses
765    // its second copy.
766    let by_id: HashMap<i64, TrackRow> = rows.into_iter().map(|r| (r.id, r)).collect();
767    Ok(ids.iter().filter_map(|id| by_id.get(id).cloned()).collect())
768}
769
770/// Get a single track by ID with full metadata.
771pub fn get_track_row(conn: &Connection, track_id: i64) -> Result<Option<TrackRow>, DbError> {
772    let result = conn
773        .prepare_cached(
774            "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
775                t.disc, t.track_number, t.title, t.duration_ms, t.path,
776                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
777                t.genre, t.source, t.remote_id, t.cached_path
778         FROM tracks t
779         LEFT JOIN artists a ON t.artist_id = a.id
780         LEFT JOIN albums al ON t.album_id = al.id
781         LEFT JOIN artists aa ON al.artist_id = aa.id
782         WHERE t.id = ?1",
783        )?
784        .query_row(params![track_id], row_to_track_row);
785
786    match result {
787        Ok(row) => Ok(Some(row)),
788        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
789        Err(e) => Err(e.into()),
790    }
791}
792
793/// Look up a track ID by its local file path.
794pub fn track_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>, DbError> {
795    let result = conn
796        .prepare_cached(
797            "SELECT id FROM tracks WHERE path = ?1 OR cached_path = ?1 OR remote_url = ?1",
798        )?
799        .query_row(params![path], |row| row.get(0));
800    match result {
801        Ok(id) => Ok(Some(id)),
802        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
803        Err(e) => Err(e.into()),
804    }
805}
806
807/// Clear all cached_path values (used when purging the download cache).
808pub fn clear_cached_paths(conn: &Connection) -> Result<(), DbError> {
809    // Only the rows that hold a download: an unqualified UPDATE rewrites every
810    // track in the library through the WAL.
811    conn.execute(
812        "UPDATE tracks SET cached_path = NULL, cache_size_bytes = NULL, cache_download_date = NULL,
813                cache_pinned = 0
814         WHERE cached_path IS NOT NULL",
815        params![],
816    )?;
817    Ok(())
818}
819
820/// Where the named tracks were downloaded to, for the ones that were.
821pub fn cached_paths_for(conn: &Connection, track_ids: &[i64]) -> Result<Vec<String>, DbError> {
822    if track_ids.is_empty() {
823        return Ok(Vec::new());
824    }
825    let mut stmt = conn.prepare_cached(
826        "SELECT DISTINCT cached_path FROM tracks
827         WHERE id IN (SELECT value FROM json_each(?1)) AND cached_path IS NOT NULL",
828    )?;
829    let rows = stmt.query_map([super::json_list(track_ids)], |row| row.get(0))?;
830    Ok(rows.filter_map(Result::ok).collect())
831}
832
833/// Forget where the named tracks were downloaded to, and every other track
834/// recorded against the same file: two editions laid out alike share one. The
835/// rows stay: a remote track is still in the library, it just has to be
836/// fetched again to play.
837pub fn clear_cached_paths_for(conn: &Connection, track_ids: &[i64]) -> Result<(), DbError> {
838    if track_ids.is_empty() {
839        return Ok(());
840    }
841    conn.execute(
842        "UPDATE tracks SET cached_path = NULL, cache_size_bytes = NULL, cache_download_date = NULL,
843                cache_pinned = 0
844         WHERE cached_path IN (SELECT cached_path FROM tracks
845                               WHERE id IN (SELECT value FROM json_each(?1)))
846            OR id IN (SELECT value FROM json_each(?1))",
847        [super::json_list(track_ids)],
848    )?;
849    Ok(())
850}
851
852/// Update the cached_path for a track after downloading, recording size and timestamp.
853pub fn set_cached_path(conn: &Connection, track_id: i64, path: &str) -> Result<(), DbError> {
854    let size_bytes: Option<i64> = std::fs::metadata(path).ok().map(|m| m.len() as i64);
855    let now = std::time::SystemTime::now()
856        .duration_since(std::time::UNIX_EPOCH)
857        .unwrap_or_default()
858        .as_secs() as i64;
859    conn.execute(
860        "UPDATE tracks SET cached_path = ?1, cache_size_bytes = ?2, cache_download_date = ?3
861         WHERE id = ?4",
862        params![path, size_bytes, now, track_id],
863    )?;
864    Ok(())
865}
866
867/// Mark these tracks' downloads as asked for, so eviction takes them last.
868/// Tracks with no download are left alone.
869pub fn pin_cached(conn: &Connection, track_ids: &[i64]) -> Result<(), DbError> {
870    if track_ids.is_empty() {
871        return Ok(());
872    }
873    conn.execute(
874        "UPDATE tracks SET cache_pinned = 1
875         WHERE id IN (SELECT value FROM json_each(?1)) AND cached_path IS NOT NULL",
876        [super::json_list(track_ids)],
877    )?;
878    Ok(())
879}
880
881/// One downloaded file eviction may remove.
882#[derive(Debug, Clone)]
883pub struct CachedFile {
884    pub track_id: i64,
885    pub path: String,
886    pub size: i64,
887    pub pinned: bool,
888}
889
890/// Downloaded files in the order eviction takes them: everything fetched to
891/// play before anything downloaded on request, least recently used first
892/// within each. A file whose album has a favourite in it is never listed, nor
893/// is one shared with such a file's track. A file recorded by several tracks
894/// is listed once per track, and the caller removes it once.
895///
896/// A download counts as a use: a record fetched for offline listening has
897/// never been played, and ranking it by plays alone would make it the first to go.
898pub fn cached_files_lru(conn: &Connection) -> Result<Vec<CachedFile>, DbError> {
899    // play_history is pre-aggregated rather than queried per track. Rows with
900    // no recorded use sort first (NULL before any value).
901    let mut stmt = conn.prepare(
902        "SELECT t.id, t.cached_path, COALESCE(t.cache_size_bytes, 0), t.cache_pinned != 0
903         FROM tracks t
904         LEFT JOIN (SELECT track_id, MAX(played_at) as last_play
905                    FROM play_history GROUP BY track_id) ph_max
906                ON ph_max.track_id = t.id
907         WHERE t.cached_path IS NOT NULL
908           AND NOT EXISTS (SELECT 1 FROM tracks s
909                             JOIN favourites f
910                             JOIN tracks ft ON ft.id = f.track_id
911                                           AND (ft.id = s.id OR ft.album_id = s.album_id)
912                           WHERE s.cached_path = t.cached_path)
913         ORDER BY t.cache_pinned != 0,
914                  NULLIF(MAX(COALESCE(ph_max.last_play, 0), COALESCE(t.cache_download_date, 0)), 0),
915                  t.album_id, t.disc, t.track_number",
916    )?;
917    let rows = stmt.query_map([], |row| {
918        Ok(CachedFile {
919            track_id: row.get(0)?,
920            path: row.get(1)?,
921            size: row.get(2)?,
922            pinned: row.get(3)?,
923        })
924    })?;
925    Ok(rows.collect::<Result<_, _>>()?)
926}
927
928/// What downloading each of these tracks would add to the cache, for those
929/// not already in it. Tracks with a library file or no server entry cost
930/// nothing. The server rarely says how large a file is, so the size is
931/// reckoned from its bitrate and length, or from length at CD rate when the
932/// bitrate is missing too.
933pub fn download_estimates(
934    conn: &Connection,
935    track_ids: &[i64],
936) -> Result<HashMap<i64, i64>, DbError> {
937    if track_ids.is_empty() {
938        return Ok(HashMap::new());
939    }
940    let mut stmt = conn.prepare_cached(
941        "SELECT t.id,
942                CASE WHEN EXISTS(SELECT 1 FROM local_files l WHERE l.track_id = t.id) THEN 0
943                     ELSE COALESCE(r.size_bytes, r.bitrate * r.duration_ms / 8,
944                                   r.duration_ms * 1411 / 8, 0)
945                END
946         FROM tracks t
947         JOIN remote_entries r ON r.track_id = t.id
948         WHERE t.id IN (SELECT value FROM json_each(?1)) AND t.cached_path IS NULL",
949    )?;
950    let rows = stmt.query_map([super::json_list(track_ids)], |r| {
951        Ok((r.get(0)?, r.get(1)?))
952    })?;
953    Ok(rows.collect::<Result<_, _>>()?)
954}
955
956/// The recorded downloads' total size, each file once however many tracks
957/// record it.
958pub fn total_cache_size(conn: &Connection) -> Result<i64, DbError> {
959    let size: i64 = conn.query_row(
960        "SELECT COALESCE(SUM(size), 0) FROM
961           (SELECT MAX(cache_size_bytes) AS size FROM tracks
962            WHERE cached_path IS NOT NULL GROUP BY cached_path)",
963        [],
964        |row| row.get(0),
965    )?;
966    Ok(size)
967}
968
969/// Resolve the best playback source for a track. Local > Cached > Remote.
970pub fn resolve_playback_path(
971    conn: &Connection,
972    track_id: i64,
973) -> Result<Option<PlaybackSource>, DbError> {
974    let row = conn
975        .prepare_cached("SELECT path, cached_path, remote_url FROM tracks WHERE id = ?1")?
976        .query_row(params![track_id], |row| {
977            Ok((
978                row.get::<_, Option<String>>(0)?,
979                row.get::<_, Option<String>>(1)?,
980                row.get::<_, Option<String>>(2)?,
981            ))
982        })
983        .optional()?;
984    Ok(row.and_then(|(path, cached_path, remote_url)| {
985        choose_playback_source(
986            path.as_deref(),
987            cached_path.as_deref(),
988            remote_url.as_deref(),
989        )
990    }))
991}
992
993/// The first of a track's copies that is there: its file, then its download,
994/// then its stream.
995pub fn choose_playback_source(
996    path: Option<&str>,
997    cached_path: Option<&str>,
998    remote_url: Option<&str>,
999) -> Option<PlaybackSource> {
1000    if let Some(p) = path.map(PathBuf::from).filter(|p| p.exists()) {
1001        return Some(PlaybackSource::Local(p));
1002    }
1003    if let Some(p) = cached_path.map(PathBuf::from).filter(|p| p.exists()) {
1004        return Some(PlaybackSource::Cached(p));
1005    }
1006    remote_url.map(|url| PlaybackSource::Remote(url.to_owned()))
1007}
1008
1009/// What a queue item needs that a `TrackRow` does not carry.
1010#[derive(Debug, Clone, Default)]
1011pub struct QueueItemExtras {
1012    pub remote_url: Option<String>,
1013    pub album_date: Option<String>,
1014}
1015
1016/// [`QueueItemExtras`] for many tracks, in one query, keyed by track id.
1017pub fn queue_item_extras(
1018    conn: &Connection,
1019    track_ids: &[i64],
1020) -> Result<HashMap<i64, QueueItemExtras>, DbError> {
1021    if track_ids.is_empty() {
1022        return Ok(HashMap::new());
1023    }
1024    let mut stmt = conn.prepare_cached(
1025        "SELECT t.id, t.remote_url, al.date FROM tracks t
1026         LEFT JOIN albums al ON al.id = t.album_id
1027         WHERE t.id IN (SELECT value FROM json_each(?1))",
1028    )?;
1029    let rows = stmt.query_map([super::json_list(track_ids)], |row| {
1030        Ok((
1031            row.get::<_, i64>(0)?,
1032            QueueItemExtras {
1033                remote_url: row.get(1)?,
1034                album_date: row.get(2)?,
1035            },
1036        ))
1037    })?;
1038    rows.collect::<Result<HashMap<_, _>, _>>()
1039        .map_err(Into::into)
1040}
1041
1042/// One page of every track, in id order.
1043///
1044/// What a client walking the whole library pages through. Ordered by the
1045/// primary key, so the page is a range scan rather than a sort, and a track
1046/// added mid-walk lands after the offset rather than shifting every page past
1047/// it.
1048pub fn tracks_page(conn: &Connection, limit: u32, offset: u32) -> Result<Vec<TrackRow>, DbError> {
1049    let mut stmt = conn.prepare_cached(
1050        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1051                t.disc, t.track_number, t.title, t.duration_ms, t.path,
1052                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1053                t.genre, t.source, t.remote_id, t.cached_path
1054         FROM tracks t
1055         LEFT JOIN artists a ON t.artist_id = a.id
1056         LEFT JOIN albums al ON t.album_id = al.id
1057         LEFT JOIN artists aa ON al.artist_id = aa.id
1058         ORDER BY t.id
1059         LIMIT ?1 OFFSET ?2",
1060    )?;
1061    let rows = stmt
1062        .query_map(params![limit, offset], row_to_track_row)?
1063        .collect::<Result<Vec<_>, _>>()?;
1064    Ok(rows)
1065}
1066
1067/// Get tracks for a specific album, ordered by disc/track number.
1068pub fn tracks_for_album(conn: &Connection, album_id: i64) -> Result<Vec<TrackRow>, DbError> {
1069    let mut stmt = conn.prepare_cached(
1070        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1071                t.disc, t.track_number, t.title, t.duration_ms, t.path,
1072                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1073                t.genre, t.source, t.remote_id, t.cached_path
1074         FROM tracks t
1075         LEFT JOIN artists a ON t.artist_id = a.id
1076         LEFT JOIN albums al ON t.album_id = al.id
1077         LEFT JOIN artists aa ON al.artist_id = aa.id
1078         WHERE t.album_id = ?1
1079         ORDER BY t.disc, t.track_number",
1080    )?;
1081
1082    let rows = stmt
1083        .query_map(params![album_id], row_to_track_row)?
1084        .collect::<Result<Vec<_>, _>>()?;
1085
1086    Ok(rows)
1087}
1088
1089/// One track off an album, for anything that only needs a representative.
1090///
1091/// Artwork is the case: every track on a record shares the record's cover, so a
1092/// client wanting it needs any one of them. Asking for the album's tracks and
1093/// picking an id out of the answer is a listing built and carried across a
1094/// boundary to be thrown away — and on a grid of tiles, one of those per tile.
1095///
1096/// Prefers a track with a file, because art can then be read straight out of
1097/// the tag without asking the server at all.
1098pub fn cover_track_for_album(
1099    conn: &Connection,
1100    album_id: i64,
1101) -> Result<Option<TrackRow>, DbError> {
1102    let mut stmt = conn.prepare_cached(
1103        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1104                t.disc, t.track_number, t.title, t.duration_ms, t.path,
1105                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1106                t.genre, t.source, t.remote_id, t.cached_path
1107         FROM tracks t
1108         LEFT JOIN artists a ON t.artist_id = a.id
1109         LEFT JOIN albums al ON t.album_id = al.id
1110         LEFT JOIN artists aa ON al.artist_id = aa.id
1111         WHERE t.album_id = ?1
1112         ORDER BY (t.path IS NULL AND t.cached_path IS NULL), t.disc, t.track_number
1113         LIMIT 1",
1114    )?;
1115
1116    let mut rows = stmt.query_map(params![album_id], row_to_track_row)?;
1117    rows.next().transpose().map_err(Into::into)
1118}
1119
1120/// Get distinct genres for a batch of artist IDs in a single query.
1121/// Returns a map from artist_id → set of lowercased genre strings.
1122pub fn genres_by_artist_ids(
1123    conn: &Connection,
1124    ids: &[i64],
1125) -> Result<HashMap<i64, HashSet<String>>, DbError> {
1126    if ids.is_empty() {
1127        return Ok(HashMap::new());
1128    }
1129    let mut stmt = conn.prepare_cached(
1130        "SELECT t.artist_id, t.genre FROM tracks t
1131         WHERE t.artist_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL
1132         UNION
1133         SELECT al.artist_id, t.genre FROM tracks t
1134         JOIN albums al ON t.album_id = al.id
1135         WHERE al.artist_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL",
1136    )?;
1137    let rows = stmt.query_map([super::json_list(ids)], |row| {
1138        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1139    })?;
1140    let mut map: HashMap<i64, HashSet<String>> = HashMap::new();
1141    for row in rows {
1142        let (artist_id, genre) = row?;
1143        map.entry(artist_id)
1144            .or_default()
1145            .insert(genre.to_lowercase());
1146    }
1147    Ok(map)
1148}
1149
1150/// Get distinct genres for a batch of album IDs in a single query.
1151/// Returns a map from album_id → set of lowercased genre strings.
1152pub fn genres_by_album_ids(
1153    conn: &Connection,
1154    ids: &[i64],
1155) -> Result<HashMap<i64, HashSet<String>>, DbError> {
1156    if ids.is_empty() {
1157        return Ok(HashMap::new());
1158    }
1159    let mut stmt = conn.prepare_cached(
1160        "SELECT t.album_id, t.genre FROM tracks t
1161         WHERE t.album_id IN (SELECT value FROM json_each(?1)) AND t.genre IS NOT NULL",
1162    )?;
1163    let rows = stmt.query_map([super::json_list(ids)], |row| {
1164        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
1165    })?;
1166    let mut map: HashMap<i64, HashSet<String>> = HashMap::new();
1167    for row in rows {
1168        let (album_id, genre) = row?;
1169        map.entry(album_id)
1170            .or_default()
1171            .insert(genre.to_lowercase());
1172    }
1173    Ok(map)
1174}
1175
1176/// The ids of a user's favourited tracks, as a subquery binding the user as
1177/// `user`.
1178///
1179/// Favourites are keyed by path, and a track is reached by any of three: its
1180/// file, its download, or its stream URL. Three indexed lookups rather than one
1181/// join with an `OR` across the three columns: SQLite cannot use an index for
1182/// that `OR`, so it read every track in the library and probed favourites for
1183/// each — fifty milliseconds to find a hundred rows.
1184pub(crate) fn favourite_track_ids_sql(user: &str) -> String {
1185    format!("SELECT track_id FROM favourites WHERE user_id = {user}")
1186}
1187
1188/// Get all artist IDs that have at least one favourited track, in a single query.
1189pub fn favourite_artist_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
1190    let mut stmt = conn.prepare_cached(&format!(
1191        "SELECT DISTINCT artist_id FROM tracks
1192         WHERE artist_id IS NOT NULL AND id IN ({})",
1193        favourite_track_ids_sql("?1")
1194    ))?;
1195    let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
1196        row.get::<_, i64>(0)
1197    })?;
1198    let mut ids = HashSet::new();
1199    for row in rows {
1200        ids.insert(row?);
1201    }
1202    Ok(ids)
1203}
1204
1205/// Get all favourited track IDs in a single query.
1206pub fn favourite_track_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
1207    let mut stmt = conn.prepare_cached(&favourite_track_ids_sql("?1"))?;
1208    let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
1209        row.get::<_, i64>(0)
1210    })?;
1211    let mut ids = HashSet::new();
1212    for row in rows {
1213        ids.insert(row?);
1214    }
1215    Ok(ids)
1216}
1217
1218/// Every favourited track, narrowed by `search` and ordered as a library
1219/// reads: artist, record, then running order.
1220///
1221/// One query rather than a favourite id list the caller resolves row by row —
1222/// which is what the id set is for, and it is not for this.
1223///
1224/// Matched through [`favourite_track_ids_sql`].
1225pub fn favourite_tracks(
1226    conn: &Connection,
1227    user: i64,
1228    search: Option<&str>,
1229) -> Result<Vec<TrackRow>, DbError> {
1230    let mut sql = format!(
1231        "SELECT t.id, t.album_id, t.artist_id, a.name, aa.name, al.title,
1232                t.disc, t.track_number, t.title, t.duration_ms, t.path,
1233                t.codec, t.sample_rate, t.bit_depth, t.channels, t.bitrate,
1234                t.genre, t.source, t.remote_id, t.cached_path
1235         FROM tracks t
1236         LEFT JOIN artists a ON t.artist_id = a.id
1237         LEFT JOIN albums al ON t.album_id = al.id
1238         LEFT JOIN artists aa ON al.artist_id = aa.id
1239         WHERE t.id IN ({})",
1240        favourite_track_ids_sql("?1")
1241    );
1242    let mut params: Vec<Box<dyn rusqlite::ToSql>> =
1243        vec![Box::new(super::auth::resolve_user(conn, user)?)];
1244    if let Some(query) = search {
1245        let pattern = format!("%{}%", super::artists::escape_like(query));
1246        for _ in 0..3 {
1247            params.push(Box::new(pattern.clone()));
1248        }
1249        sql.push_str(
1250            " AND (t.title LIKE ? COLLATE NOCASE ESCAPE '\\'
1251                OR a.name LIKE ? COLLATE NOCASE ESCAPE '\\'
1252                OR al.title LIKE ? COLLATE NOCASE ESCAPE '\\')",
1253        );
1254    }
1255    sql.push_str(
1256        " ORDER BY a.name COLLATE LIBRARY, al.title COLLATE LIBRARY, t.disc, t.track_number",
1257    );
1258
1259    let mut stmt = conn.prepare(&sql)?;
1260    let rows = stmt
1261        .query_map(rusqlite::params_from_iter(params.iter()), row_to_track_row)?
1262        .collect::<Result<Vec<_>, _>>()?;
1263    Ok(rows)
1264}
1265
1266/// Get all album IDs that have at least one favourited track, in a single query.
1267pub fn favourite_album_ids_batch(conn: &Connection, user: i64) -> Result<HashSet<i64>, DbError> {
1268    let mut stmt = conn.prepare_cached(&format!(
1269        "SELECT DISTINCT album_id FROM tracks
1270         WHERE album_id IS NOT NULL AND id IN ({})",
1271        favourite_track_ids_sql("?1")
1272    ))?;
1273    let rows = stmt.query_map([super::auth::resolve_user(conn, user)?], |row| {
1274        row.get::<_, i64>(0)
1275    })?;
1276    let mut ids = HashSet::new();
1277    for row in rows {
1278        ids.insert(row?);
1279    }
1280    Ok(ids)
1281}
1282
1283#[cfg(test)]
1284mod tests {
1285    use super::*;
1286    use crate::db::connection::Database;
1287    use crate::db::queries::{library_stats, sample_meta, search_tracks};
1288
1289    fn test_db() -> Database {
1290        let conn = rusqlite::Connection::open_in_memory().unwrap();
1291        conn.pragma_update(None, "foreign_keys", "on").unwrap();
1292        crate::db::schema::create_tables(&conn).unwrap();
1293        Database { conn }
1294    }
1295
1296    #[test]
1297    fn a_track_lists_each_source_file_first() {
1298        let db = test_db();
1299        let local = sample_meta("Archangel", "Burial", "Untrue");
1300        let id = upsert_track(&db.conn, &local).unwrap();
1301        let mut remote = local.clone();
1302        remote.path = None;
1303        remote.source = "remote".into();
1304        remote.remote_id = Some("song-1".into());
1305        remote.mbid = Some("recording".into());
1306        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id, "one track");
1307
1308        let sources = crate::db::queries::sources_of_track(&db.conn, id).unwrap();
1309        let kinds: Vec<&str> = sources.iter().map(|m| m.source.as_str()).collect();
1310        assert_eq!(kinds, ["local", "remote"]);
1311        assert_eq!(sources[0].path, local.path);
1312        assert_eq!(sources[1].remote_id.as_deref(), Some("song-1"));
1313        assert_eq!(sources[1].mbid.as_deref(), Some("recording"));
1314        assert!(
1315            crate::db::queries::sources_of_track(&db.conn, id + 1)
1316                .unwrap()
1317                .is_empty()
1318        );
1319    }
1320
1321    /// A resync passes every track through the upsert, and nearly all of them
1322    /// are as they were. None of that should reach the disk.
1323    #[test]
1324    fn an_unchanged_track_is_not_rewritten() {
1325        let db = test_db();
1326        let local = sample_meta("Archangel", "Burial", "Untrue");
1327        let mut remote = sample_meta("Near Dark", "Burial", "Untrue");
1328        remote.path = None;
1329        remote.source = "remote".into();
1330        remote.remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e6f".into());
1331        remote.remote_url = Some("https://music.example/rest/stream?id=a".into());
1332        remote.album_remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e70".into());
1333        remote.artist_remote_id = Some("0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e71".into());
1334        remote.album_mbid = Some("release".into());
1335        remote.album_added_at = Some("2026-01-01T00:00:00Z".into());
1336        let seen: HashSet<String> = remote.remote_id.iter().cloned().collect();
1337        upsert_track(&db.conn, &local).unwrap();
1338        upsert_synced_track(&db.conn, &remote, &seen).unwrap();
1339
1340        let before = db.conn.total_changes();
1341        upsert_track(&db.conn, &local).unwrap();
1342        upsert_synced_track(&db.conn, &remote, &seen).unwrap();
1343        assert_eq!(db.conn.total_changes(), before, "nothing was written");
1344        assert_eq!(search_tracks(&db.conn, "Archangel").unwrap().len(), 1);
1345        assert_eq!(search_tracks(&db.conn, "Near Dark").unwrap().len(), 1);
1346    }
1347
1348    #[test]
1349    fn a_new_server_uid_is_inserted_with_the_row() {
1350        let db = test_db();
1351        let uid = "0191d0c4-7c1e-7a2b-9f00-1a2b3c4d5e6f";
1352        let mut meta = sample_meta("Near Dark", "Burial", "Untrue");
1353        meta.path = None;
1354        meta.remote_id = Some(uid.into());
1355        let id = upsert_track(&db.conn, &meta).unwrap();
1356        let stored: String = db
1357            .conn
1358            .query_row("SELECT uid FROM tracks WHERE id = ?1", [id], |r| r.get(0))
1359            .unwrap();
1360        assert_eq!(stored, uid);
1361    }
1362
1363    #[test]
1364    fn a_changed_track_is_still_rewritten() {
1365        let db = test_db();
1366        let mut meta = sample_meta("Archangel", "Burial", "Untrue");
1367        meta.album_added_at = Some("2026-02-01T00:00:00Z".into());
1368        let id = upsert_track(&db.conn, &meta).unwrap();
1369
1370        meta.title = "Archangel (Remastered)".into();
1371        meta.genre = Some("Dubstep".into());
1372        meta.bit_depth = Some(24);
1373        meta.codec = Some("ALAC".into());
1374        meta.album_added_at = Some("2026-01-01T00:00:00Z".into());
1375        assert_eq!(upsert_track(&db.conn, &meta).unwrap(), id);
1376
1377        let (title, genre, bit_depth): (String, String, i32) = db
1378            .conn
1379            .query_row(
1380                "SELECT title, genre, bit_depth FROM tracks WHERE id = ?1",
1381                [id],
1382                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1383            )
1384            .unwrap();
1385        assert_eq!(
1386            (title.as_str(), genre.as_str(), bit_depth),
1387            ("Archangel (Remastered)", "Dubstep", 24)
1388        );
1389        assert_eq!(search_tracks(&db.conn, "Remastered").unwrap().len(), 1);
1390        assert_eq!(search_tracks(&db.conn, "Dubstep").unwrap().len(), 1);
1391        assert!(search_tracks(&db.conn, "Electronic").unwrap().is_empty());
1392        let (codec, added): (String, String) = db
1393            .conn
1394            .query_row("SELECT codec, added_at FROM albums", [], |r| {
1395                Ok((r.get(0)?, r.get(1)?))
1396            })
1397            .unwrap();
1398        assert_eq!(
1399            (codec.as_str(), added.as_str()),
1400            ("ALAC", "2026-01-01T00:00:00Z"),
1401            "the album takes the new codec and the earlier date"
1402        );
1403
1404        meta.album_added_at = Some("2026-03-01T00:00:00Z".into());
1405        upsert_track(&db.conn, &meta).unwrap();
1406        let added: String = db
1407            .conn
1408            .query_row("SELECT added_at FROM albums", [], |r| r.get(0))
1409            .unwrap();
1410        assert_eq!(added, "2026-01-01T00:00:00Z", "a later date does not win");
1411    }
1412
1413    #[test]
1414    fn favourite_tracks_come_back_as_rows_narrowed_by_search() {
1415        use crate::db::queries::toggle_favourite;
1416        let db = test_db();
1417        let amber = upsert_track(&db.conn, &sample_meta("Amber", "Autechre", "Amber")).unwrap();
1418        let mut foil = sample_meta("Foil", "Autechre", "Amber");
1419        foil.track_number = Some(2);
1420        let foil = upsert_track(&db.conn, &foil).unwrap();
1421
1422        let titles = |q| {
1423            favourite_tracks(&db.conn, crate::db::queries::LOCAL_USER, q)
1424                .unwrap()
1425                .into_iter()
1426                .map(|t| t.title)
1427                .collect::<Vec<_>>()
1428        };
1429        assert!(titles(None).is_empty(), "nothing is favourite until it is");
1430
1431        toggle_favourite(&db.conn, crate::db::queries::LOCAL_USER, amber).unwrap();
1432        toggle_favourite(&db.conn, crate::db::queries::LOCAL_USER, foil).unwrap();
1433        assert_eq!(titles(None), ["Amber", "Foil"]);
1434        assert_eq!(titles(Some("foil")), ["Foil"]);
1435        assert_eq!(
1436            titles(Some("autechre")).len(),
1437            2,
1438            "matched on the artist name"
1439        );
1440    }
1441
1442    /// A track belongs to an artist by its own credit or its album's, and a
1443    /// compilation is the case where only the second one holds.
1444    #[test]
1445    fn tracks_for_artist_counts_the_album_credit() {
1446        let db = test_db();
1447        db.conn
1448            .execute_batch(
1449                "INSERT INTO artists (id, name) VALUES (1, 'Aphex Twin'), (2, 'Various');
1450                 INSERT INTO albums (id, title, artist_id) VALUES
1451                   (1, 'Selected Ambient Works', 1), (2, 'Artificial Intelligence', 2);
1452                 -- Own credit, on their own record.
1453                 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
1454                   VALUES (1, 'Xtal', 1, 1, 'local', '/music/xtal.flac');
1455                 -- Own credit, on somebody else's compilation.
1456                 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
1457                   VALUES (2, 'Polygon Window', 1, 2, 'local', '/music/polygon.flac');
1458                 -- Album credit only: uncredited track on their record.
1459                 INSERT INTO tracks (id, title, album_id, source, path)
1460                   VALUES (3, 'Untitled', 1, 'local', '/music/untitled.flac');
1461                 -- Neither.
1462                 INSERT INTO tracks (id, title, artist_id, album_id, source, path)
1463                   VALUES (4, 'The Clan Call', 2, 2, 'local', '/music/clan.flac');",
1464            )
1465            .unwrap();
1466
1467        let mut ids = tracks_for_artist(&db.conn, 1)
1468            .unwrap()
1469            .into_iter()
1470            .map(|t| t.id)
1471            .collect::<Vec<_>>();
1472        ids.sort();
1473        assert_eq!(ids, [1, 2, 3]);
1474    }
1475
1476    #[test]
1477    fn an_id_asked_for_twice_comes_back_twice() {
1478        let db = test_db();
1479        let a = upsert_track(&db.conn, &sample_meta("One", "Artist", "Album")).unwrap();
1480        let b = upsert_track(&db.conn, &sample_meta("Two", "Artist", "Album")).unwrap();
1481
1482        let rows = tracks_by_ids(&db.conn, &[a, b, a]).unwrap();
1483        assert_eq!(
1484            rows.iter().map(|r| r.id).collect::<Vec<_>>(),
1485            vec![a, b, a],
1486            "a queue may hold the same track twice, and a playlist certainly may"
1487        );
1488    }
1489
1490    #[test]
1491    fn test_upsert_track() {
1492        let db = test_db();
1493        let meta = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
1494        let id1 = upsert_track(&db.conn, &meta).unwrap();
1495
1496        // Same path → same track ID (upsert).
1497        let id2 = upsert_track(&db.conn, &meta).unwrap();
1498        assert_eq!(id1, id2);
1499
1500        let stats = library_stats(&db.conn).unwrap();
1501        assert_eq!(stats.total_tracks, 1);
1502        assert_eq!(stats.local_tracks, 1);
1503    }
1504
1505    #[test]
1506    fn test_dedup_keeps_discs_apart() {
1507        let db = test_db();
1508
1509        // A 2-CD box set: same album, same title, both track 1, differing only in disc.
1510        let mut cd1 = sample_meta("Overture", "Wagner", "Ring Cycle");
1511        cd1.disc = Some(1);
1512        cd1.path = Some("/music/Ring Cycle/CD1/01 - Overture.flac".into());
1513        let mut cd2 = cd1.clone();
1514        cd2.disc = Some(2);
1515        cd2.path = Some("/music/Ring Cycle/CD2/01 - Overture.flac".into());
1516
1517        let id1 = upsert_track(&db.conn, &cd1).unwrap();
1518        let id2 = upsert_track(&db.conn, &cd2).unwrap();
1519
1520        assert_ne!(id1, id2, "discs 1 and 2 must not collapse into one row");
1521        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1522
1523        let paths: Vec<String> = db
1524            .conn
1525            .prepare("SELECT path FROM tracks ORDER BY disc")
1526            .unwrap()
1527            .query_map([], |row| row.get(0))
1528            .unwrap()
1529            .map(|r| r.unwrap())
1530            .collect();
1531        assert_eq!(paths, vec![cd1.path.unwrap(), cd2.path.unwrap()]);
1532    }
1533
1534    #[test]
1535    fn test_dedup_never_merges_two_local_files() {
1536        let db = test_db();
1537
1538        // Identical tags including disc — two files on disk are two tracks.
1539        let mut a = sample_meta("Intro", "Various", "Compilation");
1540        a.path = Some("/music/Compilation/a.flac".into());
1541        let mut b = a.clone();
1542        b.path = Some("/music/Compilation/b.flac".into());
1543
1544        let id_a = upsert_track(&db.conn, &a).unwrap();
1545        let id_b = upsert_track(&db.conn, &b).unwrap();
1546
1547        assert_ne!(id_a, id_b);
1548        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1549    }
1550
1551    #[test]
1552    fn test_dedup_never_merges_two_remote_entries() {
1553        let db = test_db();
1554
1555        // Two entries on the same server, no disc reported — identical but for
1556        // their remote ids. Strategy 2 misses, and strategy 3 must not catch them.
1557        let mut first = sample_meta("Untitled", "Artist", "Album");
1558        first.source = "remote".into();
1559        first.path = None;
1560        first.disc = None;
1561        first.remote_id = Some("sub-1".into());
1562        let mut second = first.clone();
1563        second.remote_id = Some("sub-2".into());
1564
1565        let id1 = upsert_track(&db.conn, &first).unwrap();
1566        let id2 = upsert_track(&db.conn, &second).unwrap();
1567
1568        assert_ne!(
1569            id1, id2,
1570            "two server entries must not collapse into one row"
1571        );
1572        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1573    }
1574
1575    fn uid_of(db: &Database, table: &str, id: i64) -> String {
1576        db.conn
1577            .query_row(
1578                &format!("SELECT uid FROM {table} WHERE id = ?1"),
1579                [id],
1580                |r| r.get(0),
1581            )
1582            .unwrap()
1583    }
1584
1585    #[test]
1586    fn a_track_synced_from_koan_takes_the_servers_uids() {
1587        let db = test_db();
1588        let [track, album, artist] = [(); 3].map(|_| uuid::Uuid::now_v7().to_string());
1589        let mut meta = remote_meta("Archangel", "Burial", "Untrue", &track);
1590        meta.album_remote_id = Some(album.clone());
1591        meta.artist_remote_id = Some(artist.clone());
1592
1593        let id = upsert_track(&db.conn, &meta).unwrap();
1594
1595        let row = get_track_row(&db.conn, id).unwrap().unwrap();
1596        assert_eq!(uid_of(&db, "tracks", id), track);
1597        assert_eq!(uid_of(&db, "albums", row.album_id.unwrap()), album);
1598        assert_eq!(uid_of(&db, "artists", row.artist_id.unwrap()), artist);
1599    }
1600
1601    #[test]
1602    fn a_local_file_merged_with_its_koan_copy_takes_the_servers_uid() {
1603        let db = test_db();
1604        let local = upsert_track(&db.conn, &sample_meta("Archangel", "Burial", "Untrue")).unwrap();
1605        let minted = uid_of(&db, "tracks", local);
1606
1607        let server = uuid::Uuid::now_v7().to_string();
1608        let merged = upsert_track(
1609            &db.conn,
1610            &remote_meta("Archangel", "Burial", "Untrue", &server),
1611        )
1612        .unwrap();
1613
1614        assert_eq!(merged, local);
1615        assert_ne!(minted, server);
1616        assert_eq!(uid_of(&db, "tracks", local), server);
1617    }
1618
1619    #[test]
1620    fn ids_from_other_servers_leave_the_minted_uid() {
1621        let db = test_db();
1622        for remote_id in [
1623            "3xJ9kQ2pZ",
1624            "42",
1625            "0f8fad5b-d9cb-469f-a165-70867728950e",
1626            "018f8fad5bd9cb769fa16570867728950e",
1627        ] {
1628            let id = upsert_track(
1629                &db.conn,
1630                &remote_meta(remote_id, "Burial", "Untrue", remote_id),
1631            )
1632            .unwrap();
1633            let uid = uid_of(&db, "tracks", id);
1634            assert_ne!(uid, remote_id);
1635            assert!(super::super::is_uid(&uid), "{uid}");
1636        }
1637    }
1638
1639    /// The remote copy of a track: no path, a remote id, correct tags.
1640    fn remote_meta(title: &str, artist: &str, album: &str, remote_id: &str) -> TrackMeta {
1641        let mut meta = sample_meta(title, artist, album);
1642        meta.source = "remote".into();
1643        meta.path = None;
1644        meta.remote_id = Some(remote_id.into());
1645        meta.remote_url = Some(format!("https://server/rest/stream?id={remote_id}"));
1646        meta.sample_rate = None;
1647        meta.bit_depth = None;
1648        meta
1649    }
1650
1651    #[test]
1652    fn test_corrected_tags_remerge_with_the_remote_copy() {
1653        let db = test_db();
1654
1655        // The file as first indexed: ID3v1 truncated the title and the album, and
1656        // the track number never made it. Nothing about it can content-match.
1657        let mut bad = sample_meta(
1658            "Golden Skans (David E Sugar R",
1659            "Klaxons",
1660            "Golden Skans (David E Sugar R",
1661        );
1662        bad.path = Some("/music/klaxons/01.mp3".into());
1663        bad.track_number = None;
1664        let local_id = upsert_track(&db.conn, &bad).unwrap();
1665
1666        let remote = remote_meta(
1667            "Golden Skans (David E Sugar Remix)",
1668            "Klaxons",
1669            "Golden Skans (David E Sugar Remix)",
1670            "sub-42",
1671        );
1672        let remote_id = upsert_track(&db.conn, &remote).unwrap();
1673        assert_ne!(local_id, remote_id, "bad tags cannot content-match");
1674        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1675
1676        // Tags read correctly this time. The path still matches, so strategy 1
1677        // wins — the re-merge is what has to notice the remote copy.
1678        let mut fixed = bad.clone();
1679        fixed.title = "Golden Skans (David E Sugar Remix)".into();
1680        fixed.album = "Golden Skans (David E Sugar Remix)".into();
1681        fixed.track_number = Some(1);
1682        let merged = upsert_track(&db.conn, &fixed).unwrap();
1683
1684        assert_eq!(merged, local_id, "the row holding the file survives");
1685        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
1686
1687        let row = get_track_row(&db.conn, merged).unwrap().unwrap();
1688        assert_eq!(row.path.as_deref(), Some("/music/klaxons/01.mp3"));
1689        assert_eq!(row.remote_id.as_deref(), Some("sub-42"));
1690        assert_eq!(row.source, "local");
1691        assert_eq!(row.sample_rate, Some(44100), "local audio properties kept");
1692
1693        // The album the bad tags invented goes with it.
1694        let albums: Vec<String> = db
1695            .conn
1696            .prepare("SELECT title FROM albums")
1697            .unwrap()
1698            .query_map([], |row| row.get(0))
1699            .unwrap()
1700            .map(|r| r.unwrap())
1701            .collect();
1702        assert_eq!(albums, vec!["Golden Skans (David E Sugar Remix)"]);
1703    }
1704
1705    #[test]
1706    fn test_remerge_carries_history_and_lyrics_across() {
1707        let db = test_db();
1708
1709        let mut bad = sample_meta("Untitled", "Boards of Canada", "Geogaddi");
1710        bad.path = Some("/music/boc/05.flac".into());
1711        let local_id = upsert_track(&db.conn, &bad).unwrap();
1712
1713        let remote = remote_meta("Sunshine Recorder", "Boards of Canada", "Geogaddi", "sub-7");
1714        let remote_id = upsert_track(&db.conn, &remote).unwrap();
1715
1716        // Both rows have been played, and only the remote one has lyrics.
1717        crate::db::queries::record_play(
1718            &db.conn,
1719            crate::db::queries::LOCAL_USER,
1720            local_id,
1721            Some(1_000),
1722        )
1723        .unwrap();
1724        crate::db::queries::record_play(
1725            &db.conn,
1726            crate::db::queries::LOCAL_USER,
1727            remote_id,
1728            Some(2_000),
1729        )
1730        .unwrap();
1731        crate::db::queries::cache_lyrics(&db.conn, remote_id, "lrclib", true, "[00:01.00] la")
1732            .unwrap();
1733
1734        let mut fixed = bad.clone();
1735        fixed.title = "Sunshine Recorder".into();
1736        let merged = upsert_track(&db.conn, &fixed).unwrap();
1737        assert_eq!(merged, local_id);
1738
1739        assert_eq!(
1740            crate::db::queries::play_count(&db.conn, crate::db::queries::LOCAL_USER, merged)
1741                .unwrap(),
1742            2,
1743            "both rows' plays were plays of this track"
1744        );
1745        assert!(
1746            crate::db::queries::get_cached_lyrics(&db.conn, merged)
1747                .unwrap()
1748                .is_some()
1749        );
1750        let orphans: i64 = db
1751            .conn
1752            .query_row(
1753                "SELECT COUNT(*) FROM play_history WHERE track_id NOT IN (SELECT id FROM tracks)",
1754                [],
1755                |row| row.get(0),
1756            )
1757            .unwrap();
1758        assert_eq!(orphans, 0);
1759    }
1760
1761    #[test]
1762    fn test_remerge_keeps_playlist_entries() {
1763        let db = test_db();
1764
1765        let mut bad = sample_meta("Untitled", "Boards of Canada", "Geogaddi");
1766        bad.path = Some("/music/boc/05.flac".into());
1767        let local_id = upsert_track(&db.conn, &bad).unwrap();
1768        let remote = remote_meta("Sunshine Recorder", "Boards of Canada", "Geogaddi", "sub-7");
1769        let remote_id = upsert_track(&db.conn, &remote).unwrap();
1770
1771        let list = crate::db::queries::create_playlist(
1772            &db.conn,
1773            crate::db::queries::LOCAL_USER,
1774            "Road trip",
1775            None,
1776        )
1777        .unwrap();
1778        let entry = crate::db::queries::add_tracks(&db.conn, list, &[remote_id]).unwrap()[0];
1779
1780        let mut fixed = bad.clone();
1781        fixed.title = "Sunshine Recorder".into();
1782        let merged = upsert_track(&db.conn, &fixed).unwrap();
1783        assert_eq!(merged, local_id);
1784
1785        let entries = crate::db::queries::playlist_entries(&db.conn, list).unwrap();
1786        assert_eq!(
1787            entries.len(),
1788            1,
1789            "the merge left the playlist entry in place"
1790        );
1791        assert_eq!((entries[0].id, entries[0].track.id), (entry, merged));
1792    }
1793
1794    #[test]
1795    fn test_remerge_never_folds_two_local_files() {
1796        let db = test_db();
1797
1798        let mut first = sample_meta("Intro", "Various", "Compilation");
1799        first.path = Some("/music/comp/a.flac".into());
1800        let mut second = first.clone();
1801        second.title = "Untitled".into();
1802        second.path = Some("/music/comp/b.flac".into());
1803
1804        let id_a = upsert_track(&db.conn, &first).unwrap();
1805        let id_b = upsert_track(&db.conn, &second).unwrap();
1806
1807        // b's tags are corrected into an exact match for a. Two files on disk are
1808        // still two tracks.
1809        second.title = "Intro".into();
1810        assert_eq!(upsert_track(&db.conn, &second).unwrap(), id_b);
1811        assert_ne!(id_a, id_b);
1812        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
1813    }
1814
1815    #[test]
1816    fn test_remerge_folds_a_renamed_remote_entry_into_the_local_file() {
1817        let db = test_db();
1818
1819        let local = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker");
1820        let local_id = upsert_track(&db.conn, &local).unwrap();
1821
1822        // The server first reported the title wrong, so the sync made its own row.
1823        let mut remote = remote_meta("Windowlickr", "Aphex Twin", "Windowlicker", "sub-1");
1824        let remote_id = upsert_track(&db.conn, &remote).unwrap();
1825        assert_ne!(local_id, remote_id);
1826
1827        // The server's metadata is fixed; strategy 2 matches its own row, and the
1828        // re-merge has to spot the local file it now describes.
1829        remote.title = "Windowlicker".into();
1830        let merged = upsert_track(&db.conn, &remote).unwrap();
1831
1832        assert_eq!(
1833            merged, local_id,
1834            "the older row survives, with its id and history"
1835        );
1836        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
1837        let row = get_track_row(&db.conn, merged).unwrap().unwrap();
1838        assert_eq!(row.path, local.path);
1839        assert_eq!(row.remote_id.as_deref(), Some("sub-1"));
1840        assert_eq!(row.source, "local");
1841    }
1842
1843    #[test]
1844    fn test_force_remove_lifts_the_fraction_brake_only() {
1845        let db = test_db();
1846        for i in 0..STALE_CHECK_MIN_ROWS + 20 {
1847            let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
1848            meta.track_number = Some(i as i32);
1849            meta.path = Some(format!("/music/Album/{}.flac", i));
1850            upsert_track(&db.conn, &meta).unwrap();
1851        }
1852        let total = library_stats(&db.conn).unwrap().total_tracks as usize;
1853
1854        let removed = remove_stale_tracks(&db.conn, Path::new("/music"), true).unwrap();
1855        assert_eq!(removed.len(), total, "every missing file should go");
1856        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
1857        assert!(
1858            removed.iter().all(|p| p.starts_with("/music/Album/")),
1859            "the removed paths should be reported back"
1860        );
1861    }
1862
1863    #[test]
1864    fn test_remote_upsert_preserves_local_audio_properties() {
1865        let db = test_db();
1866
1867        // Local scan: full audio properties.
1868        let local = sample_meta("Song", "Artist", "Album");
1869        let id = upsert_track(&db.conn, &local).unwrap();
1870
1871        // Remote sync knows the codec suffix and nothing else about the file.
1872        let mut remote = sample_meta("Song", "Artist", "Album");
1873        remote.source = "remote".into();
1874        remote.path = None;
1875        remote.remote_id = Some("sub-1".into());
1876        remote.sample_rate = None;
1877        remote.bit_depth = None;
1878        remote.channels = None;
1879        remote.size_bytes = None;
1880        remote.mtime = None;
1881        remote.codec = None;
1882        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
1883
1884        let codec: Option<String> = db
1885            .conn
1886            .query_row("SELECT codec FROM tracks WHERE id = ?1", params![id], |r| {
1887                r.get(0)
1888            })
1889            .unwrap();
1890        let num = |col: &str| -> Option<i64> {
1891            db.conn
1892                .query_row(
1893                    &format!("SELECT {} FROM tracks WHERE id = ?1", col),
1894                    params![id],
1895                    |r| r.get(0),
1896                )
1897                .unwrap()
1898        };
1899
1900        assert_eq!(codec.as_deref(), Some("FLAC"));
1901        assert_eq!(num("sample_rate"), Some(44100));
1902        assert_eq!(num("bit_depth"), Some(16));
1903        assert_eq!(num("channels"), Some(2));
1904        assert_eq!(num("size_bytes"), Some(30_000_000));
1905        assert_eq!(num("mtime"), Some(1700000000));
1906    }
1907
1908    #[test]
1909    fn test_dedup_matches_across_differing_artist_credits() {
1910        let db = test_db();
1911
1912        // Local tags name the band. Navidrome hands back the same recording with
1913        // every contributor spliced onto the credit, so the two used to land as
1914        // separate artists and therefore separate tracks on one album page.
1915        let local = sample_meta("Treading Water", "Petrol Girls", "Talk of Violence");
1916        let id = upsert_track(&db.conn, &local).unwrap();
1917
1918        let mut remote = sample_meta(
1919            "Treading Water",
1920            "Petrol Girls • Ren Aldridge",
1921            "Talk of Violence",
1922        );
1923        remote.album_artist = Some("Petrol Girls".into());
1924        remote.source = "remote".into();
1925        remote.path = None;
1926        remote.remote_id = Some("sub-1".into());
1927
1928        assert_eq!(
1929            upsert_track(&db.conn, &remote).unwrap(),
1930            id,
1931            "one recording, however each source spells the credit"
1932        );
1933
1934        let rows: i64 = db
1935            .conn
1936            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
1937            .unwrap();
1938        assert_eq!(rows, 1, "the album page must not show the track twice");
1939    }
1940
1941    #[test]
1942    fn test_dedup_reads_disc_zero_as_no_disc() {
1943        let db = test_db();
1944
1945        // The file says disc 0; the server leaves the field out.
1946        let mut local = sample_meta(
1947            "Miss Broadway (Main Version)",
1948            "Glass Candy",
1949            "Miss Broadway",
1950        );
1951        local.disc = Some(0);
1952        let id = upsert_track(&db.conn, &local).unwrap();
1953
1954        let mut remote = remote_meta(
1955            "Miss Broadway (Main Version)",
1956            "Glass Candy",
1957            "Miss Broadway",
1958            "sub-1",
1959        );
1960        remote.disc = None;
1961        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
1962
1963        let disc: Option<i32> = db
1964            .conn
1965            .query_row("SELECT disc FROM tracks WHERE id = ?1", params![id], |r| {
1966                r.get(0)
1967            })
1968            .unwrap();
1969        assert_eq!(disc, None);
1970    }
1971
1972    #[test]
1973    fn test_migration_folds_tracks_split_by_a_zero_disc() {
1974        let db = test_db();
1975
1976        let local = sample_meta("Sumo", "Simtek", "In The Face EP");
1977        let winner = upsert_track(&db.conn, &local).unwrap();
1978        db.conn
1979            .execute("UPDATE tracks SET disc = 0 WHERE id = ?1", params![winner])
1980            .unwrap();
1981        db.conn
1982            .execute(
1983                "INSERT INTO tracks (album_id, artist_id, disc, track_number, title,
1984                                     source, remote_id, remote_url)
1985                 SELECT album_id, artist_id, NULL, track_number, title,
1986                        'remote', 'sub-4', 'http://server/4'
1987                   FROM tracks WHERE id = ?1",
1988                params![winner],
1989            )
1990            .unwrap();
1991
1992        clear_zero_discs(&db.conn).unwrap();
1993        sources::build_from_tracks(&db.conn).unwrap();
1994
1995        let (rows, remote_id): (i64, Option<String>) = db
1996            .conn
1997            .query_row("SELECT COUNT(*), MAX(remote_id) FROM tracks", [], |r| {
1998                Ok((r.get(0)?, r.get(1)?))
1999            })
2000            .unwrap();
2001        assert_eq!(rows, 1);
2002        assert_eq!(remote_id.as_deref(), Some("sub-4"));
2003    }
2004
2005    /// A track as tagged by Picard: recording and release ids alongside the names.
2006    fn with_ids(mut meta: TrackMeta, recording: &str, release: &str) -> TrackMeta {
2007        meta.mbid = Some(recording.into());
2008        meta.album_mbid = Some(release.into());
2009        meta
2010    }
2011
2012    fn track_count(db: &Database) -> i64 {
2013        db.conn
2014            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2015            .unwrap()
2016    }
2017
2018    #[test]
2019    fn test_dedup_matches_by_musicbrainz_ids_whatever_the_album_is_called() {
2020        let db = test_db();
2021
2022        // Navidrome appends the release's disambiguation to the album name.
2023        let local = with_ids(
2024            sample_meta(
2025                "Hypnotized",
2026                "Oliver Koletzki",
2027                "Renaissance: The Mix Collection",
2028            ),
2029            "rec-1",
2030            "rel-1",
2031        );
2032        let id = upsert_track(&db.conn, &local).unwrap();
2033
2034        let mut remote = with_ids(
2035            remote_meta(
2036                "Hypnotized",
2037                "Oliver Koletzki",
2038                "Renaissance: The Mix Collection (Unmixed)",
2039                "sub-1",
2040            ),
2041            "rec-1",
2042            "rel-1",
2043        );
2044        remote.disc = None;
2045        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
2046        assert_eq!(track_count(&db), 1);
2047    }
2048
2049    #[test]
2050    fn test_dedup_keeps_a_recording_apart_across_releases() {
2051        let db = test_db();
2052
2053        // The same recording on the album and on a compilation is two tracks.
2054        let local = with_ids(
2055            sample_meta("Azure", "Paul Kalkbrenner", "Album"),
2056            "rec-1",
2057            "rel-1",
2058        );
2059        let first = upsert_track(&db.conn, &local).unwrap();
2060
2061        let remote = with_ids(
2062            remote_meta("Azure", "Paul Kalkbrenner", "Compilation", "sub-1"),
2063            "rec-1",
2064            "rel-2",
2065        );
2066        assert_ne!(upsert_track(&db.conn, &remote).unwrap(), first);
2067    }
2068
2069    #[test]
2070    fn test_dedup_by_musicbrainz_ids_picks_the_slot_when_a_release_repeats_a_recording() {
2071        let db = test_db();
2072
2073        // A mixed disc and an unmixed disc can carry one recording each.
2074        let mut first = with_ids(
2075            sample_meta("Azure", "Paul Kalkbrenner", "Mixes"),
2076            "rec-1",
2077            "rel-1",
2078        );
2079        first.disc = Some(1);
2080        let first = upsert_track(&db.conn, &first).unwrap();
2081        let mut second = with_ids(
2082            sample_meta("Azure", "Paul Kalkbrenner", "Mixes"),
2083            "rec-1",
2084            "rel-1",
2085        );
2086        second.disc = Some(2);
2087        second.path = Some("/music/Mixes/2-01 Azure.flac".into());
2088        let second = upsert_track(&db.conn, &second).unwrap();
2089
2090        let mut remote = with_ids(
2091            remote_meta("Azure", "Paul Kalkbrenner", "Mixes (Unmixed)", "sub-1"),
2092            "rec-1",
2093            "rel-1",
2094        );
2095        remote.disc = Some(2);
2096        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), second);
2097        assert_ne!(first, second);
2098    }
2099
2100    #[test]
2101    fn test_rescan_with_musicbrainz_ids_folds_an_already_split_pair() {
2102        let db = test_db();
2103
2104        // Scanned before koan read the ids: nothing tied the two rows together.
2105        let local = sample_meta("Hypnotized", "Oliver Koletzki", "The Mix Collection");
2106        let id = upsert_track(&db.conn, &local).unwrap();
2107        let remote = with_ids(
2108            remote_meta(
2109                "Hypnotized",
2110                "Oliver Koletzki",
2111                "The Mix Collection (Unmixed)",
2112                "sub-1",
2113            ),
2114            "rec-1",
2115            "rel-1",
2116        );
2117        upsert_track(&db.conn, &remote).unwrap();
2118        assert_eq!(track_count(&db), 2);
2119
2120        assert_eq!(
2121            upsert_track(&db.conn, &with_ids(local, "rec-1", "rel-1")).unwrap(),
2122            id
2123        );
2124        assert_eq!(track_count(&db), 1);
2125        let remote_id: Option<String> = db
2126            .conn
2127            .query_row(
2128                "SELECT remote_id FROM tracks WHERE id = ?1",
2129                params![id],
2130                |r| r.get(0),
2131            )
2132            .unwrap();
2133        assert_eq!(remote_id.as_deref(), Some("sub-1"));
2134        let albums: i64 = db
2135            .conn
2136            .query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))
2137            .unwrap();
2138        assert_eq!(albums, 1, "the server's album goes with its last track");
2139    }
2140
2141    #[test]
2142    fn test_dedup_without_a_track_number_still_needs_the_artist() {
2143        let db = test_db();
2144
2145        // No track number means no position on the release, and the artist is
2146        // then the only thing separating two different recordings that share a
2147        // title. Step 4 declines rather than guess.
2148        let mut local = sample_meta("Untitled", "One", "Split");
2149        local.album_artist = Some("Various Artists".into());
2150        local.track_number = None;
2151        let first = upsert_track(&db.conn, &local).unwrap();
2152
2153        let mut remote = sample_meta("Untitled", "Two", "Split");
2154        remote.album_artist = Some("Various Artists".into());
2155        remote.track_number = None;
2156        remote.source = "remote".into();
2157        remote.path = None;
2158        remote.remote_id = Some("sub-1".into());
2159        let second = upsert_track(&db.conn, &remote).unwrap();
2160
2161        assert_ne!(first, second, "different artists, no slot to match on");
2162    }
2163
2164    #[test]
2165    fn test_migration_folds_tracks_split_by_an_artist_credit() {
2166        let db = test_db();
2167
2168        // The state an older dedup key left behind: one recording, two rows,
2169        // because the server names contributors the local tags do not. A sync
2170        // matches the remote row by its own id, so only the migration can pair
2171        // them back up.
2172        let local = sample_meta("Rewild", "Petrol Girls", "Talk of Violence");
2173        let winner = upsert_track(&db.conn, &local).unwrap();
2174
2175        let mut remote = sample_meta("Rewild", "Petrol Girls • Ren Aldridge", "Talk of Violence");
2176        remote.album_artist = Some("Petrol Girls".into());
2177        remote.source = "remote".into();
2178        remote.path = None;
2179        remote.remote_id = Some("sub-9".into());
2180        db.conn
2181            .execute(
2182                "INSERT INTO tracks (album_id, artist_id, disc, track_number, title,
2183                                     duration_ms, source, remote_id, remote_url)
2184                 SELECT album_id, artist_id, disc, track_number, title, duration_ms,
2185                        'remote', 'sub-9', 'http://server/9'
2186                   FROM tracks WHERE id = ?1",
2187                params![winner],
2188            )
2189            .unwrap();
2190        let loser: i64 = db.conn.last_insert_rowid();
2191        assert_ne!(loser, winner);
2192
2193        sources::build_from_tracks(&db.conn).unwrap();
2194
2195        let rows: i64 = db
2196            .conn
2197            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2198            .unwrap();
2199        assert_eq!(rows, 1, "the pair collapses to one row");
2200
2201        let (path, remote_id): (Option<String>, Option<String>) = db
2202            .conn
2203            .query_row(
2204                "SELECT path, remote_id FROM tracks WHERE id = ?1",
2205                params![winner],
2206                |r| Ok((r.get(0)?, r.get(1)?)),
2207            )
2208            .unwrap();
2209        assert!(path.is_some(), "the local row survives with its file");
2210        assert_eq!(
2211            remote_id.as_deref(),
2212            Some("sub-9"),
2213            "and inherits how the server knows it"
2214        );
2215    }
2216
2217    #[test]
2218    fn test_migration_leaves_an_ambiguous_pair_alone() {
2219        let db = test_db();
2220
2221        // Two rows that both carry a path are two files, whatever the tags say.
2222        let first = upsert_track(&db.conn, &sample_meta("Rewild", "A", "Album")).unwrap();
2223        let mut second = sample_meta("Rewild", "A", "Album");
2224        second.path = Some("/music/Album/Rewild (alt).flac".into());
2225        let second = upsert_track(&db.conn, &second).unwrap();
2226        assert_ne!(first, second);
2227
2228        sources::build_from_tracks(&db.conn).unwrap();
2229
2230        let rows: i64 = db
2231            .conn
2232            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
2233            .unwrap();
2234        assert_eq!(rows, 2, "two files stay two tracks");
2235    }
2236
2237    #[test]
2238    fn test_upsert_does_not_repoint_at_a_different_live_file() {
2239        let db = test_db();
2240        let tmp = tempfile::tempdir().unwrap();
2241        let existing = tmp.path().join("original.flac");
2242        std::fs::write(&existing, b"x").unwrap();
2243
2244        let mut first = sample_meta("Song", "Artist", "Album");
2245        first.path = Some(existing.to_string_lossy().into_owned());
2246        let id = upsert_track(&db.conn, &first).unwrap();
2247
2248        // A remote_id match carrying a different path must not steal the row from
2249        // a file that is still on disk.
2250        let mut second = first.clone();
2251        second.path = Some(tmp.path().join("other.flac").to_string_lossy().into_owned());
2252        second.remote_id = None;
2253        db.conn
2254            .execute(
2255                "UPDATE tracks SET remote_id = 'r1' WHERE id = ?1",
2256                params![id],
2257            )
2258            .unwrap();
2259        second.remote_id = Some("r1".into());
2260        assert_eq!(upsert_track(&db.conn, &second).unwrap(), id);
2261
2262        let path: String = db
2263            .conn
2264            .query_row("SELECT path FROM tracks WHERE id = ?1", params![id], |r| {
2265                r.get(0)
2266            })
2267            .unwrap();
2268        assert_eq!(path, existing.to_string_lossy());
2269    }
2270
2271    #[test]
2272    fn test_stale_removal_clears_all_foreign_keys() {
2273        let db = test_db();
2274        let id = upsert_track(&db.conn, &sample_meta("Gone", "Artist", "Album")).unwrap();
2275
2276        db.conn
2277            .execute(
2278                "INSERT INTO lyrics_cache (track_id, source, content, fetched_at)
2279                 VALUES (?1, 'lrclib', 'la la', 1)",
2280                params![id],
2281            )
2282            .unwrap();
2283        db.conn
2284            .execute(
2285                "INSERT INTO play_history (track_id, played_at) VALUES (?1, 1)",
2286                params![id],
2287            )
2288            .unwrap();
2289        crate::db::queries::update_scan_cache(&db.conn, "/music/Album/Gone.flac", 1, 2, id)
2290            .unwrap();
2291
2292        assert_eq!(
2293            remove_stale_tracks(&db.conn, Path::new("/music"), false)
2294                .unwrap()
2295                .len(),
2296            1
2297        );
2298        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
2299    }
2300
2301    #[test]
2302    fn test_stale_removal_survives_orphaned_scan_cache_row() {
2303        let db = test_db();
2304        let id = upsert_track(&db.conn, &sample_meta("Gone", "Artist", "Album")).unwrap();
2305
2306        // A scan_cache row left behind under a path the track no longer has.
2307        crate::db::queries::update_scan_cache(&db.conn, "/music/Album/old-name.flac", 1, 2, id)
2308            .unwrap();
2309
2310        assert_eq!(
2311            remove_stale_tracks(&db.conn, Path::new("/music"), false)
2312                .unwrap()
2313                .len(),
2314            1
2315        );
2316        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
2317
2318        let orphans: i64 = db
2319            .conn
2320            .query_row("SELECT COUNT(*) FROM scan_cache", [], |row| row.get(0))
2321            .unwrap();
2322        assert_eq!(orphans, 0);
2323    }
2324
2325    #[test]
2326    fn test_stale_removal_ignores_sibling_folder_with_shared_prefix() {
2327        let db = test_db();
2328
2329        let mut main = sample_meta("Song", "Artist", "Album");
2330        main.path = Some("/Volumes/Music/Album/Song.flac".into());
2331        upsert_track(&db.conn, &main).unwrap();
2332
2333        let mut backup = sample_meta("Song", "Artist", "Album");
2334        backup.path = Some("/Volumes/Music Backup/Album/Song.flac".into());
2335        backup.disc = Some(2);
2336        upsert_track(&db.conn, &backup).unwrap();
2337        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 2);
2338
2339        // Scanning /Volumes/Music must not reach into /Volumes/Music Backup.
2340        assert_eq!(
2341            remove_stale_tracks(&db.conn, Path::new("/Volumes/Music"), false)
2342                .unwrap()
2343                .len(),
2344            1
2345        );
2346
2347        let survivor: String = db
2348            .conn
2349            .query_row("SELECT path FROM tracks", [], |row| row.get(0))
2350            .unwrap();
2351        assert_eq!(survivor, "/Volumes/Music Backup/Album/Song.flac");
2352    }
2353
2354    #[test]
2355    fn test_stale_removal_refuses_wholesale_disappearance() {
2356        let db = test_db();
2357        for i in 0..STALE_CHECK_MIN_ROWS + 20 {
2358            let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
2359            meta.track_number = Some(i as i32);
2360            meta.path = Some(format!("/music/Album/{}.flac", i));
2361            upsert_track(&db.conn, &meta).unwrap();
2362        }
2363        let before = library_stats(&db.conn).unwrap().total_tracks;
2364
2365        let err = remove_stale_tracks(&db.conn, Path::new("/music"), false).unwrap_err();
2366        assert!(
2367            matches!(err, DbError::UnsafeBulkDelete(_)),
2368            "expected refusal, got {:?}",
2369            err
2370        );
2371        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, before);
2372    }
2373
2374    #[test]
2375    fn server_deletions_reach_the_client() {
2376        use std::collections::HashSet;
2377        let db = test_db();
2378        let remote = |title: &str, album: &str, rid: &str, album_rid: &str| {
2379            let mut m = sample_meta(title, "Tove Lo", album);
2380            m.path = None;
2381            m.remote_id = Some(rid.into());
2382            m.album_remote_id = Some(album_rid.into());
2383            upsert_track(&db.conn, &m).unwrap();
2384        };
2385        remote("Habits", "Queen of the Clouds", "t1", "al-1");
2386        remote("Talking Body", "Queen of the Clouds", "t2", "al-1");
2387        remote("Habits", "Habits (single)", "t3", "al-2");
2388        let count = |sql: &str| -> i64 { db.conn.query_row(sql, [], |r| r.get(0)).unwrap() };
2389
2390        // A sync that could not vouch for every track: the single's album is
2391        // gone from the listing.
2392        let albums: HashSet<String> = ["al-1".to_string()].into();
2393        assert_eq!(
2394            remove_vanished_remote(&db.conn, None, Some(&albums)).unwrap(),
2395            1
2396        );
2397        assert_eq!(
2398            count("SELECT COUNT(*) FROM albums WHERE title = 'Habits (single)'"),
2399            0
2400        );
2401        assert_eq!(count("SELECT COUNT(*) FROM tracks"), 2);
2402
2403        // A sync that listed everything: one track gone from an album that stays.
2404        let tracks: HashSet<String> = ["t1".to_string()].into();
2405        assert_eq!(
2406            remove_vanished_remote(&db.conn, Some(&tracks), Some(&albums)).unwrap(),
2407            1
2408        );
2409        assert_eq!(count("SELECT COUNT(*) FROM tracks"), 1);
2410        assert_eq!(count("SELECT COUNT(*) FROM albums"), 1);
2411    }
2412
2413    /// What a walk found is there without a stat; what it did not is still
2414    /// confirmed missing before it goes.
2415    #[test]
2416    fn stale_removal_trusts_the_walk_and_confirms_the_rest() {
2417        let db = test_db();
2418        let tmp = tempfile::tempdir().unwrap();
2419        let row = |name: &str| {
2420            let path = tmp.path().join(name).to_string_lossy().into_owned();
2421            let mut meta = sample_meta(name, "Artist", "Album");
2422            meta.path = Some(path.clone());
2423            upsert_track(&db.conn, &meta).unwrap();
2424            path
2425        };
2426        // Walked, then gone before removal ran: the walk is believed.
2427        let walked = row("walked.flac");
2428        // Not walked and not there: gone.
2429        let gone = row("gone.flac");
2430        // Not walked but there, such as a name the walk skips: kept.
2431        let present = row(".stversions-copy.flac");
2432        std::fs::write(&present, b"x").unwrap();
2433        let seen: std::collections::HashSet<String> = [walked.clone()].into();
2434
2435        let removed = remove_stale_tracks_walked(&db.conn, tmp.path(), true, Some(&seen)).unwrap();
2436        assert_eq!(removed, [gone]);
2437        let paths: Vec<String> = db
2438            .conn
2439            .prepare("SELECT path FROM local_files ORDER BY path")
2440            .unwrap()
2441            .query_map([], |r| r.get(0))
2442            .unwrap()
2443            .collect::<Result<_, _>>()
2444            .unwrap();
2445        assert_eq!(paths.len(), 2);
2446        assert!(paths.contains(&walked) && paths.contains(&present));
2447    }
2448
2449    #[test]
2450    fn test_stale_removal_drops_the_album_and_artist_it_empties() {
2451        let db = test_db();
2452        let tmp = tempfile::tempdir().unwrap();
2453        let keep = tmp.path().join("keep.flac");
2454        std::fs::write(&keep, b"x").unwrap();
2455        let mut kept = sample_meta("Kept", "Artist", "Staying");
2456        kept.path = Some(keep.to_string_lossy().into_owned());
2457        upsert_track(&db.conn, &kept).unwrap();
2458        let mut moved = sample_meta("Moved", "Other Credit", "Moved Away");
2459        moved.path = Some(tmp.path().join("gone.flac").to_string_lossy().into_owned());
2460        upsert_track(&db.conn, &moved).unwrap();
2461
2462        assert_eq!(
2463            remove_stale_tracks(&db.conn, tmp.path(), false)
2464                .unwrap()
2465                .len(),
2466            1
2467        );
2468        let count = |sql: &str| -> i64 { db.conn.query_row(sql, [], |r| r.get(0)).unwrap() };
2469        assert_eq!(
2470            count("SELECT COUNT(*) FROM albums WHERE title = 'Moved Away'"),
2471            0
2472        );
2473        assert_eq!(
2474            count("SELECT COUNT(*) FROM artists WHERE name = 'Other Credit'"),
2475            0
2476        );
2477        assert_eq!(
2478            count("SELECT COUNT(*) FROM albums WHERE title = 'Staying'"),
2479            1
2480        );
2481    }
2482
2483    #[test]
2484    fn test_stale_removal_allows_a_normal_deletion() {
2485        let db = test_db();
2486        let tmp = tempfile::tempdir().unwrap();
2487        let folder = tmp.path();
2488
2489        // 120 tracks on disk, one of them deleted.
2490        for i in 0..STALE_CHECK_MIN_ROWS + 20 {
2491            let file = folder.join(format!("{}.flac", i));
2492            if i > 0 {
2493                std::fs::write(&file, b"x").unwrap();
2494            }
2495            let mut meta = sample_meta(&format!("Track{}", i), "Artist", "Album");
2496            meta.track_number = Some(i as i32);
2497            meta.path = Some(file.to_string_lossy().into_owned());
2498            upsert_track(&db.conn, &meta).unwrap();
2499        }
2500
2501        assert_eq!(
2502            remove_stale_tracks(&db.conn, folder, false).unwrap().len(),
2503            1
2504        );
2505        assert_eq!(
2506            library_stats(&db.conn).unwrap().total_tracks,
2507            STALE_CHECK_MIN_ROWS + 19
2508        );
2509    }
2510
2511    #[test]
2512    fn test_resolve_playback_local_wins() {
2513        let db = test_db();
2514
2515        // Insert a local track.
2516        let local = sample_meta("Song", "Artist", "Album");
2517        let local_id = upsert_track(&db.conn, &local).unwrap();
2518
2519        match resolve_playback_path(&db.conn, local_id).unwrap() {
2520            // Path won't exist on disk in test, so falls through.
2521            // But we can at least verify it doesn't panic.
2522            Some(_) | None => {}
2523        }
2524    }
2525
2526    #[test]
2527    fn test_resolve_playback_remote_fallback() {
2528        let db = test_db();
2529
2530        let mut meta = sample_meta("Song", "Artist", "Album");
2531        meta.source = "remote".into();
2532        meta.path = None;
2533        meta.remote_id = Some("r42".into());
2534        meta.remote_url = Some("https://example.com/stream/r42".into());
2535        let id = upsert_track(&db.conn, &meta).unwrap();
2536
2537        let source = resolve_playback_path(&db.conn, id).unwrap().unwrap();
2538        match source {
2539            PlaybackSource::Remote(url) => {
2540                assert!(url.contains("r42"));
2541            }
2542            _ => panic!("expected Remote source"),
2543        }
2544    }
2545
2546    #[test]
2547    fn test_nonexistent_track_resolution() {
2548        let db = test_db();
2549        let result = resolve_playback_path(&db.conn, 99999).unwrap();
2550        assert!(result.is_none());
2551    }
2552
2553    #[test]
2554    fn test_dedup_local_then_remote() {
2555        let db = test_db();
2556
2557        // Insert local track first.
2558        let local = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2559        let local_id = upsert_track(&db.conn, &local).unwrap();
2560
2561        // Sync same track from remote — should merge, not duplicate.
2562        let mut remote = sample_meta("Windowlicker", "Aphex Twin", "Windowlicker EP");
2563        remote.source = "remote".into();
2564        remote.path = None;
2565        remote.remote_id = Some("sub-42".into());
2566        remote.remote_url = Some("https://example.com/stream/sub-42".into());
2567        let remote_id = upsert_track(&db.conn, &remote).unwrap();
2568
2569        // Same row.
2570        assert_eq!(local_id, remote_id);
2571
2572        // Only 1 track total.
2573        let stats = library_stats(&db.conn).unwrap();
2574        assert_eq!(stats.total_tracks, 1);
2575
2576        // Source should be "local" since it has a path.
2577        assert_eq!(stats.local_tracks, 1);
2578        assert_eq!(stats.remote_tracks, 0);
2579
2580        // But it should have the remote_id merged in.
2581        let row: (Option<String>, Option<String>, Option<String>) = db
2582            .conn
2583            .query_row(
2584                "SELECT path, remote_id, remote_url FROM tracks WHERE id = ?1",
2585                params![local_id],
2586                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2587            )
2588            .unwrap();
2589        assert!(row.0.is_some()); // local path preserved
2590        assert_eq!(row.1.as_deref(), Some("sub-42")); // remote_id merged
2591        assert!(row.2.is_some()); // remote_url merged
2592    }
2593
2594    #[test]
2595    fn test_dedup_remote_then_local() {
2596        let db = test_db();
2597
2598        // Insert remote track first.
2599        let mut remote = sample_meta("Vordhosbn", "Aphex Twin", "Drukqs");
2600        remote.source = "remote".into();
2601        remote.path = None;
2602        remote.remote_id = Some("sub-99".into());
2603        remote.remote_url = Some("https://example.com/stream/sub-99".into());
2604        let remote_id = upsert_track(&db.conn, &remote).unwrap();
2605
2606        // Scan local file — same track, should merge.
2607        let local = sample_meta("Vordhosbn", "Aphex Twin", "Drukqs");
2608        let local_id = upsert_track(&db.conn, &local).unwrap();
2609
2610        // Same row.
2611        assert_eq!(remote_id, local_id);
2612
2613        // Only 1 track.
2614        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2615
2616        // Source flipped to "local" since it now has a path.
2617        assert_eq!(library_stats(&db.conn).unwrap().local_tracks, 1);
2618
2619        // Remote info preserved.
2620        let rid: Option<String> = db
2621            .conn
2622            .query_row(
2623                "SELECT remote_id FROM tracks WHERE id = ?1",
2624                params![local_id],
2625                |row| row.get(0),
2626            )
2627            .unwrap();
2628        assert_eq!(rid.as_deref(), Some("sub-99"));
2629    }
2630
2631    #[test]
2632    fn test_remove_stale_preserves_remote_backed() {
2633        let db = test_db();
2634
2635        // Create a merged local+remote track (path exists in DB but not on disk).
2636        let mut meta = sample_meta("Ageispolis", "Aphex Twin", "SAW 85-92");
2637        meta.path = Some("/nonexistent/SAW 85-92/Ageispolis.flac".into());
2638        meta.remote_id = Some("sub-10".into());
2639        meta.remote_url = Some("https://example.com/stream/sub-10".into());
2640        let id = upsert_track(&db.conn, &meta).unwrap();
2641
2642        // Verify it starts as local.
2643        let source: String = db
2644            .conn
2645            .query_row(
2646                "SELECT source FROM tracks WHERE id = ?1",
2647                params![id],
2648                |row| row.get(0),
2649            )
2650            .unwrap();
2651        assert_eq!(source, "local");
2652
2653        // Remove stale tracks in the folder — file doesn't exist on disk.
2654        let removed =
2655            remove_stale_tracks(&db.conn, Path::new("/nonexistent/SAW 85-92"), false).unwrap();
2656        assert_eq!(removed.len(), 1);
2657
2658        // Track should still exist (not deleted), demoted to remote-only.
2659        let row: (
2660            Option<String>,
2661            String,
2662            Option<i64>,
2663            Option<i64>,
2664            Option<String>,
2665        ) = db
2666            .conn
2667            .query_row(
2668                "SELECT path, source, mtime, size_bytes, remote_id FROM tracks WHERE id = ?1",
2669                params![id],
2670                |row| {
2671                    Ok((
2672                        row.get(0)?,
2673                        row.get(1)?,
2674                        row.get(2)?,
2675                        row.get(3)?,
2676                        row.get(4)?,
2677                    ))
2678                },
2679            )
2680            .unwrap();
2681        assert!(row.0.is_none(), "path should be NULL");
2682        assert_eq!(row.1, "remote", "source should be 'remote'");
2683        assert!(row.2.is_none(), "mtime should be NULL");
2684        assert!(row.3.is_none(), "size_bytes should be NULL");
2685        assert_eq!(row.4.as_deref(), Some("sub-10"), "remote_id preserved");
2686
2687        // Playback should fall through to remote stream.
2688        let playback = resolve_playback_path(&db.conn, id).unwrap().unwrap();
2689        match playback {
2690            PlaybackSource::Remote(url) => assert!(url.contains("sub-10")),
2691            _ => panic!("expected Remote playback source"),
2692        }
2693    }
2694
2695    #[test]
2696    fn test_remove_stale_deletes_pure_local() {
2697        let db = test_db();
2698
2699        // Pure local track — no remote_id.
2700        let meta = sample_meta("PureLocal", "Artist", "Album");
2701        // sample_meta generates path "/music/Album/PureLocal.flac" which won't exist.
2702        let id = upsert_track(&db.conn, &meta).unwrap();
2703
2704        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2705
2706        let removed = remove_stale_tracks(&db.conn, Path::new("/music/Album"), false).unwrap();
2707        assert_eq!(removed.len(), 1);
2708
2709        // Track should be fully deleted.
2710        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 0);
2711
2712        // Verify the row is gone.
2713        let exists: bool = db
2714            .conn
2715            .query_row(
2716                "SELECT COUNT(*) > 0 FROM tracks WHERE id = ?1",
2717                params![id],
2718                |row| row.get(0),
2719            )
2720            .unwrap();
2721        assert!(!exists, "pure local track should be deleted");
2722    }
2723
2724    #[test]
2725    fn test_reattach_on_rescan() {
2726        let db = test_db();
2727
2728        // Create a merged local+remote track with a non-existent path.
2729        let mut meta = sample_meta("Xtal", "Aphex Twin", "SAW 85-92");
2730        meta.path = Some("/nonexistent/SAW 85-92/Xtal.flac".into());
2731        meta.remote_id = Some("sub-20".into());
2732        meta.remote_url = Some("https://example.com/stream/sub-20".into());
2733        let original_id = upsert_track(&db.conn, &meta).unwrap();
2734
2735        // Simulate stale removal (drive unplugged).
2736        remove_stale_tracks(&db.conn, Path::new("/nonexistent/SAW 85-92"), false).unwrap();
2737
2738        // Verify demoted to remote-only.
2739        let source: String = db
2740            .conn
2741            .query_row(
2742                "SELECT source FROM tracks WHERE id = ?1",
2743                params![original_id],
2744                |row| row.get(0),
2745            )
2746            .unwrap();
2747        assert_eq!(source, "remote");
2748
2749        // Simulate re-scan: same track shows up again with a path.
2750        // upsert_track content match (strategy 3) should re-merge the path.
2751        let mut rescan = sample_meta("Xtal", "Aphex Twin", "SAW 85-92");
2752        rescan.path = Some("/nonexistent/SAW 85-92/Xtal.flac".into());
2753        let rescan_id = upsert_track(&db.conn, &rescan).unwrap();
2754
2755        // Same row — content match merged it back.
2756        assert_eq!(original_id, rescan_id);
2757
2758        // Source should flip back to "local" since it has a path again.
2759        let row: (Option<String>, String, Option<String>) = db
2760            .conn
2761            .query_row(
2762                "SELECT path, source, remote_id FROM tracks WHERE id = ?1",
2763                params![rescan_id],
2764                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2765            )
2766            .unwrap();
2767        assert_eq!(
2768            row.0.as_deref(),
2769            Some("/nonexistent/SAW 85-92/Xtal.flac"),
2770            "path re-attached"
2771        );
2772        assert_eq!(row.1, "local", "source flipped back to local");
2773        assert_eq!(row.2.as_deref(), Some("sub-20"), "remote_id preserved");
2774
2775        // Only 1 track — no duplication.
2776        assert_eq!(library_stats(&db.conn).unwrap().total_tracks, 1);
2777    }
2778
2779    #[test]
2780    fn test_genres_by_artist_ids() {
2781        let db = test_db();
2782        let mut meta1 = sample_meta("Track1", "ArtistA", "Album1");
2783        meta1.genre = Some("Rock".into());
2784        upsert_track(&db.conn, &meta1).unwrap();
2785
2786        let mut meta2 = sample_meta("Track2", "ArtistA", "Album1");
2787        meta2.genre = Some("Jazz".into());
2788        meta2.track_number = Some(2);
2789        meta2.path = Some("/music/Album1/Track2.flac".into());
2790        upsert_track(&db.conn, &meta2).unwrap();
2791
2792        let mut meta3 = sample_meta("Track3", "ArtistB", "Album2");
2793        meta3.genre = Some("Metal".into());
2794        upsert_track(&db.conn, &meta3).unwrap();
2795
2796        // Look up ArtistA's ID.
2797        let artist_a_id: i64 = db
2798            .conn
2799            .query_row("SELECT id FROM artists WHERE name = 'ArtistA'", [], |row| {
2800                row.get(0)
2801            })
2802            .unwrap();
2803        let artist_b_id: i64 = db
2804            .conn
2805            .query_row("SELECT id FROM artists WHERE name = 'ArtistB'", [], |row| {
2806                row.get(0)
2807            })
2808            .unwrap();
2809
2810        let genres = genres_by_artist_ids(&db.conn, &[artist_a_id, artist_b_id]).unwrap();
2811        let a_genres = genres.get(&artist_a_id).unwrap();
2812        assert!(a_genres.contains("rock"));
2813        assert!(a_genres.contains("jazz"));
2814        let b_genres = genres.get(&artist_b_id).unwrap();
2815        assert!(b_genres.contains("metal"));
2816    }
2817
2818    #[test]
2819    fn test_genres_by_artist_ids_empty() {
2820        let db = test_db();
2821        let genres = genres_by_artist_ids(&db.conn, &[]).unwrap();
2822        assert!(genres.is_empty());
2823    }
2824
2825    #[test]
2826    fn test_genres_by_album_ids() {
2827        let db = test_db();
2828        let mut meta1 = sample_meta("Track1", "Artist", "AlbumX");
2829        meta1.genre = Some("Ambient".into());
2830        upsert_track(&db.conn, &meta1).unwrap();
2831
2832        let mut meta2 = sample_meta("Track2", "Artist", "AlbumX");
2833        meta2.genre = Some("IDM".into());
2834        meta2.track_number = Some(2);
2835        meta2.path = Some("/music/AlbumX/Track2.flac".into());
2836        upsert_track(&db.conn, &meta2).unwrap();
2837
2838        let album_id: i64 = db
2839            .conn
2840            .query_row("SELECT id FROM albums WHERE title = 'AlbumX'", [], |row| {
2841                row.get(0)
2842            })
2843            .unwrap();
2844
2845        let genres = genres_by_album_ids(&db.conn, &[album_id]).unwrap();
2846        let album_genres = genres.get(&album_id).unwrap();
2847        assert!(album_genres.contains("ambient"));
2848        assert!(album_genres.contains("idm"));
2849    }
2850
2851    #[test]
2852    fn test_favourite_artist_ids_batch() {
2853        let db = test_db();
2854        let meta = sample_meta("FavTrack", "FavArtist", "FavAlbum");
2855        let id = upsert_track(&db.conn, &meta).unwrap();
2856        crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, id).unwrap();
2857
2858        let artist_id: i64 = db
2859            .conn
2860            .query_row(
2861                "SELECT id FROM artists WHERE name = 'FavArtist'",
2862                [],
2863                |row| row.get(0),
2864            )
2865            .unwrap();
2866
2867        let fav_ids = favourite_artist_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2868        assert!(fav_ids.contains(&artist_id));
2869    }
2870
2871    #[test]
2872    fn test_favourite_artist_ids_batch_empty() {
2873        let db = test_db();
2874        let fav_ids = favourite_artist_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2875        assert!(fav_ids.is_empty());
2876    }
2877
2878    #[test]
2879    fn test_favourite_album_ids_batch() {
2880        let db = test_db();
2881        let meta = sample_meta("FavTrack", "FavArtist", "FavAlbum");
2882        let id = upsert_track(&db.conn, &meta).unwrap();
2883        crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, id).unwrap();
2884
2885        let album_id: i64 = db
2886            .conn
2887            .query_row(
2888                "SELECT id FROM albums WHERE title = 'FavAlbum'",
2889                [],
2890                |row| row.get(0),
2891            )
2892            .unwrap();
2893
2894        let fav_ids = favourite_album_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2895        assert!(fav_ids.contains(&album_id));
2896    }
2897
2898    #[test]
2899    fn test_favourite_album_ids_batch_empty() {
2900        let db = test_db();
2901        let fav_ids = favourite_album_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
2902        assert!(fav_ids.is_empty());
2903    }
2904
2905    #[test]
2906    fn test_set_cached_path_records_size_and_date() {
2907        let db = test_db();
2908        let mut meta = sample_meta("Song", "Artist", "Album");
2909        meta.source = "remote".into();
2910        meta.path = None;
2911        meta.remote_id = Some("r1".into());
2912        meta.remote_url = Some("https://example.com/r1".into());
2913        let id = upsert_track(&db.conn, &meta).unwrap();
2914
2915        // Create a temp file to simulate a cached download.
2916        let tmp = tempfile::NamedTempFile::new().unwrap();
2917        std::io::Write::write_all(&mut tmp.as_file().try_clone().unwrap(), &[0u8; 1024]).unwrap();
2918        let path = tmp.path().to_string_lossy().to_string();
2919
2920        set_cached_path(&db.conn, id, &path).unwrap();
2921
2922        let (cached_path, size, download_date): (Option<String>, Option<i64>, Option<i64>) = db
2923            .conn
2924            .query_row(
2925                "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
2926                params![id],
2927                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2928            )
2929            .unwrap();
2930
2931        assert_eq!(cached_path.as_deref(), Some(path.as_str()));
2932        assert!(size.unwrap() > 0, "cache_size_bytes should be positive");
2933        assert!(
2934            download_date.unwrap() > 0,
2935            "cache_download_date should be set"
2936        );
2937    }
2938
2939    #[test]
2940    fn test_total_cache_size() {
2941        let db = test_db();
2942
2943        // Start with zero.
2944        assert_eq!(total_cache_size(&db.conn).unwrap(), 0);
2945
2946        // Insert a cached track with known size.
2947        let mut meta = sample_meta("Song", "Artist", "Album");
2948        meta.source = "remote".into();
2949        meta.path = None;
2950        meta.remote_id = Some("r1".into());
2951        let id = upsert_track(&db.conn, &meta).unwrap();
2952
2953        db.conn
2954            .execute(
2955                "UPDATE tracks SET cached_path = '/cache/song.flac', cache_size_bytes = 50000000 WHERE id = ?1",
2956                params![id],
2957            )
2958            .unwrap();
2959
2960        assert_eq!(total_cache_size(&db.conn).unwrap(), 50_000_000);
2961    }
2962
2963    #[test]
2964    fn test_clear_cache_for_tracks() {
2965        let db = test_db();
2966        let mut meta = sample_meta("Song", "Artist", "Album");
2967        meta.source = "remote".into();
2968        meta.path = None;
2969        meta.remote_id = Some("r1".into());
2970        let id = upsert_track(&db.conn, &meta).unwrap();
2971
2972        db.conn
2973            .execute(
2974                "UPDATE tracks SET cached_path = '/cache/song.flac', cache_size_bytes = 1000, cache_download_date = 12345 WHERE id = ?1",
2975                params![id],
2976            )
2977            .unwrap();
2978
2979        clear_cached_paths_for(&db.conn, &[id]).unwrap();
2980
2981        let (path, size, date): (Option<String>, Option<i64>, Option<i64>) = db
2982            .conn
2983            .query_row(
2984                "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
2985                params![id],
2986                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
2987            )
2988            .unwrap();
2989
2990        assert!(path.is_none());
2991        assert!(size.is_none());
2992        assert!(date.is_none());
2993    }
2994
2995    #[test]
2996    fn test_cached_files_lru_excludes_favourited_albums() {
2997        let db = test_db();
2998
2999        // Create two remote-cached albums.
3000        for (album, tracks) in &[("AlbumA", vec!["T1", "T2"]), ("AlbumB", vec!["T3", "T4"])] {
3001            for (i, title) in tracks.iter().enumerate() {
3002                let mut meta = sample_meta(title, "Artist", album);
3003                meta.source = "remote".into();
3004                meta.path = None;
3005                meta.remote_id = Some(format!("r-{}", title));
3006                meta.track_number = Some((i + 1) as i32);
3007                let id = upsert_track(&db.conn, &meta).unwrap();
3008                let cached = format!("/cache/{}/{}.flac", album, title);
3009                db.conn
3010                    .execute(
3011                        "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3012                        params![cached, id],
3013                    )
3014                    .unwrap();
3015            }
3016        }
3017
3018        // Favourite a track from AlbumB.
3019        let t3: i64 = db
3020            .conn
3021            .query_row(
3022                "SELECT id FROM tracks WHERE cached_path = '/cache/AlbumB/T3.flac'",
3023                [],
3024                |r| r.get(0),
3025            )
3026            .unwrap();
3027        crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, t3).unwrap();
3028
3029        let files = cached_files_lru(&db.conn).unwrap();
3030
3031        // AlbumB has a favourite in it, so neither of its files is listed.
3032        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
3033        assert_eq!(paths, ["/cache/AlbumA/T1.flac", "/cache/AlbumA/T2.flac"]);
3034    }
3035
3036    #[test]
3037    fn eviction_keeps_what_the_queue_holds() {
3038        crate::config::isolate_config_for_tests();
3039        let db = test_db();
3040        let mut ids = Vec::new();
3041        for (album, played_at) in &[("OldAlbum", 1000), ("NewAlbum", 9000)] {
3042            let mut meta = sample_meta("Track", "Artist", album);
3043            meta.source = "remote".into();
3044            meta.path = None;
3045            meta.remote_id = Some(format!("r-{}", album));
3046            let id = upsert_track(&db.conn, &meta).unwrap();
3047            db.conn
3048                .execute(
3049                    "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3050                    params![format!("/nonexistent/{album}/Track.flac"), id],
3051                )
3052                .unwrap();
3053            db.conn
3054                .execute(
3055                    "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3056                    params![id, played_at],
3057                )
3058                .unwrap();
3059            ids.push(id);
3060        }
3061        let mut cfg = crate::config::Config::default();
3062        cfg.remote.cache_limit = Some("15MB".into());
3063
3064        // The older album would go first, but it is queued.
3065        let keep = std::collections::HashSet::from([ids[0]]);
3066        crate::helpers::evict_cache(&db, &cfg, &keep, false);
3067
3068        let cached: Vec<i64> = ids
3069            .iter()
3070            .copied()
3071            .filter(|id| {
3072                db.conn
3073                    .query_row(
3074                        "SELECT cached_path IS NOT NULL FROM tracks WHERE id = ?1",
3075                        params![id],
3076                        |r| r.get(0),
3077                    )
3078                    .unwrap()
3079            })
3080            .collect();
3081        assert_eq!(cached, vec![ids[0]]);
3082    }
3083
3084    /// One cached track per `(album, last used, pinned)`, 10MB each.
3085    fn cached_tracks(db: &Database, albums: &[(&str, i64, bool)]) -> Vec<i64> {
3086        albums
3087            .iter()
3088            .map(|(album, used, pinned)| {
3089                let mut meta = sample_meta("Track", "Artist", album);
3090                meta.source = "remote".into();
3091                meta.path = None;
3092                meta.remote_id = Some(format!("r-{album}"));
3093                let id = upsert_track(&db.conn, &meta).unwrap();
3094                db.conn
3095                    .execute(
3096                        "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000,
3097                                cache_download_date = ?2, cache_pinned = ?3
3098                         WHERE id = ?4",
3099                        params![format!("/nonexistent/{album}/Track.flac"), used, pinned, id],
3100                    )
3101                    .unwrap();
3102                id
3103            })
3104            .collect()
3105    }
3106
3107    fn still_cached(db: &Database, ids: &[i64]) -> Vec<i64> {
3108        ids.iter()
3109            .copied()
3110            .filter(|id| {
3111                db.conn
3112                    .query_row(
3113                        "SELECT cached_path IS NOT NULL FROM tracks WHERE id = ?1",
3114                        params![id],
3115                        |r| r.get(0),
3116                    )
3117                    .unwrap()
3118            })
3119            .collect()
3120    }
3121
3122    #[test]
3123    fn pinned_downloads_are_evicted_after_everything_fetched_to_play() {
3124        crate::config::isolate_config_for_tests();
3125        let db = test_db();
3126        // The pinned album is the least recently used, and still goes last.
3127        let ids = cached_tracks(
3128            &db,
3129            &[
3130                ("Pinned", 1000, true),
3131                ("Old", 2000, false),
3132                ("New", 3000, false),
3133            ],
3134        );
3135        let mut cfg = crate::config::Config::default();
3136        cfg.remote.cache_limit = Some("15MB".into());
3137
3138        crate::helpers::evict_cache(&db, &cfg, &Default::default(), false);
3139        assert_eq!(still_cached(&db, &ids), vec![ids[0]]);
3140
3141        cfg.remote.cache_limit = Some("5MB".into());
3142        crate::helpers::evict_cache(&db, &cfg, &Default::default(), false);
3143        assert!(still_cached(&db, &ids).is_empty(), "pinned is not forever");
3144    }
3145
3146    #[test]
3147    fn eviction_takes_the_played_half_of_a_queued_album() {
3148        crate::config::isolate_config_for_tests();
3149        let db = test_db();
3150        let mut ids = Vec::new();
3151        for n in 1..=4 {
3152            let mut meta = sample_meta(&format!("T{n}"), "Artist", "Album");
3153            meta.source = "remote".into();
3154            meta.path = None;
3155            meta.remote_id = Some(format!("r-{n}"));
3156            meta.track_number = Some(n);
3157            let id = upsert_track(&db.conn, &meta).unwrap();
3158            db.conn
3159                .execute(
3160                    "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3161                    params![format!("/nonexistent/T{n}.flac"), id],
3162                )
3163                .unwrap();
3164            ids.push(id);
3165        }
3166        let mut cfg = crate::config::Config::default();
3167        cfg.remote.cache_limit = Some("25MB".into());
3168
3169        // Two played, two to come.
3170        let keep = std::collections::HashSet::from([ids[2], ids[3]]);
3171        crate::helpers::evict_cache(&db, &cfg, &keep, false);
3172        assert_eq!(still_cached(&db, &ids), vec![ids[2], ids[3]]);
3173    }
3174
3175    #[test]
3176    fn the_window_is_what_fits_beside_pinned_downloads() {
3177        crate::config::isolate_config_for_tests();
3178        let db = test_db();
3179        let pinned = cached_tracks(&db, &[("Pinned", 1000, true)]);
3180        let mut upcoming = Vec::new();
3181        for n in 1..=5 {
3182            let mut meta = sample_meta(&format!("Q{n}"), "Artist", "Queued");
3183            meta.source = "remote".into();
3184            meta.path = None;
3185            meta.remote_id = Some(format!("q-{n}"));
3186            meta.track_number = Some(n);
3187            // No size from the server: 10MB, reckoned at 1000kbps.
3188            meta.size_bytes = None;
3189            meta.bitrate = Some(1000);
3190            meta.duration_ms = Some(80_000);
3191            upcoming.push(upsert_track(&db.conn, &meta).unwrap());
3192        }
3193
3194        // 10MB pinned, so a 45MB limit leaves room for three queued tracks.
3195        let fits = crate::helpers::playback_window(&db, 45_000_000, &upcoming).unwrap();
3196        assert_eq!(fits, 3);
3197
3198        // The playing track and the next are held however small the limit.
3199        let fits = crate::helpers::playback_window(&db, 1, &upcoming).unwrap();
3200        assert_eq!(fits, 2);
3201
3202        // A pinned track in the queue is already counted.
3203        let mut with_pinned = vec![pinned[0]];
3204        with_pinned.extend(&upcoming);
3205        let fits = crate::helpers::playback_window(&db, 45_000_000, &with_pinned).unwrap();
3206        assert_eq!(fits, 4);
3207    }
3208
3209    #[test]
3210    fn test_cached_files_lru_sorted_by_last_play() {
3211        let db = test_db();
3212
3213        // Create two cached albums.
3214        let mut album_ids = Vec::new();
3215        for (album, played_at) in &[("OldAlbum", 1000), ("NewAlbum", 9000)] {
3216            let mut meta = sample_meta("Track", "Artist", album);
3217            meta.source = "remote".into();
3218            meta.path = None;
3219            meta.remote_id = Some(format!("r-{}", album));
3220            let id = upsert_track(&db.conn, &meta).unwrap();
3221            let cached = format!("/cache/{}/Track.flac", album);
3222            db.conn
3223                .execute(
3224                    "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 10000000 WHERE id = ?2",
3225                    params![cached, id],
3226                )
3227                .unwrap();
3228
3229            // Record play history.
3230            db.conn
3231                .execute(
3232                    "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3233                    params![id, played_at],
3234                )
3235                .unwrap();
3236
3237            album_ids.push(id);
3238        }
3239
3240        let files = cached_files_lru(&db.conn).unwrap();
3241        // OldAlbum (played_at=1000) comes first: evicted first.
3242        let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
3243        assert_eq!(
3244            paths,
3245            ["/cache/OldAlbum/Track.flac", "/cache/NewAlbum/Track.flac"]
3246        );
3247    }
3248
3249    #[test]
3250    fn test_cached_files_lru_counts_a_fresh_download_as_used() {
3251        let db = test_db();
3252        // Played long ago, and downloaded just now for offline listening.
3253        for (album, played_at, downloaded) in
3254            [("Played", Some(5000), 1000), ("Fetched", None, 9000)]
3255        {
3256            let mut meta = sample_meta("Track", "Artist", album);
3257            meta.source = "remote".into();
3258            meta.path = None;
3259            meta.remote_id = Some(format!("r-{album}"));
3260            let id = upsert_track(&db.conn, &meta).unwrap();
3261            db.conn
3262                .execute(
3263                    "UPDATE tracks SET cached_path = ?1, cache_size_bytes = 1, cache_download_date = ?2
3264                     WHERE id = ?3",
3265                    params![format!("/cache/{album}.flac"), downloaded, id],
3266                )
3267                .unwrap();
3268            if let Some(played_at) = played_at {
3269                db.conn
3270                    .execute(
3271                        "INSERT INTO play_history (track_id, played_at) VALUES (?1, ?2)",
3272                        params![id, played_at],
3273                    )
3274                    .unwrap();
3275            }
3276        }
3277
3278        let files = cached_files_lru(&db.conn).unwrap();
3279        let order: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect();
3280        assert_eq!(order, ["/cache/Played.flac", "/cache/Fetched.flac"]);
3281    }
3282
3283    #[test]
3284    fn test_clear_cached_paths_clears_all_tracking() {
3285        let db = test_db();
3286        let mut meta = sample_meta("Song", "Artist", "Album");
3287        meta.source = "remote".into();
3288        meta.path = None;
3289        meta.remote_id = Some("r1".into());
3290        let id = upsert_track(&db.conn, &meta).unwrap();
3291
3292        db.conn
3293            .execute(
3294                "UPDATE tracks SET cached_path = '/x', cache_size_bytes = 100, cache_download_date = 999 WHERE id = ?1",
3295                params![id],
3296            )
3297            .unwrap();
3298
3299        clear_cached_paths(&db.conn).unwrap();
3300
3301        let (path, size, date): (Option<String>, Option<i64>, Option<i64>) = db
3302            .conn
3303            .query_row(
3304                "SELECT cached_path, cache_size_bytes, cache_download_date FROM tracks WHERE id = ?1",
3305                params![id],
3306                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
3307            )
3308            .unwrap();
3309
3310        assert!(path.is_none());
3311        assert!(size.is_none());
3312        assert!(date.is_none());
3313    }
3314
3315    #[test]
3316    fn test_migration_folds_a_file_indexed_under_two_spellings() {
3317        use unicode_normalization::UnicodeNormalization;
3318        let db = test_db();
3319        let nfc: String = "/music/Roman Flügel - Softice.flac".nfc().collect();
3320        let nfd: String = "/music/Roman Flügel - Softice.flac".nfd().collect();
3321        assert_ne!(nfc, nfd);
3322
3323        // The state a drop left behind: the file under Foundation's spelling,
3324        // with the sync link, then the next scan's row under the disk's.
3325        let mut dropped = sample_meta("Softice", "Roman Flügel", "Renaissance");
3326        dropped.path = Some(nfc.clone());
3327        dropped.remote_id = Some("sub-7".into());
3328        let winner = upsert_track(&db.conn, &dropped).unwrap();
3329        let mut scanned = sample_meta("Softice", "Roman Flügel", "Renaissance");
3330        scanned.path = Some(nfd.clone());
3331        let loser = upsert_track(&db.conn, &scanned).unwrap();
3332        assert_ne!(
3333            winner, loser,
3334            "the bytes differ, so the old key made two rows"
3335        );
3336        crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, loser).unwrap();
3337        for (path, id) in [(&nfc, winner), (&nfd, loser)] {
3338            db.conn
3339                .execute(
3340                    "INSERT INTO scan_cache (path, mtime, size, track_id) VALUES (?1, 1, 1, ?2)",
3341                    params![path, id],
3342                )
3343                .unwrap();
3344        }
3345
3346        // The fold runs before the source rows are built from the tracks.
3347        db.conn
3348            .execute_batch("DELETE FROM local_files; DELETE FROM remote_entries;")
3349            .unwrap();
3350        merge_spelling_twins(&db.conn).unwrap();
3351
3352        let rows: Vec<(i64, String, Option<String>)> = db
3353            .conn
3354            .prepare("SELECT id, path, remote_id FROM tracks")
3355            .unwrap()
3356            .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
3357            .unwrap()
3358            .collect::<Result<_, _>>()
3359            .unwrap();
3360        assert_eq!(rows.len(), 1, "one file, one row");
3361        assert_eq!(rows[0].0, winner, "the older row keeps its identity");
3362        assert_eq!(rows[0].1, nfd, "and takes the disk's spelling");
3363        assert_eq!(rows[0].2.as_deref(), Some("sub-7"), "and its sync link");
3364        let starred: i64 = db
3365            .conn
3366            .query_row("SELECT track_id FROM favourites", [], |r| r.get(0))
3367            .unwrap();
3368        assert_eq!(starred, winner, "the favourite follows the row");
3369        let cached: Vec<(String, i64)> = db
3370            .conn
3371            .prepare("SELECT path, track_id FROM scan_cache")
3372            .unwrap()
3373            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
3374            .unwrap()
3375            .collect::<Result<_, _>>()
3376            .unwrap();
3377        assert_eq!(
3378            cached,
3379            vec![(nfd, winner)],
3380            "one cache row, under the spelling a scan will ask for"
3381        );
3382    }
3383
3384    #[test]
3385    fn test_migration_leaves_a_pair_with_no_disk_spelling_alone() {
3386        use unicode_normalization::UnicodeNormalization;
3387        let db = test_db();
3388        // Two precomposed rows can't both have come from the directory, so there
3389        // is nothing to say which is the file.
3390        let a: String = "/music/Isolée - Allowance.flac".nfc().collect();
3391        let b: String = "/music/Isolée - Allowance.flac"
3392            .nfc()
3393            .chain(" ".chars())
3394            .collect();
3395        for path in [&a, &b] {
3396            let mut meta = sample_meta("Allowance", "Isolée", "Renaissance");
3397            meta.path = Some(path.clone());
3398            upsert_track(&db.conn, &meta).unwrap();
3399        }
3400        merge_spelling_twins(&db.conn).unwrap();
3401        let rows: i64 = db
3402            .conn
3403            .query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
3404            .unwrap();
3405        assert_eq!(rows, 2);
3406    }
3407
3408    fn names_of(db: &Database, id: i64) -> (String, String, String, Option<String>) {
3409        db.conn
3410            .query_row(
3411                "SELECT t.title, a.name, al.title, t.mbid FROM tracks t
3412                   JOIN artists a ON a.id = t.artist_id
3413                   JOIN albums al ON al.id = t.album_id
3414                  WHERE t.id = ?1",
3415                [id],
3416                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
3417            )
3418            .unwrap()
3419    }
3420
3421    #[test]
3422    fn a_merged_track_keeps_its_files_names_through_every_sync() {
3423        let db = test_db();
3424        let local = with_ids(
3425            sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
3426            "rec-1",
3427            "rel-1",
3428        );
3429        let id = upsert_track(&db.conn, &local).unwrap();
3430        let album = db
3431            .conn
3432            .query_row("SELECT album_id FROM tracks WHERE id = ?1", [id], |r| {
3433                r.get::<_, i64>(0)
3434            })
3435            .unwrap();
3436        let album_uid = uid_of(&db, "albums", album);
3437
3438        let mut remote = with_ids(
3439            remote_meta(
3440                "Hypnotized",
3441                "Oliver Koletzki • Fran",
3442                "Renaissance (Unmixed)",
3443                "sub-1",
3444            ),
3445            "rec-1",
3446            "rel-1",
3447        );
3448        remote.album_artist = Some("Oliver Koletzki".into());
3449        for _ in 0..2 {
3450            assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3451            assert_eq!(upsert_track(&db.conn, &local).unwrap(), id);
3452            assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3453            let (_, artist, album_title, _) = names_of(&db, id);
3454            assert_eq!(artist, "Oliver Koletzki");
3455            assert_eq!(album_title, "Renaissance");
3456        }
3457
3458        // The album the file named keeps its uid, and the server's spellings
3459        // leave nothing behind in the browser.
3460        assert_eq!(uid_of(&db, "albums", album), album_uid);
3461        let albums: i64 = db
3462            .conn
3463            .query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))
3464            .unwrap();
3465        let artists: i64 = db
3466            .conn
3467            .query_row("SELECT COUNT(*) FROM artists", [], |r| r.get(0))
3468            .unwrap();
3469        assert_eq!((albums, artists), (1, 1));
3470        let hits: i64 = db
3471            .conn
3472            .query_row(
3473                "SELECT COUNT(*) FROM tracks_fts WHERE tracks_fts MATCH 'Unmixed'",
3474                [],
3475                |r| r.get(0),
3476            )
3477            .unwrap();
3478        assert_eq!(hits, 0, "search indexes the names the row holds");
3479    }
3480
3481    #[test]
3482    fn a_corrected_tag_survives_a_sync_of_the_old_one() {
3483        let db = test_db();
3484        let mut local = sample_meta("Treading Water", "Petrol Girls", "Talk of Violence");
3485        local.genre = Some("Punk".into());
3486        let id = upsert_track(&db.conn, &local).unwrap();
3487        let mut remote = remote_meta(
3488            "Treading Water",
3489            "Petrol Girls",
3490            "Talk of Violence",
3491            "sub-1",
3492        );
3493        remote.genre = Some("Punk".into());
3494        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3495
3496        local.genre = Some("Post-Hardcore".into());
3497        local.mbid = Some("rec-corrected".into());
3498        upsert_track(&db.conn, &local).unwrap();
3499        remote.mbid = Some("rec-stale".into());
3500        upsert_track(&db.conn, &remote).unwrap();
3501
3502        let row = get_track_row(&db.conn, id).unwrap().unwrap();
3503        assert_eq!(row.genre.as_deref(), Some("Post-Hardcore"));
3504        assert_eq!(names_of(&db, id).3.as_deref(), Some("rec-corrected"));
3505    }
3506
3507    #[test]
3508    fn a_server_only_track_takes_the_servers_corrections() {
3509        let db = test_db();
3510        let mut remote = remote_meta(
3511            "Treading Water",
3512            "Petrol Girls",
3513            "Talk of Violence",
3514            "sub-1",
3515        );
3516        let id = upsert_track(&db.conn, &remote).unwrap();
3517        remote.title = "Treading Water (Live)".into();
3518        remote.mbid = Some("rec-2".into());
3519        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3520        let (title, _, _, mbid) = names_of(&db, id);
3521        assert_eq!(title, "Treading Water (Live)");
3522        assert_eq!(mbid.as_deref(), Some("rec-2"));
3523    }
3524
3525    #[test]
3526    fn a_corrected_musicbrainz_id_lands() {
3527        let db = test_db();
3528        let mut local = with_ids(
3529            sample_meta("Azure", "Paul Kalkbrenner", "Album"),
3530            "rec-wrong",
3531            "rel-wrong",
3532        );
3533        let id = upsert_track(&db.conn, &local).unwrap();
3534        local.mbid = Some("rec-right".into());
3535        local.album_mbid = Some("rel-right".into());
3536        upsert_track(&db.conn, &local).unwrap();
3537
3538        let release: String = db
3539            .conn
3540            .query_row(
3541                "SELECT al.mbid FROM tracks t JOIN albums al ON al.id = t.album_id WHERE t.id = ?1",
3542                [id],
3543                |r| r.get(0),
3544            )
3545            .unwrap();
3546        assert_eq!(names_of(&db, id).3.as_deref(), Some("rec-right"));
3547        assert_eq!(release, "rel-right");
3548
3549        // A server disagreeing about the release does not overrule the file.
3550        let remote = with_ids(
3551            remote_meta("Azure", "Paul Kalkbrenner", "Album", "sub-1"),
3552            "rec-right",
3553            "rel-other",
3554        );
3555        upsert_track(&db.conn, &remote).unwrap();
3556        let release: String = db
3557            .conn
3558            .query_row(
3559                "SELECT al.mbid FROM tracks t JOIN albums al ON al.id = t.album_id WHERE t.id = ?1",
3560                [id],
3561                |r| r.get(0),
3562            )
3563            .unwrap();
3564        assert_eq!(release, "rel-right");
3565    }
3566
3567    #[test]
3568    fn corrected_tags_remerge_across_differing_artist_credits() {
3569        let db = test_db();
3570        let mut bad = sample_meta("Treading Wat", "Petrol Girls", "Talk of Viol");
3571        bad.path = Some("/music/petrol-girls/01.mp3".into());
3572        let local_id = upsert_track(&db.conn, &bad).unwrap();
3573
3574        let mut remote = remote_meta(
3575            "Treading Water",
3576            "Petrol Girls • Ren Aldridge",
3577            "Talk of Violence",
3578            "sub-1",
3579        );
3580        remote.album_artist = Some("Petrol Girls".into());
3581        upsert_track(&db.conn, &remote).unwrap();
3582        assert_eq!(track_count(&db), 2);
3583
3584        let mut fixed = bad.clone();
3585        fixed.title = "Treading Water".into();
3586        fixed.album = "Talk of Violence".into();
3587        assert_eq!(upsert_track(&db.conn, &fixed).unwrap(), local_id);
3588        assert_eq!(track_count(&db), 1, "the re-merge is the whole cascade");
3589    }
3590
3591    #[test]
3592    fn a_remerge_keeps_the_servers_uid() {
3593        let db = test_db();
3594        let mut bad = sample_meta("Archangel", "Burial", "Untru");
3595        bad.path = Some("/music/burial/01.flac".into());
3596        let local_id = upsert_track(&db.conn, &bad).unwrap();
3597
3598        let server = uuid::Uuid::now_v7().to_string();
3599        upsert_track(
3600            &db.conn,
3601            &remote_meta("Archangel", "Burial", "Untrue", &server),
3602        )
3603        .unwrap();
3604        assert_eq!(track_count(&db), 2);
3605
3606        let mut fixed = bad.clone();
3607        fixed.album = "Untrue".into();
3608        assert_eq!(upsert_track(&db.conn, &fixed).unwrap(), local_id);
3609        assert_eq!(track_count(&db), 1);
3610        assert_eq!(uid_of(&db, "tracks", local_id), server);
3611    }
3612
3613    #[test]
3614    fn a_relinked_file_keeps_the_servers_uid() {
3615        let db = test_db();
3616        let old = uuid::Uuid::now_v7().to_string();
3617        let mut file = sample_meta("Archangel", "Burial", "Untrue");
3618        file.remote_id = Some(old.clone());
3619        let file_id = upsert_track(&db.conn, &file).unwrap();
3620
3621        let new = uuid::Uuid::now_v7().to_string();
3622        upsert_track(
3623            &db.conn,
3624            &remote_meta("Archangel", "Burial", "Untrue", &new),
3625        )
3626        .unwrap();
3627        remove_vanished_remote(&db.conn, Some(&HashSet::from([new.clone()])), None).unwrap();
3628
3629        assert_eq!(track_count(&db), 1);
3630        assert_eq!(uid_of(&db, "tracks", file_id), new);
3631    }
3632
3633    fn source_names(db: &Database, table: &str) -> Vec<(String, String)> {
3634        db.conn
3635            .prepare(&format!("SELECT title, artist FROM {table} ORDER BY title"))
3636            .unwrap()
3637            .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
3638            .unwrap()
3639            .collect::<rusqlite::Result<_>>()
3640            .unwrap()
3641    }
3642
3643    #[test]
3644    fn each_source_keeps_its_own_tags() {
3645        let db = test_db();
3646        let local = sample_meta("Treading Water", "Petrol Girls", "Talk of Violence");
3647        let id = upsert_track(&db.conn, &local).unwrap();
3648        let mut remote = remote_meta(
3649            "Treading Water",
3650            "Petrol Girls • Ren Aldridge",
3651            "Talk of Violence",
3652            "sub-1",
3653        );
3654        remote.album_artist = Some("Petrol Girls".into());
3655        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3656
3657        assert_eq!(
3658            source_names(&db, "local_files"),
3659            [("Treading Water".into(), "Petrol Girls".into())]
3660        );
3661        assert_eq!(
3662            source_names(&db, "remote_entries"),
3663            [(
3664                "Treading Water".into(),
3665                "Petrol Girls • Ren Aldridge".into()
3666            )]
3667        );
3668    }
3669
3670    #[test]
3671    fn a_file_retagged_as_another_track_leaves_the_server_copy() {
3672        let db = test_db();
3673        let mut local = sample_meta("Archangel", "Burial", "Untrue");
3674        let id = upsert_track(&db.conn, &local).unwrap();
3675        let remote = remote_meta("Archangel", "Burial", "Untrue", "sub-1");
3676        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3677
3678        local.title = "Near Dark".into();
3679        local.track_number = Some(2);
3680        let moved = upsert_track(&db.conn, &local).unwrap();
3681
3682        assert_eq!(track_count(&db), 2);
3683        let stays = get_track_row(&db.conn, id).unwrap().unwrap();
3684        let left = get_track_row(&db.conn, moved).unwrap().unwrap();
3685        assert_eq!(
3686            (stays.title.as_str(), stays.remote_id.as_deref(), stays.path),
3687            ("Archangel", Some("sub-1"), None)
3688        );
3689        assert_eq!(
3690            (left.title.as_str(), left.remote_id, left.path),
3691            ("Near Dark", None, local.path)
3692        );
3693    }
3694
3695    #[test]
3696    fn an_ambiguous_match_is_declined() {
3697        let db = test_db();
3698        // The server holds the same file twice under two ids.
3699        upsert_track(
3700            &db.conn,
3701            &remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3702        )
3703        .unwrap();
3704        upsert_track(
3705            &db.conn,
3706            &remote_meta("Archangel", "Burial", "Untrue", "sub-2"),
3707        )
3708        .unwrap();
3709        upsert_track(&db.conn, &sample_meta("Archangel", "Burial", "Untrue")).unwrap();
3710        assert_eq!(track_count(&db), 3, "which copy is the file is a guess");
3711    }
3712
3713    #[test]
3714    fn names_match_whatever_their_case_or_normal_form() {
3715        use unicode_normalization::UnicodeNormalization;
3716        let db = test_db();
3717        let local = sample_meta("Sæglópur", "Sigur Rós", "Takk...");
3718        let id = upsert_track(&db.conn, &local).unwrap();
3719        let mut remote = remote_meta(
3720            &"SÆGLÓPUR".nfd().collect::<String>(),
3721            "SIGUR RÓS",
3722            "TAKK...",
3723            "sub-1",
3724        );
3725        remote.album_artist = Some("SIGUR RÓS".into());
3726        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3727    }
3728
3729    #[test]
3730    fn a_moved_file_takes_over_its_server_copy() {
3731        let tmp = tempfile::tempdir().unwrap();
3732        let db = test_db();
3733        let old = tmp.path().join("old.flac");
3734        let new = tmp.path().join("new.flac");
3735        std::fs::write(&new, b"").unwrap();
3736
3737        let mut local = sample_meta("Archangel", "Burial", "Untrue");
3738        local.path = Some(old.to_string_lossy().into_owned());
3739        let id = upsert_track(&db.conn, &local).unwrap();
3740        upsert_track(
3741            &db.conn,
3742            &remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3743        )
3744        .unwrap();
3745        db.conn
3746            .execute(
3747                "INSERT INTO play_history (track_id, played_at) VALUES (?1, 1)",
3748                params![id],
3749            )
3750            .unwrap();
3751
3752        // The scan meets the file at its new path before noticing the old one gone.
3753        local.path = Some(new.to_string_lossy().into_owned());
3754        upsert_track(&db.conn, &local).unwrap();
3755        assert_eq!(track_count(&db), 2);
3756        remove_stale_tracks(&db.conn, tmp.path(), false).unwrap();
3757
3758        assert_eq!(track_count(&db), 1);
3759        let row = tracks_by_ids(&db.conn, &[id]).unwrap();
3760        let row = row
3761            .first()
3762            .expect("the older row, with its history, survives");
3763        assert_eq!(row.path, local.path);
3764        assert_eq!(row.remote_id.as_deref(), Some("sub-1"));
3765        let plays: i64 = db
3766            .conn
3767            .query_row(
3768                "SELECT COUNT(*) FROM play_history WHERE track_id = ?1",
3769                [id],
3770                |r| r.get(0),
3771            )
3772            .unwrap();
3773        assert_eq!(plays, 1);
3774    }
3775
3776    #[test]
3777    fn a_favourite_follows_a_track_whose_file_is_gone() {
3778        let tmp = tempfile::tempdir().unwrap();
3779        let db = test_db();
3780        let mut local = sample_meta("Archangel", "Burial", "Untrue");
3781        local.path = Some(tmp.path().join("a.flac").to_string_lossy().into_owned());
3782        let id = upsert_track(&db.conn, &local).unwrap();
3783        upsert_track(
3784            &db.conn,
3785            &remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3786        )
3787        .unwrap();
3788        crate::db::queries::add_favourite(&db.conn, crate::db::queries::LOCAL_USER, id).unwrap();
3789
3790        remove_stale_tracks(&db.conn, tmp.path(), false).unwrap();
3791
3792        let favourites =
3793            favourite_track_ids_batch(&db.conn, crate::db::queries::LOCAL_USER).unwrap();
3794        assert!(favourites.contains(&id), "streamed now, and still starred");
3795    }
3796
3797    #[test]
3798    fn the_servers_album_id_lands_on_the_album_the_file_names() {
3799        let db = test_db();
3800        let id = upsert_track(
3801            &db.conn,
3802            &with_ids(
3803                sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
3804                "rec-1",
3805                "rel-1",
3806            ),
3807        )
3808        .unwrap();
3809        let album_uid = uuid::Uuid::now_v7().to_string();
3810        let artist_uid = uuid::Uuid::now_v7().to_string();
3811        let mut remote = with_ids(
3812            remote_meta(
3813                "Hypnotized",
3814                "Oliver Koletzki",
3815                "Renaissance (Unmixed)",
3816                "sub-1",
3817            ),
3818            "rec-1",
3819            "rel-1",
3820        );
3821        remote.album_remote_id = Some(album_uid.clone());
3822        remote.artist_remote_id = Some(artist_uid.clone());
3823        upsert_track(&db.conn, &remote).unwrap();
3824
3825        let (album, artist): (i64, i64) = db
3826            .conn
3827            .query_row(
3828                "SELECT al.id, al.artist_id FROM tracks t JOIN albums al ON al.id = t.album_id
3829                  WHERE t.id = ?1",
3830                [id],
3831                |r| Ok((r.get(0)?, r.get(1)?)),
3832            )
3833            .unwrap();
3834        let rid: String = db
3835            .conn
3836            .query_row("SELECT remote_id FROM albums WHERE id = ?1", [album], |r| {
3837                r.get(0)
3838            })
3839            .unwrap();
3840        assert_eq!(rid, album_uid, "album favourites reconcile by it");
3841        assert_eq!(uid_of(&db, "albums", album), album_uid);
3842        assert_eq!(uid_of(&db, "artists", artist), artist_uid);
3843    }
3844
3845    #[test]
3846    fn a_server_fills_a_number_the_file_lacks() {
3847        let db = test_db();
3848        let mut local = with_ids(
3849            sample_meta("Archangel", "Burial", "Untrue"),
3850            "rec-1",
3851            "rel-1",
3852        );
3853        local.track_number = None;
3854        let id = upsert_track(&db.conn, &local).unwrap();
3855        let remote = with_ids(
3856            remote_meta("Archangel", "Burial", "Untrue", "sub-1"),
3857            "rec-1",
3858            "rel-1",
3859        );
3860        assert_eq!(upsert_track(&db.conn, &remote).unwrap(), id);
3861        let row = get_track_row(&db.conn, id).unwrap().unwrap();
3862        assert_eq!(
3863            (row.track_number, row.title.as_str()),
3864            (Some(1), "Archangel")
3865        );
3866    }
3867
3868    #[test]
3869    fn two_editions_with_their_own_release_ids_are_two_albums() {
3870        let db = test_db();
3871        let edition = |title: &str, number: i32, release: &str| {
3872            let mut m = with_ids(
3873                sample_meta(title, "Paul Kalkbrenner", "Album"),
3874                title,
3875                release,
3876            );
3877            m.track_number = Some(number);
3878            m
3879        };
3880        let mut original = edition("Azure", 1, "rel-original");
3881        let remaster = edition("Azure", 1, "rel-remaster");
3882        let mut remaster = TrackMeta {
3883            path: Some("/music/Album (Remaster)/Azure.flac".into()),
3884            ..remaster
3885        };
3886        let a = upsert_track(&db.conn, &original).unwrap();
3887        let b = upsert_track(&db.conn, &remaster).unwrap();
3888        let albums = |db: &Database| -> Vec<(i64, String)> {
3889            db.conn
3890                .prepare("SELECT id, mbid FROM albums ORDER BY id")
3891                .unwrap()
3892                .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
3893                .unwrap()
3894                .collect::<rusqlite::Result<_>>()
3895                .unwrap()
3896        };
3897        let before = albums(&db);
3898        assert_eq!(before.len(), 2);
3899        let album_of = |id| get_track_row(&db.conn, id).unwrap().unwrap().album_id;
3900        assert_ne!(album_of(a), album_of(b));
3901
3902        // Rescans of either edition leave both where they are.
3903        for _ in 0..2 {
3904            original.mtime = original.mtime.map(|t| t + 1);
3905            upsert_track(&db.conn, &original).unwrap();
3906            remaster.mtime = remaster.mtime.map(|t| t + 1);
3907            upsert_track(&db.conn, &remaster).unwrap();
3908            assert_eq!(albums(&db), before);
3909        }
3910    }
3911
3912    #[test]
3913    fn a_server_album_named_differently_joins_the_files_album() {
3914        let db = test_db();
3915        let file = with_ids(
3916            sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
3917            "rec-1",
3918            "rel-1",
3919        );
3920        let held = upsert_track(&db.conn, &file).unwrap();
3921        let entry = |title: &str, number: i32, id: &str, rec: &str| {
3922            let mut m = with_ids(
3923                remote_meta(title, "Oliver Koletzki", "Renaissance (Unmixed)", id),
3924                rec,
3925                "rel-1",
3926            );
3927            m.track_number = Some(number);
3928            m.album_remote_id = Some("al-1".into());
3929            m
3930        };
3931        // The server's copy of the file, and a track only the server has.
3932        upsert_track(&db.conn, &entry("Hypnotized", 1, "s-1", "rec-1")).unwrap();
3933        let streamed = upsert_track(&db.conn, &entry("Dance Tonight", 2, "s-2", "rec-2")).unwrap();
3934
3935        let album_of = |id| {
3936            get_track_row(&db.conn, id)
3937                .unwrap()
3938                .unwrap()
3939                .album_id
3940                .unwrap()
3941        };
3942        assert_eq!(album_of(held), album_of(streamed), "one record, one album");
3943        let (title, rid): (String, String) = db
3944            .conn
3945            .query_row(
3946                "SELECT title, remote_id FROM albums WHERE id = ?1",
3947                [album_of(held)],
3948                |r| Ok((r.get(0)?, r.get(1)?)),
3949            )
3950            .unwrap();
3951        assert_eq!((title.as_str(), rid.as_str()), ("Renaissance", "al-1"));
3952        let albums: i64 = db
3953            .conn
3954            .query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))
3955            .unwrap();
3956        assert_eq!(albums, 1);
3957    }
3958
3959    #[test]
3960    fn an_artist_is_one_row_whatever_its_case_or_normal_form() {
3961        use unicode_normalization::UnicodeNormalization;
3962        let db = test_db();
3963        upsert_track(&db.conn, &sample_meta("Hoppípolla", "Sigur Rós", "Takk...")).unwrap();
3964        let mut other = sample_meta(
3965            "Svefn-g-englar",
3966            &"SIGUR RÓS".nfd().collect::<String>(),
3967            "Ágætis byrjun",
3968        );
3969        other.path = Some("/music/agaetis/01.flac".into());
3970        upsert_track(&db.conn, &other).unwrap();
3971        let artists: Vec<String> = db
3972            .conn
3973            .prepare("SELECT name FROM artists")
3974            .unwrap()
3975            .query_map([], |r| r.get(0))
3976            .unwrap()
3977            .collect::<rusqlite::Result<_>>()
3978            .unwrap();
3979        assert_eq!(artists, ["Sigur Rós"], "the first spelling seen names it");
3980    }
3981
3982    #[test]
3983    fn a_server_album_synced_before_the_files_folds_into_theirs() {
3984        let db = test_db();
3985        let entry = |title: &str, number: i32, id: &str, rec: &str| {
3986            let mut m = with_ids(
3987                remote_meta(title, "Oliver Koletzki", "Renaissance (Unmixed)", id),
3988                rec,
3989                "rel-1",
3990            );
3991            m.track_number = Some(number);
3992            m.album_remote_id = Some("al-1".into());
3993            m
3994        };
3995        upsert_track(&db.conn, &entry("Hypnotized", 1, "s-1", "rec-1")).unwrap();
3996        let streamed = upsert_track(&db.conn, &entry("Dance Tonight", 2, "s-2", "rec-2")).unwrap();
3997        let held = upsert_track(
3998            &db.conn,
3999            &with_ids(
4000                sample_meta("Hypnotized", "Oliver Koletzki", "Renaissance"),
4001                "rec-1",
4002                "rel-1",
4003            ),
4004        )
4005        .unwrap();
4006
4007        let album_of = |id| {
4008            get_track_row(&db.conn, id)
4009                .unwrap()
4010                .unwrap()
4011                .album_id
4012                .unwrap()
4013        };
4014        assert_eq!(album_of(held), album_of(streamed));
4015        let titles: Vec<String> = db
4016            .conn
4017            .prepare("SELECT title FROM albums")
4018            .unwrap()
4019            .query_map([], |r| r.get(0))
4020            .unwrap()
4021            .collect::<rusqlite::Result<_>>()
4022            .unwrap();
4023        assert_eq!(titles, ["Renaissance"], "named as the files name it");
4024    }
4025}