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.
This commit is contained in:
@@ -18,6 +18,7 @@ WHERE EXISTS (
|
||||
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($1::text)
|
||||
)
|
||||
`
|
||||
@@ -97,6 +98,7 @@ WHERE EXISTS (
|
||||
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($1::text)
|
||||
)
|
||||
ORDER BY albums.sort_title, albums.id
|
||||
@@ -219,6 +221,7 @@ 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)
|
||||
`
|
||||
|
||||
@@ -251,6 +254,7 @@ 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)
|
||||
`
|
||||
|
||||
@@ -278,10 +282,12 @@ func (q *Queries) ListGenresForArtist(ctx context.Context, artistID pgtype.UUID)
|
||||
}
|
||||
|
||||
const listGenresWithCount = `-- name: ListGenresWithCount :many
|
||||
|
||||
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)
|
||||
ORDER BY track_count DESC, trim(g.genre)
|
||||
`
|
||||
@@ -291,6 +297,17 @@ type ListGenresWithCountRow struct {
|
||||
TrackCount int64
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Genre browse index (#367).
|
||||
//
|
||||
// Genres live inline on tracks.genre as a delimited string, so this splits on
|
||||
|
||||
@@ -15,7 +15,8 @@ const listCrossUserLikedTracksForDiscover = `-- name: ListCrossUserLikedTracksFo
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id != $1
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND gl.user_id != $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
@@ -95,7 +96,8 @@ dormant_artists AS (
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN dormant_artists da ON da.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.track_id = t.id
|
||||
@@ -159,7 +161,8 @@ func (q *Queries) ListDormantArtistTracksForDiscover(ctx context.Context, arg Li
|
||||
const listRandomUnheardTracksForDiscover = `-- name: ListRandomUnheardTracksForDiscover :many
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.track_id = t.id
|
||||
@@ -217,7 +220,8 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||
WHERE nt.weight > 0
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND nt.weight > 0
|
||||
AND trim(g_split.g) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
|
||||
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
||||
}
|
||||
|
||||
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version FROM tracks t
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
|
||||
JOIN play_events pe ON pe.track_id = t.id
|
||||
WHERE pe.session_id = $1
|
||||
AND pe.started_at < $2
|
||||
@@ -306,6 +306,7 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
const listUserHistory = `-- name: ListUserHistory :many
|
||||
SELECT pe.id AS event_id,
|
||||
pe.started_at,
|
||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM play_events pe
|
||||
@@ -80,6 +80,7 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
||||
&i.Track.TagSource,
|
||||
&i.Track.TagSourcesVersion,
|
||||
&i.Track.TagReadVersion,
|
||||
&i.Track.MissingSince,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
|
||||
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
||||
}
|
||||
|
||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version FROM tracks t
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
|
||||
JOIN general_likes l ON l.track_id = t.id
|
||||
WHERE l.user_id = $1
|
||||
ORDER BY l.liked_at DESC
|
||||
@@ -300,6 +300,7 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -643,6 +643,7 @@ type Track struct {
|
||||
TagSource *string
|
||||
TagSourcesVersion int32
|
||||
TagReadVersion int16
|
||||
MissingSince pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TrackSimilarity struct {
|
||||
|
||||
@@ -208,7 +208,7 @@ WITH plays AS (
|
||||
WHERE user_id = $2 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
@@ -216,6 +216,7 @@ JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = $1
|
||||
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||
@@ -268,6 +269,7 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
||||
&i.Track.TagSource,
|
||||
&i.Track.TagSourcesVersion,
|
||||
&i.Track.TagReadVersion,
|
||||
&i.Track.MissingSince,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
@@ -288,14 +290,15 @@ WITH plays AS (
|
||||
WHERE user_id = $1 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
@@ -350,6 +353,7 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
||||
&i.Track.TagSource,
|
||||
&i.Track.TagSourcesVersion,
|
||||
&i.Track.TagReadVersion,
|
||||
&i.Track.MissingSince,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
@@ -687,7 +691,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
|
||||
|
||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||
SELECT
|
||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
@@ -705,6 +709,7 @@ LEFT JOIN LATERAL (
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
) pe ON true
|
||||
WHERE t.id <> $2
|
||||
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
@@ -766,6 +771,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
||||
&i.Track.TagSource,
|
||||
&i.Track.TagSourcesVersion,
|
||||
&i.Track.TagReadVersion,
|
||||
&i.Track.MissingSince,
|
||||
&i.IsLiked,
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
@@ -898,7 +904,7 @@ random_fill AS (
|
||||
LIMIT $9
|
||||
)
|
||||
SELECT
|
||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
@@ -914,7 +920,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
JOIN tracks t ON t.id = u.track_id AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -1008,6 +1014,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
&i.Track.TagSource,
|
||||
&i.Track.TagSourcesVersion,
|
||||
&i.Track.TagReadVersion,
|
||||
&i.Track.MissingSince,
|
||||
&i.IsLiked,
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
|
||||
@@ -39,7 +39,8 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||
WHERE COALESCE(pc.c, 0) <= 2
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND COALESCE(pc.c, 0) <= 2
|
||||
AND COALESCE(sc.c, 0) < 2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
@@ -124,7 +125,8 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||
WHERE alt.tier IS NOT NULL
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND alt.tier IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
@@ -225,7 +227,8 @@ albums_tiered AS (
|
||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
@@ -303,7 +306,8 @@ WITH windowed AS (
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN windowed w ON w.track_id = t.id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
@@ -367,7 +371,8 @@ WITH stats AS (
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 3
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND s.c >= 3
|
||||
AND s.last_at <= now() - interval '30 days'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
|
||||
@@ -189,6 +189,7 @@ func (q *Queries) GetSystemPlaylistRun(ctx context.Context, userID pgtype.UUID)
|
||||
|
||||
const listActiveUsersForSystemPlaylists = `-- name: ListActiveUsersForSystemPlaylists :many
|
||||
|
||||
|
||||
SELECT u.id FROM users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
@@ -197,6 +198,13 @@ SELECT u.id FROM users u
|
||||
)
|
||||
`
|
||||
|
||||
// Track picks here join `tracks ... AND t.missing_since IS NULL` (#2523): a
|
||||
// seed or For-You candidate has to be something that can actually play. Note
|
||||
// this only affects newly GENERATED playlists — already-stored system
|
||||
// playlists keep their rows until the next daily rebuild, which is why the
|
||||
// shared ListPlaylistTracks read path is deliberately left unfiltered (it
|
||||
// also serves user-curated playlists, where hiding a track the user added
|
||||
// themselves would be wrong).
|
||||
// M7 #352 slice 2: system-generated playlist queries.
|
||||
// Active = had a play in the last 7 days. The cron iterates this list.
|
||||
func (q *Queries) ListActiveUsersForSystemPlaylists(ctx context.Context) ([]pgtype.UUID, error) {
|
||||
@@ -298,7 +306,7 @@ recent7 AS (
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
AND t.artist_id IS NOT NULL
|
||||
@@ -309,7 +317,7 @@ recent30 AS (
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND t.artist_id IS NOT NULL
|
||||
@@ -320,7 +328,7 @@ alltime AS (
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
2 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
@@ -432,7 +440,7 @@ const pickTopPlayedTrackForArtistByUser = `-- name: PickTopPlayedTrackForArtistB
|
||||
SELECT COALESCE(
|
||||
(SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND t.artist_id = $2
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
@@ -472,7 +480,7 @@ const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
|
||||
WITH recent AS (
|
||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND pe.was_skipped = false
|
||||
@@ -481,7 +489,7 @@ WITH recent AS (
|
||||
alltime AS (
|
||||
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
|
||||
@@ -11,6 +11,24 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const clearTracksMissing = `-- name: ClearTracksMissing :execrows
|
||||
UPDATE tracks
|
||||
SET missing_since = NULL
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND missing_since IS NOT NULL
|
||||
`
|
||||
|
||||
// Clears the mark on rows whose file is back. Runs independently of the mtime
|
||||
// skip check, so a file that reappears unchanged is un-marked even though the
|
||||
// scanner skips re-reading its tags.
|
||||
func (q *Queries) ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, clearTracksMissing, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
|
||||
SELECT count(*) FROM tracks WHERE album_id = $1
|
||||
`
|
||||
@@ -90,7 +108,7 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
|
||||
}
|
||||
|
||||
const getTrackByID = `-- name: GetTrackByID :one
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks WHERE id = $1
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
||||
@@ -115,12 +133,13 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTrackByPath = `-- name: GetTrackByPath :one
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks WHERE file_path = $1
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE file_path = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
||||
@@ -145,12 +164,13 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks WHERE id = ANY($1::uuid[])
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||
@@ -183,6 +203,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -195,7 +216,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
||||
}
|
||||
|
||||
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM tracks t
|
||||
@@ -254,6 +275,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
||||
&i.Track.TagSource,
|
||||
&i.Track.TagSourcesVersion,
|
||||
&i.Track.TagReadVersion,
|
||||
&i.Track.MissingSince,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
@@ -268,7 +290,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
||||
}
|
||||
|
||||
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM tracks t
|
||||
@@ -324,6 +346,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
||||
&i.Track.TagSource,
|
||||
&i.Track.TagSourcesVersion,
|
||||
&i.Track.TagReadVersion,
|
||||
&i.Track.MissingSince,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
@@ -337,8 +360,43 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
|
||||
SELECT id, file_path, missing_since FROM tracks
|
||||
`
|
||||
|
||||
type ListTrackPathsForReconcileRow struct {
|
||||
ID pgtype.UUID
|
||||
FilePath string
|
||||
MissingSince pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// Every row's path + current missing mark, for the scanner's reconcile pass
|
||||
// (#2523). Deliberately unfiltered and unpaged: reconcile has to compare the
|
||||
// WHOLE table against what the walk saw, and a filtered subset would let rows
|
||||
// outside it drift forever. Three narrow columns keep it cheap even on a
|
||||
// library of a few hundred thousand tracks.
|
||||
func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPathsForReconcileRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTrackPathsForReconcile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTrackPathsForReconcileRow
|
||||
for rows.Next() {
|
||||
var i ListTrackPathsForReconcileRow
|
||||
if err := rows.Scan(&i.ID, &i.FilePath, &i.MissingSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
|
||||
WHERE album_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
@@ -383,6 +441,7 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -429,8 +488,31 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markTracksMissing = `-- name: MarkTracksMissing :execrows
|
||||
UPDATE tracks
|
||||
SET missing_since = now()
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND missing_since IS NULL
|
||||
`
|
||||
|
||||
// Marks rows whose file the walk did not see. `missing_since IS NULL` in the
|
||||
// predicate makes this idempotent: a row already marked keeps its ORIGINAL
|
||||
// timestamp, so "how long has it been gone" survives repeated scans. Losing
|
||||
// that would make any age-based cleanup policy meaningless.
|
||||
//
|
||||
// updated_at is deliberately NOT touched. It tracks content changes and gates
|
||||
// the scanner's mtime skip; moving it here would make a returning file look
|
||||
// newer than its own mtime and stop its tags being re-read.
|
||||
func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, markTracksMissing, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const searchTracks = `-- name: SearchTracks :many
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks
|
||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
|
||||
WHERE title ILIKE '%' || $1::text || '%'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
@@ -482,6 +564,7 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -533,7 +616,7 @@ ON CONFLICT (file_path) DO UPDATE SET
|
||||
-- next scan can short-circuit them again (#2499).
|
||||
tag_read_version = EXCLUDED.tag_read_version,
|
||||
updated_at = now()
|
||||
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version
|
||||
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since
|
||||
`
|
||||
|
||||
type UpsertTrackParams struct {
|
||||
@@ -589,6 +672,7 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
||||
&i.TagSource,
|
||||
&i.TagSourcesVersion,
|
||||
&i.TagReadVersion,
|
||||
&i.MissingSince,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS tracks_missing_since_idx;
|
||||
|
||||
ALTER TABLE tracks
|
||||
DROP COLUMN missing_since;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Marks a track whose file the scanner could no longer find (#2523).
|
||||
--
|
||||
-- NULL means present. A timestamp means the file was absent as of that scan,
|
||||
-- and is the point from which "how long has this been gone" is measured — which
|
||||
-- is what a later cleanup pass needs in order to require a grace period rather
|
||||
-- than deleting on a single missed stat.
|
||||
--
|
||||
-- Deliberately a nullable timestamp rather than a boolean: "missing" is not a
|
||||
-- state we want to act on immediately, and the age is the only thing that makes
|
||||
-- an automated deletion safe to reason about.
|
||||
--
|
||||
-- No default and no backfill. Existing rows start NULL (present) and the next
|
||||
-- full scan sets the mark where it belongs — a migration cannot check the
|
||||
-- filesystem, and guessing here would mark the whole library on a server whose
|
||||
-- media volume happens to be detached at upgrade time.
|
||||
ALTER TABLE tracks
|
||||
ADD COLUMN missing_since timestamptz;
|
||||
|
||||
-- Partial index: the only query that filters on this column positively is the
|
||||
-- admin "what's missing" list, which is a small set. Playback and browse
|
||||
-- queries filter `missing_since IS NULL`, which matches nearly every row and is
|
||||
-- better served by a sequential scan than an index lookup.
|
||||
CREATE INDEX tracks_missing_since_idx
|
||||
ON tracks (missing_since)
|
||||
WHERE missing_since IS NOT NULL;
|
||||
@@ -1,3 +1,15 @@
|
||||
-- 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).
|
||||
--
|
||||
@@ -22,6 +34,7 @@ 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.
|
||||
@@ -41,6 +54,7 @@ WHERE EXISTS (
|
||||
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
|
||||
@@ -56,6 +70,7 @@ WHERE EXISTS (
|
||||
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)
|
||||
);
|
||||
|
||||
@@ -96,6 +111,7 @@ 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
|
||||
@@ -106,4 +122,5 @@ 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);
|
||||
|
||||
@@ -29,7 +29,8 @@ dormant_artists AS (
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN dormant_artists da ON da.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.track_id = t.id
|
||||
@@ -60,7 +61,8 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id != $1
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND gl.user_id != $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
@@ -86,7 +88,8 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
-- $1 = user_id, $2 = date string for md5 ordering.
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.track_id = t.id
|
||||
@@ -117,7 +120,8 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||
WHERE nt.weight > 0
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND nt.weight > 0
|
||||
AND trim(g_split.g) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events pe
|
||||
|
||||
@@ -24,6 +24,7 @@ LEFT JOIN LATERAL (
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
) pe ON true
|
||||
WHERE t.id <> $2
|
||||
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM play_events
|
||||
WHERE user_id = $1 AND track_id = t.id
|
||||
@@ -177,7 +178,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
JOIN tracks t ON t.id = u.track_id AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -382,7 +383,8 @@ FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
@@ -408,6 +410,7 @@ JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = sqlc.arg(artist_id)
|
||||
AND t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||
|
||||
@@ -40,7 +40,8 @@ SELECT t.id, t.album_id, t.artist_id
|
||||
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||
WHERE COALESCE(pc.c, 0) <= 2
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND COALESCE(pc.c, 0) <= 2
|
||||
AND COALESCE(sc.c, 0) < 2
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
@@ -70,7 +71,8 @@ WITH stats AS (
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN stats s ON s.track_id = t.id
|
||||
WHERE s.c >= 3
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND s.c >= 3
|
||||
AND s.last_at <= now() - interval '30 days'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
@@ -149,7 +151,8 @@ albums_tiered AS (
|
||||
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
@@ -187,7 +190,8 @@ WITH windowed AS (
|
||||
SELECT t.id, t.album_id, t.artist_id
|
||||
FROM tracks t
|
||||
JOIN windowed w ON w.track_id = t.id
|
||||
WHERE NOT EXISTS (
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
@@ -240,7 +244,8 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||
WHERE alt.tier IS NOT NULL
|
||||
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
|
||||
AND alt.tier IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
-- Track picks here join `tracks ... AND t.missing_since IS NULL` (#2523): a
|
||||
-- seed or For-You candidate has to be something that can actually play. Note
|
||||
-- this only affects newly GENERATED playlists — already-stored system
|
||||
-- playlists keep their rows until the next daily rebuild, which is why the
|
||||
-- shared ListPlaylistTracks read path is deliberately left unfiltered (it
|
||||
-- also serves user-curated playlists, where hiding a track the user added
|
||||
-- themselves would be wrong).
|
||||
|
||||
-- M7 #352 slice 2: system-generated playlist queries.
|
||||
|
||||
-- name: ListActiveUsersForSystemPlaylists :many
|
||||
@@ -72,7 +80,7 @@ recent7 AS (
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
AND t.artist_id IS NOT NULL
|
||||
@@ -83,7 +91,7 @@ recent30 AS (
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND t.artist_id IS NOT NULL
|
||||
@@ -94,7 +102,7 @@ alltime AS (
|
||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||
2 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND t.artist_id IS NOT NULL
|
||||
GROUP BY t.artist_id
|
||||
@@ -139,7 +147,7 @@ SELECT c.artist_id,
|
||||
WITH recent AS (
|
||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - INTERVAL '30 days'
|
||||
AND pe.was_skipped = false
|
||||
@@ -148,7 +156,7 @@ WITH recent AS (
|
||||
alltime AS (
|
||||
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
GROUP BY t.id
|
||||
@@ -181,7 +189,7 @@ SELECT id
|
||||
SELECT COALESCE(
|
||||
(SELECT t.id
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN tracks t ON t.id = pe.track_id AND t.missing_since IS NULL
|
||||
WHERE pe.user_id = $1
|
||||
AND t.artist_id = $2
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
|
||||
@@ -137,3 +137,34 @@ RETURNING id, album_id, artist_id, file_path, mbid;
|
||||
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||
-- (#357). Mirror of GetArtistsByIDs.
|
||||
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
|
||||
|
||||
-- name: ListTrackPathsForReconcile :many
|
||||
-- Every row's path + current missing mark, for the scanner's reconcile pass
|
||||
-- (#2523). Deliberately unfiltered and unpaged: reconcile has to compare the
|
||||
-- WHOLE table against what the walk saw, and a filtered subset would let rows
|
||||
-- outside it drift forever. Three narrow columns keep it cheap even on a
|
||||
-- library of a few hundred thousand tracks.
|
||||
SELECT id, file_path, missing_since FROM tracks;
|
||||
|
||||
-- name: MarkTracksMissing :execrows
|
||||
-- Marks rows whose file the walk did not see. `missing_since IS NULL` in the
|
||||
-- predicate makes this idempotent: a row already marked keeps its ORIGINAL
|
||||
-- timestamp, so "how long has it been gone" survives repeated scans. Losing
|
||||
-- that would make any age-based cleanup policy meaningless.
|
||||
--
|
||||
-- updated_at is deliberately NOT touched. It tracks content changes and gates
|
||||
-- the scanner's mtime skip; moving it here would make a returning file look
|
||||
-- newer than its own mtime and stop its tags being re-read.
|
||||
UPDATE tracks
|
||||
SET missing_since = now()
|
||||
WHERE id = ANY(sqlc.arg(ids)::uuid[])
|
||||
AND missing_since IS NULL;
|
||||
|
||||
-- name: ClearTracksMissing :execrows
|
||||
-- Clears the mark on rows whose file is back. Runs independently of the mtime
|
||||
-- skip check, so a file that reappears unchanged is un-marked even though the
|
||||
-- scanner skips re-reading its tags.
|
||||
UPDATE tracks
|
||||
SET missing_since = NULL
|
||||
WHERE id = ANY(sqlc.arg(ids)::uuid[])
|
||||
AND missing_since IS NOT NULL;
|
||||
|
||||
Reference in New Issue
Block a user