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