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