DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
100 changed files with 648 additions and 698 deletions
Showing only changes of commit 1d4ef1a23f - Show all commits
+2 -2
View File
@@ -120,7 +120,7 @@ type ListTracksNeedingAcoustIDLookupRow struct {
DurationMs int32
}
// AcoustID recording-id lookup (M401). Migration 0069 says what each
// AcoustID recording-id lookup. Migration 0069 says what each
// lookup state means.
// The lookup worker's queue: present tracks with no tag MBID and no lookup
// since their file last changed. A looked-up id is included once its row is
@@ -257,7 +257,7 @@ type SetTrackMbidFromAcoustIDParams struct {
ID pgtype.UUID
}
// Never over a tag id (D4): the guard is here, not only in the worker, so a
// Never over a tag id: the guard is here, not only in the worker, so a
// tag the scan read between the worker's list and this write still wins.
// updated_at is left alone: it gates the scan's mtime skip, and moving it
// past a file's mtime would hide a change made just before the lookup.
+1 -1
View File
@@ -147,7 +147,7 @@ type ListAcquisitionServicesRow struct {
UpdatedAt pgtype.Timestamptz
}
// Indexers and download clients (M502 #5518).
// Indexers and download clients.
// Returns no secret, only whether one is set: this feeds the settings page.
func (q *Queries) ListAcquisitionServices(ctx context.Context) ([]ListAcquisitionServicesRow, error) {
rows, err := q.db.Query(ctx, listAcquisitionServices)
+1 -1
View File
@@ -109,7 +109,7 @@ type ListListeningNowRow struct {
AlbumTitle string
}
// The admin Overview's status sections (M504): who is listening, what is
// The admin Overview's status sections: who is listening, what is
// downloading, what the library holds. Each is one cheap read.
// Plays in progress: open play events still inside their track's length,
// with a few minutes' grace for a pause. A client that never says a play
+3 -3
View File
@@ -16,7 +16,7 @@ SELECT
(SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests,
(SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since,
-- A broken track is one report however it was found: a listener flagged
-- it, or it failed to play (#5606). Counted by track, as the Reports
-- it, or it failed to play. Counted by track, as the Reports
-- page lists them.
(SELECT count(*) FROM (
SELECT track_id FROM quarantine
@@ -53,11 +53,11 @@ type AdminSummaryRow struct {
MissingSince pgtype.Timestamptz
}
// What the admin rail and the Overview's verdict read (#5605): each queue
// What the admin rail and the Overview's verdict read: each queue
// that waits on a person, with how many and since when. One round trip, so
// the rail costs one cheap read on every admin page. The "since" is the
// oldest item still waiting: how long the queue has been left, which is what
// tells the operator a count of 2 is fine today and wrong after a month.
// tells an admin a count of 2 is fine today and wrong after a month.
func (q *Queries) AdminSummary(ctx context.Context) (AdminSummaryRow, error) {
row := q.db.QueryRow(ctx, adminSummary)
var i AdminSummaryRow
+22 -27
View File
@@ -15,7 +15,7 @@ const countAlbums = `-- name: CountAlbums :one
SELECT COUNT(*) FROM albums
`
// M6a: total album count for the /api/library/albums envelope.
// Total album count for the /api/library/albums envelope.
func (q *Queries) CountAlbums(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countAlbums)
var count int64
@@ -59,7 +59,7 @@ type DeleteAlbumIfEmptyRow struct {
ArtistID pgtype.UUID
}
// M7 #372: deletes the album row only when it has no remaining tracks.
// Deletes the album row only when it has no remaining tracks.
// Used after a track delete to tidy up orphaned albums. RETURNING gives
// us the artist_id so the service can chain into the artist-empty check.
// Returns no rows when the album still has tracks; the service treats
@@ -91,12 +91,12 @@ type GetAlbumByArtistAndTitleParams struct {
}
// Scanner uses this for the no-mbid dedupe path: resolve-or-create. An album
// of the same name in another release group is a different album (Weezer's
// six self-titled albums shared one row until 2026-10-20), so a row whose
// of the same name in another release group is a different album (one
// artist's self-titled albums), so a row whose
// group is known and differs is not returned. A row of the file's own group
// comes first, then one with no group yet, then the one holding the most
// tracks from the file's folder: a file tagged with no group (a live bonus
// track beside Weezer's 1994 album) belongs with its folder, not the oldest
// track beside its album) belongs with its folder, not the oldest
// row, and a few strays already misfiled do not outvote the folder.
func (q *Queries) GetAlbumByArtistAndTitle(ctx context.Context, arg GetAlbumByArtistAndTitleParams) (Album, error) {
row := q.db.QueryRow(ctx, getAlbumByArtistAndTitle,
@@ -166,7 +166,7 @@ type GetAlbumCoverageRollupRow struct {
PendingNoMbid int64
}
// M7 coverage-gauge: library-wide cover-art coverage rollup for the
// Library-wide cover-art coverage rollup for the
// admin gauge. Single sequential scan with FILTER aggregates —
// sub-millisecond on realistic libraries. pending_no_mbid is a SUBSET
// of pending; the UI surfaces it separately so operators can see how
@@ -176,12 +176,9 @@ type GetAlbumCoverageRollupRow struct {
// Invariant: with_art + pending + settled = total. (pending_no_mbid is
// not part of the sum — it's a subset of pending, surfaced separately.)
//
// The IN list below must stay in sync with the cover_art_source CHECK
// constraint — currently relaxed by migration 0020 to include 'deezer'
// and 'lastfm', and migration 0030 further relaxed it to any non-empty
// string. If a new source value is added without updating this query,
// with_art will silently undercount. Drift #556 caught the deezer +
// lastfm omission introduced by migration 0020.
// The IN list below must name every source the enrichers write (the
// cover_art_source CHECK accepts any non-empty string since migration
// 0030). A source missing here makes with_art silently undercount.
func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageRollupRow, error) {
row := q.db.QueryRow(ctx, getAlbumCoverageRollup)
var i GetAlbumCoverageRollupRow
@@ -236,7 +233,7 @@ SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, cre
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). Mirror of GetArtistsByIDs.
// . Mirror of GetArtistsByIDs.
func (q *Queries) GetAlbumsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Album, error) {
rows, err := q.db.Query(ctx, getAlbumsByIDs, dollar_1)
if err != nil {
@@ -277,7 +274,7 @@ SELECT release_group_mbid::text
LIMIT 1
`
// #5241: the release group the library already knows for a release id, so a
// The release group the library already knows for a release id, so a
// request carrying a release id can be repaired without asking MusicBrainz.
func (q *Queries) GetReleaseGroupForReleaseMbid(ctx context.Context, mbid *string) (string, error) {
row := q.db.QueryRow(ctx, getReleaseGroupForReleaseMbid, mbid)
@@ -400,7 +397,7 @@ type ListAlbumsAlphaWithArtistRow struct {
ArtistName string
}
// M6a: alpha-sorted album list joined with artist_name. Used by
// Alpha-sorted album list joined with artist_name. Used by
// /api/library/albums for the wrapping-grid page. Stable id-tiebreak.
func (q *Queries) ListAlbumsAlphaWithArtist(ctx context.Context, arg ListAlbumsAlphaWithArtistParams) ([]ListAlbumsAlphaWithArtistRow, error) {
rows, err := q.db.Query(ctx, listAlbumsAlphaWithArtist, arg.Limit, arg.Offset)
@@ -547,12 +544,10 @@ type ListAlbumsByGenreParams struct {
// Album "belongs to" a genre if any of its tracks carry that genre.
// Serves Subsonic getAlbumList?type=byGenre.
//
// Splits tracks.genre on [;,] as of #367. It previously compared the whole
// column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
// "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
// every multi-genre track. This also aligns the endpoint with
// recommendation.sql / discover.sql, which have always split, and with the
// genre browse index that #367 adds.
// Splits tracks.genre on [;,], so a track tagged "Rock;Pop" is reachable from
// both "Rock" and "Pop"; comparing the whole column would hide every
// multi-genre track from a Subsonic client asking for a genre. The same split
// as recommendation.sql, discover.sql and the genre browse index.
//
// EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
// (track, genre-fragment), so a join would multiply rows per album and lean
@@ -620,7 +615,7 @@ type ListAlbumsMissingMbidWithTrackRow struct {
// one of its tracks' file_path so the worker can re-read tags. LIMIT
// supplied by caller for batching/progress purposes. The track read is
// one still on disk; an album with none left is skipped until a scan
// finds its files again (#5139).
// finds its files again.
func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) {
rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit)
if err != nil {
@@ -738,7 +733,7 @@ type ListRecentlyAddedAlbumsWithArtistRow struct {
ArtistName string
}
// M6a: recently-added albums joined with artist_name + artist_id for the
// Recently-added albums joined with artist_name + artist_id for the
// home-page section. created_at is the row-insert timestamp from the
// scanner — the closest proxy to "added to library". Stable secondary
// ordering by id keeps pagination tie-break deterministic.
@@ -838,7 +833,7 @@ type SetAlbumCoverWithVersionParams struct {
CoverArtSourcesVersion int32
}
// M7 cover-sources: records a successful or settled enrichment with the
// Records a successful or settled enrichment with the
// current sources_version stamp. cover_art_path is NULL when source is
// 'none' (no file written). Atomic; called per album per pass.
func (q *Queries) SetAlbumCoverWithVersion(ctx context.Context, arg SetAlbumCoverWithVersionParams) error {
@@ -863,7 +858,7 @@ type SetAlbumMbidIfNullParams struct {
Mbid *string
}
// M7 #379: heal MBID on existing album rows during rescan. Idempotent —
// Heal MBID on existing album rows during rescan. Idempotent —
// only updates when the existing mbid is NULL.
func (q *Queries) SetAlbumMbidIfNull(ctx context.Context, arg SetAlbumMbidIfNullParams) error {
_, err := q.db.Exec(ctx, setAlbumMbidIfNull, arg.ID, arg.Mbid)
@@ -882,7 +877,7 @@ type SetAlbumReleaseGroupMbidIfNullParams struct {
ReleaseGroupMbid *string
}
// #5241: heal the release-group id on an existing album row, from a rescan's
// Heal the release-group id on an existing album row, from a rescan's
// tags or from the re-acquisition sweeper's MusicBrainz lookup. Only fills a
// NULL, so a tag never gets overwritten by a later guess or vice versa.
func (q *Queries) SetAlbumReleaseGroupMbidIfNull(ctx context.Context, arg SetAlbumReleaseGroupMbidIfNullParams) error {
@@ -902,7 +897,7 @@ type SetReleaseGroupForReleaseMbidIfNullParams struct {
ReleaseMbid string
}
// #5241: cache a MusicBrainz-resolved release group onto the album row that
// Cache a MusicBrainz-resolved release group onto the album row that
// holds the release, filling only a NULL.
func (q *Queries) SetReleaseGroupForReleaseMbidIfNull(ctx context.Context, arg SetReleaseGroupForReleaseMbidIfNullParams) error {
_, err := q.db.Exec(ctx, setReleaseGroupForReleaseMbidIfNull, arg.ReleaseGroupMbid, arg.ReleaseMbid)
+6 -6
View File
@@ -19,7 +19,7 @@ UPDATE artists
AND artist_art_source = 'none'
`
// M7 cover-sources: parallel of ClearAlbumCoverNone. Used by the
// Parallel of ClearAlbumCoverNone. Used by the
// eligibility loop to flip stale 'none' rows back to NULL.
func (q *Queries) ClearArtistArtNone(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, clearArtistArtNone, id)
@@ -56,7 +56,7 @@ WHERE a.id = $1
RETURNING a.id
`
// M7 #372: deletes the artist row only when it has no remaining albums
// Deletes the artist row only when it has no remaining albums
// AND no remaining tracks (defensive — tracks can in principle exist
// without an album, though the scanner doesn't write that shape today).
func (q *Queries) DeleteArtistIfEmpty(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) {
@@ -253,7 +253,7 @@ type ListArtistsAlphaWithCoversRow struct {
AlbumCount int64
}
// M6a: alpha-sorted artist list with derived cover_album_id (most-recent
// Alpha-sorted artist list with derived cover_album_id (most-recent
// album whose cover_art_path is set). Replaces ListArtistsAlpha for the
// /api/artists?sort=alpha branch — the new column is appended, so the
// alpha branch is purely additive on the wire. album_count is computed
@@ -305,7 +305,7 @@ type ListArtistsMissingArtParams struct {
Limit int32
}
// M7 cover-sources: returns artist rows the artist-art enricher should
// Returns artist rows the artist-art enricher should
// attempt. Eligible: artist_art_source IS NULL OR (= 'none' AND stale
// artist_art_sources_version). MBID-less artists are returned but the
// enricher skips them at the row.mbid == nil guard inside the chain.
@@ -432,7 +432,7 @@ type SetArtistArtWithVersionParams struct {
ArtistArtSourcesVersion int32
}
// M7 cover-sources: records a successful or settled artist-art
// Records a successful or settled artist-art
// enrichment with the current sources_version stamp. thumb_path /
// fanart_path are NULL on a 'none' result, or independently NULL if
// the provider returned only one of the two image types (e.g.
@@ -460,7 +460,7 @@ type SetArtistMbidIfNullParams struct {
Mbid *string
}
// M7 #379: heal MBID on existing artist rows during rescan. Idempotent —
// Heal MBID on existing artist rows during rescan. Idempotent —
// only updates when the existing mbid is NULL, so concurrent scans don't
// overwrite an existing value with a stale one.
func (q *Queries) SetArtistMbidIfNull(ctx context.Context, arg SetArtistMbidIfNullParams) error {
+1 -1
View File
@@ -84,7 +84,7 @@ type ListAuditLogByActionsParams struct {
}
// One page of the given actions, newest first: the duplicates report's
// "resolved automatically" activity (M498) reads the resolver's merges from
// "resolved automatically" activity reads the resolver's merges from
// here.
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogByActions,
+16 -23
View File
@@ -75,7 +75,7 @@ type ListAlbumYearsWithCountRow struct {
AlbumCount int64
}
// Year browse index (#367). Only albums with a release_date appear — an
// Year browse index. 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.
@@ -244,8 +244,7 @@ ORDER BY trim(g.genre)
// 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.
// chip always leads to a page that actually contains this album.
func (q *Queries) ListGenresForAlbum(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listGenresForAlbum, albumID)
if err != nil {
@@ -314,30 +313,24 @@ 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
// Every query in this file filters `tracks.missing_since IS NULL`. 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 — and
// browsing is a way of finding something to play, so a track that cannot play
// should not shape it.
//
// The year axis filters too (#2702). It used to join albums only, on the
// reasoning that an album is a real release even when some of its tracks are
// gone — true, but it left the two browse axes disagreeing: the genre queries
// above filter tracks.missing_since, so an album whose every file had vanished
// was already absent from genre while still listed under its year, where
// opening it found nothing playable.
// The year axis filters too, so the two axes agree: an album whose every file
// has vanished is absent from both, rather than absent from its genre and
// still listed under its year, where opening it would find nothing playable.
//
// Hiding is the answer rather than showing-and-marking because browsing is how
// you go looking for something to play, and the operator's rule for that case
// is to take it out of view; the admin missing-files surface is where absence
// is reported. It also means changing the axis that was inconsistent rather
// than the one that was already right.
// Hiding rather than showing-and-marking, because browsing is how you go
// looking for something to play; the admin missing-files surface is where
// absence is reported.
//
// The predicate is "has at least one playable track", so it also excludes an
// album with no tracks at all. That is the same answer for the same reason:
// nothing to play, nothing to browse to.
// Genre browse index (#367).
// Genre browse index.
//
// 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 —
@@ -348,14 +341,14 @@ type ListGenresWithCountRow struct {
// 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.
// exposed as-is, 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.
// so alphabetical would bury the handful of genres a library actually has
// a lot of. Name breaks ties for a stable order.
// Ordered by the expression, not the output alias: `ORDER BY genre` is
// ambiguous between the alias and tracks.genre, and sqlc rejects it.
func (q *Queries) ListGenresWithCount(ctx context.Context) ([]ListGenresWithCountRow, error) {
+2 -2
View File
@@ -110,7 +110,7 @@ SELECT candidate_mbid, tag, weight
WHERE candidate_mbid = ANY($1::text[])
`
// Cached tags for a set of candidates, for slice 6's taste-overlap ranking.
// Cached tags for a set of candidates, for the taste-overlap ranking.
// One row per (candidate, tag).
func (q *Queries) ListCandidateArtistTagsForMbids(ctx context.Context, dollar_1 []string) ([]CandidateArtistTag, error) {
rows, err := q.db.Query(ctx, listCandidateArtistTagsForMbids, dollar_1)
@@ -160,7 +160,7 @@ type ListCandidateArtistsMissingTagsRow struct {
TotalScore float64
}
// Folksonomy tags for out-of-library Discover candidates (#2376). Parallel to
// Folksonomy tags for out-of-library Discover candidates. Parallel to
// track_tags.sql, but keyed by MBID because the artist has no local row. See
// 0050_candidate_artist_tags.up.sql for why the bookkeeping is its own table.
// Candidates eligible for tag enrichment: never processed (no state row) or
+1 -1
View File
@@ -352,7 +352,7 @@ type UpsertMBArtistParams struct {
Country string
}
// The release catalogue read from MusicBrainz (M502 #5519).
// The release catalogue read from MusicBrainz.
func (q *Queries) UpsertMBArtist(ctx context.Context, arg UpsertMBArtistParams) error {
_, err := q.db.Exec(ctx, upsertMBArtist,
arg.Mbid,
+1 -1
View File
@@ -14,7 +14,7 @@ const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence'
`
// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
// Household co-play similarity. Minstrel is a
// single shared-library, multi-user server (no per-user library ACL), so the
// "household" is the whole instance's user set. These queries recompute
// artist–artist co-occurrence edges from play_events and store them in
+2 -2
View File
@@ -77,7 +77,7 @@ SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
ORDER BY display_order, provider_id
`
// M7 cover-sources: queries for the cover_art_provider_settings table
// Queries for the cover_art_provider_settings table
// and the cover_art_sources_meta singleton. The SettingsService
// consumes these.
// Returns all rows from cover_art_provider_settings. Used by the
@@ -155,7 +155,7 @@ type UpsertProviderSettingsParams struct {
// INSERT a default row for a newly-registered provider, or update an
// existing row's display_order on boot reconciliation. Does NOT
// overwrite enabled / api_key on conflict — the operator's settings
// overwrite enabled / api_key on conflict — an admin's settings
// persist across restarts.
func (q *Queries) UpsertProviderSettings(ctx context.Context, arg UpsertProviderSettingsParams) error {
_, err := q.db.Exec(ctx, upsertProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder)
+3 -3
View File
@@ -31,7 +31,7 @@ UPDATE albums
WHERE id = $1 AND cover_art_source = 'none'
`
// M7 #380: when MBID backfill heals an album, clear its 'none' cover_art_source
// When MBID backfill heals an album, clear its 'none' cover_art_source
// back to NULL so the enricher's next batch retries the MBCAA fetch.
// Idempotent — only updates rows currently stamped 'none'.
func (q *Queries) ClearAlbumCoverNone(ctx context.Context, id pgtype.UUID) error {
@@ -128,8 +128,8 @@ type ListAlbumsMissingCoverParams struct {
Limit int32
}
// M7 #353: album cover enrichment queries.
// M7 cover-sources: returns rows the enricher should attempt. Eligible:
// Album cover enrichment queries.
// Returns rows the enricher should attempt. Eligible:
// cover_art_source IS NULL (never tried), OR cover_art_source = 'none'
// AND cover_art_sources_version != current (settled under an older
// source set; recheck against the current chain). LIMIT supplied by
+14 -14
View File
@@ -15,7 +15,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND gl.user_id != $1
AND NOT EXISTS (
SELECT 1 FROM play_events pe
@@ -55,8 +55,8 @@ type ListCrossUserLikedTracksForDiscoverRow struct {
//
// 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.
// by-expression at plan time (SQLSTATE 42P10), which would fail the
// entire Discover build.
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 {
@@ -96,7 +96,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
@@ -161,7 +161,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
@@ -223,7 +223,7 @@ WITH scored AS (
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 = $3 AND trim(g_split.g) = nt.tag
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
@@ -274,7 +274,7 @@ type ListTasteUnheardTracksForDiscoverRow struct {
}
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
// taste-profile tags (taste_profile_tags), 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
@@ -282,13 +282,13 @@ type ListTasteUnheardTracksForDiscoverRow struct {
// when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
//
// The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
// weight rewards a track for carrying many of the user's tags, so a few
// artists whose every track is tagged with the whole profile take every row
// of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
// the best-performing arm handed its slots to the others. Ranking within
// album, then within artist over what the album cap kept, is the same walk
// capByAlbumAndArtist makes, so the caller's caps keep everything here.
// The per-album and per-artist caps apply BEFORE the LIMIT. Summed weight
// rewards a track for carrying many of the user's tags, so a few artists
// whose every track is tagged with the whole profile would take every row of
// a plain LIMIT, the caller's caps would then leave a handful, and the arm
// would hand its slots to the others. Ranking within album, then within
// artist over what the album cap kept, is the same walk capByAlbumAndArtist
// makes, so the caller's caps keep everything here.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover,
arg.MaxPerArtist,
+10 -10
View File
@@ -55,9 +55,9 @@ SELECT count(*)::bigint
END
`
// CountPendingDuplicateGroups narrowed to one view of the report (M498):
// CountPendingDuplicateGroups narrowed to one view of the report:
//
// review what the operator still has to decide: every class but
// review what an admin still has to decide: every class but
// cross_release, and groups not yet classified
// cross_release the same song on different releases, which is kept, not merged
func (q *Queries) CountPendingDuplicateGroupsInView(ctx context.Context, view string) (int64, error) {
@@ -205,7 +205,7 @@ type ListDismissedDuplicateMemberSetsRow struct {
TrackIds []pgtype.UUID
}
// What the operator has already said are not duplicates. A new proposal whose
// What an admin has already said are not duplicates. A new proposal whose
// every member sat together in one of these is not proposed again.
func (q *Queries) ListDismissedDuplicateMemberSets(ctx context.Context) ([]ListDismissedDuplicateMemberSetsRow, error) {
rows, err := q.db.Query(ctx, listDismissedDuplicateMemberSets)
@@ -236,7 +236,7 @@ SELECT t.id, t.duration_ms, f.audio_stream_sha256, f.chromaprint
AND f.chromaprint IS NOT NULL
-- Only chromaprints taken at the current length: prints at two lengths are not
-- comparable, and after a length change the backfill is still re-deriving the
-- rest (#3913).
-- rest.
AND f.chromaprint_length_sec = $2
AND (t.duration_ms, t.id) > ($3::integer, $4::uuid)
ORDER BY t.duration_ms, t.id
@@ -356,19 +356,19 @@ type ListDuplicateGroupsForResolveRow struct {
AcoustidRecording *string
}
// Every pending group's present copies, for the resolver (M498 #5435): what it
// Every pending group's present copies, for the resolver: what it
// needs to classify the group and to choose the copy to keep. One row per copy,
// grouped by group. position_clash is true when another present track on the
// same album claims the same disc and track number: tags that do not fit the
// album, which the survivor rule weighs against a copy. source_verdict says
// whether the copy is a video rip ('suspect') or one the operator kept ('fine').
// whether the copy is a video rip ('suspect') or one an admin kept ('fine').
// prior_class is the class the last pass gave the group, NULL for a group no
// pass has seen. release_group_mbid is the album's MusicBrainz group, from its
// tags or the library listing's match; ” when neither names one.
// acoustid_state and acoustid_recording are what AcoustID said of the file,
// when it was looked up (#6169: which song one recording cut to several slots
// is). group_id narrows it to one group, for the operator settling it (#6184);
// NULL reads them all.
// when it was looked up: which song a recording cut to several slots is.
// group_id narrows it to one group, for an admin settling it; NULL reads them
// all.
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupsForResolveRow, error) {
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve, groupID)
if err != nil {
@@ -533,7 +533,7 @@ type ListPendingDuplicateGroupMembersRow struct {
// One page of proposals in one view (see CountPendingDuplicateGroupsInView),
// newest first, flattened to one row per member so the handler folds them
// without a query per group. What each copy carries — likes and plays from
// every user — is here because it is what the operator weighs when deciding
// every user — is here because it is what an admin weighs when deciding
// which copy to keep.
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.View, arg.PageOffset, arg.PageLimit)
+2 -2
View File
@@ -131,7 +131,7 @@ type GetSystemPickKindForTrackParams struct {
// Looks a track up in the user's CURRENT snapshot of the given system
// variant and returns its pick_kind. Used at play-ingestion time to
// freeze provenance onto the play_event — snapshots rebuild daily, so
// attribution can't be reconstructed at read time (#1249/#1270). No
// attribution can't be reconstructed at read time. No
// row = track not in today's snapshot (caller stores NULL); a row with
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
// is non-singleton (up to 3 mixes/user); a track in two of them takes
@@ -166,7 +166,7 @@ type InsertPlayEventParams struct {
// pick_kind is non-NULL only for system-playlist plays whose track was
// found (with a stamped kind) in the user's live snapshot for that
// variant at ingestion time (#1249, generalized in #1270).
// variant at ingestion time.
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID,
+3 -3
View File
@@ -81,7 +81,7 @@ SELECT t.id, t.file_path
LEFT JOIN track_fingerprints f ON f.track_id = t.id
WHERE t.missing_since IS NULL
-- A row taken at another length is as stale as one from an older method:
-- chromaprints at two lengths cannot be compared (#3913).
-- chromaprints at two lengths cannot be compared.
AND (f.track_id IS NULL
OR f.fingerprint_version < $1
OR f.chromaprint_length_sec <> $2)
@@ -102,7 +102,7 @@ type ListTracksNeedingFingerprintRow struct {
FilePath string
}
// The backfill's work queue (#3908): tracks with no fingerprint, or one derived
// The backfill's work queue: tracks with no fingerprint, or one derived
// by an older method. Keyset-paged on id so a pass visits each track at most
// once. That cursor is load-bearing: an inconclusive attempt writes no row, so
// without it a file that keeps timing out would be listed again straight away
@@ -157,7 +157,7 @@ type UpsertTrackFingerprintParams struct {
}
// Written whenever a track's fingerprint is derived: by the scan when a file is
// new or its bytes changed, and by the backfill (#3908) for rows derived by an
// new or its bytes changed, and by the backfill for rows derived by an
// older method. Replaces the row wholesale — a fingerprint of the old bytes has
// no standing once the file has changed.
func (q *Queries) UpsertTrackFingerprint(ctx context.Context, arg UpsertTrackFingerprintParams) error {
+9 -11
View File
@@ -20,10 +20,9 @@ UPDATE play_sessions
)
`
// #565: play_sessions.ended_at was added but never populated by any
// writer. Close sessions whose last_event_at is older than 6h —
// treating that as "user moved on" the same way audio_service does
// after grace periods. Sessions with NO events (track_count = 0)
// play_sessions.ended_at has no other writer. Close sessions whose
// last_event_at is older than 6h — treating that as "user moved on" the
// same way audio_service does after grace periods. Sessions with NO events (track_count = 0)
// older than 1h are also closed (stale handshakes from clients that
// never recorded a play). ended_at is set to last_event_at so the
// session's duration reads naturally.
@@ -46,12 +45,11 @@ UPDATE play_events
AND started_at < now() - INTERVAL '24 hours'
`
// Background garbage-collector / lifecycle queries. All five address
// drift findings from the 2026-06-02 audit (Scribe parent #552):
// #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
// Background garbage-collector / lifecycle queries: rows nothing else closes
// or removes. Sweeper runs on a long tick (default 1h)
// so per-query cost is amortised; each is idempotent (re-running on
// already-closed/-deleted rows is a no-op).
// #566: play_events rows opened more than 24h ago that never got a
// Play_events rows opened more than 24h ago that never got a
// play_ended. Client crashed mid-track, network dropped, etc. We
// synthesize ended_at = started_at + duration_played_ms when present,
// otherwise leave duration_played_ms null and stamp ended_at = now()
@@ -72,7 +70,7 @@ DELETE FROM password_resets
OR (used_at IS NULL AND expires_at < now() - INTERVAL '1 hour')
`
// #575: password_resets accumulates expired + used rows forever. The
// Password_resets accumulates expired + used rows forever. The
// validation path already rejects them; this just keeps the table
// from growing unbounded. Used rows are kept for 7 days for audit;
// unused expired rows go immediately.
@@ -106,7 +104,7 @@ DELETE FROM scrobble_queue
AND enqueued_at < now() - INTERVAL '14 days'
`
// #567: scrobble_queue rows that have been in status='failed' for
// Scrobble_queue rows that have been in status='failed' for
// more than 14 days. The worker stops retrying after maxAttempts;
// failed rows accumulate forever otherwise. CASCADE from play_events
// already drops the row when the underlying event is deleted, so this
@@ -127,7 +125,7 @@ UPDATE system_playlist_runs
AND last_run_at < now() - INTERVAL '10 minutes'
`
// #574: system_playlist_runs.in_flight = true can wedge on a
// System_playlist_runs.in_flight = true can wedge on a
// goroutine panic between SET in_flight=true and SET in_flight=false.
// The duplicate-prevention check refuses to start a fresh regen while
// in_flight, so a stuck row blocks all future regens for that user.
+1 -1
View File
@@ -45,7 +45,7 @@ type ListUserHistoryRow struct {
ArtistName string
}
// M7 #365: track-level listening history. Returns one row per
// Track-level listening history. Returns one row per
// play_events entry, newest first, joined with tracks/albums/artists
// so the SPA can render a TrackRef without follow-up fetches. Filters
// out skipped events and tracks the user has quarantined (per-user
+5 -5
View File
@@ -297,7 +297,7 @@ type ListGrabsRow struct {
// The Wanted view's Activity tab: downloads waiting on a person first, then
// the rest still running, then the ended ones newest first. Each carries
// what is wrong with it now, if anything (#5523).
// what is wrong with it now, if anything.
func (q *Queries) ListGrabs(ctx context.Context, arg ListGrabsParams) ([]ListGrabsRow, error) {
rows, err := q.db.Query(ctx, listGrabs, arg.Skip, arg.MaxRows)
if err != nil {
@@ -366,7 +366,7 @@ type ListImportedGrabsHoldingRow struct {
// The downloads Minstrel imported into the folder holding path: the releases a
// file there came from, to block when the files proved not to be the album
// (#6169). A file imported before Minstrel downloaded anything has none.
// . A file imported before Minstrel downloaded anything has none.
func (q *Queries) ListImportedGrabsHolding(ctx context.Context, path string) ([]ListImportedGrabsHoldingRow, error) {
rows, err := q.db.Query(ctx, listImportedGrabsHolding, path)
if err != nil {
@@ -441,7 +441,7 @@ type ListLiveGrabsRow struct {
WantAlbumID pgtype.UUID
}
// Import with verification (M502 #5522).
// Import with verification.
// Every download still running or waiting on its import, oldest first.
func (q *Queries) ListLiveGrabs(ctx context.Context) ([]ListLiveGrabsRow, error) {
rows, err := q.db.Query(ctx, listLiveGrabs)
@@ -574,7 +574,7 @@ type ListUpgradeTracksRow struct {
SourceVerdict *string
}
// The tracks an upgrade may replace (#5524): the present tracks of the album
// The tracks an upgrade may replace: the present tracks of the album
// the want holds.
func (q *Queries) ListUpgradeTracks(ctx context.Context, albumID pgtype.UUID) ([]ListUpgradeTracksRow, error) {
rows, err := q.db.Query(ctx, listUpgradeTracks, albumID)
@@ -694,7 +694,7 @@ UPDATE grabs
import_forced = false,
imported_at = CASE WHEN $1::text = 'imported' THEN now() ELSE imported_at END,
-- The files were copied, so an imported download goes on sharing
-- until the lifecycle removes it (#5523).
-- until the lifecycle removes it.
seeding = $1::text = 'imported',
imported_files = $4,
destination = $5,
+4 -5
View File
@@ -116,11 +116,10 @@ type InsertLibraryChangesParams struct {
Op string
}
// Batch form, for a mutation that touches many rows at once (#2704: the scan's
// reconcile pass can mark up to a quarter of a library missing in one go).
// Every caller before this one changed a single entity, so per-row inserts
// were the right shape; a loop here would be thousands of round-trips inside
// a scan that is already the slow path.
// Batch form, for a mutation that touches many rows at once (the scan's
// reconcile pass can mark up to a quarter of a library missing in one go). A
// loop of single inserts would be thousands of round-trips inside a scan that
// is already the slow path.
func (q *Queries) InsertLibraryChanges(ctx context.Context, arg InsertLibraryChangesParams) error {
_, err := q.db.Exec(ctx, insertLibraryChanges, arg.EntityType, arg.EntityIds, arg.Op)
return err
+1 -1
View File
@@ -101,7 +101,7 @@ type RerootTrackPathsParams struct {
OldRoot string
}
// A library folder that moved (#6129): every path under old_root is rewritten
// A library folder that moved: every path under old_root is rewritten
// to the same place under new_root, so the tracks keep their ids and with them
// their plays, likes and playlists. updated_at is left alone on purpose: the
// scan compares it with each file's mtime, and the files have not changed.
+2 -2
View File
@@ -22,7 +22,7 @@ type CountGoneGrabsParams struct {
}
// How many of a release's grabs ended gone since a time: a release that keeps
// leaving its client is held back rather than taken on every pass (#6176).
// leaving its client is held back rather than taken on every pass.
func (q *Queries) CountGoneGrabs(ctx context.Context, arg CountGoneGrabsParams) (int64, error) {
row := q.db.QueryRow(ctx, countGoneGrabs, arg.InfoHash, arg.Since)
var count int64
@@ -289,7 +289,7 @@ type RaiseInterventionParams struct {
Detail string
}
// The download lifecycle (M502 #5523): the intervention ledger, giving a
// The download lifecycle: the intervention ledger, giving a
// download up, and seeding after import.
// A condition seen, or seen again: a second pass lands on the open row, so
// a download stuck for a week is one row that says how long. The name only
+1 -1
View File
@@ -93,7 +93,7 @@ type LikeTrackParams struct {
TrackID pgtype.UUID
}
// A like is on the song (M498 #5438): every copy of it sharing the track's
// A like is on the song: every copy of it sharing the track's
// song_key is liked. Returns the tracks newly liked, for the sync log; empty
// when the song was already liked.
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) ([]pgtype.UUID, error) {
+3 -3
View File
@@ -164,7 +164,7 @@ type GetReplayGainByTrackIDsRow struct {
}
// Track and album loudness for a set of tracks, for every surface that hands
// gains to a client (#4997). A measurement from an older analysis version is
// gains to a client. A measurement from an older analysis version is
// still delivered: it is a better gain than none until the backfill redoes it.
func (q *Queries) GetReplayGainByTrackIDs(ctx context.Context, ids []pgtype.UUID) ([]GetReplayGainByTrackIDsRow, error) {
rows, err := q.db.Query(ctx, getReplayGainByTrackIDs, ids)
@@ -280,7 +280,7 @@ type ListAlbumsNeedingLoudnessRow struct {
Digest string
}
// The album pass's work queue (#4996): albums whose present tracks or their
// The album pass's work queue: albums whose present tracks or their
// measurements have changed since album loudness was last computed, or that
// never had it. The digest is over every present track's id and the
// measurement it holds at the current version ('-' for none), so a track
@@ -484,7 +484,7 @@ type UpsertTrackLoudnessParams struct {
AnalysisVersion int16
}
// Written when the backfill measures a track (#4995). Replaces the row
// Written when the backfill measures a track. Replaces the row
// wholesale: a measurement of the old bytes has no standing once the file has
// changed.
func (q *Queries) UpsertTrackLoudness(ctx context.Context, arg UpsertTrackLoudnessParams) error {
+1 -1
View File
@@ -69,7 +69,7 @@ type LockDuplicateGroupForMergeRow struct {
Status string
}
// Duplicate merge (Scribe #3911). Every statement here runs inside the one
// Duplicate merge. Every statement here runs inside the one
// transaction library.MergeDuplicateGroup opens, after the removed copy's file
// is already gone. The loser's own track row is deleted last with DeleteTrack;
// what these do is move everything it carries onto the survivor first, so that
+8 -8
View File
@@ -63,27 +63,27 @@ type ListMissingTrackPairsRow struct {
PresentCandidates int32
}
// Missing-pair pass (M485). A track marked missing whose replacement is already
// Missing-pair pass. A track marked missing whose replacement is already
// on disk under another row — most often a quality upgrade, mp3 → flac at a new
// path — is folded into that replacement, so its history moves across and
// re-acquisition stops chasing music the library already has.
//
// Move adoption (#2528) cannot catch these: it links a new file to a missing row
// Move adoption cannot catch these: it links a new file to a missing row
// at the moment the file is first inserted, and the replacement arrives as a
// re-encode with no recording MBID, so it has nothing to match on. AcoustID may
// give it an MBID later; by then both rows exist and only a fold can join them.
// Every candidate (missing, present) pair, with how many candidates each side
// has. Two rules, measured on the operator's library on 2026-10-07 (#5274):
// has. Two rules:
//
// mbid the same recording MBID, within the album group (the album row, or
// albums sharing a release group). 142 of 359 pairs.
// title the same album row and the same title, ignoring case. 213 more. The
// albums sharing a release group).
// title the same album row and the same title, ignoring case. The
// replacements are often not the original file — another edition,
// re-encoded, untagged — so the metadata is what is left to go on.
//
// Deliberately NOT gated on duration or track position. On the 142 pairs known
// to be one recording, 18% differed by over 2s (old mp3 durations are poor
// estimates), and the poorly tagged set is exactly where numbering is broken.
// Deliberately NOT gated on duration or track position. Pairs known to be one
// recording often differ by over 2s (old mp3 durations are poor estimates),
// and the poorly tagged set is exactly where numbering is broken.
//
// A pair whose MBIDs are both set and differ is not a title match: the tags say
// they are different recordings. The caller folds a pair only when each side has
+7 -7
View File
@@ -54,7 +54,7 @@ type InsertNotificationParams struct {
EmailWanted bool
}
// M489 notifications inbox (#726). Rows are written only through
// Notifications inbox. Rows are written only through
// internal/notifications.Notifier, which decides recipients and honours each
// recipient's inbox preference before it reaches these.
// email_wanted is the recipient's email channel for this kind as of now. A
@@ -122,7 +122,7 @@ type ListEmailPendingNotificationsRow struct {
ReadAsOf pgtype.Timestamptz
}
// Email digest (#5346) ------------------------------------------------------
// Email digest ------------------------------------------------------
// Every unread, un-emailed row of a user who has an address, oldest first.
// Read rows are never selected: the user has seen them, so they are not news.
// read_as_of is the database's clock at the read, handed back to
@@ -348,7 +348,7 @@ type MarkAllNotificationsReadParams struct {
// all read" queued offline and replayed later must not mark notices that
// arrived in between, which the user never saw. A coalesced row that grew
// since then is marked read with it: it arrived before they asked, and its
// growth is a quiet update to a notice they had seen (M542).
// growth is a quiet update to a notice they had seen.
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
if err != nil {
@@ -484,10 +484,10 @@ type UpsertCoalescedNotificationParams struct {
// ten. Every coalesced kind's event is "N more happened", never a restated
// total, so the row always says what is new since it was last read.
//
// created_at and emailed_at are left alone (M542): an update changes what the
// notice says, not when it arrived. Moving created_at made the phone announce
// it again and clearing emailed_at sent it again, so a notice the hourly
// duplicate pass kept growing alerted every hour.
// created_at and emailed_at are left alone: an update changes what the
// notice says, not when it arrived. Moving created_at would make the phone
// announce it again and clearing emailed_at would send it again, so a notice
// that keeps growing would alert on every update.
func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, upsertCoalescedNotification,
arg.UserID,
+3 -3
View File
@@ -38,7 +38,7 @@ type AppendPlaylistTrackParams struct {
// Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms
// is converted to seconds for the snapshot. pick_kind is NULL for manual
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
// playlists; the For You builder stamps 'taste'/'fresh'.
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
var i PlaylistTrack
@@ -182,7 +182,7 @@ SELECT id, user_id, name, description, is_public, cover_path, track_count, durat
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). Mirror of GetArtistsByIDs.
// . Mirror of GetArtistsByIDs.
func (q *Queries) GetPlaylistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Playlist, error) {
rows, err := q.db.Query(ctx, getPlaylistsByIDs, dollar_1)
if err != nil {
@@ -295,7 +295,7 @@ type ListPlaylistTracksRow struct {
// (track was removed from the library). The denormalized snapshot fields
// on playlist_tracks remain authoritative for title/artist/album text.
//
// Deliberately NOT filtered on missing_since (#2527), unlike every browse /
// Deliberately NOT filtered on missing_since, unlike every browse /
// discover / mix query. A playlist entry is something the user put here on
// purpose, so a missing file stays in the list and renders as a dead row
// rather than silently vanishing; missing_since rides along so the service
+8 -8
View File
@@ -35,9 +35,9 @@ const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
SELECT id, enabled, grace_hours, max_per_pass FROM reacquisition_settings WHERE id = true
`
// Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
// Re-acquisition of missing files.
// The unit is the album: an album whose files went missing becomes a want for
// its release group, and the search schedule (#5521) looks for it.
// its release group, and the search schedule looks for it.
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, getReacquisitionSettings)
var i ReacquisitionSetting
@@ -140,17 +140,17 @@ type ListAlbumsDueReacquisitionRow struct {
//
// - it still has at least one track whose file has been missing longer than
// the grace window. Measured on missing_since, which reconcile never
// re-stamps (#2523), so it is a genuine "gone since" clock;
// re-stamps, so it is a genuine "gone since" clock;
// - MusicBrainz can name it: the artist has an MBID, and the album a
// release MBID or a release group. The release MBID is only ever used to
// find the group, and 238 albums matched to a group carried no release
// id, so their losses were never wanted back (#6167). Albums failing this
// are counted separately (CountAlbumsMissingWithoutMbid);
// - it is on the wanted list (#5567): an album taken off the list, or
// find the group, and an album matched to a group may carry no release
// id. Albums failing this are counted separately
// (CountAlbumsMissingWithoutMbid);
// - it is on the wanted list: an album taken off the list, or
// dropped into the library by hand, is not fetched back;
// - no want already covers it: none open, and none held since its files
// went missing. A want held after the loss means the library has a full
// copy despite the missing rows (#5245), so asking again would loop.
// copy despite the missing rows, so asking again would loop.
func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbumsDueReacquisitionParams) ([]ListAlbumsDueReacquisitionRow, error) {
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, arg.GraceHours, arg.PageLimit)
if err != nil {
+42 -49
View File
@@ -19,7 +19,7 @@ ORDER BY started_at DESC
LIMIT 1
`
// The device_class of the user's most recent play (#1551), used as the
// The device_class of the user's most recent play, used as the
// "current device" for radio context conditioning. NULL when the latest play
// predates device capture or came from a client that didn't send one.
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
@@ -87,10 +87,10 @@ type ListArtistContextPlayCountsForUserRow struct {
}
// Per-artist completed-play counts split by whether each play falls in the
// CURRENT context cell, in the user's local timezone (#1531). The cell is
// daypart × weekday-type, optionally narrowed by device class (#1551): when
// $2 (the current device) is ”, the device dimension is ignored (identical to
// the time-only #1531 behaviour, used by the daily mixes which have no device);
// CURRENT context cell, in the user's local timezone. The cell is
// daypart × weekday-type, optionally narrowed by device class: when
// $2 (the current device) is ”, the device dimension is ignored (time only,
// for the daily mixes, which have no device);
// when $2 is set (radio), a play only counts in the cell if its device_class
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
@@ -154,7 +154,7 @@ type ListLastPlayedArtistsForUserRow struct {
LastPlayedAt pgtype.Timestamptz
}
// M6a: artists ranked by max(play_events.started_at) for the user, with
// Artists ranked by max(play_events.started_at) for the user, with
// a derived cover_album_id via a representative-album lateral join (most
// recent album that has cover_art_path set). album_count joined for the
// ArtistRef wire shape.
@@ -216,7 +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 t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
@@ -303,7 +303,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -323,7 +323,7 @@ type ListMostPlayedTracksForUserRow struct {
ArtistName string
}
// M6a: top-N tracks by completed-play count for the user. was_skipped
// Top-N tracks by completed-play count for the user. was_skipped
// excludes skips so a user spamming next doesn't fabricate a top track.
// Quarantined tracks (per-user soft-hide from M5b) are filtered out.
//
@@ -723,7 +723,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 t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events
WHERE user_id = $1 AND track_id = t.id
@@ -843,11 +843,10 @@ lb_similar AS (
LIMIT $5
),
similar_artists AS (
-- Round-robin across the similar artists (#5297): each artist's first
-- Round-robin across the similar artists: each artist's first
-- track, best-scoring artist first, then each one's second, and so on.
-- Ordering by artist score alone let the closest similar artist's
-- catalogue fill the whole LIMIT; on the operator's library this arm held
-- exactly one artist for all 17 seeds measured (#3879).
-- Ordering by artist score alone would let the closest similar artist's
-- catalogue fill the whole LIMIT.
SELECT track_id, sim_score
FROM (
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score,
@@ -873,11 +872,10 @@ tag_overlap AS (
AND t.id <> $2
GROUP BY t.id
HAVING count(DISTINCT trim(g_split.g)) > 0
-- md5 breaks the ties (#3880). A seed with one tag gives every track of
-- that genre the same score, 7,804 tracks on the operator's library, and
-- with no tie-break the LIMIT took whichever rows Postgres reached first:
-- the same slice for every seed of the genre, and not one the order seed
-- controls.
-- md5 breaks the ties. A seed with one tag gives every track of that
-- genre the same score, and with no tie-break the LIMIT would take
-- whichever rows Postgres reached first: the same slice for every seed of
-- the genre, and not one the order seed controls.
ORDER BY sim_score DESC, md5(t.id::text || $12::text)
LIMIT $7
),
@@ -896,12 +894,10 @@ likes_overlap AS (
LIMIT $8
),
taste_overlap AS (
-- Round-robin across the taste artists (#3880), as similar_artists is
-- (#5297): each artist's first track, strongest artist first, then each
-- one's second. Ordered by weight then track id, the strongest artist's
-- catalogue filled the whole arm with the same lowest-id tracks every
-- time; on the operator's library that was 20 tracks of one artist of
-- 174, whose catalogue is 197 tracks.
-- Round-robin across the taste artists, as similar_artists is: each
-- artist's first track, strongest artist first, then each one's second.
-- Ordered by weight then track id, the strongest artist's catalogue would
-- fill the whole arm with the same lowest-id tracks every time.
SELECT track_id, sim_score
FROM (
SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight,
@@ -918,7 +914,7 @@ taste_overlap AS (
LIMIT $10
),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- Household co-play: tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
@@ -972,7 +968,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 AND t.missing_since IS NULL -- #2523: never offer a file that is gone
JOIN tracks t ON t.id = u.track_id AND t.missing_since IS NULL -- 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 (
@@ -1014,26 +1010,23 @@ type LoadRadioCandidatesV2Row struct {
SimilarityScore interface{}
}
// M4c: similarity-driven candidate pool. 6-way UNION:
// Similarity-driven candidate pool. 6-way UNION:
//
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
// $10 taste_overlap K (tracks by the user's top
// positively-weighted taste-profile artists, so taste-relevant tracks
// enter the pool even when the similarity/random arms miss them; scored
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
// $11 coplay_artists K (tracks by artists co-played across the
// instance with the seed's artist; source='user_cooccurrence'),
// $12 order_seed (text) — see below.
//
// $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned
// a stable set only while their LIMIT exceeded the rows eligible for them: at
// that point they returned all of them and the order stopped mattering,
// because the caller sorts by track id before scoring. Below that threshold
// they returned a random SUBSET, and two builds on the same day drew
// different ones — so "daily determinism" held by accident, and only for
// libraries smaller than the limits.
// $12 orders four arms by md5(id || seed), not random(). An arm whose LIMIT is
// below the rows eligible for it returns a SUBSET, and with random() two
// builds on the same day would draw different ones; the caller sorts by track
// id before scoring, so only the subset matters.
//
// md5(id || seed) keeps the intent — an arbitrary spread that changes when
// the seed does — while making it reproducible for a given seed. The CALLER
@@ -1042,7 +1035,7 @@ type LoadRadioCandidatesV2Row struct {
// keeps varying, which is what a radio should do.
// Returns same shape as LoadRadioCandidates plus similarity_score column.
//
// sim_score BANDS keep the tiers in order (rule 131, #6177). Similarity edges
// sim_score BANDS keep the eligibility tiers in order. Similarity edges
// are stored relative to the seed's best match, 0 to 1, so the arms are placed
// in bands rather than compared raw:
//
@@ -1052,9 +1045,9 @@ type LoadRadioCandidatesV2Row struct {
// likes_overlap 0.3, flat
// tier 3 taste_overlap, random_fill 0
//
// Without the bands, the seed's weaker LB edges fell below a shared genre tag
// and the flat likes score, and the one arm that measures the seed lost to
// the ones that guess.
// Without the bands, the seed's weaker LB edges would fall below a shared
// genre tag and the flat likes score, and the one arm that measures the seed
// would lose to the ones that guess.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
arg.UserID,
@@ -1122,9 +1115,9 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
-- Completed plays only: counting every play_event would make skipping an
-- artist repeatedly INCREASE its signal and push more of its neighbours
-- at the user.
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
@@ -1181,7 +1174,7 @@ contributions AS (
AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
-- Snoozed by this user and not yet expired. Time-boxed and
-- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a
@@ -1226,7 +1219,7 @@ type SuggestArtistsForUserRow struct {
// Per-user artist suggestions ranked by taste signal x similarity, projected
// through artist_similarity_unmatched (out-of-library candidates only).
//
// Seeds are TIERED (rule #131) so the surface never empties:
// Seeds are TIERED so the surface never empties:
//
// tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
// SIGNED by internal/taste, so an artist the user has drifted away
@@ -1234,9 +1227,9 @@ type SuggestArtistsForUserRow struct {
// tier 2 - likes + completed plays, used ONLY when the profile has no rows
// (new account, or before the first daily recompute).
//
// The signal is log-damped: contribution is signal x similarity, and the old
// undamped sum let one heavily-played artist's neighbours take every slot --
// entrenching harder the MORE the user listened (issue #2367 mechanism 2).
// The signal is log-damped: contribution is signal x similarity, and an
// undamped sum would let one heavily-played artist's neighbours take every
// slot -- entrenching harder the MORE the user listened.
//
// Candidates already in the library, already requested and not terminal, or
// snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
@@ -44,12 +44,12 @@ type RecommendationSourceMetricsForUserRow struct {
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
// mean completion ratio over the completion_n plays that recorded one.
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
// pick_kind splits For You plays into taste/fresh/unattributed;
// it is NULL for every other source, so those still group to one row.
//
// completion_sqsum carries the sum of SQUARED completion ratios so the Go
// handler can compute a variance — needed for the margin of error on a
// completion delta (#2495). It is the sum rather than `stddev_samp` on purpose:
// completion delta. It is the sum rather than `stddev_samp` on purpose:
// raw source rows get merged into surface families in Go, and sums of squares
// add across groups exactly, whereas standard deviations cannot be combined
// without them. Variance = (sqsum - sum²/n) / (n-1), with sum = avg × n.
@@ -112,17 +112,17 @@ type RecommendationWeeklyTrendsRow struct {
TasteHits int64
}
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
// Recommendation observability. Per-source play outcomes so the
// operator can see whether each recommendation surface is landing and tune the
// taste weights. Source is stamped on play_events when a play is launched from
// a recommendation surface; NULL means the user picked the track manually —
// those rows are INCLUDED here as the baseline control group the surfaces are
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
// actionable). Raw source strings are bucketed into stable surface families in
// the Go handler; completion_n is carried so family merges can weight
// judged against (delta-vs-baseline is what makes the numbers actionable).
// Raw source strings are bucketed into stable surface families in the Go
// handler; completion_n is carried so family merges can weight
// avg_completion correctly.
// Weekly per-source outcome series for the tuning lab's trend view
// (#1251). Aggregated across ALL users: the tuning knobs are global,
// . Aggregated across ALL users: the tuning knobs are global,
// so judging a knob turn needs global outcomes — rows carry rates
// only, no track or user identity. NULL-source (manual) rows are
// included as the baseline family.
+5 -5
View File
@@ -70,7 +70,7 @@ SELECT id, changed_at, scope, action, changes
LIMIT $1
`
// Newest first; consumed by the metrics trend view (#1251) to annotate
// Newest first; consumed by the metrics trend view to annotate
// knob turns on the timeline.
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
rows, err := q.db.Query(ctx, listTuningAudit, limit)
@@ -279,8 +279,8 @@ type UpsertDiscoverTuningDefaultsParams struct {
SnoozeDays float64
}
// Boot reconcile for the Discover scope (#2377). Never overwrites
// operator-tuned values, same contract as the other two.
// Boot reconcile for the Discover scope. Never overwrites
// admin-tuned values, same contract as the other two.
func (q *Queries) UpsertDiscoverTuningDefaults(ctx context.Context, arg UpsertDiscoverTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertDiscoverTuningDefaults, arg.TagOverlapWeight, arg.SnoozeDays)
return err
@@ -341,10 +341,10 @@ type UpsertWeightProfileDefaultsParams struct {
ContextTimeWeight float64
}
// Recommendation tuning lab queries (#1250). Seeding happens via the
// Recommendation tuning lab queries. Seeding happens via the
// recsettings boot reconcile; shipped defaults live in Go only.
// Boot reconcile: insert the shipped defaults for a profile if the row
// doesn't exist yet. Never overwrites operator-tuned values.
// doesn't exist yet. Never overwrites admin-tuned values.
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
arg.Profile,
+1 -1
View File
@@ -520,7 +520,7 @@ type RepointRequestsAlbumMbidParams struct {
OldMbid *string
}
// A want moved from a release id to its group (#5241) takes the requests
// A want moved from a release id to its group takes the requests
// that name it along, so the reconciler finds it.
func (q *Queries) RepointRequestsAlbumMbid(ctx context.Context, arg RepointRequestsAlbumMbidParams) error {
_, err := q.db.Exec(ctx, repointRequestsAlbumMbid, arg.NewMbid, arg.OldMbid)
+2 -2
View File
@@ -98,7 +98,7 @@ type InsertScanRunRow struct {
StartedAt pgtype.Timestamptz
}
// M7 #381: scan-run lifecycle queries.
// Scan-run lifecycle queries.
// Creates a new scan_runs row with started_at=now(). Stages are filled in
// as each completes via UpdateScanRunStage; finished_at is set by FinishScanRun.
func (q *Queries) InsertScanRun(ctx context.Context) (InsertScanRunRow, error) {
@@ -119,7 +119,7 @@ type UpdateScanRunArtistArtEnrichParams struct {
ArtistArtEnrich []byte
}
// M7 cover-sources: persists the artist-art enrich stage tally JSON.
// Persists the artist-art enrich stage tally JSON.
func (q *Queries) UpdateScanRunArtistArtEnrich(ctx context.Context, arg UpdateScanRunArtistArtEnrichParams) error {
_, err := q.db.Exec(ctx, updateScanRunArtistArtEnrich, arg.ID, arg.ArtistArtEnrich)
return err
+3 -3
View File
@@ -99,7 +99,7 @@ const getAcquisitionSettings = `-- name: GetAcquisitionSettings :one
SELECT id, auto_grab, search_interval_hours, allowed_qualities, last_pass_at, last_pass, updated_at, import_root, seed_ratio, seed_days, stall_hours, library_listing_started_at, library_listed_at, library_unlisted, upgrade_until FROM acquisition_settings WHERE id = true
`
// Searching for wants and grabbing releases (M502 #5521).
// Searching for wants and grabbing releases.
func (q *Queries) GetAcquisitionSettings(ctx context.Context) (AcquisitionSetting, error) {
row := q.db.QueryRow(ctx, getAcquisitionSettings)
var i AcquisitionSetting
@@ -385,7 +385,7 @@ SELECT info_hash, name, reason, detail, want_id, blocked_by, blocked_at, expires
`
// Every release set aside and still held back: a stalled one lifts by itself
// at expires_at (#5523).
// at expires_at.
func (q *Queries) ListReleaseBlocks(ctx context.Context) ([]ReleaseBlock, error) {
rows, err := q.db.Query(ctx, listReleaseBlocks)
if err != nil {
@@ -500,7 +500,7 @@ type ListWantsDueForSearchRow struct {
// Wants with no download running that were never searched or not since
// @hours ago: open wants first (never-searched, then requests, then missing
// files, then followed artists' releases), then held albums wanted for an
// upgrade (#5524), which are searched at most weekly.
// upgrade, which are searched at most weekly.
func (q *Queries) ListWantsDueForSearch(ctx context.Context, arg ListWantsDueForSearchParams) ([]ListWantsDueForSearchRow, error) {
rows, err := q.db.Query(ctx, listWantsDueForSearch, arg.Hours, arg.MaxRows)
if err != nil {
+2 -1
View File
@@ -57,7 +57,8 @@ type DeleteSessionForUserParams struct {
UserID pgtype.UUID
}
// Scoped by user_id, not just id (rule #47). Keyed on the id alone, any
// Scoped by user_id, not just id: household data is per-user. Keyed on the
// id alone, any
// household member could revoke another member's session by guessing a uuid.
// execrows lets the handler answer 404 rather than a false 204 when the row
// isn't theirs.
+5 -5
View File
@@ -134,8 +134,8 @@ type ListPlayedTracksNeedingSimilarityRow struct {
// ListenBrainz has never answered for, or not in 30 days. Never-fetched first,
// then the oldest answer. Freshness is read from track_similarity_fetches, not
// from the edges, so a seed whose answer matched nothing in the library still
// counts as fetched (#5296); reading the edges is what let 25 such seeds hold
// the head of the queue forever.
// counts as fetched; reading the edges would let such seeds hold the head of
// the queue forever.
func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit int32) ([]ListPlayedTracksNeedingSimilarityRow, error) {
rows, err := q.db.Query(ctx, listPlayedTracksNeedingSimilarity, limit)
if err != nil {
@@ -310,9 +310,9 @@ WHERE ts.source = 'listenbrainz'
//
// The score is RELATIVE: each edge's LB score over the seed's best score in its
// whole cached answer, so 1.0 is the seed's closest recording and the rest fall
// below it (#6177). LB's raw scores are counts (16 to 1445 on the operator's
// library) and reached the scorer, which reads similarity as 0 to 1, so one
// edge outweighed likes, skips and recency together. The cache keeps the raw
// below it. LB's raw scores are counts, into the thousands, and the scorer
// reads similarity as 0 to 1, so a raw edge would outweigh likes, skips and
// recency together. The cache keeps the raw
// answer; only the edge is scaled.
//
// Only edges of seeds the cache owns are removed, those with a fetch row.
+2 -2
View File
@@ -33,7 +33,7 @@ type LinkTracksAsSongRow struct {
Moved int64
}
// Song links (M498 #5438): copies of one song on different releases share a
// Song links: copies of one song on different releases share a
// song_key (migration 0076).
// Links the tracks into one song, joining any songs they already belong to.
// The song keeps the lowest key among them, so linking the same set again
@@ -57,7 +57,7 @@ type ListTrackSongKeysRow struct {
}
// What the mix writer needs to place a track: its song, and whether it is a
// video rip held back from the mixes (#5439).
// video rip held back from the mixes.
func (q *Queries) ListTrackSongKeys(ctx context.Context, ids []pgtype.UUID) ([]ListTrackSongKeysRow, error) {
rows, err := q.db.Query(ctx, listTrackSongKeys, ids)
if err != nil {
+1 -1
View File
@@ -85,7 +85,7 @@ type SnoozeSuggestionParams struct {
Column4 float64
}
// Time-boxed "not right now" on a Discover artist suggestion (#2374).
// Time-boxed "not right now" on a Discover artist suggestion.
//
// See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
// dismissal: it records no verdict on the music, expires on its own, and
+37 -40
View File
@@ -39,7 +39,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
@@ -62,18 +62,18 @@ type ListDeepCutsTracksRow struct {
}
// Candidate queries for the discovery system-playlist mixes
// (#419-423). Each returns (id, album_id, artist_id) rows; the Go
// . Each returns (id, album_id, artist_id) rows; the Go
// producer caps + truncates and converts to rankedCandidate. Daily-
// deterministic md5(id||dateStr) ordering (where used) keeps a mix
// stable within a day while rotating across days; shuffle-on-play
// gives per-play variety on top.
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
// Deep Cuts: low-play tracks (<=2 plays) from artists the user
// has liked OR played heavily (>=5 non-skip plays across the artist).
// Tracks the user has skipped twice or more don't qualify (#1257):
// eligibility used to count only unskipped plays, so the most actively
// rejected tracks read as "barely heard" and kept being re-offered.
// Threshold 2 so a single accidental skip doesn't banish a track;
// passive-signal only (no dislike UI, rule #101).
// Tracks the user has skipped twice or more don't qualify: counting only
// unskipped plays would make the most actively rejected tracks read as
// "barely heard" and keep re-offering them. Threshold 2 so a single
// accidental skip doesn't banish a track; passive signal only (there is no
// dislike button).
// $1 user_id, $2 date string.
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
@@ -125,7 +125,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- 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 (
@@ -153,11 +153,10 @@ type ListFirstListensTracksRow struct {
Tier int32
}
// #423 First Listens: songs the user has never heard or even
// attempted. "Attempted" is track-level with a >=30s listen threshold
// (#1268, operator decision 2026-07-03) — the old version disqualified
// a whole album on ANY play_event, so a 2-second accidental brush
// banished it permanently.
// First Listens: songs the user has never heard or even
// attempted. "Attempted" is track-level with a >=30s listen threshold:
// disqualifying a whole album on ANY play_event would let a 2-second
// accidental brush banish it permanently.
//
// tier 1 the exact desire: albums with ZERO attempted tracks
// tier 2 step back: barely-attempted albums (<=25% of tracks
@@ -227,7 +226,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -244,14 +243,13 @@ type ListNewForYouTracksRow struct {
Tier int32
}
// #421 New for you, tiered per project rule #131 (#1267). The old
// single hard rule (added <30d AND direct affinity) had two failure
// modes with one root: no notion of consumption — the album you
// devoured in week one crowded the mix for three more weeks, and
// after a quiet month nothing qualified at all.
// New for you, tiered so the mix degrades rather than vanishes. A
// single hard rule (added <30d AND direct affinity) has no notion of
// consumption: the album you devoured in week one would crowd the mix
// for three more weeks, and after a quiet month nothing would qualify.
//
// "Consumed" = any track attempted for >=30s (the First Listens
// threshold, #1268): once you've meaningfully engaged, the album is
// threshold): once you've meaningfully engaged, the album is
// no longer news. Applies to every tier — played albums leave the
// mix the next build.
//
@@ -306,7 +304,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -326,18 +324,18 @@ type ListOnThisDayTracksRow struct {
ArtistID pgtype.UUID
}
// #422 On This Day: tracks the user played around this calendar date
// On This Day: tracks the user played around this calendar date
// (±10 day-of-year) in the past, excluding the very recent (>30 days
// ago) so it's nostalgic, not just "last week". Weighted by how much
// they were played in those windows. Floor relaxed from 60→30 days
// and window ±7→±10 so it surfaces on a months-old library instead
// of needing a full year of history; still skips cleanly (no rows →
// no playlist) when there's no qualifying history yet.
// Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
// Jan 3 read as 359 days apart, silently gutting the window for ~3
// weeks around every New Year — precisely when holiday nostalgia is
// the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
// ±1 is absorbed by the ±10 window. The build date ($2, already here
// they were played in those windows. The 30-day floor and ±10 window
// let it surface on a months-old library rather than needing a full
// year of history; it still skips cleanly (no rows → no playlist)
// when there's no qualifying history yet.
// Day-of-year distance is circular: plain ABS would read Dec 28 vs
// Jan 3 as 359 days apart, gutting the window for ~3 weeks around
// every New Year — precisely when holiday nostalgia is the point.
// LEAST(d, 365-d) wraps the boundary; leap-year drift of ±1 is absorbed
// by the ±10 window. The build date ($2, already here
// for the md5 rotation) anchors "today" instead of now() so the
// window is testable and consistent with the mix's daily determinism.
// $1 user_id, $2 date string.
@@ -371,18 +369,18 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- 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 quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
-- Declined-resurfacing cooldown: a skip on a
-- rediscover-sourced play is the user explicitly saying "I've moved
-- on" to the exact invitation this mix extends — such tracks sit
-- out ~90 days instead of re-qualifying the next day. Passive
-- signal only (rule #101).
-- signal only.
AND NOT EXISTS (
SELECT 1 FROM play_events rp
WHERE rp.user_id = $1
@@ -403,11 +401,10 @@ type ListRediscoverTracksRow struct {
ArtistID pgtype.UUID
}
// #420 Rediscover: tracks the user played a lot but has drifted away
// from. One blended pool (issue #1246: the old two-tier UNION was
// all-or-nothing — a single ">=6 months cold" row suppressed the whole
// ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
// tier was also a strict subset of the 30d tier). Eligibility is >=3
// Rediscover: tracks the user played a lot but has drifted away
// from. One blended pool, not a two-tier UNION: the ">=6 months cold" tier is
// a strict subset of the ">=30 days" one, and a UNION that let one row of it
// suppress the wider tier would ship a one-song playlist. Eligibility is >=3
// non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
// so a weeks-old library still fields a pool — on young histories the
// >=5-play tracks are precisely the ones still in rotation. Ordering
@@ -34,7 +34,7 @@ type AppendRotationPlayedParams struct {
TrackID pgtype.UUID
}
// Rotation state for system-playlist sample-history dedup (#415).
// Rotation state for system-playlist sample-history dedup.
// One row per (user, playlist_kind). played_track_ids accumulates
// tracks heard from that playlist in the current rotation; shuffle
// prefers ids NOT in this set, then resets when the snapshot is
+10 -10
View File
@@ -198,14 +198,14 @@ SELECT u.id FROM users u
)
`
// Track picks here join `tracks ... AND t.missing_since IS NULL` (#2523): a
// Track picks here join `tracks ... AND t.missing_since IS NULL`: 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.
// 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) {
rows, err := q.db.Query(ctx, listActiveUsersForSystemPlaylists)
@@ -368,10 +368,10 @@ type PickSeedArtistsRow struct {
}
// Top-12 most-engaged distinct artist candidates, tiered so the
// "Songs like X" mixes never silently vanish (#1255): the old hard
// 7-day window emptied the seed pool after a quiet week, and the
// daily atomic-replace build then deleted every existing mix until
// the user played something again. Same fallback shape as
// "Songs like X" mixes never silently vanish: a hard 7-day window
// would empty the seed pool after a quiet week, and the daily
// atomic-replace build would then delete every existing mix until the
// user played something again. Same fallback shape as
// PickTopPlayedTracksForUser (For You's seeds):
//
// tier 0 engagement in the last 7 days
@@ -380,12 +380,12 @@ type PickSeedArtistsRow struct {
// tier 3 (only if 0-2 empty) liked artists with no play history
//
// All returned rows share one tier; produceSeedMixes maps it onto the
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
// tiered pick-kind ladder and stamps the built tracks, so metrics
// can compare mixes seeded from fresh vs stale engagement.
// Score = unskipped-play count + 5 if user has liked the artist. The
// Go-side picker (pickSeedArtistsForDay) shuffles the 12
// daily-deterministically and takes songsLikeSeedCount (6) so the mix
// set both rotates day-to-day and fills the dedicated Home row (#1491).
// set both rotates day-to-day and fills the dedicated Home row.
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
if err != nil {
@@ -452,7 +452,7 @@ SELECT COALESCE(
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE t.artist_id = $2
AND t.missing_since IS NULL -- #2701: the play branch filters; this one must too
AND t.missing_since IS NULL -- the play branch filters; this one must too
ORDER BY a.release_date DESC NULLS LAST,
t.disc_number NULLS LAST,
t.track_number NULLS LAST,
@@ -530,7 +530,7 @@ SELECT id
// library with sparse history.
// A likes-only read still joins tracks: a like outlives its file, and a
// seed whose file is gone points For You at something the user can't hear
// (#2701). The other two tiers get the same filter from their play_events
// . The other two tiers get the same filter from their play_events
// join.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
+2 -2
View File
@@ -74,7 +74,7 @@ SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
ORDER BY display_order, provider_id
`
// Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
// Tag-enrichment provider settings queries.
// The tags.SettingsService consumes these — parallel to the cover-art
// SettingsService (coverart_settings.sql). Keeping the two independent
// means a new tag source is added without touching the art settings.
@@ -150,7 +150,7 @@ type UpsertTagProviderSettingsParams struct {
// INSERT a default row for a newly-registered provider, or refresh an
// existing row's display_order on boot. Does NOT overwrite enabled /
// api_key on conflict — the operator's settings persist across restarts.
// api_key on conflict — an admin's settings persist across restarts.
func (q *Queries) UpsertTagProviderSettings(ctx context.Context, arg UpsertTagProviderSettingsParams) error {
_, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder)
return err
+7 -7
View File
@@ -153,7 +153,7 @@ type ListLikedTrackTasteInputsForUserRow struct {
// (track_id, artist_id, genre, release_date) for each track the user has
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
// tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
// tags, and era; track_id keys the enriched track_tags lookup.
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil {
@@ -216,14 +216,14 @@ type ListPlayEngagementInputsForUserRow struct {
AgeDays float64
}
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
// Taste profile. The daily build computes signed artist/tag
// weights in Go (graded engagement + decay + like bonus) and atomic-replaces
// them here. Read queries serve "top taste" lookups (used in phase 2).
// them here. Read queries serve the scorer's "top taste" lookups.
// One row per play in the decay-relevant window. completion is the play's
// completion ratio (precomputed column when present, else duration_played /
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
// is split into tags in Go; release_date derives the decade for the era
// facet (#1530). Quarantined tracks are excluded.
// facet. Quarantined tracks are excluded.
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
if err != nil {
@@ -269,7 +269,7 @@ type ListTasteProfileArtistsForUserRow struct {
Weight float64
}
// Top-weighted taste artists (phase-2 consumption + tests).
// Top-weighted taste artists, for the scorer and tests.
func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTasteProfileArtistsForUserParams) ([]ListTasteProfileArtistsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileArtistsForUser, arg.UserID, arg.Limit)
if err != nil {
@@ -308,7 +308,7 @@ type ListTasteProfileErasForUserRow struct {
Weight float64
}
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
// Top-weighted taste eras; consumed by the scorer's era term.
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
if err != nil {
@@ -347,7 +347,7 @@ type ListTasteProfileMoodsForUserRow struct {
Weight float64
}
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
// Top-weighted taste moods; consumed by the scorer's mood term.
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
if err != nil {
+3 -3
View File
@@ -121,7 +121,7 @@ type ListTrackTagsForTracksRow struct {
Tag string
}
// Enriched tags for a set of candidate tracks (#1534), so the scorer can
// Enriched tags for a set of candidate tracks, so the scorer can
// derive each candidate's mood buckets at scoring time. One row per (track,
// tag); the caller filters to mood words via the internal/mood vocabulary.
func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
@@ -168,7 +168,7 @@ type ListTracksMissingTagsRow struct {
ArtistMbid *string
}
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
// Track-tag enrichment queries. The tag enricher
// drains ListTracksMissingTags, fetches from the provider chain, and
// writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
// stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
@@ -176,7 +176,7 @@ type ListTracksMissingTagsRow struct {
// or previously settled 'none' under an older provider version. Returns
// the fields the provider chain needs — recording MBID (nullable) for
// keyed lookups, title + artist name for name-based fallback, and the
// artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
// artist MBID (nullable) for MusicBrainz's artist-tag fallback.
// $1 = current tag_sources_version, $2 = limit.
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
+28 -29
View File
@@ -62,10 +62,10 @@ UPDATE tracks
WHERE id = ANY($1::uuid[])
`
// Forgets the recording MBID of rows whose file was another song's (M498's
// wrong-slot removal). The MBID came from that file, so it names the song kept
// elsewhere on the album, and missing-pair folding would fold the emptied slot
// into it: twelve slots went that way on 2026-10-20 and were never wanted back.
// Forgets the recording MBID of rows whose file was another song's (the
// duplicate resolver's wrong-slot removal). The MBID came from that file, so it
// names the song kept elsewhere on the album, and missing-pair folding would
// fold the emptied slot into it, so it would never be wanted back.
// mbid_source goes with it (tracks_mbid_source_pair).
func (q *Queries) ClearTrackMbids(ctx context.Context, ids []pgtype.UUID) error {
_, err := q.db.Exec(ctx, clearTrackMbids, ids)
@@ -177,7 +177,7 @@ type DeleteTrackRow struct {
Mbid *string
}
// M7 #372: hard delete with FK cascade. The CASCADE on track_id from
// Hard delete with FK cascade. The CASCADE on track_id from
// play_events / general_likes_tracks / quarantine /
// quarantine_actions handles their cleanup. RETURNING gives us
// album_id + artist_id for the album-empty / artist-empty cascade
@@ -209,11 +209,11 @@ type FindMissingTrackByAudioHashRow struct {
FilePath string
}
// Move detection fallback for files with no MBID (#2528, #3914). The audio stream
// Move detection fallback for files with no MBID. The audio stream
// hash identifies the encoded audio itself, so it survives a rename, a move and a
// retag — anything short of a re-encode. It replaced (file_size, duration_ms),
// which could pair two unrelated files that happened to share a byte count and a
// duration, and missed a file retagged in place, whose size changes.
// retag — anything short of a re-encode. (file_size, duration_ms) would pair two
// unrelated files that share a byte count and a duration, and miss a file
// retagged in place, whose size changes.
//
// Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
func (q *Queries) FindMissingTrackByAudioHash(ctx context.Context, audioStreamSha256 []byte) ([]FindMissingTrackByAudioHashRow, error) {
@@ -249,7 +249,7 @@ type FindMissingTrackByMbidRow struct {
FilePath string
}
// Move detection, strongest signal (#2528). A file that turned up at a new path
// Move detection, strongest signal. A file that turned up at a new path
// carrying a recording MBID we already have on a MISSING row is that recording,
// moved — not a new track.
//
@@ -287,8 +287,8 @@ UPDATE tracks
AND regexp_replace(file_path, '^.*/', '') ~* $1::text
`
// Holds back present tracks whose basename carries a video-rip marker (M498
// #5439), the same pattern the report lists. A track the operator called fine
// Holds back present tracks whose basename carries a video-rip marker, the
// same pattern the report lists. A track an admin called fine
// keeps that verdict.
func (q *Queries) FlagSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
result, err := q.db.Exec(ctx, flagSuspectSourceTracks, pattern)
@@ -377,7 +377,7 @@ SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, f
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). Mirror of GetArtistsByIDs.
// . Mirror of GetArtistsByIDs.
func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Track, error) {
rows, err := q.db.Query(ctx, getTracksByIDs, dollar_1)
if err != nil {
@@ -451,7 +451,7 @@ type ListArtistTracksForUserRow struct {
ArtistName string
}
// M6a: every track for the artist across their albums, with album_title
// Every track for the artist across their albums, with album_title
// and artist_name joined. Honors per-user quarantine. Used by
// /api/artists/{id}/tracks for the artist-card play affordance, which
// shuffles client-side. Ordering matches album/track natural order so
@@ -545,19 +545,18 @@ type ListMissingTracksRow struct {
LastPlayedAt pgtype.Timestamptz
}
// The admin review surface for files the scan could not find (#2527).
// The admin review surface for files the scan could not find.
//
// Ordered by directory, then by the file's own position within its album,
// because the unit an operator actually reasons about is a FOLDER: the case
// this was built for was three whole albums that had been reorganised, and a
// flat list ordered by timestamp presents that as forty unrelated decisions.
// because the unit an admin actually reasons about is a FOLDER: a reorganised
// album, listed flat by timestamp, reads as dozens of unrelated decisions.
// Grouping happens in the handler; the ordering here is what makes a group
// contiguous, so a page boundary splits a directory at worst.
//
// last_played_at is a correlated MAX rather than a join so a track with no
// plays stays in the result with NULL. It is here because "gone six months,
// never played" and "gone yesterday, played 200 times" deserve opposite
// reactions, and the operator can't tell them apart from a path.
// reactions, and an admin can't tell them apart from a path.
func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksParams) ([]ListMissingTracksRow, error) {
rows, err := q.db.Query(ctx, listMissingTracks, arg.PageOffset, arg.PageLimit)
if err != nil {
@@ -616,7 +615,7 @@ type ListRandomTracksForUserRow struct {
ArtistName string
}
// #427 S4: backing query for GET /api/library/shuffle — the online
// Backing query for GET /api/library/shuffle — the online
// source for "Shuffle all". N random tracks across the whole
// library, per-user-quarantine filtered. $1 user_id, $2 limit.
func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTracksForUserParams) ([]ListRandomTracksForUserRow, error) {
@@ -682,7 +681,7 @@ type ListStillMissingTrackLabelsRow struct {
}
// Which of the tracks a scan marked missing still are once it has adopted
// moves and folded replacements (M542): the ones worth telling an admin
// moves and folded replacements: the ones worth telling an admin
// about, by name. A quality upgrade folds into its replacement and drops out.
func (q *Queries) ListStillMissingTrackLabels(ctx context.Context, ids []pgtype.UUID) ([]ListStillMissingTrackLabelsRow, error) {
rows, err := q.db.Query(ctx, listStillMissingTrackLabels, ids)
@@ -719,7 +718,7 @@ SELECT t.id,
t.source_verdict,
t.source_verdict_at,
-- The album's place on the wanted list: wanted for a clean copy, and
-- when that was last searched for (#5524).
-- when that was last searched for.
COALESCE(w.upgrade, '')::text AS want_upgrade,
w.searched_at AS searched_at
FROM tracks t
@@ -760,7 +759,7 @@ type ListSuspectSourceTracksRow struct {
}
// The admin report of present tracks whose filename looks like a video rip
// (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
// : "(Official Video)", "[Audio]", "Visualizer" and the like.
//
// pattern is built in Go (internal/api/admin_suspect_sources.go) from the
// same marker list that labels each row, so the filter and the labels cannot
@@ -816,7 +815,7 @@ type ListTrackPathsForReconcileRow struct {
}
// Every row's path + current missing mark, for the scanner's reconcile pass
// (#2523). Deliberately unfiltered and unpaged: reconcile has to compare the
// . 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.
@@ -921,7 +920,7 @@ type ListTracksMissingMbidWithPathRow struct {
// Track recording-MBID backfill: tracks with NULL mbid that still have
// a file to re-read. $1 caps the batch (mirrors the album backfill).
// Missing tracks are skipped: there is no file, and opening it anyway
// logged a warning per track on every scan (#5139).
// would log a warning per track on every scan.
func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
if err != nil {
@@ -1081,7 +1080,7 @@ type SetTrackSourceVerdictParams struct {
ID pgtype.UUID
}
// The operator's call on a flagged track: 'fine' lets it back into radio and
// An admin's call on a flagged track: 'fine' lets it back into radio and
// the mixes for good; 'suspect' holds it back again.
func (q *Queries) SetTrackSourceVerdict(ctx context.Context, arg SetTrackSourceVerdictParams) (int64, error) {
result, err := q.db.Exec(ctx, setTrackSourceVerdict, arg.Verdict, arg.ID)
@@ -1115,7 +1114,7 @@ ON CONFLICT (file_path) DO UPDATE SET
WHEN tracks.mbid_source = 'acoustid' THEN 'acoustid' END,
genre = EXCLUDED.genre,
-- Stamped on update too, so a tag-repair pass marks rows as done and the
-- next scan can short-circuit them again (#2499).
-- next scan can short-circuit them again.
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, missing_since, mbid_source, song_id, song_key, source_verdict, source_verdict_at, emptied_at
@@ -1138,8 +1137,8 @@ type UpsertTrackParams struct {
}
// file_path is the canonical identity for library scan; mbid is secondary.
// mbid is the file's tag. A looked-up id (mbid_source 'acoustid', M401)
// survives a re-read that finds no tag id, and a tag id replaces it (D4).
// mbid is the file's tag. A looked-up id (mbid_source 'acoustid')
// survives a re-read that finds no tag id, and a tag id replaces it.
func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track, error) {
row := q.db.QueryRow(ctx, upsertTrack,
arg.Title,
+1 -1
View File
@@ -584,7 +584,7 @@ type SetSubsonicPasswordParams struct {
// Stores (or clears with NULL) the per-user Subsonic legacy credential used
// for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only
// ever a server-generated value, never the login password (#5026).
// ever a server-generated value, never the login password.
func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error {
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
return err
+21 -21
View File
@@ -68,15 +68,15 @@ const countWants = `-- name: CountWants :one
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
-- Held albums wanted for a better copy (#5524).
-- Held albums wanted for a better copy.
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL AND upgrade <> '')::bigint AS upgrades,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
-- Library albums waiting to be matched by hand (#5579).
-- Library albums waiting to be matched by hand.
(SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS unmatched,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- The Wanted view's other tabs: downloads running, those
-- waiting on a person, and releases set aside.
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs
@@ -324,7 +324,7 @@ type GetLibraryListingRow struct {
LibraryUnlisted int32
}
// The one-time listing of the library (#5567). ---------------------------
// The one-time listing of the library. ---------------------------
func (q *Queries) GetLibraryListing(ctx context.Context) (GetLibraryListingRow, error) {
row := q.db.QueryRow(ctx, getLibraryListing)
var i GetLibraryListingRow
@@ -361,7 +361,7 @@ type GetWantAlbumRow struct {
AlbumID pgtype.UUID
}
// What the Wanted view's track list needs of a want (#5572).
// What the Wanted view's track list needs of a want.
func (q *Queries) GetWantAlbum(ctx context.Context, id pgtype.UUID) (GetWantAlbumRow, error) {
row := q.db.QueryRow(ctx, getWantAlbum, id)
var i GetWantAlbumRow
@@ -547,9 +547,9 @@ type ListAlbumsToMatchRow struct {
// Library albums the listing has not decided, or that an older matcher
// refused, or whose tracks changed since they were refused: each is matched
// to MusicBrainz (#5569). A library album is one from before the cutoff, or
// to MusicBrainz. A library album is one from before the cutoff, or
// one whose tracks were: a re-tag files a track under a new album row but
// keeps the track (#5579). An album row with no tracks is not an album in
// keeps the track. An album row with no tracks is not an album in
// the library; one a person left off stays off.
func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) {
rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.Matcher, arg.MaxRows)
@@ -752,7 +752,7 @@ type ListHeldWantFormatsRow struct {
Rips int32
}
// Upgrades (#5524). -------------------------------------------------------
// Upgrades. -------------------------------------------------------
// What each held album's copy is made of, for judging whether it is worth
// upgrading: its present tracks grouped by format, with their bytes and
// length summed, and how many are held back as video rips. A held want whose
@@ -936,7 +936,7 @@ type ListReleaseTypeProfilesRow struct {
FollowCount int64
}
// The wanted list (M502 #5520): release-type profiles, follows and wants.
// The wanted list: release-type profiles, follows and wants.
func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTypeProfilesRow, error) {
rows, err := q.db.Query(ctx, listReleaseTypeProfiles)
if err != nil {
@@ -988,7 +988,7 @@ type ListUnmatchedLibraryAlbumsRow struct {
Tracks int32
}
// The Collection's Unmatched section (#5579): the albums the listing could
// The Collection's Unmatched section: the albums the listing could
// not match, and below them the ones a person left off. An album whose
// tracks have all moved to another row is gone, not unmatched.
func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32) ([]ListUnmatchedLibraryAlbumsRow, error) {
@@ -1070,7 +1070,7 @@ type ListWantArtistsRow struct {
Total int64
}
// The Wanted view's main page (#5572): every artist on the list, A–Z like
// The Wanted view's main page: every artist on the list, A–Z like
// the library, with how many of their albums are held of how many are on
// the list. q matches the artist's name or any of their albums' titles;
// artist_mbid narrows to one artist, for their page's header.
@@ -1114,7 +1114,7 @@ func (q *Queries) ListWantArtists(ctx context.Context, arg ListWantArtistsParams
const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- Why a held album is wanted again, and the copy it has (#5524).
-- Why a held album is wanted again, and the copy it has.
w.upgrade, w.held_quality,
-- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has.
@@ -1124,10 +1124,10 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
u.username AS requested_by,
la.id AS library_artist_id,
COUNT(*) OVER () AS total,
-- The search schedule's last look (#5521), and the download running.
-- The search schedule's last look, and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
-- Its latest download, running or ended, and what the import made of
-- it (#5522).
-- it.
gr.id AS grab_id, gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin,
gr.quality AS grab_quality, gr.ended_at AS grab_ended_at, gr.end_reason AS grab_end_reason,
gr.progress AS grab_progress, gr.import_state AS grab_import_state,
@@ -1147,7 +1147,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
AND ($2::text = '' OR w.artist_mbid = $2::text)
-- want_ids narrows to given wants: the Missing page's folders (M504).
-- want_ids narrows to given wants: the Missing page's folders.
AND (COALESCE(cardinality($3::uuid[]), 0) = 0 OR w.id = ANY($3::uuid[]))
AND ($4::text = ''
OR strpos(lower(w.artist_name), lower($4::text)) > 0
@@ -1208,7 +1208,7 @@ type ListWantsRow struct {
// The Wanted page: open wants (all but missing ones, or only missing ones),
// the held ones, or all of them; artist_mbid narrows to one artist's page,
// want_ids to given wants. Ordered like the library (#5572): artists A–Z by the
// want_ids to given wants. Ordered like the library: artists A–Z by the
// library's sort name when it holds the artist, each artist's albums by
// release date, so the page groups them as they arrive. q narrows to an
// artist or album title; total counts every match, past the page.
@@ -1349,7 +1349,7 @@ ON CONFLICT (album_id) DO NOTHING
`
// The albums ListLibraryAlbums takes are decided as listed by their tags,
// so the matching that follows leaves them alone (#5569).
// so the matching that follows leaves them alone.
func (q *Queries) RecordListedAlbums(ctx context.Context, cutoff pgtype.Timestamptz) error {
_, err := q.db.Exec(ctx, recordListedAlbums, cutoff)
return err
@@ -1379,7 +1379,7 @@ type RepointWantParams struct {
ReleaseGroupMbid string
}
// A want recorded under a release id before #5241 is moved to its group.
// A want recorded under a release id is moved to its group.
func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error {
_, err := q.db.Exec(ctx, repointWant, arg.ID, arg.ReleaseGroupMbid)
return err
@@ -1571,10 +1571,10 @@ SELECT a.id AS album_id,
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > $1::timestamptz), false)::bool AS arrived_since,
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < $1::timestamptz), false)::bool AS missing_before,
-- Missing tracks no track on disk stands in for, by position or
-- title. A missing row whose song is back at a new path (#5245) is
-- title. A missing row whose song is back at a new path is
-- covered; a lost bonus track is not. A track with no disc number is
-- on disc 1: Air's Love 2 stayed wanted, and was downloaded again for
-- nothing, over a missing track 6 with no disc beside a present 1/6.
-- on disc 1, so a missing track 6 with no disc beside a present 1/6
-- does not keep the album wanted.
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL
+1 -1
View File
@@ -26,7 +26,7 @@ type CountListeningSignalForUserRow struct {
DistinctArtists int64
}
// "You might like" Home rows (#790). The daily build computes ranked
// "You might like" Home rows. The daily build computes ranked
// album/artist IDs in Go (similarity roll-up + cold-start gate) and
// atomic-replaces them here; /api/home reads them back hydrated.
// Cold-start gate. Returns the breadth of the user's real listening:
+2 -2
View File
@@ -1,4 +1,4 @@
-- AcoustID recording-id lookup (M401). Migration 0069 says what each
-- AcoustID recording-id lookup. Migration 0069 says what each
-- lookup state means.
-- name: ListTracksNeedingAcoustIDLookup :many
@@ -29,7 +29,7 @@ ON CONFLICT (track_id) DO UPDATE SET
looked_up_at = now();
-- name: SetTrackMbidFromAcoustID :execrows
-- Never over a tag id (D4): the guard is here, not only in the worker, so a
-- Never over a tag id: the guard is here, not only in the worker, so a
-- tag the scan read between the worker's list and this write still wins.
-- updated_at is left alone: it gates the scan's mtime skip, and moving it
-- past a file's mtime would hide a change made just before the lookup.
+1 -1
View File
@@ -1,4 +1,4 @@
-- Indexers and download clients (M502 #5518).
-- Indexers and download clients.
-- name: ListAcquisitionServices :many
-- Returns no secret, only whether one is set: this feeds the settings page.
+1 -1
View File
@@ -1,4 +1,4 @@
-- The admin Overview's status sections (M504): who is listening, what is
-- The admin Overview's status sections: who is listening, what is
-- downloading, what the library holds. Each is one cheap read.
-- name: ListListeningNow :many
+3 -3
View File
@@ -1,14 +1,14 @@
-- name: AdminSummary :one
-- What the admin rail and the Overview's verdict read (#5605): each queue
-- What the admin rail and the Overview's verdict read: each queue
-- that waits on a person, with how many and since when. One round trip, so
-- the rail costs one cheap read on every admin page. The "since" is the
-- oldest item still waiting: how long the queue has been left, which is what
-- tells the operator a count of 2 is fine today and wrong after a month.
-- tells an admin a count of 2 is fine today and wrong after a month.
SELECT
(SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests,
(SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since,
-- A broken track is one report however it was found: a listener flagged
-- it, or it failed to play (#5606). Counted by track, as the Reports
-- it, or it failed to play. Counted by track, as the Reports
-- page lists them.
(SELECT count(*) FROM (
SELECT track_id FROM quarantine
+22 -27
View File
@@ -37,12 +37,12 @@ ORDER BY release_date NULLS LAST, sort_title;
-- name: GetAlbumByArtistAndTitle :one
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create. An album
-- of the same name in another release group is a different album (Weezer's
-- six self-titled albums shared one row until 2026-10-20), so a row whose
-- of the same name in another release group is a different album (one
-- artist's self-titled albums), so a row whose
-- group is known and differs is not returned. A row of the file's own group
-- comes first, then one with no group yet, then the one holding the most
-- tracks from the file's folder: a file tagged with no group (a live bonus
-- track beside Weezer's 1994 album) belongs with its folder, not the oldest
-- track beside its album) belongs with its folder, not the oldest
-- row, and a few strays already misfiled do not outvote the folder.
SELECT albums.* FROM albums
WHERE albums.artist_id = sqlc.arg(artist_id) AND albums.title = sqlc.arg(title)
@@ -79,12 +79,10 @@ SELECT * FROM albums ORDER BY random() LIMIT $1;
-- Album "belongs to" a genre if any of its tracks carry that genre.
-- Serves Subsonic getAlbumList?type=byGenre.
--
-- Splits tracks.genre on [;,] as of #367. It previously compared the whole
-- column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
-- "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
-- every multi-genre track. This also aligns the endpoint with
-- recommendation.sql / discover.sql, which have always split, and with the
-- genre browse index that #367 adds.
-- Splits tracks.genre on [;,], so a track tagged "Rock;Pop" is reachable from
-- both "Rock" and "Pop"; comparing the whole column would hide every
-- multi-genre track from a Subsonic client asking for a genre. The same split
-- as recommendation.sql, discover.sql and the genre browse index.
--
-- EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
-- (track, genre-fragment), so a join would multiply rows per album and lean
@@ -111,7 +109,7 @@ LIMIT $2 OFFSET $3;
SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
-- name: ListRecentlyAddedAlbumsWithArtist :many
-- M6a: recently-added albums joined with artist_name + artist_id for the
-- Recently-added albums joined with artist_name + artist_id for the
-- home-page section. created_at is the row-insert timestamp from the
-- scanner — the closest proxy to "added to library". Stable secondary
-- ordering by id keeps pagination tie-break deterministic.
@@ -122,7 +120,7 @@ ORDER BY albums.created_at DESC, albums.id
LIMIT $1;
-- name: ListAlbumsAlphaWithArtist :many
-- M6a: alpha-sorted album list joined with artist_name. Used by
-- Alpha-sorted album list joined with artist_name. Used by
-- /api/library/albums for the wrapping-grid page. Stable id-tiebreak.
SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums
@@ -131,7 +129,7 @@ ORDER BY albums.sort_title, albums.id
LIMIT $1 OFFSET $2;
-- name: CountAlbums :one
-- M6a: total album count for the /api/library/albums envelope.
-- Total album count for the /api/library/albums envelope.
SELECT COUNT(*) FROM albums;
-- name: CountAlbumsByArtist :one
@@ -140,7 +138,7 @@ SELECT COUNT(*) FROM albums;
SELECT COUNT(*) FROM albums WHERE artist_id = $1;
-- name: DeleteAlbumIfEmpty :one
-- M7 #372: deletes the album row only when it has no remaining tracks.
-- Deletes the album row only when it has no remaining tracks.
-- Used after a track delete to tidy up orphaned albums. RETURNING gives
-- us the artist_id so the service can chain into the artist-empty check.
-- Returns no rows when the album still has tracks; the service treats
@@ -151,7 +149,7 @@ WHERE a.id = $1
RETURNING a.id, a.artist_id;
-- name: SetAlbumMbidIfNull :exec
-- M7 #379: heal MBID on existing album rows during rescan. Idempotent —
-- Heal MBID on existing album rows during rescan. Idempotent —
-- only updates when the existing mbid is NULL.
UPDATE albums
SET mbid = $2,
@@ -159,7 +157,7 @@ UPDATE albums
WHERE id = $1 AND mbid IS NULL;
-- name: SetAlbumReleaseGroupMbidIfNull :exec
-- #5241: heal the release-group id on an existing album row, from a rescan's
-- Heal the release-group id on an existing album row, from a rescan's
-- tags or from the re-acquisition sweeper's MusicBrainz lookup. Only fills a
-- NULL, so a tag never gets overwritten by a later guess or vice versa.
UPDATE albums
@@ -168,7 +166,7 @@ UPDATE albums
WHERE id = $1 AND release_group_mbid IS NULL;
-- name: GetReleaseGroupForReleaseMbid :one
-- #5241: the release group the library already knows for a release id, so a
-- The release group the library already knows for a release id, so a
-- request carrying a release id can be repaired without asking MusicBrainz.
SELECT release_group_mbid::text
FROM albums
@@ -176,7 +174,7 @@ SELECT release_group_mbid::text
LIMIT 1;
-- name: SetReleaseGroupForReleaseMbidIfNull :exec
-- #5241: cache a MusicBrainz-resolved release group onto the album row that
-- Cache a MusicBrainz-resolved release group onto the album row that
-- holds the release, filling only a NULL.
UPDATE albums
SET release_group_mbid = sqlc.arg(release_group_mbid)::text,
@@ -188,7 +186,7 @@ UPDATE albums
-- one of its tracks' file_path so the worker can re-read tags. LIMIT
-- supplied by caller for batching/progress purposes. The track read is
-- one still on disk; an album with none left is skipped until a scan
-- finds its files again (#5139).
-- finds its files again.
SELECT a.id AS album_id,
a.artist_id AS artist_id,
a.title AS title,
@@ -207,7 +205,7 @@ SELECT a.id AS album_id,
LIMIT $1;
-- name: GetAlbumCoverageRollup :one
-- M7 coverage-gauge: library-wide cover-art coverage rollup for the
-- Library-wide cover-art coverage rollup for the
-- admin gauge. Single sequential scan with FILTER aggregates —
-- sub-millisecond on realistic libraries. pending_no_mbid is a SUBSET
-- of pending; the UI surfaces it separately so operators can see how
@@ -217,12 +215,9 @@ SELECT a.id AS album_id,
-- Invariant: with_art + pending + settled = total. (pending_no_mbid is
-- not part of the sum — it's a subset of pending, surfaced separately.)
--
-- The IN list below must stay in sync with the cover_art_source CHECK
-- constraint — currently relaxed by migration 0020 to include 'deezer'
-- and 'lastfm', and migration 0030 further relaxed it to any non-empty
-- string. If a new source value is added without updating this query,
-- with_art will silently undercount. Drift #556 caught the deezer +
-- lastfm omission introduced by migration 0020.
-- The IN list below must name every source the enrichers write (the
-- cover_art_source CHECK accepts any non-empty string since migration
-- 0030). A source missing here makes with_art silently undercount.
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE cover_art_source IN
@@ -233,7 +228,7 @@ SELECT
FROM albums;
-- name: SetAlbumCoverWithVersion :exec
-- M7 cover-sources: records a successful or settled enrichment with the
-- Records a successful or settled enrichment with the
-- current sources_version stamp. cover_art_path is NULL when source is
-- 'none' (no file written). Atomic; called per album per pass.
UPDATE albums
@@ -246,5 +241,5 @@ UPDATE albums
-- name: GetAlbumsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
--. Mirror of GetArtistsByIDs.
SELECT * FROM albums WHERE id = ANY($1::uuid[]);
+6 -6
View File
@@ -46,7 +46,7 @@ SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%';
SELECT * FROM artists WHERE id = ANY($1::uuid[]);
-- name: ListArtistsAlphaWithCovers :many
-- M6a: alpha-sorted artist list with derived cover_album_id (most-recent
-- Alpha-sorted artist list with derived cover_album_id (most-recent
-- album whose cover_art_path is set). Replaces ListArtistsAlpha for the
-- /api/artists?sort=alpha branch — the new column is appended, so the
-- alpha branch is purely additive on the wire. album_count is computed
@@ -68,7 +68,7 @@ ORDER BY artists.sort_name, artists.name, artists.id
LIMIT $1 OFFSET $2;
-- name: DeleteArtistIfEmpty :one
-- M7 #372: deletes the artist row only when it has no remaining albums
-- Deletes the artist row only when it has no remaining albums
-- AND no remaining tracks (defensive — tracks can in principle exist
-- without an album, though the scanner doesn't write that shape today).
DELETE FROM artists a
@@ -78,7 +78,7 @@ WHERE a.id = $1
RETURNING a.id;
-- name: SetArtistMbidIfNull :exec
-- M7 #379: heal MBID on existing artist rows during rescan. Idempotent —
-- Heal MBID on existing artist rows during rescan. Idempotent —
-- only updates when the existing mbid is NULL, so concurrent scans don't
-- overwrite an existing value with a stale one.
UPDATE artists
@@ -87,7 +87,7 @@ UPDATE artists
WHERE id = $1 AND mbid IS NULL;
-- name: ListArtistsMissingArt :many
-- M7 cover-sources: returns artist rows the artist-art enricher should
-- Returns artist rows the artist-art enricher should
-- attempt. Eligible: artist_art_source IS NULL OR (= 'none' AND stale
-- artist_art_sources_version). MBID-less artists are returned but the
-- enricher skips them at the row.mbid == nil guard inside the chain.
@@ -99,7 +99,7 @@ SELECT a.id
LIMIT $2;
-- name: SetArtistArtWithVersion :exec
-- M7 cover-sources: records a successful or settled artist-art
-- Records a successful or settled artist-art
-- enrichment with the current sources_version stamp. thumb_path /
-- fanart_path are NULL on a 'none' result, or independently NULL if
-- the provider returned only one of the two image types (e.g.
@@ -113,7 +113,7 @@ UPDATE artists
WHERE id = $1;
-- name: ClearArtistArtNone :exec
-- M7 cover-sources: parallel of ClearAlbumCoverNone. Used by the
-- Parallel of ClearAlbumCoverNone. Used by the
-- eligibility loop to flip stale 'none' rows back to NULL.
UPDATE artists
SET artist_art_source = NULL,
+1 -1
View File
@@ -15,7 +15,7 @@ SELECT count(*)::bigint FROM audit_log
-- name: ListAuditLogByActions :many
-- One page of the given actions, newest first: the duplicates report's
-- "resolved automatically" activity (M498) reads the resolver's merges from
-- "resolved automatically" activity reads the resolver's merges from
-- here.
SELECT * FROM audit_log
WHERE action = ANY(sqlc.arg(actions)::text[])
+16 -23
View File
@@ -1,29 +1,23 @@
-- 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
-- Every query in this file filters `tracks.missing_since IS NULL`. 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 — and
-- browsing is a way of finding something to play, so a track that cannot play
-- should not shape it.
--
-- The year axis filters too (#2702). It used to join albums only, on the
-- reasoning that an album is a real release even when some of its tracks are
-- gone — true, but it left the two browse axes disagreeing: the genre queries
-- above filter tracks.missing_since, so an album whose every file had vanished
-- was already absent from genre while still listed under its year, where
-- opening it found nothing playable.
-- The year axis filters too, so the two axes agree: an album whose every file
-- has vanished is absent from both, rather than absent from its genre and
-- still listed under its year, where opening it would find nothing playable.
--
-- Hiding is the answer rather than showing-and-marking because browsing is how
-- you go looking for something to play, and the operator's rule for that case
-- is to take it out of view; the admin missing-files surface is where absence
-- is reported. It also means changing the axis that was inconsistent rather
-- than the one that was already right.
-- Hiding rather than showing-and-marking, because browsing is how you go
-- looking for something to play; the admin missing-files surface is where
-- absence is reported.
--
-- The predicate is "has at least one playable track", so it also excludes an
-- album with no tracks at all. That is the same answer for the same reason:
-- nothing to play, nothing to browse to.
-- name: ListGenresWithCount :many
-- Genre browse index (#367).
-- Genre browse index.
--
-- 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 —
@@ -34,14 +28,14 @@
-- 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.
-- exposed as-is, 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.
-- so alphabetical would bury the handful of genres a library actually has
-- a lot 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
@@ -87,7 +81,7 @@ WHERE EXISTS (
);
-- name: ListAlbumYearsWithCount :many
-- Year browse index (#367). Only albums with a release_date appear — an
-- Year browse index. 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.
@@ -132,8 +126,7 @@ WHERE release_date IS NOT NULL
-- 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.
-- chip always leads to a page that actually contains this album.
SELECT DISTINCT trim(g.genre) AS genre
FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
@@ -1,4 +1,4 @@
-- Folksonomy tags for out-of-library Discover candidates (#2376). Parallel to
-- Folksonomy tags for out-of-library Discover candidates. Parallel to
-- track_tags.sql, but keyed by MBID because the artist has no local row. See
-- 0050_candidate_artist_tags.up.sql for why the bookkeeping is its own table.
@@ -63,7 +63,7 @@ ON CONFLICT (candidate_mbid) DO UPDATE
updated_at = now();
-- name: ListCandidateArtistTagsForMbids :many
-- Cached tags for a set of candidates, for slice 6's taste-overlap ranking.
-- Cached tags for a set of candidates, for the taste-overlap ranking.
-- One row per (candidate, tag).
SELECT candidate_mbid, tag, weight
FROM candidate_artist_tags
+1 -1
View File
@@ -1,4 +1,4 @@
-- The release catalogue read from MusicBrainz (M502 #5519).
-- The release catalogue read from MusicBrainz.
-- name: UpsertMBArtist :exec
INSERT INTO mb_artists (mbid, name, sort_name, disambiguation, artist_type, country)
+1 -1
View File
@@ -1,4 +1,4 @@
-- Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
-- Household co-play similarity. Minstrel is a
-- single shared-library, multi-user server (no per-user library ACL), so the
-- "household" is the whole instance's user set. These queries recompute
-- artist–artist co-occurrence edges from play_events and store them in
+2 -2
View File
@@ -1,4 +1,4 @@
-- M7 cover-sources: queries for the cover_art_provider_settings table
-- Queries for the cover_art_provider_settings table
-- and the cover_art_sources_meta singleton. The SettingsService
-- consumes these.
@@ -13,7 +13,7 @@ SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
-- name: UpsertProviderSettings :exec
-- INSERT a default row for a newly-registered provider, or update an
-- existing row's display_order on boot reconciliation. Does NOT
-- overwrite enabled / api_key on conflict — the operator's settings
-- overwrite enabled / api_key on conflict — an admin's settings
-- persist across restarts.
INSERT INTO cover_art_provider_settings (provider_id, enabled, display_order)
VALUES ($1, $2, $3)
+3 -3
View File
@@ -1,7 +1,7 @@
-- M7 #353: album cover enrichment queries.
-- Album cover enrichment queries.
-- name: ListAlbumsMissingCover :many
-- M7 cover-sources: returns rows the enricher should attempt. Eligible:
-- Returns rows the enricher should attempt. Eligible:
-- cover_art_source IS NULL (never tried), OR cover_art_source = 'none'
-- AND cover_art_sources_version != current (settled under an older
-- source set; recheck against the current chain). LIMIT supplied by
@@ -53,7 +53,7 @@ UPDATE albums
WHERE id = $1;
-- name: ClearAlbumCoverNone :exec
-- M7 #380: when MBID backfill heals an album, clear its 'none' cover_art_source
-- When MBID backfill heals an album, clear its 'none' cover_art_source
-- back to NULL so the enricher's next batch retries the MBCAA fetch.
-- Idempotent — only updates rows currently stamped 'none'.
UPDATE albums
+14 -14
View File
@@ -29,7 +29,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
@@ -56,12 +56,12 @@ SELECT t.id, t.album_id, t.artist_id
--
-- 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.
-- by-expression at plan time (SQLSTATE 42P10), which would fail the
-- entire Discover build.
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
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND gl.user_id != $1
AND NOT EXISTS (
SELECT 1 FROM play_events pe
@@ -88,7 +88,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
@@ -108,7 +108,7 @@ SELECT t.id, t.album_id, t.artist_id
-- name: ListTasteUnheardTracksForDiscover :many
-- Taste-targeted novelty: unheard tracks whose genres overlap the user's
-- taste-profile tags (taste_profile_tags, #796), ranked by summed tag
-- taste-profile tags (taste_profile_tags), 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
@@ -116,13 +116,13 @@ SELECT t.id, t.album_id, t.artist_id
-- when the user has no taste tags yet (cold start), so the caller
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
--
-- The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed
-- weight rewards a track for carrying many of the user's tags, so a few
-- artists whose every track is tagged with the whole profile take every row
-- of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and
-- the best-performing arm handed its slots to the others. Ranking within
-- album, then within artist over what the album cap kept, is the same walk
-- capByAlbumAndArtist makes, so the caller's caps keep everything here.
-- The per-album and per-artist caps apply BEFORE the LIMIT. Summed weight
-- rewards a track for carrying many of the user's tags, so a few artists
-- whose every track is tagged with the whole profile would take every row of
-- a plain LIMIT, the caller's caps would then leave a handful, and the arm
-- would hand its slots to the others. Ranking within album, then within
-- artist over what the album cap kept, is the same walk capByAlbumAndArtist
-- makes, so the caller's caps keep everything here.
WITH scored AS (
SELECT t.id, t.album_id, t.artist_id,
SUM(nt.weight) AS weight,
@@ -130,7 +130,7 @@ WITH scored AS (
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 = sqlc.arg(user_id) AND trim(g_split.g) = nt.tag
WHERE t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
+10 -10
View File
@@ -55,14 +55,14 @@ SELECT t.id, t.duration_ms, f.audio_stream_sha256, f.chromaprint
AND f.chromaprint IS NOT NULL
-- Only chromaprints taken at the current length: prints at two lengths are not
-- comparable, and after a length change the backfill is still re-deriving the
-- rest (#3913).
-- rest.
AND f.chromaprint_length_sec = sqlc.arg(chromaprint_length_sec)
AND (t.duration_ms, t.id) > (sqlc.arg(after_duration_ms)::integer, sqlc.arg(after_id)::uuid)
ORDER BY t.duration_ms, t.id
LIMIT sqlc.arg(page_limit);
-- name: ListDismissedDuplicateMemberSets :many
-- What the operator has already said are not duplicates. A new proposal whose
-- What an admin has already said are not duplicates. A new proposal whose
-- every member sat together in one of these is not proposed again.
SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
FROM duplicate_groups g
@@ -113,8 +113,8 @@ SELECT count(*)::bigint
AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2;
-- name: CountPendingDuplicateGroupsInView :one
-- CountPendingDuplicateGroups narrowed to one view of the report (M498):
-- review what the operator still has to decide: every class but
-- CountPendingDuplicateGroups narrowed to one view of the report:
-- review what an admin still has to decide: every class but
-- cross_release, and groups not yet classified
-- cross_release the same song on different releases, which is kept, not merged
SELECT count(*)::bigint
@@ -130,7 +130,7 @@ SELECT count(*)::bigint
-- One page of proposals in one view (see CountPendingDuplicateGroupsInView),
-- newest first, flattened to one row per member so the handler folds them
-- without a query per group. What each copy carries — likes and plays from
-- every user — is here because it is what the operator weighs when deciding
-- every user — is here because it is what an admin weighs when deciding
-- which copy to keep.
WITH page AS (
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note
@@ -186,19 +186,19 @@ UPDATE duplicate_groups
WHERE id = sqlc.arg(id) AND status = 'pending';
-- name: ListDuplicateGroupsForResolve :many
-- Every pending group's present copies, for the resolver (M498 #5435): what it
-- Every pending group's present copies, for the resolver: what it
-- needs to classify the group and to choose the copy to keep. One row per copy,
-- grouped by group. position_clash is true when another present track on the
-- same album claims the same disc and track number: tags that do not fit the
-- album, which the survivor rule weighs against a copy. source_verdict says
-- whether the copy is a video rip ('suspect') or one the operator kept ('fine').
-- whether the copy is a video rip ('suspect') or one an admin kept ('fine').
-- prior_class is the class the last pass gave the group, NULL for a group no
-- pass has seen. release_group_mbid is the album's MusicBrainz group, from its
-- tags or the library listing's match; '' when neither names one.
-- acoustid_state and acoustid_recording are what AcoustID said of the file,
-- when it was looked up (#6169: which song one recording cut to several slots
-- is). group_id narrows it to one group, for the operator settling it (#6184);
-- NULL reads them all.
-- when it was looked up: which song a recording cut to several slots is.
-- group_id narrows it to one group, for an admin settling it; NULL reads them
-- all.
SELECT g.id AS group_id,
g.tier,
g.worst_bit_error_rate,
+2 -2
View File
@@ -26,7 +26,7 @@ LIMIT 1;
-- name: InsertPlayEvent :one
-- pick_kind is non-NULL only for system-playlist plays whose track was
-- found (with a stamped kind) in the user's live snapshot for that
-- variant at ingestion time (#1249, generalized in #1270).
-- variant at ingestion time.
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
device_class
@@ -38,7 +38,7 @@ RETURNING *;
-- Looks a track up in the user's CURRENT snapshot of the given system
-- variant and returns its pick_kind. Used at play-ingestion time to
-- freeze provenance onto the play_event — snapshots rebuild daily, so
-- attribution can't be reconstructed at read time (#1249/#1270). No
-- attribution can't be reconstructed at read time. No
-- row = track not in today's snapshot (caller stores NULL); a row with
-- NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
-- is non-singleton (up to 3 mixes/user); a track in two of them takes
+3 -3
View File
@@ -1,6 +1,6 @@
-- name: UpsertTrackFingerprint :exec
-- Written whenever a track's fingerprint is derived: by the scan when a file is
-- new or its bytes changed, and by the backfill (#3908) for rows derived by an
-- new or its bytes changed, and by the backfill for rows derived by an
-- older method. Replaces the row wholesale — a fingerprint of the old bytes has
-- no standing once the file has changed.
INSERT INTO track_fingerprints (
@@ -23,7 +23,7 @@ ON CONFLICT (track_id) DO UPDATE SET
DELETE FROM track_fingerprints WHERE track_id = $1;
-- name: ListTracksNeedingFingerprint :many
-- The backfill's work queue (#3908): tracks with no fingerprint, or one derived
-- The backfill's work queue: tracks with no fingerprint, or one derived
-- by an older method. Keyset-paged on id so a pass visits each track at most
-- once. That cursor is load-bearing: an inconclusive attempt writes no row, so
-- without it a file that keeps timing out would be listed again straight away
@@ -34,7 +34,7 @@ SELECT t.id, t.file_path
LEFT JOIN track_fingerprints f ON f.track_id = t.id
WHERE t.missing_since IS NULL
-- A row taken at another length is as stale as one from an older method:
-- chromaprints at two lengths cannot be compared (#3913).
-- chromaprints at two lengths cannot be compared.
AND (f.track_id IS NULL
OR f.fingerprint_version < sqlc.arg(current_version)
OR f.chromaprint_length_sec <> sqlc.arg(chromaprint_length_sec))
+9 -11
View File
@@ -1,11 +1,10 @@
-- Background garbage-collector / lifecycle queries. All five address
-- drift findings from the 2026-06-02 audit (Scribe parent #552):
-- #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
-- Background garbage-collector / lifecycle queries: rows nothing else closes
-- or removes. Sweeper runs on a long tick (default 1h)
-- so per-query cost is amortised; each is idempotent (re-running on
-- already-closed/-deleted rows is a no-op).
-- name: GcCloseStalePlayEvents :execrows
-- #566: play_events rows opened more than 24h ago that never got a
-- Play_events rows opened more than 24h ago that never got a
-- play_ended. Client crashed mid-track, network dropped, etc. We
-- synthesize ended_at = started_at + duration_played_ms when present,
-- otherwise leave duration_played_ms null and stamp ended_at = now()
@@ -21,10 +20,9 @@ UPDATE play_events
AND started_at < now() - INTERVAL '24 hours';
-- name: GcClosePlaySessionsWithNoRecentEvents :execrows
-- #565: play_sessions.ended_at was added but never populated by any
-- writer. Close sessions whose last_event_at is older than 6h —
-- treating that as "user moved on" the same way audio_service does
-- after grace periods. Sessions with NO events (track_count = 0)
-- play_sessions.ended_at has no other writer. Close sessions whose
-- last_event_at is older than 6h — treating that as "user moved on" the
-- same way audio_service does after grace periods. Sessions with NO events (track_count = 0)
-- older than 1h are also closed (stale handshakes from clients that
-- never recorded a play). ended_at is set to last_event_at so the
-- session's duration reads naturally.
@@ -38,7 +36,7 @@ UPDATE play_sessions
);
-- name: GcExpireScrobbleQueueFailedRows :execrows
-- #567: scrobble_queue rows that have been in status='failed' for
-- Scrobble_queue rows that have been in status='failed' for
-- more than 14 days. The worker stops retrying after maxAttempts;
-- failed rows accumulate forever otherwise. CASCADE from play_events
-- already drops the row when the underlying event is deleted, so this
@@ -48,7 +46,7 @@ DELETE FROM scrobble_queue
AND enqueued_at < now() - INTERVAL '14 days';
-- name: GcResetStuckSystemPlaylistRuns :execrows
-- #574: system_playlist_runs.in_flight = true can wedge on a
-- System_playlist_runs.in_flight = true can wedge on a
-- goroutine panic between SET in_flight=true and SET in_flight=false.
-- The duplicate-prevention check refuses to start a fresh regen while
-- in_flight, so a stuck row blocks all future regens for that user.
@@ -61,7 +59,7 @@ UPDATE system_playlist_runs
AND last_run_at < now() - INTERVAL '10 minutes';
-- name: GcDeleteExpiredPasswordResets :execrows
-- #575: password_resets accumulates expired + used rows forever. The
-- Password_resets accumulates expired + used rows forever. The
-- validation path already rejects them; this just keeps the table
-- from growing unbounded. Used rows are kept for 7 days for audit;
-- unused expired rows go immediately.
+1 -1
View File
@@ -1,5 +1,5 @@
-- name: ListUserHistory :many
-- M7 #365: track-level listening history. Returns one row per
-- Track-level listening history. Returns one row per
-- play_events entry, newest first, joined with tracks/albums/artists
-- so the SPA can render a TrackRef without follow-up fetches. Filters
-- out skipped events and tracks the user has quarantined (per-user
+5 -5
View File
@@ -1,4 +1,4 @@
-- Import with verification (M502 #5522).
-- Import with verification.
-- name: ListLiveGrabs :many
-- Every download still running or waiting on its import, oldest first.
@@ -52,7 +52,7 @@ UPDATE grabs
import_forced = false,
imported_at = CASE WHEN sqlc.arg(import_state)::text = 'imported' THEN now() ELSE imported_at END,
-- The files were copied, so an imported download goes on sharing
-- until the lifecycle removes it (#5523).
-- until the lifecycle removes it.
seeding = sqlc.arg(import_state)::text = 'imported',
imported_files = sqlc.arg(imported_files),
destination = sqlc.arg(destination),
@@ -114,7 +114,7 @@ SELECT DISTINCT COALESCE(t.disc_number, 1)::int AS disc, t.track_number::int AS
-- name: ListGrabs :many
-- The Wanted view's Activity tab: downloads waiting on a person first, then
-- the rest still running, then the ended ones newest first. Each carries
-- what is wrong with it now, if anything (#5523).
-- what is wrong with it now, if anything.
SELECT g.id, g.want_id, g.name, g.indexer, g.client, g.quality, g.origin, g.forced,
g.grabbed_at, g.ended_at, g.end_reason, g.end_detail, g.progress, g.import_state,
g.import_reason, g.import_detail, g.import_updated_at, g.imported_at, g.imported_files,
@@ -142,7 +142,7 @@ SELECT b.info_hash, b.name, b.reason, b.detail, b.blocked_at, b.expires_at, w.ti
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListUpgradeTracks :many
-- The tracks an upgrade may replace (#5524): the present tracks of the album
-- The tracks an upgrade may replace: the present tracks of the album
-- the want holds.
SELECT id, file_path, mbid, COALESCE(disc_number, 1)::int AS disc, track_number,
file_format, file_size, duration_ms, source_verdict
@@ -167,7 +167,7 @@ UPDATE tracks SET source_verdict = NULL, source_verdict_at = NULL
-- name: ListImportedGrabsHolding :many
-- The downloads Minstrel imported into the folder holding path: the releases a
-- file there came from, to block when the files proved not to be the album
-- (#6169). A file imported before Minstrel downloaded anything has none.
--. A file imported before Minstrel downloaded anything has none.
SELECT id, info_hash, name, want_id
FROM grabs
WHERE import_state = 'imported'
+4 -5
View File
@@ -3,11 +3,10 @@ INSERT INTO library_changes (entity_type, entity_id, op)
VALUES ($1, $2, $3);
-- name: InsertLibraryChanges :exec
-- Batch form, for a mutation that touches many rows at once (#2704: the scan's
-- reconcile pass can mark up to a quarter of a library missing in one go).
-- Every caller before this one changed a single entity, so per-row inserts
-- were the right shape; a loop here would be thousands of round-trips inside
-- a scan that is already the slow path.
-- Batch form, for a mutation that touches many rows at once (the scan's
-- reconcile pass can mark up to a quarter of a library missing in one go). A
-- loop of single inserts would be thousands of round-trips inside a scan that
-- is already the slow path.
INSERT INTO library_changes (entity_type, entity_id, op)
SELECT sqlc.arg(entity_type)::text,
unnest(sqlc.arg(entity_ids)::text[]),
+1 -1
View File
@@ -8,7 +8,7 @@ UPDATE library_settings
WHERE id = true
RETURNING *;
-- A library folder that moved (#6129): every path under old_root is rewritten
-- A library folder that moved: every path under old_root is rewritten
-- to the same place under new_root, so the tracks keep their ids and with them
-- their plays, likes and playlists. updated_at is left alone on purpose: the
-- scan compares it with each file's mtime, and the files have not changed.
+2 -2
View File
@@ -1,4 +1,4 @@
-- The download lifecycle (M502 #5523): the intervention ledger, giving a
-- The download lifecycle: the intervention ledger, giving a
-- download up, and seeding after import.
-- name: RaiseIntervention :one
@@ -79,7 +79,7 @@ RETURNING *;
-- name: CountGoneGrabs :one
-- How many of a release's grabs ended gone since a time: a release that keeps
-- leaving its client is held back rather than taken on every pass (#6176).
-- leaving its client is held back rather than taken on every pass.
SELECT count(*) FROM grabs
WHERE info_hash = sqlc.arg(info_hash) AND end_reason = 'gone' AND ended_at > sqlc.arg(since);
+1 -1
View File
@@ -1,5 +1,5 @@
-- name: LikeTrack :many
-- A like is on the song (M498 #5438): every copy of it sharing the track's
-- A like is on the song: every copy of it sharing the track's
-- song_key is liked. Returns the tracks newly liked, for the sync log; empty
-- when the song was already liked.
INSERT INTO general_likes (user_id, track_id)
+3 -3
View File
@@ -1,5 +1,5 @@
-- name: UpsertTrackLoudness :exec
-- Written when the backfill measures a track (#4995). Replaces the row
-- Written when the backfill measures a track. Replaces the row
-- wholesale: a measurement of the old bytes has no standing once the file has
-- changed.
INSERT INTO track_loudness (
@@ -78,7 +78,7 @@ UPDATE loudness_settings
RETURNING *;
-- name: ListAlbumsNeedingLoudness :many
-- The album pass's work queue (#4996): albums whose present tracks or their
-- The album pass's work queue: albums whose present tracks or their
-- measurements have changed since album loudness was last computed, or that
-- never had it. The digest is over every present track's id and the
-- measurement it holds at the current version ('-' for none), so a track
@@ -154,7 +154,7 @@ SELECT integrated_lufs, true_peak_dbtp FROM album_loudness WHERE album_id = $1;
-- name: GetReplayGainByTrackIDs :many
-- Track and album loudness for a set of tracks, for every surface that hands
-- gains to a client (#4997). A measurement from an older analysis version is
-- gains to a client. A measurement from an older analysis version is
-- still delivered: it is a better gain than none until the backfill redoes it.
SELECT t.id,
tl.integrated_lufs AS track_lufs,
+1 -1
View File
@@ -1,4 +1,4 @@
-- Duplicate merge (Scribe #3911). Every statement here runs inside the one
-- Duplicate merge. Every statement here runs inside the one
-- transaction library.MergeDuplicateGroup opens, after the removed copy's file
-- is already gone. The loser's own track row is deleted last with DeleteTrack;
-- what these do is move everything it carries onto the survivor first, so that
+8 -8
View File
@@ -1,26 +1,26 @@
-- Missing-pair pass (M485). A track marked missing whose replacement is already
-- Missing-pair pass. A track marked missing whose replacement is already
-- on disk under another row — most often a quality upgrade, mp3 → flac at a new
-- path — is folded into that replacement, so its history moves across and
-- re-acquisition stops chasing music the library already has.
--
-- Move adoption (#2528) cannot catch these: it links a new file to a missing row
-- Move adoption cannot catch these: it links a new file to a missing row
-- at the moment the file is first inserted, and the replacement arrives as a
-- re-encode with no recording MBID, so it has nothing to match on. AcoustID may
-- give it an MBID later; by then both rows exist and only a fold can join them.
-- name: ListMissingTrackPairs :many
-- Every candidate (missing, present) pair, with how many candidates each side
-- has. Two rules, measured on the operator's library on 2026-10-07 (#5274):
-- has. Two rules:
--
-- mbid the same recording MBID, within the album group (the album row, or
-- albums sharing a release group). 142 of 359 pairs.
-- title the same album row and the same title, ignoring case. 213 more. The
-- albums sharing a release group).
-- title the same album row and the same title, ignoring case. The
-- replacements are often not the original file — another edition,
-- re-encoded, untagged — so the metadata is what is left to go on.
--
-- Deliberately NOT gated on duration or track position. On the 142 pairs known
-- to be one recording, 18% differed by over 2s (old mp3 durations are poor
-- estimates), and the poorly tagged set is exactly where numbering is broken.
-- Deliberately NOT gated on duration or track position. Pairs known to be one
-- recording often differ by over 2s (old mp3 durations are poor estimates),
-- and the poorly tagged set is exactly where numbering is broken.
--
-- A pair whose MBIDs are both set and differ is not a title match: the tags say
-- they are different recordings. The caller folds a pair only when each side has
+7 -7
View File
@@ -1,4 +1,4 @@
-- M489 notifications inbox (#726). Rows are written only through
-- Notifications inbox. Rows are written only through
-- internal/notifications.Notifier, which decides recipients and honours each
-- recipient's inbox preference before it reaches these.
@@ -18,10 +18,10 @@ RETURNING id;
-- ten. Every coalesced kind's event is "N more happened", never a restated
-- total, so the row always says what is new since it was last read.
--
-- created_at and emailed_at are left alone (M542): an update changes what the
-- notice says, not when it arrived. Moving created_at made the phone announce
-- it again and clearing emailed_at sent it again, so a notice the hourly
-- duplicate pass kept growing alerted every hour.
-- created_at and emailed_at are left alone: an update changes what the
-- notice says, not when it arrived. Moving created_at would make the phone
-- announce it again and clearing emailed_at would send it again, so a notice
-- that keeps growing would alert on every update.
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at)
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload), sqlc.arg(coalesce_key),
CASE WHEN sqlc.arg(email_wanted)::boolean THEN NULL ELSE now() END)
@@ -66,7 +66,7 @@ UPDATE user_notifications
-- all read" queued offline and replayed later must not mark notices that
-- arrived in between, which the user never saw. A coalesced row that grew
-- since then is marked read with it: it arrived before they asked, and its
-- growth is a quiet update to a notice they had seen (M542).
-- growth is a quiet update to a notice they had seen.
UPDATE user_notifications
SET read_at = now()
WHERE user_id = sqlc.arg(user_id)
@@ -104,7 +104,7 @@ ON CONFLICT (user_id, kind) DO UPDATE SET
email = EXCLUDED.email,
updated_at = now();
-- Email digest (#5346) ------------------------------------------------------
-- Email digest ------------------------------------------------------
-- name: ListEmailPendingNotifications :many
-- Every unread, un-emailed row of a user who has an address, oldest first.
+3 -3
View File
@@ -55,7 +55,7 @@ RETURNING id, cover_path;
-- (track was removed from the library). The denormalized snapshot fields
-- on playlist_tracks remain authoritative for title/artist/album text.
--
-- Deliberately NOT filtered on missing_since (#2527), unlike every browse /
-- Deliberately NOT filtered on missing_since, unlike every browse /
-- discover / mix query. A playlist entry is something the user put here on
-- purpose, so a missing file stays in the list and renders as a dead row
-- rather than silently vanishing; missing_since rides along so the service
@@ -76,7 +76,7 @@ ORDER BY pt.position;
-- Inserts at the next available position. Snapshot fields are copied
-- from the tracks/albums/artists join at insert time. tracks.duration_ms
-- is converted to seconds for the snapshot. pick_kind is NULL for manual
-- playlists; the For You builder stamps 'taste'/'fresh' (#1249).
-- playlists; the For You builder stamps 'taste'/'fresh'.
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT
sqlc.arg(playlist_id)::uuid,
@@ -120,5 +120,5 @@ LIMIT $2;
-- name: GetPlaylistsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
--. Mirror of GetArtistsByIDs.
SELECT * FROM playlists WHERE id = ANY($1::uuid[]);
+8 -8
View File
@@ -1,6 +1,6 @@
-- Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
-- Re-acquisition of missing files.
-- The unit is the album: an album whose files went missing becomes a want for
-- its release group, and the search schedule (#5521) looks for it.
-- its release group, and the search schedule looks for it.
-- name: GetReacquisitionSettings :one
SELECT * FROM reacquisition_settings WHERE id = true;
@@ -20,17 +20,17 @@ RETURNING *;
--
-- * it still has at least one track whose file has been missing longer than
-- the grace window. Measured on missing_since, which reconcile never
-- re-stamps (#2523), so it is a genuine "gone since" clock;
-- re-stamps, so it is a genuine "gone since" clock;
-- * MusicBrainz can name it: the artist has an MBID, and the album a
-- release MBID or a release group. The release MBID is only ever used to
-- find the group, and 238 albums matched to a group carried no release
-- id, so their losses were never wanted back (#6167). Albums failing this
-- are counted separately (CountAlbumsMissingWithoutMbid);
-- * it is on the wanted list (#5567): an album taken off the list, or
-- find the group, and an album matched to a group may carry no release
-- id. Albums failing this are counted separately
-- (CountAlbumsMissingWithoutMbid);
-- * it is on the wanted list: an album taken off the list, or
-- dropped into the library by hand, is not fetched back;
-- * no want already covers it: none open, and none held since its files
-- went missing. A want held after the loss means the library has a full
-- copy despite the missing rows (#5245), so asking again would loop.
-- copy despite the missing rows, so asking again would loop.
SELECT albums.id AS album_id,
albums.title AS album_title,
albums.mbid AS album_mbid,
+42 -49
View File
@@ -24,7 +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 t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM play_events
WHERE user_id = $1 AND track_id = t.id
@@ -36,25 +36,22 @@ WHERE t.id <> $2
);
-- name: LoadRadioCandidatesV2 :many
-- M4c: similarity-driven candidate pool. 6-way UNION:
-- Similarity-driven candidate pool. 6-way UNION:
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
-- $10 taste_overlap K (tracks by the user's top
-- positively-weighted taste-profile artists, so taste-relevant tracks
-- enter the pool even when the similarity/random arms miss them; scored
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
-- $11 coplay_artists K (#1533 — tracks by artists co-played across the
-- $11 coplay_artists K (tracks by artists co-played across the
-- instance with the seed's artist; source='user_cooccurrence'),
-- $12 order_seed (text) — see below.
--
-- $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned
-- a stable set only while their LIMIT exceeded the rows eligible for them: at
-- that point they returned all of them and the order stopped mattering,
-- because the caller sorts by track id before scoring. Below that threshold
-- they returned a random SUBSET, and two builds on the same day drew
-- different ones — so "daily determinism" held by accident, and only for
-- libraries smaller than the limits.
-- $12 orders four arms by md5(id || seed), not random(). An arm whose LIMIT is
-- below the rows eligible for it returns a SUBSET, and with random() two
-- builds on the same day would draw different ones; the caller sorts by track
-- id before scoring, so only the subset matters.
--
-- md5(id || seed) keeps the intent — an arbitrary spread that changes when
-- the seed does — while making it reproducible for a given seed. The CALLER
@@ -63,7 +60,7 @@ WHERE t.id <> $2
-- keeps varying, which is what a radio should do.
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
--
-- sim_score BANDS keep the tiers in order (rule 131, #6177). Similarity edges
-- sim_score BANDS keep the eligibility tiers in order. Similarity edges
-- are stored relative to the seed's best match, 0 to 1, so the arms are placed
-- in bands rather than compared raw:
-- tier 1 lb_similar 0.5 + 0.5 × edge (0.5, 1.0]
@@ -71,9 +68,9 @@ WHERE t.id <> $2
-- tag_overlap 0.5 × share of the seed's tags
-- likes_overlap 0.3, flat
-- tier 3 taste_overlap, random_fill 0
-- Without the bands, the seed's weaker LB edges fell below a shared genre tag
-- and the flat likes score, and the one arm that measures the seed lost to
-- the ones that guess.
-- Without the bands, the seed's weaker LB edges would fall below a shared
-- genre tag and the flat likes score, and the one arm that measures the seed
-- would lose to the ones that guess.
WITH
seed_artist AS (
@@ -108,11 +105,10 @@ lb_similar AS (
LIMIT $5
),
similar_artists AS (
-- Round-robin across the similar artists (#5297): each artist's first
-- Round-robin across the similar artists: each artist's first
-- track, best-scoring artist first, then each one's second, and so on.
-- Ordering by artist score alone let the closest similar artist's
-- catalogue fill the whole LIMIT; on the operator's library this arm held
-- exactly one artist for all 17 seeds measured (#3879).
-- Ordering by artist score alone would let the closest similar artist's
-- catalogue fill the whole LIMIT.
SELECT track_id, sim_score
FROM (
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score,
@@ -138,11 +134,10 @@ tag_overlap AS (
AND t.id <> $2
GROUP BY t.id
HAVING count(DISTINCT trim(g_split.g)) > 0
-- md5 breaks the ties (#3880). A seed with one tag gives every track of
-- that genre the same score, 7,804 tracks on the operator's library, and
-- with no tie-break the LIMIT took whichever rows Postgres reached first:
-- the same slice for every seed of the genre, and not one the order seed
-- controls.
-- md5 breaks the ties. A seed with one tag gives every track of that
-- genre the same score, and with no tie-break the LIMIT would take
-- whichever rows Postgres reached first: the same slice for every seed of
-- the genre, and not one the order seed controls.
ORDER BY sim_score DESC, md5(t.id::text || $12::text)
LIMIT $7
),
@@ -161,12 +156,10 @@ likes_overlap AS (
LIMIT $8
),
taste_overlap AS (
-- Round-robin across the taste artists (#3880), as similar_artists is
-- (#5297): each artist's first track, strongest artist first, then each
-- one's second. Ordered by weight then track id, the strongest artist's
-- catalogue filled the whole arm with the same lowest-id tracks every
-- time; on the operator's library that was 20 tracks of one artist of
-- 174, whose catalogue is 197 tracks.
-- Round-robin across the taste artists, as similar_artists is: each
-- artist's first track, strongest artist first, then each one's second.
-- Ordered by weight then track id, the strongest artist's catalogue would
-- fill the whole arm with the same lowest-id tracks every time.
SELECT track_id, sim_score
FROM (
SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight,
@@ -183,7 +176,7 @@ taste_overlap AS (
LIMIT $10
),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- Household co-play: tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
@@ -237,7 +230,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 AND t.missing_since IS NULL -- #2523: never offer a file that is gone
JOIN tracks t ON t.id = u.track_id AND t.missing_since IS NULL -- 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 (
@@ -255,10 +248,10 @@ GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
-- name: ListArtistContextPlayCountsForUser :many
-- Per-artist completed-play counts split by whether each play falls in the
-- CURRENT context cell, in the user's local timezone (#1531). The cell is
-- daypart × weekday-type, optionally narrowed by device class (#1551): when
-- $2 (the current device) is '', the device dimension is ignored (identical to
-- the time-only #1531 behaviour, used by the daily mixes which have no device);
-- CURRENT context cell, in the user's local timezone. The cell is
-- daypart × weekday-type, optionally narrowed by device class: when
-- $2 (the current device) is '', the device dimension is ignored (time only,
-- for the daily mixes, which have no device);
-- when $2 is set (radio), a play only counts in the cell if its device_class
-- matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
-- window bounds cost. Daypart buckets: night [22,5) morning [5,12)
@@ -308,7 +301,7 @@ FROM plays p
GROUP BY p.artist_id;
-- name: GetLatestPlayDeviceClassForUser :one
-- The device_class of the user's most recent play (#1551), used as the
-- The device_class of the user's most recent play, used as the
-- "current device" for radio context conditioning. NULL when the latest play
-- predates device capture or came from a client that didn't send one.
SELECT device_class
@@ -321,24 +314,24 @@ LIMIT 1;
-- Per-user artist suggestions ranked by taste signal x similarity, projected
-- through artist_similarity_unmatched (out-of-library candidates only).
--
-- Seeds are TIERED (rule #131) so the surface never empties:
-- Seeds are TIERED so the surface never empties:
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- SIGNED by internal/taste, so an artist the user has drifted away
-- from stops contributing instead of accumulating forever.
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute).
--
-- The signal is log-damped: contribution is signal x similarity, and the old
-- undamped sum let one heavily-played artist's neighbours take every slot --
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
-- The signal is log-damped: contribution is signal x similarity, and an
-- undamped sum would let one heavily-played artist's neighbours take every
-- slot -- entrenching harder the MORE the user listened.
--
-- Candidates already in the library, already requested and not terminal, or
-- snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
-- decay), $3=limit.
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
-- Completed plays only: counting every play_event would make skipping an
-- artist repeatedly INCREASE its signal and push more of its neighbours
-- at the user.
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
@@ -395,7 +388,7 @@ contributions AS (
AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed')
)
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
-- Snoozed by this user and not yet expired. Time-boxed and
-- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a
@@ -421,7 +414,7 @@ ORDER BY total_score DESC
LIMIT $3;
-- name: ListMostPlayedTracksForUser :many
-- M6a: top-N tracks by completed-play count for the user. was_skipped
-- Top-N tracks by completed-play count for the user. was_skipped
-- excludes skips so a user spamming next doesn't fabricate a top track.
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out.
--
@@ -442,7 +435,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -469,7 +462,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 t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
@@ -478,7 +471,7 @@ ORDER BY p.cnt DESC, t.id
LIMIT sqlc.arg(result_limit);
-- name: ListLastPlayedArtistsForUser :many
-- M6a: artists ranked by max(play_events.started_at) for the user, with
-- Artists ranked by max(play_events.started_at) for the user, with
-- a derived cover_album_id via a representative-album lateral join (most
-- recent album that has cover_art_path set). album_count joined for the
-- ArtistRef wire shape.
@@ -1,16 +1,16 @@
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
-- Recommendation observability. Per-source play outcomes so the
-- operator can see whether each recommendation surface is landing and tune the
-- taste weights. Source is stamped on play_events when a play is launched from
-- a recommendation surface; NULL means the user picked the track manually —
-- those rows are INCLUDED here as the baseline control group the surfaces are
-- judged against (milestone #127: delta-vs-baseline is what makes the numbers
-- actionable). Raw source strings are bucketed into stable surface families in
-- the Go handler; completion_n is carried so family merges can weight
-- judged against (delta-vs-baseline is what makes the numbers actionable).
-- Raw source strings are bucketed into stable surface families in the Go
-- handler; completion_n is carried so family merges can weight
-- avg_completion correctly.
-- name: RecommendationWeeklyTrends :many
-- Weekly per-source outcome series for the tuning lab's trend view
-- (#1251). Aggregated across ALL users: the tuning knobs are global,
--. Aggregated across ALL users: the tuning knobs are global,
-- so judging a knob turn needs global outcomes — rows carry rates
-- only, no track or user identity. NULL-source (manual) rows are
-- included as the baseline family.
@@ -42,12 +42,12 @@ ORDER BY 1, 2;
-- name: RecommendationSourceMetricsForUser :many
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
-- mean completion ratio over the completion_n plays that recorded one.
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
-- pick_kind splits For You plays into taste/fresh/unattributed;
-- it is NULL for every other source, so those still group to one row.
--
-- completion_sqsum carries the sum of SQUARED completion ratios so the Go
-- handler can compute a variance — needed for the margin of error on a
-- completion delta (#2495). It is the sum rather than `stddev_samp` on purpose:
-- completion delta. It is the sum rather than `stddev_samp` on purpose:
-- raw source rows get merged into surface families in Go, and sums of squares
-- add across groups exactly, whereas standard deviations cannot be combined
-- without them. Variance = (sqsum - sum²/n) / (n-1), with sum = avg × n.
@@ -1,9 +1,9 @@
-- Recommendation tuning lab queries (#1250). Seeding happens via the
-- Recommendation tuning lab queries. Seeding happens via the
-- recsettings boot reconcile; shipped defaults live in Go only.
-- name: UpsertWeightProfileDefaults :exec
-- Boot reconcile: insert the shipped defaults for a profile if the row
-- doesn't exist yet. Never overwrites operator-tuned values.
-- doesn't exist yet. Never overwrites admin-tuned values.
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight,
@@ -54,8 +54,8 @@ UPDATE taste_tuning
RETURNING *;
-- name: UpsertDiscoverTuningDefaults :exec
-- Boot reconcile for the Discover scope (#2377). Never overwrites
-- operator-tuned values, same contract as the other two.
-- Boot reconcile for the Discover scope. Never overwrites
-- admin-tuned values, same contract as the other two.
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, $1, $2)
ON CONFLICT (singleton) DO NOTHING;
@@ -77,7 +77,7 @@ INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3);
-- name: ListTuningAudit :many
-- Newest first; consumed by the metrics trend view (#1251) to annotate
-- Newest first; consumed by the metrics trend view to annotate
-- knob turns on the timeline.
SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit
+1 -1
View File
@@ -111,7 +111,7 @@ SELECT * FROM requests
LIMIT 1;
-- name: RepointRequestsAlbumMbid :exec
-- A want moved from a release id to its group (#5241) takes the requests
-- A want moved from a release id to its group takes the requests
-- that name it along, so the reconciler finds it.
UPDATE requests
SET album_mbid = sqlc.arg(new_mbid),
+2 -2
View File
@@ -1,4 +1,4 @@
-- M7 #381: scan-run lifecycle queries.
-- Scan-run lifecycle queries.
-- name: InsertScanRun :one
-- Creates a new scan_runs row with started_at=now(). Stages are filled in
@@ -33,7 +33,7 @@ SELECT id, started_at, finished_at, library, mbid_backfill, cover_enrich, artist
LIMIT 1;
-- name: UpdateScanRunArtistArtEnrich :exec
-- M7 cover-sources: persists the artist-art enrich stage tally JSON.
-- Persists the artist-art enrich stage tally JSON.
UPDATE scan_runs
SET artist_art_enrich = $2
WHERE id = $1;
+3 -3
View File
@@ -1,4 +1,4 @@
-- Searching for wants and grabbing releases (M502 #5521).
-- Searching for wants and grabbing releases.
-- name: GetAcquisitionSettings :one
SELECT * FROM acquisition_settings WHERE id = true;
@@ -24,7 +24,7 @@ UPDATE acquisition_settings SET last_pass_at = now(), last_pass = sqlc.arg(repor
-- Wants with no download running that were never searched or not since
-- @hours ago: open wants first (never-searched, then requests, then missing
-- files, then followed artists' releases), then held albums wanted for an
-- upgrade (#5524), which are searched at most weekly.
-- upgrade, which are searched at most weekly.
SELECT w.id, w.release_group_mbid, w.artist_name, w.title, w.reason, w.upgrade, w.held_tier, w.held_quality,
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
@@ -120,7 +120,7 @@ UPDATE grabs
-- name: ListReleaseBlocks :many
-- Every release set aside and still held back: a stalled one lifts by itself
-- at expires_at (#5523).
-- at expires_at.
SELECT * FROM release_blocks
WHERE expires_at IS NULL OR expires_at > now()
ORDER BY blocked_at DESC;
+2 -1
View File
@@ -41,7 +41,8 @@ DELETE FROM sessions WHERE id = $1;
DELETE FROM sessions WHERE token_hash = $1;
-- name: DeleteSessionForUser :execrows
-- Scoped by user_id, not just id (rule #47). Keyed on the id alone, any
-- Scoped by user_id, not just id: household data is per-user. Keyed on the
-- id alone, any
-- household member could revoke another member's session by guessing a uuid.
-- execrows lets the handler answer 404 rather than a false 204 when the row
-- isn't theirs.
+5 -5
View File
@@ -3,8 +3,8 @@
-- ListenBrainz has never answered for, or not in 30 days. Never-fetched first,
-- then the oldest answer. Freshness is read from track_similarity_fetches, not
-- from the edges, so a seed whose answer matched nothing in the library still
-- counts as fetched (#5296); reading the edges is what let 25 such seeds hold
-- the head of the queue forever.
-- counts as fetched; reading the edges would let such seeds hold the head of
-- the queue forever.
SELECT t.id, t.mbid
FROM tracks t
LEFT JOIN track_similarity_fetches f ON f.track_id = t.id
@@ -70,9 +70,9 @@ ON CONFLICT (artist_id) DO UPDATE SET fetched_at = now(), returned = EXCLUDED.re
--
-- The score is RELATIVE: each edge's LB score over the seed's best score in its
-- whole cached answer, so 1.0 is the seed's closest recording and the rest fall
-- below it (#6177). LB's raw scores are counts (16 to 1445 on the operator's
-- library) and reached the scorer, which reads similarity as 0 to 1, so one
-- edge outweighed likes, skips and recency together. The cache keeps the raw
-- below it. LB's raw scores are counts, into the thousands, and the scorer
-- reads similarity as 0 to 1, so a raw edge would outweigh likes, skips and
-- recency together. The cache keeps the raw
-- answer; only the edge is scaled.
--
-- Only edges of seeds the cache owns are removed, those with a fetch row.
+2 -2
View File
@@ -1,4 +1,4 @@
-- Song links (M498 #5438): copies of one song on different releases share a
-- Song links: copies of one song on different releases share a
-- song_key (migration 0076).
-- name: LinkTracksAsSong :one
@@ -41,7 +41,7 @@ UPDATE tracks SET song_id = NULL
-- name: ListTrackSongKeys :many
-- What the mix writer needs to place a track: its song, and whether it is a
-- video rip held back from the mixes (#5439).
-- video rip held back from the mixes.
SELECT id, song_key, (source_verdict IS NOT DISTINCT FROM 'suspect')::boolean AS held_back
FROM tracks WHERE id = ANY(sqlc.arg(ids)::uuid[]);
+1 -1
View File
@@ -1,4 +1,4 @@
-- Time-boxed "not right now" on a Discover artist suggestion (#2374).
-- Time-boxed "not right now" on a Discover artist suggestion.
--
-- See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
-- dismissal: it records no verdict on the music, expires on its own, and
+37 -40
View File
@@ -1,18 +1,18 @@
-- Candidate queries for the discovery system-playlist mixes
-- (#419-423). Each returns (id, album_id, artist_id) rows; the Go
--. Each returns (id, album_id, artist_id) rows; the Go
-- producer caps + truncates and converts to rankedCandidate. Daily-
-- deterministic md5(id||dateStr) ordering (where used) keeps a mix
-- stable within a day while rotating across days; shuffle-on-play
-- gives per-play variety on top.
-- name: ListDeepCutsTracks :many
-- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
-- Deep Cuts: low-play tracks (<=2 plays) from artists the user
-- has liked OR played heavily (>=5 non-skip plays across the artist).
-- Tracks the user has skipped twice or more don't qualify (#1257):
-- eligibility used to count only unskipped plays, so the most actively
-- rejected tracks read as "barely heard" and kept being re-offered.
-- Threshold 2 so a single accidental skip doesn't banish a track;
-- passive-signal only (no dislike UI, rule #101).
-- Tracks the user has skipped twice or more don't qualify: counting only
-- unskipped plays would make the most actively rejected tracks read as
-- "barely heard" and keep re-offering them. Threshold 2 so a single
-- accidental skip doesn't banish a track; passive signal only (there is no
-- dislike button).
-- $1 user_id, $2 date string.
WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
@@ -40,7 +40,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND COALESCE(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS (
@@ -51,11 +51,10 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200;
-- name: ListRediscoverTracks :many
-- #420 Rediscover: tracks the user played a lot but has drifted away
-- from. One blended pool (issue #1246: the old two-tier UNION was
-- all-or-nothing — a single ">=6 months cold" row suppressed the whole
-- ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
-- tier was also a strict subset of the 30d tier). Eligibility is >=3
-- Rediscover: tracks the user played a lot but has drifted away
-- from. One blended pool, not a two-tier UNION: the ">=6 months cold" tier is
-- a strict subset of the ">=30 days" one, and a UNION that let one row of it
-- suppress the wider tier would ship a one-song playlist. Eligibility is >=3
-- non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
-- so a weeks-old library still fields a pool — on young histories the
-- >=5-play tracks are precisely the ones still in rotation. Ordering
@@ -71,18 +70,18 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- 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 quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
-- Declined-resurfacing cooldown (#1258): a skip on a
-- Declined-resurfacing cooldown: a skip on a
-- rediscover-sourced play is the user explicitly saying "I've moved
-- on" to the exact invitation this mix extends — such tracks sit
-- out ~90 days instead of re-qualifying the next day. Passive
-- signal only (rule #101).
-- signal only.
AND NOT EXISTS (
SELECT 1 FROM play_events rp
WHERE rp.user_id = $1
@@ -97,14 +96,13 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200;
-- name: ListNewForYouTracks :many
-- #421 New for you, tiered per project rule #131 (#1267). The old
-- single hard rule (added <30d AND direct affinity) had two failure
-- modes with one root: no notion of consumption — the album you
-- devoured in week one crowded the mix for three more weeks, and
-- after a quiet month nothing qualified at all.
-- New for you, tiered so the mix degrades rather than vanishes. A
-- single hard rule (added <30d AND direct affinity) has no notion of
-- consumption: the album you devoured in week one would crowd the mix
-- for three more weeks, and after a quiet month nothing would qualify.
--
-- "Consumed" = any track attempted for >=30s (the First Listens
-- threshold, #1268): once you've meaningfully engaged, the album is
-- threshold): once you've meaningfully engaged, the album is
-- no longer news. Applies to every tier — played albums leave the
-- mix the next build.
--
@@ -151,7 +149,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -161,18 +159,18 @@ SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
LIMIT 300;
-- name: ListOnThisDayTracks :many
-- #422 On This Day: tracks the user played around this calendar date
-- On This Day: tracks the user played around this calendar date
-- (±10 day-of-year) in the past, excluding the very recent (>30 days
-- ago) so it's nostalgic, not just "last week". Weighted by how much
-- they were played in those windows. Floor relaxed from 60→30 days
-- and window ±7→±10 so it surfaces on a months-old library instead
-- of needing a full year of history; still skips cleanly (no rows →
-- no playlist) when there's no qualifying history yet.
-- Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
-- Jan 3 read as 359 days apart, silently gutting the window for ~3
-- weeks around every New Year — precisely when holiday nostalgia is
-- the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
-- ±1 is absorbed by the ±10 window. The build date ($2, already here
-- they were played in those windows. The 30-day floor and ±10 window
-- let it surface on a months-old library rather than needing a full
-- year of history; it still skips cleanly (no rows → no playlist)
-- when there's no qualifying history yet.
-- Day-of-year distance is circular: plain ABS would read Dec 28 vs
-- Jan 3 as 359 days apart, gutting the window for ~3 weeks around
-- every New Year — precisely when holiday nostalgia is the point.
-- LEAST(d, 365-d) wraps the boundary; leap-year drift of ±1 is absorbed
-- by the ±10 window. The build date ($2, already here
-- for the md5 rotation) anchors "today" instead of now() so the
-- window is testable and consistent with the mix's daily determinism.
-- $1 user_id, $2 date string.
@@ -190,7 +188,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT EXISTS (
SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
@@ -199,11 +197,10 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200;
-- name: ListFirstListensTracks :many
-- #423 First Listens: songs the user has never heard or even
-- attempted. "Attempted" is track-level with a >=30s listen threshold
-- (#1268, operator decision 2026-07-03) — the old version disqualified
-- a whole album on ANY play_event, so a 2-second accidental brush
-- banished it permanently.
-- First Listens: songs the user has never heard or even
-- attempted. "Attempted" is track-level with a >=30s listen threshold:
-- disqualifying a whole album on ANY play_event would let a 2-second
-- accidental brush banish it permanently.
--
-- tier 1 the exact desire: albums with ZERO attempted tracks
-- tier 2 step back: barely-attempted albums (<=25% of tracks
@@ -244,7 +241,7 @@ 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 t.missing_since IS NULL -- #2523: never offer a file that is gone
WHERE t.missing_since IS NULL -- 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 (
@@ -1,4 +1,4 @@
-- Rotation state for system-playlist sample-history dedup (#415).
-- Rotation state for system-playlist sample-history dedup.
-- One row per (user, playlist_kind). played_track_ids accumulates
-- tracks heard from that playlist in the current rotation; shuffle
-- prefers ids NOT in this set, then resets when the snapshot is
+10 -10
View File
@@ -1,4 +1,4 @@
-- Track picks here join `tracks ... AND t.missing_since IS NULL` (#2523): a
-- Track picks here join `tracks ... AND t.missing_since IS NULL`: 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
@@ -6,7 +6,7 @@
-- also serves user-curated playlists, where hiding a track the user added
-- themselves would be wrong).
-- M7 #352 slice 2: system-generated playlist queries.
-- System-generated playlist queries.
-- name: ListActiveUsersForSystemPlaylists :many
-- Active = had a play in the last 7 days. The cron iterates this list.
@@ -56,22 +56,22 @@ UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
-- name: PickSeedArtists :many
-- Top-12 most-engaged distinct artist candidates, tiered so the
-- "Songs like X" mixes never silently vanish (#1255): the old hard
-- 7-day window emptied the seed pool after a quiet week, and the
-- daily atomic-replace build then deleted every existing mix until
-- the user played something again. Same fallback shape as
-- "Songs like X" mixes never silently vanish: a hard 7-day window
-- would empty the seed pool after a quiet week, and the daily
-- atomic-replace build would then delete every existing mix until the
-- user played something again. Same fallback shape as
-- PickTopPlayedTracksForUser (For You's seeds):
-- tier 0 engagement in the last 7 days
-- tier 1 (only if tier 0 empty) last 30 days
-- tier 2 (only if tiers 0+1 empty) all-time
-- tier 3 (only if 0-2 empty) liked artists with no play history
-- All returned rows share one tier; produceSeedMixes maps it onto the
-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics
-- tiered pick-kind ladder and stamps the built tracks, so metrics
-- can compare mixes seeded from fresh vs stale engagement.
-- Score = unskipped-play count + 5 if user has liked the artist. The
-- Go-side picker (pickSeedArtistsForDay) shuffles the 12
-- daily-deterministically and takes songsLikeSeedCount (6) so the mix
-- set both rotates day-to-day and fills the dedicated Home row (#1491).
-- set both rotates day-to-day and fills the dedicated Home row.
WITH liked AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
),
@@ -163,7 +163,7 @@ alltime AS (
),
-- A likes-only read still joins tracks: a like outlives its file, and a
-- seed whose file is gone points For You at something the user can't hear
-- (#2701). The other two tiers get the same filter from their play_events
--. The other two tiers get the same filter from their play_events
-- join.
liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
@@ -206,7 +206,7 @@ SELECT COALESCE(
FROM tracks t
JOIN albums a ON a.id = t.album_id
WHERE t.artist_id = $2
AND t.missing_since IS NULL -- #2701: the play branch filters; this one must too
AND t.missing_since IS NULL -- the play branch filters; this one must too
ORDER BY a.release_date DESC NULLS LAST,
t.disc_number NULLS LAST,
t.track_number NULLS LAST,
+2 -2
View File
@@ -1,4 +1,4 @@
-- Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
-- Tag-enrichment provider settings queries.
-- The tags.SettingsService consumes these — parallel to the cover-art
-- SettingsService (coverart_settings.sql). Keeping the two independent
-- means a new tag source is added without touching the art settings.
@@ -12,7 +12,7 @@ SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
-- name: UpsertTagProviderSettings :exec
-- INSERT a default row for a newly-registered provider, or refresh an
-- existing row's display_order on boot. Does NOT overwrite enabled /
-- api_key on conflict — the operator's settings persist across restarts.
-- api_key on conflict — an admin's settings persist across restarts.
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
VALUES ($1, $2, $3)
ON CONFLICT (provider_id) DO UPDATE
+7 -7
View File
@@ -1,13 +1,13 @@
-- Taste profile (#796 phase 1). The daily build computes signed artist/tag
-- Taste profile. The daily build computes signed artist/tag
-- weights in Go (graded engagement + decay + like bonus) and atomic-replaces
-- them here. Read queries serve "top taste" lookups (used in phase 2).
-- them here. Read queries serve the scorer's "top taste" lookups.
-- name: ListPlayEngagementInputsForUser :many
-- One row per play in the decay-relevant window. completion is the play's
-- completion ratio (precomputed column when present, else duration_played /
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
-- is split into tags in Go; release_date derives the decade for the era
-- facet (#1530). Quarantined tracks are excluded.
-- facet. Quarantined tracks are excluded.
SELECT
t.id AS track_id,
t.artist_id,
@@ -31,7 +31,7 @@ WHERE pe.user_id = $1
-- name: ListLikedTrackTasteInputsForUser :many
-- (track_id, artist_id, genre, release_date) for each track the user has
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
-- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
-- tags, and era; track_id keys the enriched track_tags lookup.
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
@@ -57,7 +57,7 @@ INSERT INTO taste_profile_tags (user_id, tag, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileArtistsForUser :many
-- Top-weighted taste artists (phase-2 consumption + tests).
-- Top-weighted taste artists, for the scorer and tests.
SELECT artist_id, weight
FROM taste_profile_artists
WHERE user_id = $1
@@ -79,7 +79,7 @@ INSERT INTO taste_profile_eras (user_id, era, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileErasForUser :many
-- Top-weighted taste eras (#1530); consumed by the scorer's era term.
-- Top-weighted taste eras; consumed by the scorer's era term.
SELECT era, weight
FROM taste_profile_eras
WHERE user_id = $1
@@ -94,7 +94,7 @@ INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileMoodsForUser :many
-- Top-weighted taste moods (#1534); consumed by the scorer's mood term.
-- Top-weighted taste moods; consumed by the scorer's mood term.
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
+3 -3
View File
@@ -1,4 +1,4 @@
-- Track-tag enrichment queries (milestone #160, #1490). The tag enricher
-- Track-tag enrichment queries. The tag enricher
-- drains ListTracksMissingTags, fetches from the provider chain, and
-- writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
-- stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
@@ -8,7 +8,7 @@
-- or previously settled 'none' under an older provider version. Returns
-- the fields the provider chain needs — recording MBID (nullable) for
-- keyed lookups, title + artist name for name-based fallback, and the
-- artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
-- artist MBID (nullable) for MusicBrainz's artist-tag fallback.
-- $1 = current tag_sources_version, $2 = limit.
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
FROM tracks t
@@ -58,7 +58,7 @@ SELECT tt.track_id, tt.tag, tt.weight
WHERE gl.user_id = $1;
-- name: ListTrackTagsForTracks :many
-- Enriched tags for a set of candidate tracks (#1534), so the scorer can
-- Enriched tags for a set of candidate tracks, so the scorer can
-- derive each candidate's mood buckets at scoring time. One row per (track,
-- tag); the caller filters to mood words via the internal/mood vocabulary.
SELECT tt.track_id, tt.tag
+28 -29
View File
@@ -1,7 +1,7 @@
-- name: UpsertTrack :one
-- file_path is the canonical identity for library scan; mbid is secondary.
-- mbid is the file's tag. A looked-up id (mbid_source 'acoustid', M401)
-- survives a re-read that finds no tag id, and a tag id replaces it (D4).
-- mbid is the file's tag. A looked-up id (mbid_source 'acoustid')
-- survives a re-read that finds no tag id, and a tag id replaces it.
INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number,
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
@@ -25,7 +25,7 @@ ON CONFLICT (file_path) DO UPDATE SET
WHEN tracks.mbid_source = 'acoustid' THEN 'acoustid' END,
genre = EXCLUDED.genre,
-- Stamped on update too, so a tag-repair pass marks rows as done and the
-- next scan can short-circuit them again (#2499).
-- next scan can short-circuit them again.
tag_read_version = EXCLUDED.tag_read_version,
updated_at = now()
RETURNING *;
@@ -34,7 +34,7 @@ RETURNING *;
-- Track recording-MBID backfill: tracks with NULL mbid that still have
-- a file to re-read. $1 caps the batch (mirrors the album backfill).
-- Missing tracks are skipped: there is no file, and opening it anyway
-- logged a warning per track on every scan (#5139).
-- would log a warning per track on every scan.
SELECT id, file_path
FROM tracks
WHERE mbid IS NULL
@@ -92,7 +92,7 @@ WHERE title ILIKE '%' || $1::text || '%'
);
-- name: ListArtistTracksForUser :many
-- M6a: every track for the artist across their albums, with album_title
-- Every track for the artist across their albums, with album_title
-- and artist_name joined. Honors per-user quarantine. Used by
-- /api/artists/{id}/tracks for the artist-card play affordance, which
-- shuffles client-side. Ordering matches album/track natural order so
@@ -112,7 +112,7 @@ ORDER BY albums.release_date NULLS LAST, albums.sort_title,
t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id;
-- name: ListRandomTracksForUser :many
-- #427 S4: backing query for GET /api/library/shuffle — the online
-- Backing query for GET /api/library/shuffle — the online
-- source for "Shuffle all". N random tracks across the whole
-- library, per-user-quarantine filtered. $1 user_id, $2 limit.
SELECT sqlc.embed(t),
@@ -135,7 +135,7 @@ LIMIT $2;
SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
-- name: DeleteTrack :one
-- M7 #372: hard delete with FK cascade. The CASCADE on track_id from
-- Hard delete with FK cascade. The CASCADE on track_id from
-- play_events / general_likes_tracks / quarantine /
-- quarantine_actions handles their cleanup. RETURNING gives us
-- album_id + artist_id for the album-empty / artist-empty cascade
@@ -145,11 +145,11 @@ RETURNING id, album_id, artist_id, file_path, mbid;
-- name: GetTracksByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
--. Mirror of GetArtistsByIDs.
SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
-- name: FindMissingTrackByMbid :many
-- Move detection, strongest signal (#2528). A file that turned up at a new path
-- Move detection, strongest signal. A file that turned up at a new path
-- carrying a recording MBID we already have on a MISSING row is that recording,
-- moved — not a new track.
--
@@ -166,11 +166,11 @@ SELECT id, file_path FROM tracks
LIMIT 2;
-- name: FindMissingTrackByAudioHash :many
-- Move detection fallback for files with no MBID (#2528, #3914). The audio stream
-- Move detection fallback for files with no MBID. The audio stream
-- hash identifies the encoded audio itself, so it survives a rename, a move and a
-- retag — anything short of a re-encode. It replaced (file_size, duration_ms),
-- which could pair two unrelated files that happened to share a byte count and a
-- duration, and missed a file retagged in place, whose size changes.
-- retag — anything short of a re-encode. (file_size, duration_ms) would pair two
-- unrelated files that share a byte count and a duration, and miss a file
-- retagged in place, whose size changes.
--
-- Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
SELECT t.id, t.file_path
@@ -196,7 +196,7 @@ UPDATE tracks
-- 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
--. 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.
@@ -217,10 +217,10 @@ UPDATE tracks
AND missing_since IS NULL;
-- name: ClearTrackMbids :exec
-- Forgets the recording MBID of rows whose file was another song's (M498's
-- wrong-slot removal). The MBID came from that file, so it names the song kept
-- elsewhere on the album, and missing-pair folding would fold the emptied slot
-- into it: twelve slots went that way on 2026-10-20 and were never wanted back.
-- Forgets the recording MBID of rows whose file was another song's (the
-- duplicate resolver's wrong-slot removal). The MBID came from that file, so it
-- names the song kept elsewhere on the album, and missing-pair folding would
-- fold the emptied slot into it, so it would never be wanted back.
-- mbid_source goes with it (tracks_mbid_source_pair).
UPDATE tracks
SET mbid = NULL, mbid_source = NULL
@@ -245,19 +245,18 @@ UPDATE tracks
AND missing_since IS NOT NULL;
-- name: ListMissingTracks :many
-- The admin review surface for files the scan could not find (#2527).
-- The admin review surface for files the scan could not find.
--
-- Ordered by directory, then by the file's own position within its album,
-- because the unit an operator actually reasons about is a FOLDER: the case
-- this was built for was three whole albums that had been reorganised, and a
-- flat list ordered by timestamp presents that as forty unrelated decisions.
-- because the unit an admin actually reasons about is a FOLDER: a reorganised
-- album, listed flat by timestamp, reads as dozens of unrelated decisions.
-- Grouping happens in the handler; the ordering here is what makes a group
-- contiguous, so a page boundary splits a directory at worst.
--
-- last_played_at is a correlated MAX rather than a join so a track with no
-- plays stays in the result with NULL. It is here because "gone six months,
-- never played" and "gone yesterday, played 200 times" deserve opposite
-- reactions, and the operator can't tell them apart from a path.
-- reactions, and an admin can't tell them apart from a path.
SELECT t.id,
t.title,
t.file_path,
@@ -285,7 +284,7 @@ SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
-- name: ListSuspectSourceTracks :many
-- The admin report of present tracks whose filename looks like a video rip
-- (#5410): "(Official Video)", "[Audio]", "Visualizer" and the like.
--: "(Official Video)", "[Audio]", "Visualizer" and the like.
--
-- pattern is built in Go (internal/api/admin_suspect_sources.go) from the
-- same marker list that labels each row, so the filter and the labels cannot
@@ -308,7 +307,7 @@ SELECT t.id,
t.source_verdict,
t.source_verdict_at,
-- The album's place on the wanted list: wanted for a clean copy, and
-- when that was last searched for (#5524).
-- when that was last searched for.
COALESCE(w.upgrade, '')::text AS want_upgrade,
w.searched_at AS searched_at
FROM tracks t
@@ -330,8 +329,8 @@ SELECT COUNT(*) FROM tracks t
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: FlagSuspectSourceTracks :execrows
-- Holds back present tracks whose basename carries a video-rip marker (M498
-- #5439), the same pattern the report lists. A track the operator called fine
-- Holds back present tracks whose basename carries a video-rip marker, the
-- same pattern the report lists. A track an admin called fine
-- keeps that verdict.
UPDATE tracks
SET source_verdict = 'suspect', source_verdict_at = now()
@@ -348,7 +347,7 @@ UPDATE tracks
AND NOT regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: SetTrackSourceVerdict :execrows
-- The operator's call on a flagged track: 'fine' lets it back into radio and
-- An admin's call on a flagged track: 'fine' lets it back into radio and
-- the mixes for good; 'suspect' holds it back again.
UPDATE tracks
SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now()
@@ -357,7 +356,7 @@ UPDATE tracks
-- name: ListStillMissingTrackLabels :many
-- Which of the tracks a scan marked missing still are once it has adopted
-- moves and folded replacements (M542): the ones worth telling an admin
-- moves and folded replacements: the ones worth telling an admin
-- about, by name. A quality upgrade folds into its replacement and drops out.
SELECT ar.name AS artist_name, t.title
FROM tracks t
+1 -1
View File
@@ -37,7 +37,7 @@ SELECT count(*) FROM users;
-- name: SetSubsonicPassword :exec
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only
-- ever a server-generated value, never the login password (#5026).
-- ever a server-generated value, never the login password.
UPDATE users SET subsonic_password = $2 WHERE id = $1;
-- name: GetUserByID :one
+21 -21
View File
@@ -1,4 +1,4 @@
-- The wanted list (M502 #5520): release-type profiles, follows and wants.
-- The wanted list: release-type profiles, follows and wants.
-- name: ListReleaseTypeProfiles :many
SELECT p.*,
@@ -145,10 +145,10 @@ SELECT a.id AS album_id,
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > sqlc.arg(since)::timestamptz), false)::bool AS arrived_since,
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < sqlc.arg(since)::timestamptz), false)::bool AS missing_before,
-- Missing tracks no track on disk stands in for, by position or
-- title. A missing row whose song is back at a new path (#5245) is
-- title. A missing row whose song is back at a new path is
-- covered; a lost bonus track is not. A track with no disc number is
-- on disc 1: Air's Love 2 stayed wanted, and was downloaded again for
-- nothing, over a missing track 6 with no disc beside a present 1/6.
-- on disc 1, so a missing track 6 with no disc beside a present 1/6
-- does not keep the album wanted.
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL
@@ -171,7 +171,7 @@ UPDATE wants
WHERE id = sqlc.arg(id) AND fulfilled_at IS NULL;
-- name: RepointWant :exec
-- A want recorded under a release id before #5241 is moved to its group.
-- A want recorded under a release id is moved to its group.
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1;
-- name: DeleteWant :execrows
@@ -180,13 +180,13 @@ DELETE FROM wants WHERE id = $1;
-- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones),
-- the held ones, or all of them; artist_mbid narrows to one artist's page,
-- want_ids to given wants. Ordered like the library (#5572): artists A–Z by the
-- want_ids to given wants. Ordered like the library: artists A–Z by the
-- library's sort name when it holds the artist, each artist's albums by
-- release date, so the page groups them as they arrive. q narrows to an
-- artist or album title; total counts every match, past the page.
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
w.album_id, w.wanted_at, w.fulfilled_at,
-- Why a held album is wanted again, and the copy it has (#5524).
-- Why a held album is wanted again, and the copy it has.
w.upgrade, w.held_quality,
-- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has.
@@ -196,10 +196,10 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
u.username AS requested_by,
la.id AS library_artist_id,
COUNT(*) OVER () AS total,
-- The search schedule's last look (#5521), and the download running.
-- The search schedule's last look, and the download running.
w.searched_at, w.search_outcome, w.search_considered, w.best_release, w.best_verdict, w.search_error,
-- Its latest download, running or ended, and what the import made of
-- it (#5522).
-- it.
gr.id AS grab_id, gr.name AS grab_name, gr.grabbed_at, gr.origin AS grab_origin,
gr.quality AS grab_quality, gr.ended_at AS grab_ended_at, gr.end_reason AS grab_end_reason,
gr.progress AS grab_progress, gr.import_state AS grab_import_state,
@@ -219,7 +219,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END
AND (sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text)
-- want_ids narrows to given wants: the Missing page's folders (M504).
-- want_ids narrows to given wants: the Missing page's folders.
AND (COALESCE(cardinality(sqlc.arg(want_ids)::uuid[]), 0) = 0 OR w.id = ANY(sqlc.arg(want_ids)::uuid[]))
AND (sqlc.arg(q)::text = ''
OR strpos(lower(w.artist_name), lower(sqlc.arg(q)::text)) > 0
@@ -230,7 +230,7 @@ SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reas
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListWantArtists :many
-- The Wanted view's main page (#5572): every artist on the list, A–Z like
-- The Wanted view's main page: every artist on the list, A–Z like
-- the library, with how many of their albums are held of how many are on
-- the list. q matches the artist's name or any of their albums' titles;
-- artist_mbid narrows to one artist, for their page's header.
@@ -263,7 +263,7 @@ SELECT a.artist_mbid, a.artist_name::text AS artist_name, a.held, a.wanted, a.mi
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: GetWantAlbum :one
-- What the Wanted view's track list needs of a want (#5572).
-- What the Wanted view's track list needs of a want.
SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1;
-- name: ListLibraryAlbumTracks :many
@@ -278,15 +278,15 @@ SELECT title, disc_number, track_number, duration_ms, (missing_since IS NOT NULL
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
-- Held albums wanted for a better copy (#5524).
-- Held albums wanted for a better copy.
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL AND upgrade <> '')::bigint AS upgrades,
COUNT(DISTINCT artist_mbid)::bigint AS artists,
-- Library albums waiting to be matched by hand (#5579).
-- Library albums waiting to be matched by hand.
(SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS unmatched,
(SELECT COUNT(*) FROM follows)::bigint AS following,
-- The Wanted view's other tabs (#5522): downloads running, those
-- The Wanted view's other tabs: downloads running, those
-- waiting on a person, and releases set aside.
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs
@@ -295,7 +295,7 @@ SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::big
WHERE b.expires_at IS NULL OR b.expires_at > now())::bigint AS blocked
FROM wants;
-- The one-time listing of the library (#5567). ---------------------------
-- The one-time listing of the library. ---------------------------
-- name: GetLibraryListing :one
SELECT library_listing_started_at, library_listed_at, library_unlisted
@@ -332,7 +332,7 @@ ON CONFLICT (release_group_mbid) DO NOTHING;
-- name: RecordListedAlbums :exec
-- The albums ListLibraryAlbums takes are decided as listed by their tags,
-- so the matching that follows leaves them alone (#5569).
-- so the matching that follows leaves them alone.
INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
SELECT a.id, 'listed', 'tags', a.release_group_mbid
FROM albums a
@@ -346,9 +346,9 @@ ON CONFLICT (album_id) DO NOTHING;
-- name: ListAlbumsToMatch :many
-- Library albums the listing has not decided, or that an older matcher
-- refused, or whose tracks changed since they were refused: each is matched
-- to MusicBrainz (#5569). A library album is one from before the cutoff, or
-- to MusicBrainz. A library album is one from before the cutoff, or
-- one whose tracks were: a re-tag files a track under a new album row but
-- keeps the track (#5579). An album row with no tracks is not an album in
-- keeps the track. An album row with no tracks is not an album in
-- the library; one a person left off stays off.
SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
FROM albums a
@@ -423,7 +423,7 @@ UPDATE acquisition_settings
RETURNING library_listed_at, library_unlisted;
-- name: ListUnmatchedLibraryAlbums :many
-- The Collection's Unmatched section (#5579): the albums the listing could
-- The Collection's Unmatched section: the albums the listing could
-- not match, and below them the ones a person left off. An album whose
-- tracks have all moved to another row is gone, not unmatched.
SELECT m.album_id, a.title, ar.name AS artist_name, m.detail, m.outcome, m.decided_at,
@@ -459,7 +459,7 @@ UPDATE library_album_matches SET outcome = sqlc.arg(outcome), decided_at = now()
WHERE album_id = sqlc.arg(album_id) AND outcome = sqlc.arg(was);
-- Upgrades (#5524). -------------------------------------------------------
-- Upgrades. -------------------------------------------------------
-- name: ListHeldWantFormats :many
-- What each held album's copy is made of, for judging whether it is worth
+1 -1
View File
@@ -1,4 +1,4 @@
-- "You might like" Home rows (#790). The daily build computes ranked
-- "You might like" Home rows. The daily build computes ranked
-- album/artist IDs in Go (similarity roll-up + cold-start gate) and
-- atomic-replaces them here; /api/home reads them back hydrated.