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.
284 lines
9.1 KiB
Go
284 lines
9.1 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: discover.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const listCrossUserLikedTracksForDiscover = `-- name: ListCrossUserLikedTracksForDiscover :many
|
|
SELECT t.id, t.album_id, t.artist_id
|
|
FROM general_likes gl
|
|
JOIN tracks t ON t.id = gl.track_id
|
|
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
|
|
AND pe.track_id = t.id
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes ml
|
|
WHERE ml.user_id = $1 AND ml.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
GROUP BY t.id, t.album_id, t.artist_id
|
|
ORDER BY md5(t.id::text || $2::text)
|
|
LIMIT 60
|
|
`
|
|
|
|
type ListCrossUserLikedTracksForDiscoverParams struct {
|
|
UserID pgtype.UUID
|
|
Column2 string
|
|
}
|
|
|
|
type ListCrossUserLikedTracksForDiscoverRow struct {
|
|
ID pgtype.UUID
|
|
AlbumID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// Tracks any OTHER user has liked, that this user hasn't played,
|
|
// liked, or had quarantined. On single-user servers this returns 0
|
|
// rows (the caller's slot redistribution rolls the deficit into the
|
|
// other two buckets).
|
|
// $1 = user_id, $2 = date string for md5 ordering.
|
|
//
|
|
// GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
|
|
// not appear in the select list — Postgres rejects DISTINCT + ORDER BY
|
|
// by-expression at plan time (SQLSTATE 42P10), which previously caused
|
|
// the entire Discover build to fail silently.
|
|
func (q *Queries) ListCrossUserLikedTracksForDiscover(ctx context.Context, arg ListCrossUserLikedTracksForDiscoverParams) ([]ListCrossUserLikedTracksForDiscoverRow, error) {
|
|
rows, err := q.db.Query(ctx, listCrossUserLikedTracksForDiscover, arg.UserID, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListCrossUserLikedTracksForDiscoverRow
|
|
for rows.Next() {
|
|
var i ListCrossUserLikedTracksForDiscoverRow
|
|
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDormantArtistTracksForDiscover = `-- name: ListDormantArtistTracksForDiscover :many
|
|
|
|
WITH user_artist_counts AS (
|
|
SELECT t.artist_id, COUNT(*) AS play_count
|
|
FROM play_events pe
|
|
JOIN tracks t ON t.id = pe.track_id
|
|
WHERE pe.user_id = $1
|
|
AND pe.was_skipped = false
|
|
GROUP BY t.artist_id
|
|
),
|
|
dormant_artists AS (
|
|
SELECT a.id
|
|
FROM artists a
|
|
LEFT JOIN user_artist_counts uac ON uac.artist_id = a.id
|
|
WHERE COALESCE(uac.play_count, 0) < 10
|
|
)
|
|
SELECT t.id, t.album_id, t.artist_id
|
|
FROM tracks t
|
|
JOIN dormant_artists da ON da.id = t.artist_id
|
|
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
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes gl
|
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
ORDER BY md5(t.id::text || $2::text)
|
|
LIMIT 80
|
|
`
|
|
|
|
type ListDormantArtistTracksForDiscoverParams struct {
|
|
UserID pgtype.UUID
|
|
Column2 string
|
|
}
|
|
|
|
type ListDormantArtistTracksForDiscoverRow struct {
|
|
ID pgtype.UUID
|
|
AlbumID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// Discover playlist bucket queries. Each returns up to its LIMIT
|
|
// count of (track_id, album_id, artist_id) triples ordered
|
|
// daily-deterministically via md5(track_id || dateStr). The Go-side
|
|
// bucket allocator (internal/playlists/discover.go) applies
|
|
// per-album (<=2) and per-artist (<=3) caps then redistributes any
|
|
// bucket deficit equally across the others. LIMIT values are
|
|
// generous so the caps + redistribution have headroom.
|
|
// Tracks whose artist this user has played fewer than 10 times total.
|
|
// The artist threshold defines "dormant" — we want to surface the
|
|
// long tail of artists in the user's library that they rarely listen
|
|
// to. Excludes tracks the user has played, liked, or has quarantined.
|
|
// $1 = user_id, $2 = date string for md5 ordering.
|
|
func (q *Queries) ListDormantArtistTracksForDiscover(ctx context.Context, arg ListDormantArtistTracksForDiscoverParams) ([]ListDormantArtistTracksForDiscoverRow, error) {
|
|
rows, err := q.db.Query(ctx, listDormantArtistTracksForDiscover, arg.UserID, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListDormantArtistTracksForDiscoverRow
|
|
for rows.Next() {
|
|
var i ListDormantArtistTracksForDiscoverRow
|
|
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listRandomUnheardTracksForDiscover = `-- name: ListRandomUnheardTracksForDiscover :many
|
|
SELECT t.id, t.album_id, t.artist_id
|
|
FROM tracks t
|
|
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
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes gl
|
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
ORDER BY md5(t.id::text || $2::text)
|
|
LIMIT 200
|
|
`
|
|
|
|
type ListRandomUnheardTracksForDiscoverParams struct {
|
|
UserID pgtype.UUID
|
|
Column2 string
|
|
}
|
|
|
|
type ListRandomUnheardTracksForDiscoverRow struct {
|
|
ID pgtype.UUID
|
|
AlbumID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// Random sample from the entire library. Same exclusion filters as
|
|
// the other two buckets. The generous LIMIT (200) gives the caller
|
|
// headroom for the per-album/artist caps and the slot redistribution.
|
|
// $1 = user_id, $2 = date string for md5 ordering.
|
|
func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg ListRandomUnheardTracksForDiscoverParams) ([]ListRandomUnheardTracksForDiscoverRow, error) {
|
|
rows, err := q.db.Query(ctx, listRandomUnheardTracksForDiscover, arg.UserID, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListRandomUnheardTracksForDiscoverRow
|
|
for rows.Next() {
|
|
var i ListRandomUnheardTracksForDiscoverRow
|
|
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
|
|
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 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
|
|
WHERE pe.user_id = $1
|
|
AND pe.track_id = t.id
|
|
AND pe.was_skipped = false
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM general_likes gl
|
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM lidarr_quarantine q
|
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
)
|
|
GROUP BY t.id, t.album_id, t.artist_id
|
|
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
|
LIMIT 120
|
|
`
|
|
|
|
type ListTasteUnheardTracksForDiscoverParams struct {
|
|
UserID pgtype.UUID
|
|
Column2 string
|
|
}
|
|
|
|
type ListTasteUnheardTracksForDiscoverRow struct {
|
|
ID pgtype.UUID
|
|
AlbumID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
|
|
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
|
|
// weight — "new to you, but your vibe" rather than the crude random arm.
|
|
// Genres live inline on tracks.genre as a delimited string, split the
|
|
// same way the radio tag_overlap arm does (regexp_split_to_table on
|
|
// [;,]). Same exclusion filters as the other buckets. Returns nothing
|
|
// when the user has no taste tags yet (cold start), so the caller
|
|
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
|
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
|
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
|
|
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListTasteUnheardTracksForDiscoverRow
|
|
for rows.Next() {
|
|
var i ListTasteUnheardTracksForDiscoverRow
|
|
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|