Files
minstrel/internal/db/queries/browse.sql
T
bvandeusen f6d1cf24f0
test-go / test (push) Successful in 1m0s
test-go / integration (push) Successful in 5m10s
feat(library): detect missing files and stop offering them — #2523
Nothing in Minstrel ever noticed a deleted file. The walk only visits
paths that exist, so a row whose file was gone was never scanned, never
errored, never counted — permanently invisible. classifyEvent ignores
fsnotify removals by design, and the safety-net scan is the same walk, so
it covers additions only. Rows accumulated forever.

Found on the operator's library: a completed scan reported
skipped=24185 errored=0 while the MBID backfill (which opens files by DB
path rather than walking) logged ~40 "no such file or directory" across
three reorganised albums. Those rows also kept their pre-#2499 welded
genre, which is how this surfaced — the version-stamped tag re-read can
only reach files the walk visits.

The harm is not cosmetic. tracks is the candidate universe for
recommendation.sql / discover.sql / system_mixes.sql and nothing filtered
on file existence, so a mix could spend a slot on a track that cannot
stream.

Marks rather than deletes. A missing file is a claim about the filesystem
and the filesystem lies transiently — an unmounted volume, a network
blip, a container that started before its media mount attached. Every
sweep in internal/gc resolves a truth INSIDE the database and is safe to
run blind; this one is not, so no deletion happens here. Three guards
refuse to act on ambiguous evidence: every scan root must resolve to a
non-empty directory, the walk must have seen at least one file, and one
reconcile may newly mark at most 25% of the library. Clearing a mark is
never the dangerous direction, so it runs unconditionally — otherwise a
library that tripped the cap could never recover once the mount returned.

Only a full Scan reconciles. The walk's set of seen paths is the
evidence, and ScanFiles has no basis for concluding anything about files
it did not look at.

Excludes marked tracks from all 13 track-emitting queries (radio x2,
system mixes x5, discover x4, most-played x2), the 6 play-history seed
picks, and the genre browse axis. Deliberately NOT filtered: the shared
ListPlaylistTracks read path, because it also serves user-curated
playlists where hiding a track the user added would be wrong — system
playlists shed orphans on their next daily rebuild instead. History and
the taste profile also keep them: those record the past, and a track you
played 200 times still says something about your taste.

Reconcile tallies land in scan_runs so a disappearance is visible rather
than discovered when a mix comes up short.
2026-08-06 14:34:53 -04:00

127 lines
5.7 KiB
SQL

-- Every query in this file filters `tracks.missing_since IS NULL` (#2523).
-- A row whose file has vanished keeps its genre forever — the scanner walks the
-- filesystem, so it never revisits a path that no longer exists — which is how
-- pre-#2499 welded genres survived a full re-scan and kept showing in the index.
-- Browsing is a way of finding something to play, so a track that cannot play
-- should not shape it.
--
-- Year queries below join albums only and are deliberately left alone: an album
-- is still a real release even if some of its tracks are gone. An album whose
-- EVERY track is missing will linger on the year axis; that's a narrower case,
-- tracked with the rest of the cleanup work.
-- name: ListGenresWithCount :many
-- Genre browse index (#367).
--
-- Genres live inline on tracks.genre as a delimited string, so this splits on
-- the same [;,] pattern already used by recommendation.sql and discover.sql —
-- a track tagged "Rock;Pop" must count toward both, and diverging from the
-- established pattern here would make the browse surface disagree with what
-- the recommendation engine believes the library contains.
--
-- trim() but deliberately NO lower(): trimming repairs an artifact of OUR
-- splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre
-- would be a bug), whereas case is what the tag actually says. Raw ID3 is
-- exposed as-is for v1, so "Rock" and "rock" appear as separate rows.
--
-- COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
-- inflate its own row.
--
-- Ordered by count first: raw ID3 data has a long tail of one-off junk tags,
-- so alphabetical would bury the handful of genres an operator actually has a
-- library's worth of. Name breaks ties for a stable order.
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE trim(g.genre) <> ''
AND tracks.missing_since IS NULL
GROUP BY trim(g.genre)
-- Ordered by the expression, not the output alias: `ORDER BY genre` is
-- ambiguous between the alias and tracks.genre, and sqlc rejects it.
ORDER BY track_count DESC, trim(g.genre);
-- name: ListAlbumsByGenreWithArtist :many
-- Albums for one genre, joined with artist_name for the browse grid.
-- An album belongs to a genre when ANY of its tracks carry it. Splits and
-- trims identically to ListGenresWithCount — if the list is built by
-- splitting and the detail matched exactly, every multi-genre track would
-- produce a genre row that leads to an empty page.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE EXISTS (
SELECT 1
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE tracks.album_id = albums.id
AND tracks.missing_since IS NULL
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
)
ORDER BY albums.sort_title, albums.id
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
-- name: CountAlbumsByGenre :one
-- Total for the paging envelope. EXISTS mirrors the list query exactly; a
-- JOIN + DISTINCT here would count differently the moment an album has two
-- tracks carrying the same genre.
SELECT COUNT(*) FROM albums
WHERE EXISTS (
SELECT 1
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE tracks.album_id = albums.id
AND tracks.missing_since IS NULL
AND trim(g.genre) = trim(sqlc.arg(genre)::text)
);
-- name: ListAlbumYearsWithCount :many
-- Year browse index (#367). Only albums with a release_date appear — an
-- album with no date isn't "year unknown" as a browsable bucket, it's absent
-- from this axis, and the UI says so rather than inventing a 0 row.
-- Newest first: recent releases are the likelier browse target.
SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count
FROM albums
WHERE release_date IS NOT NULL
GROUP BY year
ORDER BY year DESC;
-- name: ListAlbumsByYearRangeWithArtist :many
-- Albums released within an inclusive year range, for the albums-page filter.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
JOIN artists ON artists.id = albums.artist_id
WHERE albums.release_date IS NOT NULL
AND EXTRACT(YEAR FROM albums.release_date)::int
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int
ORDER BY albums.sort_title, albums.id
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
-- name: CountAlbumsByYearRange :one
SELECT COUNT(*) FROM albums
WHERE release_date IS NOT NULL
AND EXTRACT(YEAR FROM release_date)::int
BETWEEN sqlc.arg(year_from)::int AND sqlc.arg(year_to)::int;
-- name: ListGenresForAlbum :many
-- Distinct genres carried by an album's tracks, for the album detail page's
-- quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a
-- chip always leads to a page that actually contains this album — the two
-- diverging is exactly the bug #367 had to fix in ListAlbumsByGenre.
SELECT DISTINCT trim(g.genre) AS genre
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE tracks.album_id = $1 AND trim(g.genre) <> ''
AND tracks.missing_since IS NULL
ORDER BY trim(g.genre);
-- name: ListGenresForArtist :many
-- Same, across everything by one artist. Alphabetical rather than by count:
-- an artist's genre set is small, and a stable order reads better than a
-- frequency ranking nobody asked about.
SELECT DISTINCT trim(g.genre) AS genre
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
WHERE tracks.artist_id = $1 AND trim(g.genre) <> ''
AND tracks.missing_since IS NULL
ORDER BY trim(g.genre);