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 DurationMs int32
} }
// AcoustID recording-id lookup (M401). Migration 0069 says what each // AcoustID recording-id lookup. Migration 0069 says what each
// lookup state means. // lookup state means.
// The lookup worker's queue: present tracks with no tag MBID and no lookup // 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 // 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 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. // 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 // 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. // 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 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. // Returns no secret, only whether one is set: this feeds the settings page.
func (q *Queries) ListAcquisitionServices(ctx context.Context) ([]ListAcquisitionServicesRow, error) { func (q *Queries) ListAcquisitionServices(ctx context.Context) ([]ListAcquisitionServicesRow, error) {
rows, err := q.db.Query(ctx, listAcquisitionServices) rows, err := q.db.Query(ctx, listAcquisitionServices)
+1 -1
View File
@@ -109,7 +109,7 @@ type ListListeningNowRow struct {
AlbumTitle string 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. // downloading, what the library holds. Each is one cheap read.
// Plays in progress: open play events still inside their track's length, // 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 // 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 count(*) FROM requests WHERE status = 'pending')::bigint AS requests,
(SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since, (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 -- 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. -- page lists them.
(SELECT count(*) FROM ( (SELECT count(*) FROM (
SELECT track_id FROM quarantine SELECT track_id FROM quarantine
@@ -53,11 +53,11 @@ type AdminSummaryRow struct {
MissingSince pgtype.Timestamptz 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 // 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 // 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 // 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) { func (q *Queries) AdminSummary(ctx context.Context) (AdminSummaryRow, error) {
row := q.db.QueryRow(ctx, adminSummary) row := q.db.QueryRow(ctx, adminSummary)
var i AdminSummaryRow var i AdminSummaryRow
+22 -27
View File
@@ -15,7 +15,7 @@ const countAlbums = `-- name: CountAlbums :one
SELECT COUNT(*) FROM albums 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) { func (q *Queries) CountAlbums(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countAlbums) row := q.db.QueryRow(ctx, countAlbums)
var count int64 var count int64
@@ -59,7 +59,7 @@ type DeleteAlbumIfEmptyRow struct {
ArtistID pgtype.UUID 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 // 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. // 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 // 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 // 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 // of the same name in another release group is a different album (one
// six self-titled albums shared one row until 2026-10-20), so a row whose // 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 // 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 // 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 // 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. // row, and a few strays already misfiled do not outvote the folder.
func (q *Queries) GetAlbumByArtistAndTitle(ctx context.Context, arg GetAlbumByArtistAndTitleParams) (Album, error) { func (q *Queries) GetAlbumByArtistAndTitle(ctx context.Context, arg GetAlbumByArtistAndTitleParams) (Album, error) {
row := q.db.QueryRow(ctx, getAlbumByArtistAndTitle, row := q.db.QueryRow(ctx, getAlbumByArtistAndTitle,
@@ -166,7 +166,7 @@ type GetAlbumCoverageRollupRow struct {
PendingNoMbid int64 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 — // admin gauge. Single sequential scan with FILTER aggregates —
// sub-millisecond on realistic libraries. pending_no_mbid is a SUBSET // sub-millisecond on realistic libraries. pending_no_mbid is a SUBSET
// of pending; the UI surfaces it separately so operators can see how // 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 // Invariant: with_art + pending + settled = total. (pending_no_mbid is
// not part of the sum — it's a subset of pending, surfaced separately.) // 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 // The IN list below must name every source the enrichers write (the
// constraint — currently relaxed by migration 0020 to include 'deezer' // cover_art_source CHECK accepts any non-empty string since migration
// and 'lastfm', and migration 0030 further relaxed it to any non-empty // 0030). A source missing here makes with_art silently undercount.
// 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.
func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageRollupRow, error) { func (q *Queries) GetAlbumCoverageRollup(ctx context.Context) (GetAlbumCoverageRollupRow, error) {
row := q.db.QueryRow(ctx, getAlbumCoverageRollup) row := q.db.QueryRow(ctx, getAlbumCoverageRollup)
var i GetAlbumCoverageRollupRow 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 // 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) { func (q *Queries) GetAlbumsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Album, error) {
rows, err := q.db.Query(ctx, getAlbumsByIDs, dollar_1) rows, err := q.db.Query(ctx, getAlbumsByIDs, dollar_1)
if err != nil { if err != nil {
@@ -277,7 +274,7 @@ SELECT release_group_mbid::text
LIMIT 1 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. // request carrying a release id can be repaired without asking MusicBrainz.
func (q *Queries) GetReleaseGroupForReleaseMbid(ctx context.Context, mbid *string) (string, error) { func (q *Queries) GetReleaseGroupForReleaseMbid(ctx context.Context, mbid *string) (string, error) {
row := q.db.QueryRow(ctx, getReleaseGroupForReleaseMbid, mbid) row := q.db.QueryRow(ctx, getReleaseGroupForReleaseMbid, mbid)
@@ -400,7 +397,7 @@ type ListAlbumsAlphaWithArtistRow struct {
ArtistName string 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. // /api/library/albums for the wrapping-grid page. Stable id-tiebreak.
func (q *Queries) ListAlbumsAlphaWithArtist(ctx context.Context, arg ListAlbumsAlphaWithArtistParams) ([]ListAlbumsAlphaWithArtistRow, error) { func (q *Queries) ListAlbumsAlphaWithArtist(ctx context.Context, arg ListAlbumsAlphaWithArtistParams) ([]ListAlbumsAlphaWithArtistRow, error) {
rows, err := q.db.Query(ctx, listAlbumsAlphaWithArtist, arg.Limit, arg.Offset) 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. // Album "belongs to" a genre if any of its tracks carry that genre.
// Serves Subsonic getAlbumList?type=byGenre. // Serves Subsonic getAlbumList?type=byGenre.
// //
// Splits tracks.genre on [;,] as of #367. It previously compared the whole // Splits tracks.genre on [;,], so a track tagged "Rock;Pop" is reachable from
// column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER // both "Rock" and "Pop"; comparing the whole column would hide every
// "Rock" or "Pop" — a Subsonic client asking for a genre silently missed // multi-genre track from a Subsonic client asking for a genre. The same split
// every multi-genre track. This also aligns the endpoint with // as recommendation.sql, discover.sql and the genre browse index.
// recommendation.sql / discover.sql, which have always split, and with the
// genre browse index that #367 adds.
// //
// EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per // 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 // (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 // 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 // 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 // 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) { func (q *Queries) ListAlbumsMissingMbidWithTrack(ctx context.Context, limit int32) ([]ListAlbumsMissingMbidWithTrackRow, error) {
rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit) rows, err := q.db.Query(ctx, listAlbumsMissingMbidWithTrack, limit)
if err != nil { if err != nil {
@@ -738,7 +733,7 @@ type ListRecentlyAddedAlbumsWithArtistRow struct {
ArtistName string 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 // home-page section. created_at is the row-insert timestamp from the
// scanner — the closest proxy to "added to library". Stable secondary // scanner — the closest proxy to "added to library". Stable secondary
// ordering by id keeps pagination tie-break deterministic. // ordering by id keeps pagination tie-break deterministic.
@@ -838,7 +833,7 @@ type SetAlbumCoverWithVersionParams struct {
CoverArtSourcesVersion int32 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 // current sources_version stamp. cover_art_path is NULL when source is
// 'none' (no file written). Atomic; called per album per pass. // 'none' (no file written). Atomic; called per album per pass.
func (q *Queries) SetAlbumCoverWithVersion(ctx context.Context, arg SetAlbumCoverWithVersionParams) error { func (q *Queries) SetAlbumCoverWithVersion(ctx context.Context, arg SetAlbumCoverWithVersionParams) error {
@@ -863,7 +858,7 @@ type SetAlbumMbidIfNullParams struct {
Mbid *string 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. // only updates when the existing mbid is NULL.
func (q *Queries) SetAlbumMbidIfNull(ctx context.Context, arg SetAlbumMbidIfNullParams) error { func (q *Queries) SetAlbumMbidIfNull(ctx context.Context, arg SetAlbumMbidIfNullParams) error {
_, err := q.db.Exec(ctx, setAlbumMbidIfNull, arg.ID, arg.Mbid) _, err := q.db.Exec(ctx, setAlbumMbidIfNull, arg.ID, arg.Mbid)
@@ -882,7 +877,7 @@ type SetAlbumReleaseGroupMbidIfNullParams struct {
ReleaseGroupMbid *string 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 // 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. // NULL, so a tag never gets overwritten by a later guess or vice versa.
func (q *Queries) SetAlbumReleaseGroupMbidIfNull(ctx context.Context, arg SetAlbumReleaseGroupMbidIfNullParams) error { func (q *Queries) SetAlbumReleaseGroupMbidIfNull(ctx context.Context, arg SetAlbumReleaseGroupMbidIfNullParams) error {
@@ -902,7 +897,7 @@ type SetReleaseGroupForReleaseMbidIfNullParams struct {
ReleaseMbid string 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. // holds the release, filling only a NULL.
func (q *Queries) SetReleaseGroupForReleaseMbidIfNull(ctx context.Context, arg SetReleaseGroupForReleaseMbidIfNullParams) error { func (q *Queries) SetReleaseGroupForReleaseMbidIfNull(ctx context.Context, arg SetReleaseGroupForReleaseMbidIfNullParams) error {
_, err := q.db.Exec(ctx, setReleaseGroupForReleaseMbidIfNull, arg.ReleaseGroupMbid, arg.ReleaseMbid) _, 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' 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. // eligibility loop to flip stale 'none' rows back to NULL.
func (q *Queries) ClearArtistArtNone(ctx context.Context, id pgtype.UUID) error { func (q *Queries) ClearArtistArtNone(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, clearArtistArtNone, id) _, err := q.db.Exec(ctx, clearArtistArtNone, id)
@@ -56,7 +56,7 @@ WHERE a.id = $1
RETURNING a.id 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 // AND no remaining tracks (defensive — tracks can in principle exist
// without an album, though the scanner doesn't write that shape today). // 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) { func (q *Queries) DeleteArtistIfEmpty(ctx context.Context, id pgtype.UUID) (pgtype.UUID, error) {
@@ -253,7 +253,7 @@ type ListArtistsAlphaWithCoversRow struct {
AlbumCount int64 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 // album whose cover_art_path is set). Replaces ListArtistsAlpha for the
// /api/artists?sort=alpha branch — the new column is appended, so 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 // alpha branch is purely additive on the wire. album_count is computed
@@ -305,7 +305,7 @@ type ListArtistsMissingArtParams struct {
Limit int32 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 // attempt. Eligible: artist_art_source IS NULL OR (= 'none' AND stale
// artist_art_sources_version). MBID-less artists are returned but the // artist_art_sources_version). MBID-less artists are returned but the
// enricher skips them at the row.mbid == nil guard inside the chain. // enricher skips them at the row.mbid == nil guard inside the chain.
@@ -432,7 +432,7 @@ type SetArtistArtWithVersionParams struct {
ArtistArtSourcesVersion int32 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 / // enrichment with the current sources_version stamp. thumb_path /
// fanart_path are NULL on a 'none' result, or independently NULL if // fanart_path are NULL on a 'none' result, or independently NULL if
// the provider returned only one of the two image types (e.g. // the provider returned only one of the two image types (e.g.
@@ -460,7 +460,7 @@ type SetArtistMbidIfNullParams struct {
Mbid *string 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 // only updates when the existing mbid is NULL, so concurrent scans don't
// overwrite an existing value with a stale one. // overwrite an existing value with a stale one.
func (q *Queries) SetArtistMbidIfNull(ctx context.Context, arg SetArtistMbidIfNullParams) error { 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 // 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. // here.
func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) { func (q *Queries) ListAuditLogByActions(ctx context.Context, arg ListAuditLogByActionsParams) ([]AuditLog, error) {
rows, err := q.db.Query(ctx, listAuditLogByActions, rows, err := q.db.Query(ctx, listAuditLogByActions,
+16 -23
View File
@@ -75,7 +75,7 @@ type ListAlbumYearsWithCountRow struct {
AlbumCount int64 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 // 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. // from this axis, and the UI says so rather than inventing a 0 row.
// Newest first: recent releases are the likelier browse target. // 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 // 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 // quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a
// chip always leads to a page that actually contains this album — the two // chip always leads to a page that actually contains this album.
// diverging is exactly the bug #367 had to fix in ListAlbumsByGenre.
func (q *Queries) ListGenresForAlbum(ctx context.Context, albumID pgtype.UUID) ([]string, error) { func (q *Queries) ListGenresForAlbum(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
rows, err := q.db.Query(ctx, listGenresForAlbum, albumID) rows, err := q.db.Query(ctx, listGenresForAlbum, albumID)
if err != nil { if err != nil {
@@ -314,30 +313,24 @@ type ListGenresWithCountRow struct {
TrackCount int64 TrackCount int64
} }
// Every query in this file filters `tracks.missing_since IS NULL` (#2523). // Every query in this file filters `tracks.missing_since IS NULL`. A row whose
// A row whose file has vanished keeps its genre forever — the scanner walks the // 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 // filesystem, so it never revisits a path that no longer exists — and
// 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
// Browsing is a way of finding something to play, so a track that cannot play
// should not shape it. // should not shape it.
// //
// The year axis filters too (#2702). It used to join albums only, on the // The year axis filters too, so the two axes agree: an album whose every file
// reasoning that an album is a real release even when some of its tracks are // has vanished is absent from both, rather than absent from its genre and
// gone — true, but it left the two browse axes disagreeing: the genre queries // still listed under its year, where opening it would find nothing playable.
// 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.
// //
// Hiding is the answer rather than showing-and-marking because browsing is how // Hiding rather than showing-and-marking, because browsing is how you go
// you go looking for something to play, and the operator's rule for that case // looking for something to play; the admin missing-files surface is where
// is to take it out of view; the admin missing-files surface is where absence // absence is reported.
// is reported. It also means changing the axis that was inconsistent rather
// than the one that was already right.
// //
// The predicate is "has at least one playable track", so it also excludes an // 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: // album with no tracks at all. That is the same answer for the same reason:
// nothing to play, nothing to browse to. // 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 // 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 — // 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 // trim() but deliberately NO lower(): trimming repairs an artifact of OUR
// splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre // 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 // 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 // COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
// inflate its own row. // inflate its own row.
// //
// Ordered by count first: raw ID3 data has a long tail of one-off junk tags, // 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 // so alphabetical would bury the handful of genres a library actually has
// library's worth of. Name breaks ties for a stable order. // a lot of. Name breaks ties for a stable order.
// Ordered by the expression, not the output alias: `ORDER BY genre` is // Ordered by the expression, not the output alias: `ORDER BY genre` is
// ambiguous between the alias and tracks.genre, and sqlc rejects it. // ambiguous between the alias and tracks.genre, and sqlc rejects it.
func (q *Queries) ListGenresWithCount(ctx context.Context) ([]ListGenresWithCountRow, error) { 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[]) 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). // One row per (candidate, tag).
func (q *Queries) ListCandidateArtistTagsForMbids(ctx context.Context, dollar_1 []string) ([]CandidateArtistTag, error) { func (q *Queries) ListCandidateArtistTagsForMbids(ctx context.Context, dollar_1 []string) ([]CandidateArtistTag, error) {
rows, err := q.db.Query(ctx, listCandidateArtistTagsForMbids, dollar_1) rows, err := q.db.Query(ctx, listCandidateArtistTagsForMbids, dollar_1)
@@ -160,7 +160,7 @@ type ListCandidateArtistsMissingTagsRow struct {
TotalScore float64 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 // 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. // 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 // Candidates eligible for tag enrichment: never processed (no state row) or
+1 -1
View File
@@ -352,7 +352,7 @@ type UpsertMBArtistParams struct {
Country string 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 { func (q *Queries) UpsertMBArtist(ctx context.Context, arg UpsertMBArtistParams) error {
_, err := q.db.Exec(ctx, upsertMBArtist, _, err := q.db.Exec(ctx, upsertMBArtist,
arg.Mbid, arg.Mbid,
+1 -1
View File
@@ -14,7 +14,7 @@ const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence' 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 // single shared-library, multi-user server (no per-user library ACL), so the
// "household" is the whole instance's user set. These queries recompute // "household" is the whole instance's user set. These queries recompute
// artist–artist co-occurrence edges from play_events and store them in // 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 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 // and the cover_art_sources_meta singleton. The SettingsService
// consumes these. // consumes these.
// Returns all rows from cover_art_provider_settings. Used by the // 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 // INSERT a default row for a newly-registered provider, or update an
// existing row's display_order on boot reconciliation. Does NOT // 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. // persist across restarts.
func (q *Queries) UpsertProviderSettings(ctx context.Context, arg UpsertProviderSettingsParams) error { func (q *Queries) UpsertProviderSettings(ctx context.Context, arg UpsertProviderSettingsParams) error {
_, err := q.db.Exec(ctx, upsertProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder) _, 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' 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. // back to NULL so the enricher's next batch retries the MBCAA fetch.
// Idempotent — only updates rows currently stamped 'none'. // Idempotent — only updates rows currently stamped 'none'.
func (q *Queries) ClearAlbumCoverNone(ctx context.Context, id pgtype.UUID) error { func (q *Queries) ClearAlbumCoverNone(ctx context.Context, id pgtype.UUID) error {
@@ -128,8 +128,8 @@ type ListAlbumsMissingCoverParams struct {
Limit int32 Limit int32
} }
// M7 #353: album cover enrichment queries. // Album cover enrichment queries.
// 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' // cover_art_source IS NULL (never tried), OR cover_art_source = 'none'
// AND cover_art_sources_version != current (settled under an older // AND cover_art_sources_version != current (settled under an older
// source set; recheck against the current chain). LIMIT supplied by // 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 SELECT t.id, t.album_id, t.artist_id
FROM general_likes gl FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id 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 gl.user_id != $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM play_events pe 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 // GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
// not appear in the select list — Postgres rejects DISTINCT + ORDER BY // not appear in the select list — Postgres rejects DISTINCT + ORDER BY
// by-expression at plan time (SQLSTATE 42P10), which previously caused // by-expression at plan time (SQLSTATE 42P10), which would fail the
// the entire Discover build to fail silently. // entire Discover build.
func (q *Queries) ListCrossUserLikedTracksForDiscover(ctx context.Context, arg ListCrossUserLikedTracksForDiscoverParams) ([]ListCrossUserLikedTracksForDiscoverRow, error) { func (q *Queries) ListCrossUserLikedTracksForDiscover(ctx context.Context, arg ListCrossUserLikedTracksForDiscoverParams) ([]ListCrossUserLikedTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listCrossUserLikedTracksForDiscover, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listCrossUserLikedTracksForDiscover, arg.UserID, arg.Column2)
if err != nil { if err != nil {
@@ -96,7 +96,7 @@ dormant_artists AS (
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN dormant_artists da ON da.id = t.artist_id 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 ( AND NOT EXISTS (
SELECT 1 FROM play_events pe SELECT 1 FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
@@ -161,7 +161,7 @@ func (q *Queries) ListDormantArtistTracksForDiscover(ctx context.Context, arg Li
const listRandomUnheardTracksForDiscover = `-- name: ListRandomUnheardTracksForDiscover :many const listRandomUnheardTracksForDiscover = `-- name: ListRandomUnheardTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t 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 ( AND NOT EXISTS (
SELECT 1 FROM play_events pe SELECT 1 FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
@@ -223,7 +223,7 @@ WITH scored AS (
FROM tracks t FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true 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 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 nt.weight > 0
AND trim(g_split.g) <> '' AND trim(g_split.g) <> ''
AND NOT EXISTS ( AND NOT EXISTS (
@@ -274,7 +274,7 @@ type ListTasteUnheardTracksForDiscoverRow struct {
} }
// Taste-targeted novelty: unheard tracks whose genres overlap the user's // 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. // 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 // Genres live inline on tracks.genre as a delimited string, split the
// same way the radio tag_overlap arm does (regexp_split_to_table on // same 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 // when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'. // redistributes its slots to the other buckets. Stamped 'taste_unheard'.
// //
// The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed // The per-album and per-artist caps apply BEFORE the LIMIT. Summed weight
// weight rewards a track for carrying many of the user's tags, so a few // rewards a track for carrying many of the user's tags, so a few artists
// artists whose every track is tagged with the whole profile take every row // whose every track is tagged with the whole profile would take every row of
// of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and // a plain LIMIT, the caller's caps would then leave a handful, and the arm
// the best-performing arm handed its slots to the others. Ranking within // would hand its slots to the others. Ranking within album, then within
// album, then within artist over what the album cap kept, is the same walk // artist over what the album cap kept, is the same walk capByAlbumAndArtist
// capByAlbumAndArtist makes, so the caller's caps keep everything here. // makes, so the caller's caps keep everything here.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) { func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover,
arg.MaxPerArtist, arg.MaxPerArtist,
+10 -10
View File
@@ -55,9 +55,9 @@ SELECT count(*)::bigint
END 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, and groups not yet classified
// cross_release the same song on different releases, which is kept, not merged // cross_release the same song on different releases, which is kept, not merged
func (q *Queries) CountPendingDuplicateGroupsInView(ctx context.Context, view string) (int64, error) { func (q *Queries) CountPendingDuplicateGroupsInView(ctx context.Context, view string) (int64, error) {
@@ -205,7 +205,7 @@ type ListDismissedDuplicateMemberSetsRow struct {
TrackIds []pgtype.UUID 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. // every member sat together in one of these is not proposed again.
func (q *Queries) ListDismissedDuplicateMemberSets(ctx context.Context) ([]ListDismissedDuplicateMemberSetsRow, error) { func (q *Queries) ListDismissedDuplicateMemberSets(ctx context.Context) ([]ListDismissedDuplicateMemberSetsRow, error) {
rows, err := q.db.Query(ctx, listDismissedDuplicateMemberSets) 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 AND f.chromaprint IS NOT NULL
-- Only chromaprints taken at the current length: prints at two lengths are not -- 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 -- comparable, and after a length change the backfill is still re-deriving the
-- rest (#3913). -- rest.
AND f.chromaprint_length_sec = $2 AND f.chromaprint_length_sec = $2
AND (t.duration_ms, t.id) > ($3::integer, $4::uuid) AND (t.duration_ms, t.id) > ($3::integer, $4::uuid)
ORDER BY t.duration_ms, t.id ORDER BY t.duration_ms, t.id
@@ -356,19 +356,19 @@ type ListDuplicateGroupsForResolveRow struct {
AcoustidRecording *string 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, // 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 // 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 // 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 // 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 // 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 // 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. // tags or the library listing's match; ” when neither names one.
// acoustid_state and acoustid_recording are what AcoustID said of the file, // 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 // when it was looked up: which song a recording cut to several slots is.
// is). group_id narrows it to one group, for the operator settling it (#6184); // group_id narrows it to one group, for an admin settling it; NULL reads them
// NULL reads them all. // all.
func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupsForResolveRow, error) { func (q *Queries) ListDuplicateGroupsForResolve(ctx context.Context, groupID pgtype.UUID) ([]ListDuplicateGroupsForResolveRow, error) {
rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve, groupID) rows, err := q.db.Query(ctx, listDuplicateGroupsForResolve, groupID)
if err != nil { if err != nil {
@@ -533,7 +533,7 @@ type ListPendingDuplicateGroupMembersRow struct {
// One page of proposals in one view (see CountPendingDuplicateGroupsInView), // One page of proposals in one view (see CountPendingDuplicateGroupsInView),
// newest first, flattened to one row per member so the handler folds them // 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 // 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. // which copy to keep.
func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) { func (q *Queries) ListPendingDuplicateGroupMembers(ctx context.Context, arg ListPendingDuplicateGroupMembersParams) ([]ListPendingDuplicateGroupMembersRow, error) {
rows, err := q.db.Query(ctx, listPendingDuplicateGroupMembers, arg.View, arg.PageOffset, arg.PageLimit) 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 // 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 // variant and returns its pick_kind. Used at play-ingestion time to
// freeze provenance onto the play_event — snapshots rebuild daily, so // 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 // 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 // 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 // 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 // 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 // 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) { func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
row := q.db.QueryRow(ctx, insertPlayEvent, row := q.db.QueryRow(ctx, insertPlayEvent,
arg.UserID, 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 LEFT JOIN track_fingerprints f ON f.track_id = t.id
WHERE t.missing_since IS NULL WHERE t.missing_since IS NULL
-- A row taken at another length is as stale as one from an older method: -- 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 AND (f.track_id IS NULL
OR f.fingerprint_version < $1 OR f.fingerprint_version < $1
OR f.chromaprint_length_sec <> $2) OR f.chromaprint_length_sec <> $2)
@@ -102,7 +102,7 @@ type ListTracksNeedingFingerprintRow struct {
FilePath string 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 // 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 // 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 // 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 // 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 // older method. Replaces the row wholesale — a fingerprint of the old bytes has
// no standing once the file has changed. // no standing once the file has changed.
func (q *Queries) UpsertTrackFingerprint(ctx context.Context, arg UpsertTrackFingerprintParams) error { 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 // play_sessions.ended_at has no other writer. Close sessions whose
// writer. Close sessions whose last_event_at is older than 6h — // last_event_at is older than 6h — treating that as "user moved on" the
// treating that as "user moved on" the same way audio_service does // same way audio_service does after grace periods. Sessions with NO events (track_count = 0)
// after grace periods. Sessions with NO events (track_count = 0)
// older than 1h are also closed (stale handshakes from clients that // 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 // never recorded a play). ended_at is set to last_event_at so the
// session's duration reads naturally. // session's duration reads naturally.
@@ -46,12 +45,11 @@ UPDATE play_events
AND started_at < now() - INTERVAL '24 hours' AND started_at < now() - INTERVAL '24 hours'
` `
// Background garbage-collector / lifecycle queries. All five address // Background garbage-collector / lifecycle queries: rows nothing else closes
// drift findings from the 2026-06-02 audit (Scribe parent #552): // or removes. Sweeper runs on a long tick (default 1h)
// #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
// so per-query cost is amortised; each is idempotent (re-running on // so per-query cost is amortised; each is idempotent (re-running on
// already-closed/-deleted rows is a no-op). // 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 // play_ended. Client crashed mid-track, network dropped, etc. We
// synthesize ended_at = started_at + duration_played_ms when present, // synthesize ended_at = started_at + duration_played_ms when present,
// otherwise leave duration_played_ms null and stamp ended_at = now() // 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') 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 // validation path already rejects them; this just keeps the table
// from growing unbounded. Used rows are kept for 7 days for audit; // from growing unbounded. Used rows are kept for 7 days for audit;
// unused expired rows go immediately. // unused expired rows go immediately.
@@ -106,7 +104,7 @@ DELETE FROM scrobble_queue
AND enqueued_at < now() - INTERVAL '14 days' 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; // more than 14 days. The worker stops retrying after maxAttempts;
// failed rows accumulate forever otherwise. CASCADE from play_events // failed rows accumulate forever otherwise. CASCADE from play_events
// already drops the row when the underlying event is deleted, so this // 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' 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. // goroutine panic between SET in_flight=true and SET in_flight=false.
// The duplicate-prevention check refuses to start a fresh regen while // The duplicate-prevention check refuses to start a fresh regen while
// in_flight, so a stuck row blocks all future regens for that user. // 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 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 // play_events entry, newest first, joined with tracks/albums/artists
// so the SPA can render a TrackRef without follow-up fetches. Filters // so the SPA can render a TrackRef without follow-up fetches. Filters
// out skipped events and tracks the user has quarantined (per-user // 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 Wanted view's Activity tab: downloads waiting on a person first, then
// the rest still running, then the ended ones newest first. Each carries // 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) { func (q *Queries) ListGrabs(ctx context.Context, arg ListGrabsParams) ([]ListGrabsRow, error) {
rows, err := q.db.Query(ctx, listGrabs, arg.Skip, arg.MaxRows) rows, err := q.db.Query(ctx, listGrabs, arg.Skip, arg.MaxRows)
if err != nil { if err != nil {
@@ -366,7 +366,7 @@ type ListImportedGrabsHoldingRow struct {
// The downloads Minstrel imported into the folder holding path: the releases a // 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 // 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) { func (q *Queries) ListImportedGrabsHolding(ctx context.Context, path string) ([]ListImportedGrabsHoldingRow, error) {
rows, err := q.db.Query(ctx, listImportedGrabsHolding, path) rows, err := q.db.Query(ctx, listImportedGrabsHolding, path)
if err != nil { if err != nil {
@@ -441,7 +441,7 @@ type ListLiveGrabsRow struct {
WantAlbumID pgtype.UUID WantAlbumID pgtype.UUID
} }
// Import with verification (M502 #5522). // Import with verification.
// Every download still running or waiting on its import, oldest first. // Every download still running or waiting on its import, oldest first.
func (q *Queries) ListLiveGrabs(ctx context.Context) ([]ListLiveGrabsRow, error) { func (q *Queries) ListLiveGrabs(ctx context.Context) ([]ListLiveGrabsRow, error) {
rows, err := q.db.Query(ctx, listLiveGrabs) rows, err := q.db.Query(ctx, listLiveGrabs)
@@ -574,7 +574,7 @@ type ListUpgradeTracksRow struct {
SourceVerdict *string 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. // the want holds.
func (q *Queries) ListUpgradeTracks(ctx context.Context, albumID pgtype.UUID) ([]ListUpgradeTracksRow, error) { func (q *Queries) ListUpgradeTracks(ctx context.Context, albumID pgtype.UUID) ([]ListUpgradeTracksRow, error) {
rows, err := q.db.Query(ctx, listUpgradeTracks, albumID) rows, err := q.db.Query(ctx, listUpgradeTracks, albumID)
@@ -694,7 +694,7 @@ UPDATE grabs
import_forced = false, import_forced = false,
imported_at = CASE WHEN $1::text = 'imported' THEN now() ELSE imported_at END, imported_at = CASE WHEN $1::text = 'imported' THEN now() ELSE imported_at END,
-- The files were copied, so an imported download goes on sharing -- 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', seeding = $1::text = 'imported',
imported_files = $4, imported_files = $4,
destination = $5, destination = $5,
+4 -5
View File
@@ -116,11 +116,10 @@ type InsertLibraryChangesParams struct {
Op string Op string
} }
// Batch form, for a mutation that touches many rows at once (#2704: the scan's // 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). // reconcile pass can mark up to a quarter of a library missing in one go). A
// Every caller before this one changed a single entity, so per-row inserts // loop of single inserts would be thousands of round-trips inside a scan that
// were the right shape; a loop here would be thousands of round-trips inside // is already the slow path.
// a scan that is already the slow path.
func (q *Queries) InsertLibraryChanges(ctx context.Context, arg InsertLibraryChangesParams) error { func (q *Queries) InsertLibraryChanges(ctx context.Context, arg InsertLibraryChangesParams) error {
_, err := q.db.Exec(ctx, insertLibraryChanges, arg.EntityType, arg.EntityIds, arg.Op) _, err := q.db.Exec(ctx, insertLibraryChanges, arg.EntityType, arg.EntityIds, arg.Op)
return err return err
+1 -1
View File
@@ -101,7 +101,7 @@ type RerootTrackPathsParams struct {
OldRoot string 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 // 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 // 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. // 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 // 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) { func (q *Queries) CountGoneGrabs(ctx context.Context, arg CountGoneGrabsParams) (int64, error) {
row := q.db.QueryRow(ctx, countGoneGrabs, arg.InfoHash, arg.Since) row := q.db.QueryRow(ctx, countGoneGrabs, arg.InfoHash, arg.Since)
var count int64 var count int64
@@ -289,7 +289,7 @@ type RaiseInterventionParams struct {
Detail string 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. // download up, and seeding after import.
// A condition seen, or seen again: a second pass lands on the open row, so // 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 // 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 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 // song_key is liked. Returns the tracks newly liked, for the sync log; empty
// when the song was already liked. // when the song was already liked.
func (q *Queries) LikeTrack(ctx context.Context, arg LikeTrackParams) ([]pgtype.UUID, error) { 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 // 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. // 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) { func (q *Queries) GetReplayGainByTrackIDs(ctx context.Context, ids []pgtype.UUID) ([]GetReplayGainByTrackIDsRow, error) {
rows, err := q.db.Query(ctx, getReplayGainByTrackIDs, ids) rows, err := q.db.Query(ctx, getReplayGainByTrackIDs, ids)
@@ -280,7 +280,7 @@ type ListAlbumsNeedingLoudnessRow struct {
Digest string 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 // 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 // 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 // measurement it holds at the current version ('-' for none), so a track
@@ -484,7 +484,7 @@ type UpsertTrackLoudnessParams struct {
AnalysisVersion int16 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 // wholesale: a measurement of the old bytes has no standing once the file has
// changed. // changed.
func (q *Queries) UpsertTrackLoudness(ctx context.Context, arg UpsertTrackLoudnessParams) error { func (q *Queries) UpsertTrackLoudness(ctx context.Context, arg UpsertTrackLoudnessParams) error {
+1 -1
View File
@@ -69,7 +69,7 @@ type LockDuplicateGroupForMergeRow struct {
Status string 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 // transaction library.MergeDuplicateGroup opens, after the removed copy's file
// is already gone. The loser's own track row is deleted last with DeleteTrack; // 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 // 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 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 // 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 // path — is folded into that replacement, so its history moves across and
// re-acquisition stops chasing music the library already has. // 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 // 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 // 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. // 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 // 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 // mbid the same recording MBID, within the album group (the album row, or
// albums sharing a release group). 142 of 359 pairs. // albums sharing a release group).
// title the same album row and the same title, ignoring case. 213 more. The // title the same album row and the same title, ignoring case. The
// replacements are often not the original file — another edition, // replacements are often not the original file — another edition,
// re-encoded, untagged — so the metadata is what is left to go on. // 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 // Deliberately NOT gated on duration or track position. Pairs known to be one
// to be one recording, 18% differed by over 2s (old mp3 durations are poor // recording often differ by over 2s (old mp3 durations are poor estimates),
// estimates), and the poorly tagged set is exactly where numbering is broken. // 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 // 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 // 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 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 // internal/notifications.Notifier, which decides recipients and honours each
// recipient's inbox preference before it reaches these. // recipient's inbox preference before it reaches these.
// email_wanted is the recipient's email channel for this kind as of now. A // 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 ReadAsOf pgtype.Timestamptz
} }
// Email digest (#5346) ------------------------------------------------------ // Email digest ------------------------------------------------------
// Every unread, un-emailed row of a user who has an address, oldest first. // 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 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 // 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 // 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 // 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 // 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) { func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo) result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
if err != nil { if err != nil {
@@ -484,10 +484,10 @@ type UpsertCoalescedNotificationParams struct {
// ten. Every coalesced kind's event is "N more happened", never a restated // 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. // 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 // created_at and emailed_at are left alone: an update changes what the
// notice says, not when it arrived. Moving created_at made the phone announce // notice says, not when it arrived. Moving created_at would make the phone
// it again and clearing emailed_at sent it again, so a notice the hourly // announce it again and clearing emailed_at would send it again, so a notice
// duplicate pass kept growing alerted every hour. // that keeps growing would alert on every update.
func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) { func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, upsertCoalescedNotification, row := q.db.QueryRow(ctx, upsertCoalescedNotification,
arg.UserID, arg.UserID,
+3 -3
View File
@@ -38,7 +38,7 @@ type AppendPlaylistTrackParams struct {
// Inserts at the next available position. Snapshot fields are copied // Inserts at the next available position. Snapshot fields are copied
// from the tracks/albums/artists join at insert time. tracks.duration_ms // 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 // 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) { func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID) row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
var i PlaylistTrack 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 // 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) { func (q *Queries) GetPlaylistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Playlist, error) {
rows, err := q.db.Query(ctx, getPlaylistsByIDs, dollar_1) rows, err := q.db.Query(ctx, getPlaylistsByIDs, dollar_1)
if err != nil { if err != nil {
@@ -295,7 +295,7 @@ type ListPlaylistTracksRow struct {
// (track was removed from the library). The denormalized snapshot fields // (track was removed from the library). The denormalized snapshot fields
// on playlist_tracks remain authoritative for title/artist/album text. // 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 // 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 // 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 // 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 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 // 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) { func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
row := q.db.QueryRow(ctx, getReacquisitionSettings) row := q.db.QueryRow(ctx, getReacquisitionSettings)
var i ReacquisitionSetting var i ReacquisitionSetting
@@ -140,17 +140,17 @@ type ListAlbumsDueReacquisitionRow struct {
// //
// - it still has at least one track whose file has been missing longer than // - it still has at least one track whose file has been missing longer than
// the grace window. Measured on missing_since, which reconcile never // 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 // - 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 // 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 // find the group, and an album matched to a group may carry no release
// id, so their losses were never wanted back (#6167). Albums failing this // id. Albums failing this are counted separately
// are counted separately (CountAlbumsMissingWithoutMbid); // (CountAlbumsMissingWithoutMbid);
// - it is on the wanted list (#5567): an album taken off the list, or // - it is on the wanted list: an album taken off the list, or
// dropped into the library by hand, is not fetched back; // dropped into the library by hand, is not fetched back;
// - no want already covers it: none open, and none held since its files // - 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 // 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) { func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbumsDueReacquisitionParams) ([]ListAlbumsDueReacquisitionRow, error) {
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, arg.GraceHours, arg.PageLimit) rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, arg.GraceHours, arg.PageLimit)
if err != nil { if err != nil {
+42 -49
View File
@@ -19,7 +19,7 @@ ORDER BY started_at DESC
LIMIT 1 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 // "current device" for radio context conditioning. NULL when the latest play
// predates device capture or came from a client that didn't send one. // 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) { 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 // 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 // CURRENT context cell, in the user's local timezone. The cell is
// daypart × weekday-type, optionally narrowed by device class (#1551): when // daypart × weekday-type, optionally narrowed by device class: when
// $2 (the current device) is ”, the device dimension is ignored (identical to // $2 (the current device) is ”, the device dimension is ignored (time only,
// the time-only #1531 behaviour, used by the daily mixes which have no device); // for the daily mixes, which have no device);
// when $2 is set (radio), a play only counts in the cell if its device_class // 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 // matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
// window bounds cost. Daypart buckets: night [22,5) morning [5,12) // window bounds cost. Daypart buckets: night [22,5) morning [5,12)
@@ -154,7 +154,7 @@ type ListLastPlayedArtistsForUserRow struct {
LastPlayedAt pgtype.Timestamptz 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 // 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 // recent album that has cover_art_path set). album_count joined for the
// ArtistRef wire shape. // 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 albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1 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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id 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 tracks t ON t.id = p.track_id
JOIN albums ON albums.id = t.album_id JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
@@ -323,7 +323,7 @@ type ListMostPlayedTracksForUserRow struct {
ArtistName string 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. // excludes skips so a user spamming next doesn't fabricate a top track.
// Quarantined tracks (per-user soft-hide from M5b) are filtered out. // 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 WHERE user_id = $1 AND track_id = t.id
) pe ON true ) pe ON true
WHERE t.id <> $2 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 ( AND NOT EXISTS (
SELECT 1 FROM play_events SELECT 1 FROM play_events
WHERE user_id = $1 AND track_id = t.id WHERE user_id = $1 AND track_id = t.id
@@ -843,11 +843,10 @@ lb_similar AS (
LIMIT $5 LIMIT $5
), ),
similar_artists AS ( 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. -- track, best-scoring artist first, then each one's second, and so on.
-- Ordering by artist score alone let the closest similar artist's -- Ordering by artist score alone would let the closest similar artist's
-- catalogue fill the whole LIMIT; on the operator's library this arm held -- catalogue fill the whole LIMIT.
-- exactly one artist for all 17 seeds measured (#3879).
SELECT track_id, sim_score SELECT track_id, sim_score
FROM ( FROM (
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score, 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 AND t.id <> $2
GROUP BY t.id GROUP BY t.id
HAVING count(DISTINCT trim(g_split.g)) > 0 HAVING count(DISTINCT trim(g_split.g)) > 0
-- md5 breaks the ties (#3880). A seed with one tag gives every track of -- md5 breaks the ties. A seed with one tag gives every track of that
-- that genre the same score, 7,804 tracks on the operator's library, and -- genre the same score, and with no tie-break the LIMIT would take
-- with no tie-break the LIMIT took whichever rows Postgres reached first: -- whichever rows Postgres reached first: the same slice for every seed of
-- the same slice for every seed of the genre, and not one the order seed -- the genre, and not one the order seed controls.
-- controls.
ORDER BY sim_score DESC, md5(t.id::text || $12::text) ORDER BY sim_score DESC, md5(t.id::text || $12::text)
LIMIT $7 LIMIT $7
), ),
@@ -896,12 +894,10 @@ likes_overlap AS (
LIMIT $8 LIMIT $8
), ),
taste_overlap AS ( taste_overlap AS (
-- Round-robin across the taste artists (#3880), as similar_artists is -- Round-robin across the taste artists, as similar_artists is: each
-- (#5297): each artist's first track, strongest artist first, then each -- artist's first track, strongest artist first, then each one's second.
-- one's second. Ordered by weight then track id, the strongest artist's -- Ordered by weight then track id, the strongest artist's catalogue would
-- catalogue filled the whole arm with the same lowest-id tracks every -- fill the whole arm with the same lowest-id tracks every time.
-- time; on the operator's library that was 20 tracks of one artist of
-- 174, whose catalogue is 197 tracks.
SELECT track_id, sim_score SELECT track_id, sim_score
FROM ( FROM (
SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight, SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight,
@@ -918,7 +914,7 @@ taste_overlap AS (
LIMIT $10 LIMIT $10
), ),
coplay_artists AS ( 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 -- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence -- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as -- 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 coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill UNION ALL SELECT track_id, sim_score FROM random_fill
) u ) 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 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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
@@ -1014,26 +1010,23 @@ type LoadRadioCandidatesV2Row struct {
SimilarityScore interface{} 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, // $1 user_id, $2 seed_track_id, $3 recently_played_hours,
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, // $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill 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 // positively-weighted taste-profile artists, so taste-relevant tracks
// enter the pool even when the similarity/random arms miss them; scored // enter the pool even when the similarity/random arms miss them; scored
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion), // 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'), // instance with the seed's artist; source='user_cooccurrence'),
// $12 order_seed (text) — see below. // $12 order_seed (text) — see below.
// //
// $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned // $12 orders four arms by md5(id || seed), not random(). An arm whose LIMIT is
// a stable set only while their LIMIT exceeded the rows eligible for them: at // below the rows eligible for it returns a SUBSET, and with random() two
// that point they returned all of them and the order stopped mattering, // builds on the same day would draw different ones; the caller sorts by track
// because the caller sorts by track id before scoring. Below that threshold // id before scoring, so only the subset matters.
// 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.
// //
// md5(id || seed) keeps the intent — an arbitrary spread that changes when // 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 // 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. // keeps varying, which is what a radio should do.
// Returns same shape as LoadRadioCandidates plus similarity_score column. // 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 // are stored relative to the seed's best match, 0 to 1, so the arms are placed
// in bands rather than compared raw: // in bands rather than compared raw:
// //
@@ -1052,9 +1045,9 @@ type LoadRadioCandidatesV2Row struct {
// likes_overlap 0.3, flat // likes_overlap 0.3, flat
// tier 3 taste_overlap, random_fill 0 // tier 3 taste_overlap, random_fill 0
// //
// Without the bands, the seed's weaker LB edges fell below a shared genre tag // Without the bands, the seed's weaker LB edges would fall below a shared
// and the flat likes score, and the one arm that measures the seed lost to // genre tag and the flat likes score, and the one arm that measures the seed
// the ones that guess. // would lose to the ones that guess.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) { func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
rows, err := q.db.Query(ctx, loadRadioCandidatesV2, rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
arg.UserID, arg.UserID,
@@ -1122,9 +1115,9 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
WITH artist_plays AS ( WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event, -- Completed plays only: counting every play_event would make skipping an
-- so skipping an artist repeatedly INCREASED its signal and pushed more of -- artist repeatedly INCREASE its signal and push more of its neighbours
-- its neighbours at the user (issue #2367 mechanism 3). -- at the user.
SELECT t.artist_id, count(*)::bigint AS play_count SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
@@ -1181,7 +1174,7 @@ contributions AS (
AND r.artist_mbid = u.candidate_mbid AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed') 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 -- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately -- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a -- 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 // Per-user artist suggestions ranked by taste signal x similarity, projected
// through artist_similarity_unmatched (out-of-library candidates only). // 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 // tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
// SIGNED by internal/taste, so an artist the user has drifted away // 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 // tier 2 - likes + completed plays, used ONLY when the profile has no rows
// (new account, or before the first daily recompute). // (new account, or before the first daily recompute).
// //
// The signal is log-damped: contribution is signal x similarity, and the old // The signal is log-damped: contribution is signal x similarity, and an
// undamped sum let one heavily-played artist's neighbours take every slot -- // undamped sum would let one heavily-played artist's neighbours take every
// entrenching harder the MORE the user listened (issue #2367 mechanism 2). // slot -- entrenching harder the MORE the user listened.
// //
// Candidates already in the library, already requested and not terminal, or // 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 // 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 // $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. // 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. // 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 // 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 // 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 // raw source rows get merged into surface families in Go, and sums of squares
// add across groups exactly, whereas standard deviations cannot be combined // add across groups exactly, whereas standard deviations cannot be combined
// without them. Variance = (sqsum - sum²/n) / (n-1), with sum = avg × n. // without them. Variance = (sqsum - sum²/n) / (n-1), with sum = avg × n.
@@ -112,17 +112,17 @@ type RecommendationWeeklyTrendsRow struct {
TasteHits int64 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 // 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 // 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 — // a recommendation surface; NULL means the user picked the track manually —
// those rows are INCLUDED here as the baseline control group the surfaces are // 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 // judged against (delta-vs-baseline is what makes the numbers actionable).
// actionable). Raw source strings are bucketed into stable surface families in // Raw source strings are bucketed into stable surface families in the Go
// the Go handler; completion_n is carried so family merges can weight // handler; completion_n is carried so family merges can weight
// avg_completion correctly. // avg_completion correctly.
// Weekly per-source outcome series for the tuning lab's trend view // 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 // so judging a knob turn needs global outcomes — rows carry rates
// only, no track or user identity. NULL-source (manual) rows are // only, no track or user identity. NULL-source (manual) rows are
// included as the baseline family. // included as the baseline family.
+5 -5
View File
@@ -70,7 +70,7 @@ SELECT id, changed_at, scope, action, changes
LIMIT $1 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. // knob turns on the timeline.
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) { func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
rows, err := q.db.Query(ctx, listTuningAudit, limit) rows, err := q.db.Query(ctx, listTuningAudit, limit)
@@ -279,8 +279,8 @@ type UpsertDiscoverTuningDefaultsParams struct {
SnoozeDays float64 SnoozeDays float64
} }
// Boot reconcile for the Discover scope (#2377). Never overwrites // Boot reconcile for the Discover scope. Never overwrites
// operator-tuned values, same contract as the other two. // admin-tuned values, same contract as the other two.
func (q *Queries) UpsertDiscoverTuningDefaults(ctx context.Context, arg UpsertDiscoverTuningDefaultsParams) error { func (q *Queries) UpsertDiscoverTuningDefaults(ctx context.Context, arg UpsertDiscoverTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertDiscoverTuningDefaults, arg.TagOverlapWeight, arg.SnoozeDays) _, err := q.db.Exec(ctx, upsertDiscoverTuningDefaults, arg.TagOverlapWeight, arg.SnoozeDays)
return err return err
@@ -341,10 +341,10 @@ type UpsertWeightProfileDefaultsParams struct {
ContextTimeWeight float64 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. // recsettings boot reconcile; shipped defaults live in Go only.
// Boot reconcile: insert the shipped defaults for a profile if the row // 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 { func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults, _, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
arg.Profile, arg.Profile,
+1 -1
View File
@@ -520,7 +520,7 @@ type RepointRequestsAlbumMbidParams struct {
OldMbid *string 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. // that name it along, so the reconciler finds it.
func (q *Queries) RepointRequestsAlbumMbid(ctx context.Context, arg RepointRequestsAlbumMbidParams) error { func (q *Queries) RepointRequestsAlbumMbid(ctx context.Context, arg RepointRequestsAlbumMbidParams) error {
_, err := q.db.Exec(ctx, repointRequestsAlbumMbid, arg.NewMbid, arg.OldMbid) _, err := q.db.Exec(ctx, repointRequestsAlbumMbid, arg.NewMbid, arg.OldMbid)
+2 -2
View File
@@ -98,7 +98,7 @@ type InsertScanRunRow struct {
StartedAt pgtype.Timestamptz 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 // 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. // as each completes via UpdateScanRunStage; finished_at is set by FinishScanRun.
func (q *Queries) InsertScanRun(ctx context.Context) (InsertScanRunRow, error) { func (q *Queries) InsertScanRun(ctx context.Context) (InsertScanRunRow, error) {
@@ -119,7 +119,7 @@ type UpdateScanRunArtistArtEnrichParams struct {
ArtistArtEnrich []byte 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 { func (q *Queries) UpdateScanRunArtistArtEnrich(ctx context.Context, arg UpdateScanRunArtistArtEnrichParams) error {
_, err := q.db.Exec(ctx, updateScanRunArtistArtEnrich, arg.ID, arg.ArtistArtEnrich) _, err := q.db.Exec(ctx, updateScanRunArtistArtEnrich, arg.ID, arg.ArtistArtEnrich)
return err 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 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) { func (q *Queries) GetAcquisitionSettings(ctx context.Context) (AcquisitionSetting, error) {
row := q.db.QueryRow(ctx, getAcquisitionSettings) row := q.db.QueryRow(ctx, getAcquisitionSettings)
var i AcquisitionSetting 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 // 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) { func (q *Queries) ListReleaseBlocks(ctx context.Context) ([]ReleaseBlock, error) {
rows, err := q.db.Query(ctx, listReleaseBlocks) rows, err := q.db.Query(ctx, listReleaseBlocks)
if err != nil { if err != nil {
@@ -500,7 +500,7 @@ type ListWantsDueForSearchRow struct {
// Wants with no download running that were never searched or not since // Wants with no download running that were never searched or not since
// @hours ago: open wants first (never-searched, then requests, then missing // @hours ago: open wants first (never-searched, then requests, then missing
// files, then followed artists' releases), then held albums wanted for an // 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) { func (q *Queries) ListWantsDueForSearch(ctx context.Context, arg ListWantsDueForSearchParams) ([]ListWantsDueForSearchRow, error) {
rows, err := q.db.Query(ctx, listWantsDueForSearch, arg.Hours, arg.MaxRows) rows, err := q.db.Query(ctx, listWantsDueForSearch, arg.Hours, arg.MaxRows)
if err != nil { if err != nil {
+2 -1
View File
@@ -57,7 +57,8 @@ type DeleteSessionForUserParams struct {
UserID pgtype.UUID 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. // 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 // execrows lets the handler answer 404 rather than a false 204 when the row
// isn't theirs. // 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, // 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 // 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 // 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 // counts as fetched; reading the edges would let such seeds hold the head of
// the head of the queue forever. // the queue forever.
func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit int32) ([]ListPlayedTracksNeedingSimilarityRow, error) { func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit int32) ([]ListPlayedTracksNeedingSimilarityRow, error) {
rows, err := q.db.Query(ctx, listPlayedTracksNeedingSimilarity, limit) rows, err := q.db.Query(ctx, listPlayedTracksNeedingSimilarity, limit)
if err != nil { 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 // 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 // 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 // below it. LB's raw scores are counts, into the thousands, and the scorer
// library) and reached the scorer, which reads similarity as 0 to 1, so one // reads similarity as 0 to 1, so a raw edge would outweigh likes, skips and
// edge outweighed likes, skips and recency together. The cache keeps the raw // recency together. The cache keeps the raw
// answer; only the edge is scaled. // answer; only the edge is scaled.
// //
// Only edges of seeds the cache owns are removed, those with a fetch row. // 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 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). // song_key (migration 0076).
// Links the tracks into one song, joining any songs they already belong to. // 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 // 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 // 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) { func (q *Queries) ListTrackSongKeys(ctx context.Context, ids []pgtype.UUID) ([]ListTrackSongKeysRow, error) {
rows, err := q.db.Query(ctx, listTrackSongKeys, ids) rows, err := q.db.Query(ctx, listTrackSongKeys, ids)
if err != nil { if err != nil {
+1 -1
View File
@@ -85,7 +85,7 @@ type SnoozeSuggestionParams struct {
Column4 float64 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 // 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 // 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 JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.id LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.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(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2 AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS ( AND NOT EXISTS (
@@ -62,18 +62,18 @@ type ListDeepCutsTracksRow struct {
} }
// Candidate queries for the discovery system-playlist mixes // 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- // producer caps + truncates and converts to rankedCandidate. Daily-
// deterministic md5(id||dateStr) ordering (where used) keeps a mix // deterministic md5(id||dateStr) ordering (where used) keeps a mix
// stable within a day while rotating across days; shuffle-on-play // stable within a day while rotating across days; shuffle-on-play
// gives per-play variety on top. // 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). // has liked OR played heavily (>=5 non-skip plays across the artist).
// Tracks the user has skipped twice or more don't qualify (#1257): // Tracks the user has skipped twice or more don't qualify: counting only
// eligibility used to count only unskipped plays, so the most actively // unskipped plays would make the most actively rejected tracks read as
// rejected tracks read as "barely heard" and kept being re-offered. // "barely heard" and keep re-offering them. Threshold 2 so a single
// Threshold 2 so a single accidental skip doesn't banish a track; // accidental skip doesn't banish a track; passive signal only (there is no
// passive-signal only (no dislike UI, rule #101). // dislike button).
// $1 user_id, $2 date string. // $1 user_id, $2 date string.
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) { func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2) 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 FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
JOIN albums_tiered alt ON alt.album_id = al.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 alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id) AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS ( AND NOT EXISTS (
@@ -153,11 +153,10 @@ type ListFirstListensTracksRow struct {
Tier int32 Tier int32
} }
// #423 First Listens: songs the user has never heard or even // First Listens: songs the user has never heard or even
// attempted. "Attempted" is track-level with a >=30s listen threshold // attempted. "Attempted" is track-level with a >=30s listen threshold:
// (#1268, operator decision 2026-07-03) — the old version disqualified // disqualifying a whole album on ANY play_event would let a 2-second
// a whole album on ANY play_event, so a 2-second accidental brush // accidental brush banish it permanently.
// banished it permanently.
// //
// tier 1 the exact desire: albums with ZERO attempted tracks // tier 1 the exact desire: albums with ZERO attempted tracks
// tier 2 step back: barely-attempted albums (<=25% of 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 SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
FROM tracks t FROM tracks t
JOIN albums_tiered alt ON alt.album_id = t.album_id 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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
@@ -244,14 +243,13 @@ type ListNewForYouTracksRow struct {
Tier int32 Tier int32
} }
// #421 New for you, tiered per project rule #131 (#1267). The old // New for you, tiered so the mix degrades rather than vanishes. A
// single hard rule (added <30d AND direct affinity) had two failure // single hard rule (added <30d AND direct affinity) has no notion of
// modes with one root: no notion of consumption — the album you // consumption: the album you devoured in week one would crowd the mix
// devoured in week one crowded the mix for three more weeks, and // for three more weeks, and after a quiet month nothing would qualify.
// after a quiet month nothing qualified at all.
// //
// "Consumed" = any track attempted for >=30s (the First Listens // "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 // no longer news. Applies to every tier — played albums leave the
// mix the next build. // mix the next build.
// //
@@ -306,7 +304,7 @@ WITH windowed AS (
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN windowed w ON w.track_id = t.id 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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id WHERE q.user_id = $1 AND q.track_id = t.id
@@ -326,18 +324,18 @@ type ListOnThisDayTracksRow struct {
ArtistID pgtype.UUID 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 // (±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 // 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 // they were played in those windows. The 30-day floor and ±10 window
// and window ±7→±10 so it surfaces on a months-old library instead // let it surface on a months-old library rather than needing a full
// of needing a full year of history; still skips cleanly (no rows → // year of history; it still skips cleanly (no rows → no playlist)
// no playlist) when there's no qualifying history yet. // when there's no qualifying history yet.
// Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs // Day-of-year distance is circular: plain ABS would read Dec 28 vs
// Jan 3 read as 359 days apart, silently gutting the window for ~3 // Jan 3 as 359 days apart, gutting the window for ~3 weeks around
// weeks around every New Year — precisely when holiday nostalgia is // every New Year — precisely when holiday nostalgia is the point.
// the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of // LEAST(d, 365-d) wraps the boundary; leap-year drift of ±1 is absorbed
// ±1 is absorbed by the ±10 window. The build date ($2, already here // by the ±10 window. The build date ($2, already here
// for the md5 rotation) anchors "today" instead of now() so the // for the md5 rotation) anchors "today" instead of now() so the
// window is testable and consistent with the mix's daily determinism. // window is testable and consistent with the mix's daily determinism.
// $1 user_id, $2 date string. // $1 user_id, $2 date string.
@@ -371,18 +369,18 @@ WITH stats AS (
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN stats s ON s.track_id = t.id 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.c >= 3
AND s.last_at <= now() - interval '30 days' AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id 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 -- rediscover-sourced play is the user explicitly saying "I've moved
-- on" to the exact invitation this mix extends — such tracks sit -- on" to the exact invitation this mix extends — such tracks sit
-- out ~90 days instead of re-qualifying the next day. Passive -- out ~90 days instead of re-qualifying the next day. Passive
-- signal only (rule #101). -- signal only.
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM play_events rp SELECT 1 FROM play_events rp
WHERE rp.user_id = $1 WHERE rp.user_id = $1
@@ -403,11 +401,10 @@ type ListRediscoverTracksRow struct {
ArtistID pgtype.UUID ArtistID pgtype.UUID
} }
// #420 Rediscover: tracks the user played a lot but has drifted away // Rediscover: tracks the user played a lot but has drifted away
// from. One blended pool (issue #1246: the old two-tier UNION was // from. One blended pool, not a two-tier UNION: the ">=6 months cold" tier is
// all-or-nothing — a single ">=6 months cold" row suppressed the whole // a strict subset of the ">=30 days" one, and a UNION that let one row of it
// ">=30 days" tier, which is how a one-song playlist shipped; the 6mo // suppress the wider tier would ship a one-song playlist. Eligibility is >=3
// tier was also a strict subset of the 30d tier). Eligibility is >=3
// non-skip plays and >=30 days cold; the bar sits at 3 rather than 5 // 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 // so a weeks-old library still fields a pool — on young histories the
// >=5-play tracks are precisely the ones still in rotation. Ordering // >=5-play tracks are precisely the ones still in rotation. Ordering
@@ -34,7 +34,7 @@ type AppendRotationPlayedParams struct {
TrackID pgtype.UUID 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 // One row per (user, playlist_kind). played_track_ids accumulates
// tracks heard from that playlist in the current rotation; shuffle // tracks heard from that playlist in the current rotation; shuffle
// prefers ids NOT in this set, then resets when the snapshot is // 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 // seed or For-You candidate has to be something that can actually play. Note
// this only affects newly GENERATED playlists — already-stored system // this only affects newly GENERATED playlists — already-stored system
// playlists keep their rows until the next daily rebuild, which is why the // playlists keep their rows until the next daily rebuild, which is why the
// shared ListPlaylistTracks read path is deliberately left unfiltered (it // shared ListPlaylistTracks read path is deliberately left unfiltered (it
// also serves user-curated playlists, where hiding a track the user added // also serves user-curated playlists, where hiding a track the user added
// themselves would be wrong). // 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. // Active = had a play in the last 7 days. The cron iterates this list.
func (q *Queries) ListActiveUsersForSystemPlaylists(ctx context.Context) ([]pgtype.UUID, error) { func (q *Queries) ListActiveUsersForSystemPlaylists(ctx context.Context) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, listActiveUsersForSystemPlaylists) rows, err := q.db.Query(ctx, listActiveUsersForSystemPlaylists)
@@ -368,10 +368,10 @@ type PickSeedArtistsRow struct {
} }
// Top-12 most-engaged distinct artist candidates, tiered so the // Top-12 most-engaged distinct artist candidates, tiered so the
// "Songs like X" mixes never silently vanish (#1255): the old hard // "Songs like X" mixes never silently vanish: a hard 7-day window
// 7-day window emptied the seed pool after a quiet week, and the // would empty the seed pool after a quiet week, and the daily
// daily atomic-replace build then deleted every existing mix until // atomic-replace build would then delete every existing mix until the
// the user played something again. Same fallback shape as // user played something again. Same fallback shape as
// PickTopPlayedTracksForUser (For You's seeds): // PickTopPlayedTracksForUser (For You's seeds):
// //
// tier 0 engagement in the last 7 days // 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 // tier 3 (only if 0-2 empty) liked artists with no play history
// //
// All returned rows share one tier; produceSeedMixes maps it onto the // 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. // can compare mixes seeded from fresh vs stale engagement.
// Score = unskipped-play count + 5 if user has liked the artist. The // Score = unskipped-play count + 5 if user has liked the artist. The
// Go-side picker (pickSeedArtistsForDay) shuffles the 12 // Go-side picker (pickSeedArtistsForDay) shuffles the 12
// daily-deterministically and takes songsLikeSeedCount (6) so the mix // 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) { func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID) rows, err := q.db.Query(ctx, pickSeedArtists, userID)
if err != nil { if err != nil {
@@ -452,7 +452,7 @@ SELECT COALESCE(
FROM tracks t FROM tracks t
JOIN albums a ON a.id = t.album_id JOIN albums a ON a.id = t.album_id
WHERE t.artist_id = $2 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, ORDER BY a.release_date DESC NULLS LAST,
t.disc_number NULLS LAST, t.disc_number NULLS LAST,
t.track_number NULLS LAST, t.track_number NULLS LAST,
@@ -530,7 +530,7 @@ SELECT id
// library with sparse history. // library with sparse history.
// A likes-only read still joins tracks: a like outlives its file, and a // 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 // 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. // join.
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) { func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID) 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 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 // The tags.SettingsService consumes these — parallel to the cover-art
// SettingsService (coverart_settings.sql). Keeping the two independent // SettingsService (coverart_settings.sql). Keeping the two independent
// means a new tag source is added without touching the art settings. // 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 // INSERT a default row for a newly-registered provider, or refresh an
// existing row's display_order on boot. Does NOT overwrite enabled / // 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 { func (q *Queries) UpsertTagProviderSettings(ctx context.Context, arg UpsertTagProviderSettingsParams) error {
_, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder) _, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder)
return err 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 // (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, // 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) { func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID) rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil { if err != nil {
@@ -216,14 +216,14 @@ type ListPlayEngagementInputsForUserRow struct {
AgeDays float64 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 // 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 // One row per play in the decay-relevant window. completion is the play's
// completion ratio (precomputed column when present, else duration_played / // completion ratio (precomputed column when present, else duration_played /
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre // 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 // 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) { func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2) rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
if err != nil { if err != nil {
@@ -269,7 +269,7 @@ type ListTasteProfileArtistsForUserRow struct {
Weight float64 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) { func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTasteProfileArtistsForUserParams) ([]ListTasteProfileArtistsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileArtistsForUser, arg.UserID, arg.Limit) rows, err := q.db.Query(ctx, listTasteProfileArtistsForUser, arg.UserID, arg.Limit)
if err != nil { if err != nil {
@@ -308,7 +308,7 @@ type ListTasteProfileErasForUserRow struct {
Weight float64 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) { func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit) rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
if err != nil { if err != nil {
@@ -347,7 +347,7 @@ type ListTasteProfileMoodsForUserRow struct {
Weight float64 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) { func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit) rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
if err != nil { if err != nil {
+3 -3
View File
@@ -121,7 +121,7 @@ type ListTrackTagsForTracksRow struct {
Tag string 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, // 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. // 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) { func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
@@ -168,7 +168,7 @@ type ListTracksMissingTagsRow struct {
ArtistMbid *string 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 // drains ListTracksMissingTags, fetches from the provider chain, and
// writes the merged top-K via DeleteTrackTags + InsertTrackTag, then // writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
// stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape. // 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 // or previously settled 'none' under an older provider version. Returns
// the fields the provider chain needs — recording MBID (nullable) for // the fields the provider chain needs — recording MBID (nullable) for
// keyed lookups, title + artist name for name-based fallback, and the // 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. // $1 = current tag_sources_version, $2 = limit.
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) { func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit) 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[]) WHERE id = ANY($1::uuid[])
` `
// Forgets the recording MBID of rows whose file was another song's (M498's // Forgets the recording MBID of rows whose file was another song's (the
// wrong-slot removal). The MBID came from that file, so it names the song kept // duplicate resolver's wrong-slot removal). The MBID came from that file, so it
// elsewhere on the album, and missing-pair folding would fold the emptied slot // names the song kept elsewhere on the album, and missing-pair folding would
// into it: twelve slots went that way on 2026-10-20 and were never wanted back. // fold the emptied slot into it, so it would never be wanted back.
// mbid_source goes with it (tracks_mbid_source_pair). // mbid_source goes with it (tracks_mbid_source_pair).
func (q *Queries) ClearTrackMbids(ctx context.Context, ids []pgtype.UUID) error { func (q *Queries) ClearTrackMbids(ctx context.Context, ids []pgtype.UUID) error {
_, err := q.db.Exec(ctx, clearTrackMbids, ids) _, err := q.db.Exec(ctx, clearTrackMbids, ids)
@@ -177,7 +177,7 @@ type DeleteTrackRow struct {
Mbid *string 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 / // play_events / general_likes_tracks / quarantine /
// quarantine_actions handles their cleanup. RETURNING gives us // quarantine_actions handles their cleanup. RETURNING gives us
// album_id + artist_id for the album-empty / artist-empty cascade // album_id + artist_id for the album-empty / artist-empty cascade
@@ -209,11 +209,11 @@ type FindMissingTrackByAudioHashRow struct {
FilePath string 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 // 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), // retag — anything short of a re-encode. (file_size, duration_ms) would pair two
// which could pair two unrelated files that happened to share a byte count and a // unrelated files that share a byte count and a duration, and miss a file
// duration, and missed a file retagged in place, whose size changes. // retagged in place, whose size changes.
// //
// Same missing-only constraint and same LIMIT 2 rationale as the MBID variant. // Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
func (q *Queries) FindMissingTrackByAudioHash(ctx context.Context, audioStreamSha256 []byte) ([]FindMissingTrackByAudioHashRow, error) { func (q *Queries) FindMissingTrackByAudioHash(ctx context.Context, audioStreamSha256 []byte) ([]FindMissingTrackByAudioHashRow, error) {
@@ -249,7 +249,7 @@ type FindMissingTrackByMbidRow struct {
FilePath string 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, // carrying a recording MBID we already have on a MISSING row is that recording,
// moved — not a new track. // moved — not a new track.
// //
@@ -287,8 +287,8 @@ UPDATE tracks
AND regexp_replace(file_path, '^.*/', '') ~* $1::text AND regexp_replace(file_path, '^.*/', '') ~* $1::text
` `
// Holds back present tracks whose basename carries a video-rip marker (M498 // Holds back present tracks whose basename carries a video-rip marker, the
// #5439), the same pattern the report lists. A track the operator called fine // same pattern the report lists. A track an admin called fine
// keeps that verdict. // keeps that verdict.
func (q *Queries) FlagSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) { func (q *Queries) FlagSuspectSourceTracks(ctx context.Context, pattern string) (int64, error) {
result, err := q.db.Exec(ctx, flagSuspectSourceTracks, pattern) 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 // 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) { func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Track, error) {
rows, err := q.db.Query(ctx, getTracksByIDs, dollar_1) rows, err := q.db.Query(ctx, getTracksByIDs, dollar_1)
if err != nil { if err != nil {
@@ -451,7 +451,7 @@ type ListArtistTracksForUserRow struct {
ArtistName string 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 // and artist_name joined. Honors per-user quarantine. Used by
// /api/artists/{id}/tracks for the artist-card play affordance, which // /api/artists/{id}/tracks for the artist-card play affordance, which
// shuffles client-side. Ordering matches album/track natural order so // shuffles client-side. Ordering matches album/track natural order so
@@ -545,19 +545,18 @@ type ListMissingTracksRow struct {
LastPlayedAt pgtype.Timestamptz 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, // 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 // because the unit an admin actually reasons about is a FOLDER: a reorganised
// this was built for was three whole albums that had been reorganised, and a // album, listed flat by timestamp, reads as dozens of unrelated decisions.
// flat list ordered by timestamp presents that as forty unrelated decisions.
// Grouping happens in the handler; the ordering here is what makes a group // Grouping happens in the handler; the ordering here is what makes a group
// contiguous, so a page boundary splits a directory at worst. // 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 // 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, // plays stays in the result with NULL. It is here because "gone six months,
// never played" and "gone yesterday, played 200 times" deserve opposite // 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) { func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksParams) ([]ListMissingTracksRow, error) {
rows, err := q.db.Query(ctx, listMissingTracks, arg.PageOffset, arg.PageLimit) rows, err := q.db.Query(ctx, listMissingTracks, arg.PageOffset, arg.PageLimit)
if err != nil { if err != nil {
@@ -616,7 +615,7 @@ type ListRandomTracksForUserRow struct {
ArtistName string 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 // source for "Shuffle all". N random tracks across the whole
// library, per-user-quarantine filtered. $1 user_id, $2 limit. // library, per-user-quarantine filtered. $1 user_id, $2 limit.
func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTracksForUserParams) ([]ListRandomTracksForUserRow, error) { 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 // 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. // 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) { func (q *Queries) ListStillMissingTrackLabels(ctx context.Context, ids []pgtype.UUID) ([]ListStillMissingTrackLabelsRow, error) {
rows, err := q.db.Query(ctx, listStillMissingTrackLabels, ids) rows, err := q.db.Query(ctx, listStillMissingTrackLabels, ids)
@@ -719,7 +718,7 @@ SELECT t.id,
t.source_verdict, t.source_verdict,
t.source_verdict_at, t.source_verdict_at,
-- The album's place on the wanted list: wanted for a clean copy, and -- 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, COALESCE(w.upgrade, '')::text AS want_upgrade,
w.searched_at AS searched_at w.searched_at AS searched_at
FROM tracks t FROM tracks t
@@ -760,7 +759,7 @@ type ListSuspectSourceTracksRow struct {
} }
// The admin report of present tracks whose filename looks like a video rip // 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 // 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 // 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 // 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 // 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 // outside it drift forever. Three narrow columns keep it cheap even on a
// library of a few hundred thousand tracks. // 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 // Track recording-MBID backfill: tracks with NULL mbid that still have
// a file to re-read. $1 caps the batch (mirrors the album backfill). // 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 // 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) { func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit) rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
if err != nil { if err != nil {
@@ -1081,7 +1080,7 @@ type SetTrackSourceVerdictParams struct {
ID pgtype.UUID 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. // the mixes for good; 'suspect' holds it back again.
func (q *Queries) SetTrackSourceVerdict(ctx context.Context, arg SetTrackSourceVerdictParams) (int64, error) { func (q *Queries) SetTrackSourceVerdict(ctx context.Context, arg SetTrackSourceVerdictParams) (int64, error) {
result, err := q.db.Exec(ctx, setTrackSourceVerdict, arg.Verdict, arg.ID) 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, WHEN tracks.mbid_source = 'acoustid' THEN 'acoustid' END,
genre = EXCLUDED.genre, genre = EXCLUDED.genre,
-- Stamped on update too, so a tag-repair pass marks rows as done and the -- 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, tag_read_version = EXCLUDED.tag_read_version,
updated_at = now() 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 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. // 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) // 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 (D4). // 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) { func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track, error) {
row := q.db.QueryRow(ctx, upsertTrack, row := q.db.QueryRow(ctx, upsertTrack,
arg.Title, 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 // 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 // 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 { func (q *Queries) SetSubsonicPassword(ctx context.Context, arg SetSubsonicPasswordParams) error {
_, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword) _, err := q.db.Exec(ctx, setSubsonicPassword, arg.ID, arg.SubsonicPassword)
return err 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, 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 NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held, 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(*) FILTER (WHERE fulfilled_at IS NOT NULL AND upgrade <> '')::bigint AS upgrades,
COUNT(DISTINCT artist_mbid)::bigint AS artists, 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 (SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched' WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS 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, (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. -- waiting on a person, and releases set aside.
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity, (SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs (SELECT COUNT(*) FROM grabs
@@ -324,7 +324,7 @@ type GetLibraryListingRow struct {
LibraryUnlisted int32 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) { func (q *Queries) GetLibraryListing(ctx context.Context) (GetLibraryListingRow, error) {
row := q.db.QueryRow(ctx, getLibraryListing) row := q.db.QueryRow(ctx, getLibraryListing)
var i GetLibraryListingRow var i GetLibraryListingRow
@@ -361,7 +361,7 @@ type GetWantAlbumRow struct {
AlbumID pgtype.UUID 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) { func (q *Queries) GetWantAlbum(ctx context.Context, id pgtype.UUID) (GetWantAlbumRow, error) {
row := q.db.QueryRow(ctx, getWantAlbum, id) row := q.db.QueryRow(ctx, getWantAlbum, id)
var i GetWantAlbumRow var i GetWantAlbumRow
@@ -547,9 +547,9 @@ type ListAlbumsToMatchRow struct {
// Library albums the listing has not decided, or that an older matcher // Library albums the listing has not decided, or that an older matcher
// refused, or whose tracks changed since they were refused: each is matched // 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 // 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. // the library; one a person left off stays off.
func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) { func (q *Queries) ListAlbumsToMatch(ctx context.Context, arg ListAlbumsToMatchParams) ([]ListAlbumsToMatchRow, error) {
rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.Matcher, arg.MaxRows) rows, err := q.db.Query(ctx, listAlbumsToMatch, arg.Cutoff, arg.Matcher, arg.MaxRows)
@@ -752,7 +752,7 @@ type ListHeldWantFormatsRow struct {
Rips int32 Rips int32
} }
// Upgrades (#5524). ------------------------------------------------------- // Upgrades. -------------------------------------------------------
// What each held album's copy is made of, for judging whether it is worth // 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 // 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 // length summed, and how many are held back as video rips. A held want whose
@@ -936,7 +936,7 @@ type ListReleaseTypeProfilesRow struct {
FollowCount int64 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) { func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTypeProfilesRow, error) {
rows, err := q.db.Query(ctx, listReleaseTypeProfiles) rows, err := q.db.Query(ctx, listReleaseTypeProfiles)
if err != nil { if err != nil {
@@ -988,7 +988,7 @@ type ListUnmatchedLibraryAlbumsRow struct {
Tracks int32 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 // 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. // tracks have all moved to another row is gone, not unmatched.
func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32) ([]ListUnmatchedLibraryAlbumsRow, error) { func (q *Queries) ListUnmatchedLibraryAlbums(ctx context.Context, maxRows int32) ([]ListUnmatchedLibraryAlbumsRow, error) {
@@ -1070,7 +1070,7 @@ type ListWantArtistsRow struct {
Total int64 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 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; // 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. // 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 const listWants = `-- name: ListWants :many
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason, 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, 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, w.upgrade, w.held_quality,
-- A request names an album before the catalogue has read it: the -- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has. -- 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, u.username AS requested_by,
la.id AS library_artist_id, la.id AS library_artist_id,
COUNT(*) OVER () AS total, 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, 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 -- 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.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.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, 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' ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END END
AND ($2::text = '' OR w.artist_mbid = $2::text) 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 (COALESCE(cardinality($3::uuid[]), 0) = 0 OR w.id = ANY($3::uuid[]))
AND ($4::text = '' AND ($4::text = ''
OR strpos(lower(w.artist_name), lower($4::text)) > 0 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 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, // 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 // 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 // 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. // 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, // 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 { func (q *Queries) RecordListedAlbums(ctx context.Context, cutoff pgtype.Timestamptz) error {
_, err := q.db.Exec(ctx, recordListedAlbums, cutoff) _, err := q.db.Exec(ctx, recordListedAlbums, cutoff)
return err return err
@@ -1379,7 +1379,7 @@ type RepointWantParams struct {
ReleaseGroupMbid string 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 { func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error {
_, err := q.db.Exec(ctx, repointWant, arg.ID, arg.ReleaseGroupMbid) _, err := q.db.Exec(ctx, repointWant, arg.ID, arg.ReleaseGroupMbid)
return err 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 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, 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 -- 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 -- 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 -- on disc 1, so a missing track 6 with no disc beside a present 1/6
-- nothing, over 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 ( COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL 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 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 // album/artist IDs in Go (similarity roll-up + cold-start gate) and
// atomic-replaces them here; /api/home reads them back hydrated. // atomic-replaces them here; /api/home reads them back hydrated.
// Cold-start gate. Returns the breadth of the user's real listening: // 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. -- lookup state means.
-- name: ListTracksNeedingAcoustIDLookup :many -- name: ListTracksNeedingAcoustIDLookup :many
@@ -29,7 +29,7 @@ ON CONFLICT (track_id) DO UPDATE SET
looked_up_at = now(); looked_up_at = now();
-- name: SetTrackMbidFromAcoustID :execrows -- 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. -- 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 -- 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. -- 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 -- name: ListAcquisitionServices :many
-- Returns no secret, only whether one is set: this feeds the settings page. -- 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. -- downloading, what the library holds. Each is one cheap read.
-- name: ListListeningNow :many -- name: ListListeningNow :many
+3 -3
View File
@@ -1,14 +1,14 @@
-- name: AdminSummary :one -- 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 -- 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 -- 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 -- 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
(SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests, (SELECT count(*) FROM requests WHERE status = 'pending')::bigint AS requests,
(SELECT min(requested_at) FROM requests WHERE status = 'pending')::timestamptz AS requests_since, (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 -- 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. -- page lists them.
(SELECT count(*) FROM ( (SELECT count(*) FROM (
SELECT track_id FROM quarantine SELECT track_id FROM quarantine
+22 -27
View File
@@ -37,12 +37,12 @@ ORDER BY release_date NULLS LAST, sort_title;
-- name: GetAlbumByArtistAndTitle :one -- name: GetAlbumByArtistAndTitle :one
-- Scanner uses this for the no-mbid dedupe path: resolve-or-create. An album -- 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 -- of the same name in another release group is a different album (one
-- six self-titled albums shared one row until 2026-10-20), so a row whose -- 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 -- 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 -- 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 -- 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. -- row, and a few strays already misfiled do not outvote the folder.
SELECT albums.* FROM albums SELECT albums.* FROM albums
WHERE albums.artist_id = sqlc.arg(artist_id) AND albums.title = sqlc.arg(title) 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. -- Album "belongs to" a genre if any of its tracks carry that genre.
-- Serves Subsonic getAlbumList?type=byGenre. -- Serves Subsonic getAlbumList?type=byGenre.
-- --
-- Splits tracks.genre on [;,] as of #367. It previously compared the whole -- Splits tracks.genre on [;,], so a track tagged "Rock;Pop" is reachable from
-- column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER -- both "Rock" and "Pop"; comparing the whole column would hide every
-- "Rock" or "Pop" — a Subsonic client asking for a genre silently missed -- multi-genre track from a Subsonic client asking for a genre. The same split
-- every multi-genre track. This also aligns the endpoint with -- as recommendation.sql, discover.sql and the genre browse index.
-- recommendation.sql / discover.sql, which have always split, and with the
-- genre browse index that #367 adds.
-- --
-- EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per -- 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 -- (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 || '%'; SELECT COUNT(*) FROM albums WHERE title ILIKE '%' || $1::text || '%';
-- name: ListRecentlyAddedAlbumsWithArtist :many -- 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 -- home-page section. created_at is the row-insert timestamp from the
-- scanner — the closest proxy to "added to library". Stable secondary -- scanner — the closest proxy to "added to library". Stable secondary
-- ordering by id keeps pagination tie-break deterministic. -- ordering by id keeps pagination tie-break deterministic.
@@ -122,7 +120,7 @@ ORDER BY albums.created_at DESC, albums.id
LIMIT $1; LIMIT $1;
-- name: ListAlbumsAlphaWithArtist :many -- 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. -- /api/library/albums for the wrapping-grid page. Stable id-tiebreak.
SELECT sqlc.embed(albums), artists.name AS artist_name SELECT sqlc.embed(albums), artists.name AS artist_name
FROM albums FROM albums
@@ -131,7 +129,7 @@ ORDER BY albums.sort_title, albums.id
LIMIT $1 OFFSET $2; LIMIT $1 OFFSET $2;
-- name: CountAlbums :one -- 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; SELECT COUNT(*) FROM albums;
-- name: CountAlbumsByArtist :one -- name: CountAlbumsByArtist :one
@@ -140,7 +138,7 @@ SELECT COUNT(*) FROM albums;
SELECT COUNT(*) FROM albums WHERE artist_id = $1; SELECT COUNT(*) FROM albums WHERE artist_id = $1;
-- name: DeleteAlbumIfEmpty :one -- 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 -- 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. -- 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 -- 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; RETURNING a.id, a.artist_id;
-- name: SetAlbumMbidIfNull :exec -- 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. -- only updates when the existing mbid is NULL.
UPDATE albums UPDATE albums
SET mbid = $2, SET mbid = $2,
@@ -159,7 +157,7 @@ UPDATE albums
WHERE id = $1 AND mbid IS NULL; WHERE id = $1 AND mbid IS NULL;
-- name: SetAlbumReleaseGroupMbidIfNull :exec -- 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 -- 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. -- NULL, so a tag never gets overwritten by a later guess or vice versa.
UPDATE albums UPDATE albums
@@ -168,7 +166,7 @@ UPDATE albums
WHERE id = $1 AND release_group_mbid IS NULL; WHERE id = $1 AND release_group_mbid IS NULL;
-- name: GetReleaseGroupForReleaseMbid :one -- 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. -- request carrying a release id can be repaired without asking MusicBrainz.
SELECT release_group_mbid::text SELECT release_group_mbid::text
FROM albums FROM albums
@@ -176,7 +174,7 @@ SELECT release_group_mbid::text
LIMIT 1; LIMIT 1;
-- name: SetReleaseGroupForReleaseMbidIfNull :exec -- 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. -- holds the release, filling only a NULL.
UPDATE albums UPDATE albums
SET release_group_mbid = sqlc.arg(release_group_mbid)::text, 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 -- 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 -- 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 -- 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, SELECT a.id AS album_id,
a.artist_id AS artist_id, a.artist_id AS artist_id,
a.title AS title, a.title AS title,
@@ -207,7 +205,7 @@ SELECT a.id AS album_id,
LIMIT $1; LIMIT $1;
-- name: GetAlbumCoverageRollup :one -- 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 — -- admin gauge. Single sequential scan with FILTER aggregates —
-- sub-millisecond on realistic libraries. pending_no_mbid is a SUBSET -- sub-millisecond on realistic libraries. pending_no_mbid is a SUBSET
-- of pending; the UI surfaces it separately so operators can see how -- 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 -- Invariant: with_art + pending + settled = total. (pending_no_mbid is
-- not part of the sum — it's a subset of pending, surfaced separately.) -- 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 -- The IN list below must name every source the enrichers write (the
-- constraint — currently relaxed by migration 0020 to include 'deezer' -- cover_art_source CHECK accepts any non-empty string since migration
-- and 'lastfm', and migration 0030 further relaxed it to any non-empty -- 0030). A source missing here makes with_art silently undercount.
-- 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.
SELECT SELECT
COUNT(*) AS total, COUNT(*) AS total,
COUNT(*) FILTER (WHERE cover_art_source IN COUNT(*) FILTER (WHERE cover_art_source IN
@@ -233,7 +228,7 @@ SELECT
FROM albums; FROM albums;
-- name: SetAlbumCoverWithVersion :exec -- 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 -- current sources_version stamp. cover_art_path is NULL when source is
-- 'none' (no file written). Atomic; called per album per pass. -- 'none' (no file written). Atomic; called per album per pass.
UPDATE albums UPDATE albums
@@ -246,5 +241,5 @@ UPDATE albums
-- name: GetAlbumsByIDs :many -- name: GetAlbumsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads -- 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[]); 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[]); SELECT * FROM artists WHERE id = ANY($1::uuid[]);
-- name: ListArtistsAlphaWithCovers :many -- 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 -- album whose cover_art_path is set). Replaces ListArtistsAlpha for the
-- /api/artists?sort=alpha branch — the new column is appended, so 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 -- 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; LIMIT $1 OFFSET $2;
-- name: DeleteArtistIfEmpty :one -- 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 -- AND no remaining tracks (defensive — tracks can in principle exist
-- without an album, though the scanner doesn't write that shape today). -- without an album, though the scanner doesn't write that shape today).
DELETE FROM artists a DELETE FROM artists a
@@ -78,7 +78,7 @@ WHERE a.id = $1
RETURNING a.id; RETURNING a.id;
-- name: SetArtistMbidIfNull :exec -- 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 -- only updates when the existing mbid is NULL, so concurrent scans don't
-- overwrite an existing value with a stale one. -- overwrite an existing value with a stale one.
UPDATE artists UPDATE artists
@@ -87,7 +87,7 @@ UPDATE artists
WHERE id = $1 AND mbid IS NULL; WHERE id = $1 AND mbid IS NULL;
-- name: ListArtistsMissingArt :many -- 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 -- attempt. Eligible: artist_art_source IS NULL OR (= 'none' AND stale
-- artist_art_sources_version). MBID-less artists are returned but the -- artist_art_sources_version). MBID-less artists are returned but the
-- enricher skips them at the row.mbid == nil guard inside the chain. -- enricher skips them at the row.mbid == nil guard inside the chain.
@@ -99,7 +99,7 @@ SELECT a.id
LIMIT $2; LIMIT $2;
-- name: SetArtistArtWithVersion :exec -- 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 / -- enrichment with the current sources_version stamp. thumb_path /
-- fanart_path are NULL on a 'none' result, or independently NULL if -- fanart_path are NULL on a 'none' result, or independently NULL if
-- the provider returned only one of the two image types (e.g. -- the provider returned only one of the two image types (e.g.
@@ -113,7 +113,7 @@ UPDATE artists
WHERE id = $1; WHERE id = $1;
-- name: ClearArtistArtNone :exec -- 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. -- eligibility loop to flip stale 'none' rows back to NULL.
UPDATE artists UPDATE artists
SET artist_art_source = NULL, SET artist_art_source = NULL,
+1 -1
View File
@@ -15,7 +15,7 @@ SELECT count(*)::bigint FROM audit_log
-- name: ListAuditLogByActions :many -- name: ListAuditLogByActions :many
-- One page of the given actions, newest first: the duplicates report's -- 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. -- here.
SELECT * FROM audit_log SELECT * FROM audit_log
WHERE action = ANY(sqlc.arg(actions)::text[]) 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). -- Every query in this file filters `tracks.missing_since IS NULL`. A row whose
-- A row whose file has vanished keeps its genre forever — the scanner walks the -- 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 -- filesystem, so it never revisits a path that no longer exists — and
-- 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
-- Browsing is a way of finding something to play, so a track that cannot play
-- should not shape it. -- should not shape it.
-- --
-- The year axis filters too (#2702). It used to join albums only, on the -- The year axis filters too, so the two axes agree: an album whose every file
-- reasoning that an album is a real release even when some of its tracks are -- has vanished is absent from both, rather than absent from its genre and
-- gone — true, but it left the two browse axes disagreeing: the genre queries -- still listed under its year, where opening it would find nothing playable.
-- 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.
-- --
-- Hiding is the answer rather than showing-and-marking because browsing is how -- Hiding rather than showing-and-marking, because browsing is how you go
-- you go looking for something to play, and the operator's rule for that case -- looking for something to play; the admin missing-files surface is where
-- is to take it out of view; the admin missing-files surface is where absence -- absence is reported.
-- is reported. It also means changing the axis that was inconsistent rather
-- than the one that was already right.
-- --
-- The predicate is "has at least one playable track", so it also excludes an -- 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: -- album with no tracks at all. That is the same answer for the same reason:
-- nothing to play, nothing to browse to. -- nothing to play, nothing to browse to.
-- name: ListGenresWithCount :many -- name: ListGenresWithCount :many
-- Genre browse index (#367). -- Genre browse index.
-- --
-- Genres live inline on tracks.genre as a delimited string, so this splits on -- 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 — -- 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 -- trim() but deliberately NO lower(): trimming repairs an artifact of OUR
-- splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre -- 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 -- 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 -- COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
-- inflate its own row. -- inflate its own row.
-- --
-- Ordered by count first: raw ID3 data has a long tail of one-off junk tags, -- 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 -- so alphabetical would bury the handful of genres a library actually has
-- library's worth of. Name breaks ties for a stable order. -- a lot of. Name breaks ties for a stable order.
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
FROM tracks FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
@@ -87,7 +81,7 @@ WHERE EXISTS (
); );
-- name: ListAlbumYearsWithCount :many -- 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 -- 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. -- from this axis, and the UI says so rather than inventing a 0 row.
-- Newest first: recent releases are the likelier browse target. -- Newest first: recent releases are the likelier browse target.
@@ -132,8 +126,7 @@ WHERE release_date IS NOT NULL
-- name: ListGenresForAlbum :many -- name: ListGenresForAlbum :many
-- Distinct genres carried by an album's tracks, for the album detail page's -- 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 -- quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a
-- chip always leads to a page that actually contains this album — the two -- chip always leads to a page that actually contains this album.
-- diverging is exactly the bug #367 had to fix in ListAlbumsByGenre.
SELECT DISTINCT trim(g.genre) AS genre SELECT DISTINCT trim(g.genre) AS genre
FROM tracks FROM tracks
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true 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 -- 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. -- 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(); updated_at = now();
-- name: ListCandidateArtistTagsForMbids :many -- 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). -- One row per (candidate, tag).
SELECT candidate_mbid, tag, weight SELECT candidate_mbid, tag, weight
FROM candidate_artist_tags 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 -- name: UpsertMBArtist :exec
INSERT INTO mb_artists (mbid, name, sort_name, disambiguation, artist_type, country) 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 -- single shared-library, multi-user server (no per-user library ACL), so the
-- "household" is the whole instance's user set. These queries recompute -- "household" is the whole instance's user set. These queries recompute
-- artist–artist co-occurrence edges from play_events and store them in -- 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 -- and the cover_art_sources_meta singleton. The SettingsService
-- consumes these. -- consumes these.
@@ -13,7 +13,7 @@ SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
-- name: UpsertProviderSettings :exec -- name: UpsertProviderSettings :exec
-- INSERT a default row for a newly-registered provider, or update an -- INSERT a default row for a newly-registered provider, or update an
-- existing row's display_order on boot reconciliation. Does NOT -- 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. -- persist across restarts.
INSERT INTO cover_art_provider_settings (provider_id, enabled, display_order) INSERT INTO cover_art_provider_settings (provider_id, enabled, display_order)
VALUES ($1, $2, $3) 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 -- 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' -- cover_art_source IS NULL (never tried), OR cover_art_source = 'none'
-- AND cover_art_sources_version != current (settled under an older -- AND cover_art_sources_version != current (settled under an older
-- source set; recheck against the current chain). LIMIT supplied by -- source set; recheck against the current chain). LIMIT supplied by
@@ -53,7 +53,7 @@ UPDATE albums
WHERE id = $1; WHERE id = $1;
-- name: ClearAlbumCoverNone :exec -- 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. -- back to NULL so the enricher's next batch retries the MBCAA fetch.
-- Idempotent — only updates rows currently stamped 'none'. -- Idempotent — only updates rows currently stamped 'none'.
UPDATE albums UPDATE albums
+14 -14
View File
@@ -29,7 +29,7 @@ dormant_artists AS (
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN dormant_artists da ON da.id = t.artist_id 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 ( AND NOT EXISTS (
SELECT 1 FROM play_events pe SELECT 1 FROM play_events pe
WHERE pe.user_id = $1 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 -- GROUP BY (not SELECT DISTINCT) so the md5 ORDER BY expression need
-- not appear in the select list — Postgres rejects DISTINCT + ORDER BY -- not appear in the select list — Postgres rejects DISTINCT + ORDER BY
-- by-expression at plan time (SQLSTATE 42P10), which previously caused -- by-expression at plan time (SQLSTATE 42P10), which would fail the
-- the entire Discover build to fail silently. -- entire Discover build.
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM general_likes gl FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id 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 gl.user_id != $1
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM play_events pe 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. -- $1 = user_id, $2 = date string for md5 ordering.
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t 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 ( AND NOT EXISTS (
SELECT 1 FROM play_events pe SELECT 1 FROM play_events pe
WHERE pe.user_id = $1 WHERE pe.user_id = $1
@@ -108,7 +108,7 @@ SELECT t.id, t.album_id, t.artist_id
-- name: ListTasteUnheardTracksForDiscover :many -- name: ListTasteUnheardTracksForDiscover :many
-- Taste-targeted novelty: unheard tracks whose genres overlap the user's -- 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. -- 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 -- Genres live inline on tracks.genre as a delimited string, split the
-- same way the radio tag_overlap arm does (regexp_split_to_table on -- same 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 -- when the user has no taste tags yet (cold start), so the caller
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'. -- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
-- --
-- The per-album and per-artist caps apply BEFORE the LIMIT (#5356). Summed -- The per-album and per-artist caps apply BEFORE the LIMIT. Summed weight
-- weight rewards a track for carrying many of the user's tags, so a few -- rewards a track for carrying many of the user's tags, so a few artists
-- artists whose every track is tagged with the whole profile take every row -- whose every track is tagged with the whole profile would take every row of
-- of a plain LIMIT; the caller's caps then left 6 of 120 on the deploy, and -- a plain LIMIT, the caller's caps would then leave a handful, and the arm
-- the best-performing arm handed its slots to the others. Ranking within -- would hand its slots to the others. Ranking within album, then within
-- album, then within artist over what the album cap kept, is the same walk -- artist over what the album cap kept, is the same walk capByAlbumAndArtist
-- capByAlbumAndArtist makes, so the caller's caps keep everything here. -- makes, so the caller's caps keep everything here.
WITH scored AS ( WITH scored AS (
SELECT t.id, t.album_id, t.artist_id, SELECT t.id, t.album_id, t.artist_id,
SUM(nt.weight) AS weight, SUM(nt.weight) AS weight,
@@ -130,7 +130,7 @@ WITH scored AS (
FROM tracks t FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true 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 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 nt.weight > 0
AND trim(g_split.g) <> '' AND trim(g_split.g) <> ''
AND NOT EXISTS ( 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 AND f.chromaprint IS NOT NULL
-- Only chromaprints taken at the current length: prints at two lengths are not -- 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 -- 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 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) AND (t.duration_ms, t.id) > (sqlc.arg(after_duration_ms)::integer, sqlc.arg(after_id)::uuid)
ORDER BY t.duration_ms, t.id ORDER BY t.duration_ms, t.id
LIMIT sqlc.arg(page_limit); LIMIT sqlc.arg(page_limit);
-- name: ListDismissedDuplicateMemberSets :many -- 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. -- 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 SELECT g.id, array_agg(m.track_id ORDER BY m.track_id)::uuid[] AS track_ids
FROM duplicate_groups g 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; AND (SELECT count(*) FROM duplicate_group_members m WHERE m.group_id = g.id) >= 2;
-- name: CountPendingDuplicateGroupsInView :one -- name: CountPendingDuplicateGroupsInView :one
-- 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, and groups not yet classified
-- cross_release the same song on different releases, which is kept, not merged -- cross_release the same song on different releases, which is kept, not merged
SELECT count(*)::bigint SELECT count(*)::bigint
@@ -130,7 +130,7 @@ SELECT count(*)::bigint
-- One page of proposals in one view (see CountPendingDuplicateGroupsInView), -- One page of proposals in one view (see CountPendingDuplicateGroupsInView),
-- newest first, flattened to one row per member so the handler folds them -- 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 -- 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. -- which copy to keep.
WITH page AS ( WITH page AS (
SELECT g.id, g.tier, g.worst_bit_error_rate, g.detected_at, g.class, g.resolve_note 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'; WHERE id = sqlc.arg(id) AND status = 'pending';
-- name: ListDuplicateGroupsForResolve :many -- 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, -- 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 -- 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 -- 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 -- 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 -- 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 -- 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. -- tags or the library listing's match; '' when neither names one.
-- acoustid_state and acoustid_recording are what AcoustID said of the file, -- 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 -- when it was looked up: which song a recording cut to several slots is.
-- is). group_id narrows it to one group, for the operator settling it (#6184); -- group_id narrows it to one group, for an admin settling it; NULL reads them
-- NULL reads them all. -- all.
SELECT g.id AS group_id, SELECT g.id AS group_id,
g.tier, g.tier,
g.worst_bit_error_rate, g.worst_bit_error_rate,
+2 -2
View File
@@ -26,7 +26,7 @@ LIMIT 1;
-- name: InsertPlayEvent :one -- name: InsertPlayEvent :one
-- pick_kind is non-NULL only for system-playlist plays whose track was -- 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 -- 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 ( INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind, user_id, track_id, session_id, started_at, client_id, source, pick_kind,
device_class device_class
@@ -38,7 +38,7 @@ RETURNING *;
-- Looks a track up in the user's CURRENT snapshot of the given system -- 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 -- variant and returns its pick_kind. Used at play-ingestion time to
-- freeze provenance onto the play_event — snapshots rebuild daily, so -- 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 -- 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 -- 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 -- 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 -- name: UpsertTrackFingerprint :exec
-- Written whenever a track's fingerprint is derived: by the scan when a file is -- 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 -- older method. Replaces the row wholesale — a fingerprint of the old bytes has
-- no standing once the file has changed. -- no standing once the file has changed.
INSERT INTO track_fingerprints ( INSERT INTO track_fingerprints (
@@ -23,7 +23,7 @@ ON CONFLICT (track_id) DO UPDATE SET
DELETE FROM track_fingerprints WHERE track_id = $1; DELETE FROM track_fingerprints WHERE track_id = $1;
-- name: ListTracksNeedingFingerprint :many -- 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 -- 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 -- 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 -- 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 LEFT JOIN track_fingerprints f ON f.track_id = t.id
WHERE t.missing_since IS NULL WHERE t.missing_since IS NULL
-- A row taken at another length is as stale as one from an older method: -- 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 AND (f.track_id IS NULL
OR f.fingerprint_version < sqlc.arg(current_version) OR f.fingerprint_version < sqlc.arg(current_version)
OR f.chromaprint_length_sec <> sqlc.arg(chromaprint_length_sec)) 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 -- Background garbage-collector / lifecycle queries: rows nothing else closes
-- drift findings from the 2026-06-02 audit (Scribe parent #552): -- or removes. Sweeper runs on a long tick (default 1h)
-- #565 #566 #567 #574 #575. Sweeper runs on a long tick (default 1h)
-- so per-query cost is amortised; each is idempotent (re-running on -- so per-query cost is amortised; each is idempotent (re-running on
-- already-closed/-deleted rows is a no-op). -- already-closed/-deleted rows is a no-op).
-- name: GcCloseStalePlayEvents :execrows -- 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 -- play_ended. Client crashed mid-track, network dropped, etc. We
-- synthesize ended_at = started_at + duration_played_ms when present, -- synthesize ended_at = started_at + duration_played_ms when present,
-- otherwise leave duration_played_ms null and stamp ended_at = now() -- 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'; AND started_at < now() - INTERVAL '24 hours';
-- name: GcClosePlaySessionsWithNoRecentEvents :execrows -- name: GcClosePlaySessionsWithNoRecentEvents :execrows
-- #565: play_sessions.ended_at was added but never populated by any -- play_sessions.ended_at has no other writer. Close sessions whose
-- writer. Close sessions whose last_event_at is older than 6h — -- last_event_at is older than 6h — treating that as "user moved on" the
-- treating that as "user moved on" the same way audio_service does -- same way audio_service does after grace periods. Sessions with NO events (track_count = 0)
-- after grace periods. Sessions with NO events (track_count = 0)
-- older than 1h are also closed (stale handshakes from clients that -- 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 -- never recorded a play). ended_at is set to last_event_at so the
-- session's duration reads naturally. -- session's duration reads naturally.
@@ -38,7 +36,7 @@ UPDATE play_sessions
); );
-- name: GcExpireScrobbleQueueFailedRows :execrows -- 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; -- more than 14 days. The worker stops retrying after maxAttempts;
-- failed rows accumulate forever otherwise. CASCADE from play_events -- failed rows accumulate forever otherwise. CASCADE from play_events
-- already drops the row when the underlying event is deleted, so this -- 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'; AND enqueued_at < now() - INTERVAL '14 days';
-- name: GcResetStuckSystemPlaylistRuns :execrows -- 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. -- goroutine panic between SET in_flight=true and SET in_flight=false.
-- The duplicate-prevention check refuses to start a fresh regen while -- The duplicate-prevention check refuses to start a fresh regen while
-- in_flight, so a stuck row blocks all future regens for that user. -- 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'; AND last_run_at < now() - INTERVAL '10 minutes';
-- name: GcDeleteExpiredPasswordResets :execrows -- 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 -- validation path already rejects them; this just keeps the table
-- from growing unbounded. Used rows are kept for 7 days for audit; -- from growing unbounded. Used rows are kept for 7 days for audit;
-- unused expired rows go immediately. -- unused expired rows go immediately.
+1 -1
View File
@@ -1,5 +1,5 @@
-- name: ListUserHistory :many -- 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 -- play_events entry, newest first, joined with tracks/albums/artists
-- so the SPA can render a TrackRef without follow-up fetches. Filters -- so the SPA can render a TrackRef without follow-up fetches. Filters
-- out skipped events and tracks the user has quarantined (per-user -- 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 -- name: ListLiveGrabs :many
-- Every download still running or waiting on its import, oldest first. -- Every download still running or waiting on its import, oldest first.
@@ -52,7 +52,7 @@ UPDATE grabs
import_forced = false, import_forced = false,
imported_at = CASE WHEN sqlc.arg(import_state)::text = 'imported' THEN now() ELSE imported_at END, 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 -- 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', seeding = sqlc.arg(import_state)::text = 'imported',
imported_files = sqlc.arg(imported_files), imported_files = sqlc.arg(imported_files),
destination = sqlc.arg(destination), 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 -- name: ListGrabs :many
-- The Wanted view's Activity tab: downloads waiting on a person first, then -- 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 -- 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, 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.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, 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); LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListUpgradeTracks :many -- 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. -- the want holds.
SELECT id, file_path, mbid, COALESCE(disc_number, 1)::int AS disc, track_number, SELECT id, file_path, mbid, COALESCE(disc_number, 1)::int AS disc, track_number,
file_format, file_size, duration_ms, source_verdict 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 -- name: ListImportedGrabsHolding :many
-- The downloads Minstrel imported into the folder holding path: the releases a -- 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 -- 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 SELECT id, info_hash, name, want_id
FROM grabs FROM grabs
WHERE import_state = 'imported' 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); VALUES ($1, $2, $3);
-- name: InsertLibraryChanges :exec -- name: InsertLibraryChanges :exec
-- Batch form, for a mutation that touches many rows at once (#2704: the scan's -- 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). -- reconcile pass can mark up to a quarter of a library missing in one go). A
-- Every caller before this one changed a single entity, so per-row inserts -- loop of single inserts would be thousands of round-trips inside a scan that
-- were the right shape; a loop here would be thousands of round-trips inside -- is already the slow path.
-- a scan that is already the slow path.
INSERT INTO library_changes (entity_type, entity_id, op) INSERT INTO library_changes (entity_type, entity_id, op)
SELECT sqlc.arg(entity_type)::text, SELECT sqlc.arg(entity_type)::text,
unnest(sqlc.arg(entity_ids)::text[]), unnest(sqlc.arg(entity_ids)::text[]),
+1 -1
View File
@@ -8,7 +8,7 @@ UPDATE library_settings
WHERE id = true WHERE id = true
RETURNING *; 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 -- 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 -- 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. -- 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. -- download up, and seeding after import.
-- name: RaiseIntervention :one -- name: RaiseIntervention :one
@@ -79,7 +79,7 @@ RETURNING *;
-- name: CountGoneGrabs :one -- name: CountGoneGrabs :one
-- How many of a release's grabs ended gone since a time: a release that keeps -- 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 SELECT count(*) FROM grabs
WHERE info_hash = sqlc.arg(info_hash) AND end_reason = 'gone' AND ended_at > sqlc.arg(since); 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 -- 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 -- song_key is liked. Returns the tracks newly liked, for the sync log; empty
-- when the song was already liked. -- when the song was already liked.
INSERT INTO general_likes (user_id, track_id) INSERT INTO general_likes (user_id, track_id)
+3 -3
View File
@@ -1,5 +1,5 @@
-- name: UpsertTrackLoudness :exec -- 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 -- wholesale: a measurement of the old bytes has no standing once the file has
-- changed. -- changed.
INSERT INTO track_loudness ( INSERT INTO track_loudness (
@@ -78,7 +78,7 @@ UPDATE loudness_settings
RETURNING *; RETURNING *;
-- name: ListAlbumsNeedingLoudness :many -- 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 -- 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 -- 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 -- 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 -- name: GetReplayGainByTrackIDs :many
-- Track and album loudness for a set of tracks, for every surface that hands -- 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. -- still delivered: it is a better gain than none until the backfill redoes it.
SELECT t.id, SELECT t.id,
tl.integrated_lufs AS track_lufs, 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 -- transaction library.MergeDuplicateGroup opens, after the removed copy's file
-- is already gone. The loser's own track row is deleted last with DeleteTrack; -- 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 -- 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 -- 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 -- path — is folded into that replacement, so its history moves across and
-- re-acquisition stops chasing music the library already has. -- 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 -- 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 -- 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. -- give it an MBID later; by then both rows exist and only a fold can join them.
-- name: ListMissingTrackPairs :many -- name: ListMissingTrackPairs :many
-- Every candidate (missing, present) pair, with how many candidates each side -- 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 -- mbid the same recording MBID, within the album group (the album row, or
-- albums sharing a release group). 142 of 359 pairs. -- albums sharing a release group).
-- title the same album row and the same title, ignoring case. 213 more. The -- title the same album row and the same title, ignoring case. The
-- replacements are often not the original file — another edition, -- replacements are often not the original file — another edition,
-- re-encoded, untagged — so the metadata is what is left to go on. -- 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 -- Deliberately NOT gated on duration or track position. Pairs known to be one
-- to be one recording, 18% differed by over 2s (old mp3 durations are poor -- recording often differ by over 2s (old mp3 durations are poor estimates),
-- estimates), and the poorly tagged set is exactly where numbering is broken. -- 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 -- 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 -- 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 -- internal/notifications.Notifier, which decides recipients and honours each
-- recipient's inbox preference before it reaches these. -- 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 -- 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. -- 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 -- created_at and emailed_at are left alone: an update changes what the
-- notice says, not when it arrived. Moving created_at made the phone announce -- notice says, not when it arrived. Moving created_at would make the phone
-- it again and clearing emailed_at sent it again, so a notice the hourly -- announce it again and clearing emailed_at would send it again, so a notice
-- duplicate pass kept growing alerted every hour. -- that keeps growing would alert on every update.
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at) 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), 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) 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 -- 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 -- 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 -- 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 UPDATE user_notifications
SET read_at = now() SET read_at = now()
WHERE user_id = sqlc.arg(user_id) WHERE user_id = sqlc.arg(user_id)
@@ -104,7 +104,7 @@ ON CONFLICT (user_id, kind) DO UPDATE SET
email = EXCLUDED.email, email = EXCLUDED.email,
updated_at = now(); updated_at = now();
-- Email digest (#5346) ------------------------------------------------------ -- Email digest ------------------------------------------------------
-- name: ListEmailPendingNotifications :many -- name: ListEmailPendingNotifications :many
-- Every unread, un-emailed row of a user who has an address, oldest first. -- 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 -- (track was removed from the library). The denormalized snapshot fields
-- on playlist_tracks remain authoritative for title/artist/album text. -- 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 -- 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 -- 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 -- 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 -- Inserts at the next available position. Snapshot fields are copied
-- from the tracks/albums/artists join at insert time. tracks.duration_ms -- 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 -- 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) INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT SELECT
sqlc.arg(playlist_id)::uuid, sqlc.arg(playlist_id)::uuid,
@@ -120,5 +120,5 @@ LIMIT $2;
-- name: GetPlaylistsByIDs :many -- name: GetPlaylistsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads -- 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[]); 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 -- 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 -- name: GetReacquisitionSettings :one
SELECT * FROM reacquisition_settings WHERE id = true; 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 -- * it still has at least one track whose file has been missing longer than
-- the grace window. Measured on missing_since, which reconcile never -- 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 -- * 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 -- 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 -- find the group, and an album matched to a group may carry no release
-- id, so their losses were never wanted back (#6167). Albums failing this -- id. Albums failing this are counted separately
-- are counted separately (CountAlbumsMissingWithoutMbid); -- (CountAlbumsMissingWithoutMbid);
-- * it is on the wanted list (#5567): an album taken off the list, or -- * it is on the wanted list: an album taken off the list, or
-- dropped into the library by hand, is not fetched back; -- dropped into the library by hand, is not fetched back;
-- * no want already covers it: none open, and none held since its files -- * 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 -- 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, SELECT albums.id AS album_id,
albums.title AS album_title, albums.title AS album_title,
albums.mbid AS album_mbid, 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 WHERE user_id = $1 AND track_id = t.id
) pe ON true ) pe ON true
WHERE t.id <> $2 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 ( AND NOT EXISTS (
SELECT 1 FROM play_events SELECT 1 FROM play_events
WHERE user_id = $1 AND track_id = t.id WHERE user_id = $1 AND track_id = t.id
@@ -36,25 +36,22 @@ WHERE t.id <> $2
); );
-- name: LoadRadioCandidatesV2 :many -- 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, -- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K, -- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill 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 -- positively-weighted taste-profile artists, so taste-relevant tracks
-- enter the pool even when the similarity/random arms miss them; scored -- enter the pool even when the similarity/random arms miss them; scored
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion), -- 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'), -- instance with the seed's artist; source='user_cooccurrence'),
-- $12 order_seed (text) — see below. -- $12 order_seed (text) — see below.
-- --
-- $12 REPLACES `ORDER BY random()` IN FOUR ARMS (#3889). Those arms returned -- $12 orders four arms by md5(id || seed), not random(). An arm whose LIMIT is
-- a stable set only while their LIMIT exceeded the rows eligible for them: at -- below the rows eligible for it returns a SUBSET, and with random() two
-- that point they returned all of them and the order stopped mattering, -- builds on the same day would draw different ones; the caller sorts by track
-- because the caller sorts by track id before scoring. Below that threshold -- id before scoring, so only the subset matters.
-- 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.
-- --
-- md5(id || seed) keeps the intent — an arbitrary spread that changes when -- 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 -- 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. -- keeps varying, which is what a radio should do.
-- Returns same shape as LoadRadioCandidates plus similarity_score column. -- 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 -- are stored relative to the seed's best match, 0 to 1, so the arms are placed
-- in bands rather than compared raw: -- in bands rather than compared raw:
-- tier 1 lb_similar 0.5 + 0.5 × edge (0.5, 1.0] -- 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 -- tag_overlap 0.5 × share of the seed's tags
-- likes_overlap 0.3, flat -- likes_overlap 0.3, flat
-- tier 3 taste_overlap, random_fill 0 -- tier 3 taste_overlap, random_fill 0
-- Without the bands, the seed's weaker LB edges fell below a shared genre tag -- Without the bands, the seed's weaker LB edges would fall below a shared
-- and the flat likes score, and the one arm that measures the seed lost to -- genre tag and the flat likes score, and the one arm that measures the seed
-- the ones that guess. -- would lose to the ones that guess.
WITH WITH
seed_artist AS ( seed_artist AS (
@@ -108,11 +105,10 @@ lb_similar AS (
LIMIT $5 LIMIT $5
), ),
similar_artists AS ( 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. -- track, best-scoring artist first, then each one's second, and so on.
-- Ordering by artist score alone let the closest similar artist's -- Ordering by artist score alone would let the closest similar artist's
-- catalogue fill the whole LIMIT; on the operator's library this arm held -- catalogue fill the whole LIMIT.
-- exactly one artist for all 17 seeds measured (#3879).
SELECT track_id, sim_score SELECT track_id, sim_score
FROM ( FROM (
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score, asim.score AS artist_score, 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 AND t.id <> $2
GROUP BY t.id GROUP BY t.id
HAVING count(DISTINCT trim(g_split.g)) > 0 HAVING count(DISTINCT trim(g_split.g)) > 0
-- md5 breaks the ties (#3880). A seed with one tag gives every track of -- md5 breaks the ties. A seed with one tag gives every track of that
-- that genre the same score, 7,804 tracks on the operator's library, and -- genre the same score, and with no tie-break the LIMIT would take
-- with no tie-break the LIMIT took whichever rows Postgres reached first: -- whichever rows Postgres reached first: the same slice for every seed of
-- the same slice for every seed of the genre, and not one the order seed -- the genre, and not one the order seed controls.
-- controls.
ORDER BY sim_score DESC, md5(t.id::text || $12::text) ORDER BY sim_score DESC, md5(t.id::text || $12::text)
LIMIT $7 LIMIT $7
), ),
@@ -161,12 +156,10 @@ likes_overlap AS (
LIMIT $8 LIMIT $8
), ),
taste_overlap AS ( taste_overlap AS (
-- Round-robin across the taste artists (#3880), as similar_artists is -- Round-robin across the taste artists, as similar_artists is: each
-- (#5297): each artist's first track, strongest artist first, then each -- artist's first track, strongest artist first, then each one's second.
-- one's second. Ordered by weight then track id, the strongest artist's -- Ordered by weight then track id, the strongest artist's catalogue would
-- catalogue filled the whole arm with the same lowest-id tracks every -- fill the whole arm with the same lowest-id tracks every time.
-- time; on the operator's library that was 20 tracks of one artist of
-- 174, whose catalogue is 197 tracks.
SELECT track_id, sim_score SELECT track_id, sim_score
FROM ( FROM (
SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight, SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight,
@@ -183,7 +176,7 @@ taste_overlap AS (
LIMIT $10 LIMIT $10
), ),
coplay_artists AS ( 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 -- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence -- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as -- 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 coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill UNION ALL SELECT track_id, sim_score FROM random_fill
) u ) 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 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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL ( 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 -- name: ListArtistContextPlayCountsForUser :many
-- Per-artist completed-play counts split by whether each play falls in the -- 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 -- CURRENT context cell, in the user's local timezone. The cell is
-- daypart × weekday-type, optionally narrowed by device class (#1551): when -- daypart × weekday-type, optionally narrowed by device class: when
-- $2 (the current device) is '', the device dimension is ignored (identical to -- $2 (the current device) is '', the device dimension is ignored (time only,
-- the time-only #1531 behaviour, used by the daily mixes which have no device); -- for the daily mixes, which have no device);
-- when $2 is set (radio), a play only counts in the cell if its device_class -- 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 -- matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
-- window bounds cost. Daypart buckets: night [22,5) morning [5,12) -- window bounds cost. Daypart buckets: night [22,5) morning [5,12)
@@ -308,7 +301,7 @@ FROM plays p
GROUP BY p.artist_id; GROUP BY p.artist_id;
-- name: GetLatestPlayDeviceClassForUser :one -- 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 -- "current device" for radio context conditioning. NULL when the latest play
-- predates device capture or came from a client that didn't send one. -- predates device capture or came from a client that didn't send one.
SELECT device_class SELECT device_class
@@ -321,24 +314,24 @@ LIMIT 1;
-- Per-user artist suggestions ranked by taste signal x similarity, projected -- Per-user artist suggestions ranked by taste signal x similarity, projected
-- through artist_similarity_unmatched (out-of-library candidates only). -- 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 -- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- SIGNED by internal/taste, so an artist the user has drifted away -- SIGNED by internal/taste, so an artist the user has drifted away
-- from stops contributing instead of accumulating forever. -- from stops contributing instead of accumulating forever.
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows -- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute). -- (new account, or before the first daily recompute).
-- --
-- The signal is log-damped: contribution is signal x similarity, and the old -- The signal is log-damped: contribution is signal x similarity, and an
-- undamped sum let one heavily-played artist's neighbours take every slot -- -- undamped sum would let one heavily-played artist's neighbours take every
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2). -- slot -- entrenching harder the MORE the user listened.
-- --
-- Candidates already in the library, already requested and not terminal, or -- 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 -- snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
-- decay), $3=limit. -- decay), $3=limit.
WITH artist_plays AS ( WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event, -- Completed plays only: counting every play_event would make skipping an
-- so skipping an artist repeatedly INCREASED its signal and pushed more of -- artist repeatedly INCREASE its signal and push more of its neighbours
-- its neighbours at the user (issue #2367 mechanism 3). -- at the user.
SELECT t.artist_id, count(*)::bigint AS play_count SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe FROM play_events pe
JOIN tracks t ON t.id = pe.track_id JOIN tracks t ON t.id = pe.track_id
@@ -395,7 +388,7 @@ contributions AS (
AND r.artist_mbid = u.candidate_mbid AND r.artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed') 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 -- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately -- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a -- filtered at the candidate stage, NOT folded into the score — a
@@ -421,7 +414,7 @@ ORDER BY total_score DESC
LIMIT $3; LIMIT $3;
-- name: ListMostPlayedTracksForUser :many -- 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. -- excludes skips so a user spamming next doesn't fabricate a top track.
-- Quarantined tracks (per-user soft-hide from M5b) are filtered out. -- 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 tracks t ON t.id = p.track_id
JOIN albums ON albums.id = t.album_id JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id 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 albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = sqlc.arg(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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id 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); LIMIT sqlc.arg(result_limit);
-- name: ListLastPlayedArtistsForUser :many -- 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 -- 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 -- recent album that has cover_art_path set). album_count joined for the
-- ArtistRef wire shape. -- 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 -- 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 -- 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 — -- a recommendation surface; NULL means the user picked the track manually —
-- those rows are INCLUDED here as the baseline control group the surfaces are -- 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 -- judged against (delta-vs-baseline is what makes the numbers actionable).
-- actionable). Raw source strings are bucketed into stable surface families in -- Raw source strings are bucketed into stable surface families in the Go
-- the Go handler; completion_n is carried so family merges can weight -- handler; completion_n is carried so family merges can weight
-- avg_completion correctly. -- avg_completion correctly.
-- name: RecommendationWeeklyTrends :many -- name: RecommendationWeeklyTrends :many
-- Weekly per-source outcome series for the tuning lab's trend view -- 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 -- so judging a knob turn needs global outcomes — rows carry rates
-- only, no track or user identity. NULL-source (manual) rows are -- only, no track or user identity. NULL-source (manual) rows are
-- included as the baseline family. -- included as the baseline family.
@@ -42,12 +42,12 @@ ORDER BY 1, 2;
-- name: RecommendationSourceMetricsForUser :many -- name: RecommendationSourceMetricsForUser :many
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the -- $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. -- 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. -- 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 -- 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 -- 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 -- raw source rows get merged into surface families in Go, and sums of squares
-- add across groups exactly, whereas standard deviations cannot be combined -- add across groups exactly, whereas standard deviations cannot be combined
-- without them. Variance = (sqsum - sum²/n) / (n-1), with sum = avg × n. -- 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. -- recsettings boot reconcile; shipped defaults live in Go only.
-- name: UpsertWeightProfileDefaults :exec -- name: UpsertWeightProfileDefaults :exec
-- Boot reconcile: insert the shipped defaults for a profile if the row -- 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 ( INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty, profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight, jitter_magnitude, context_weight, similarity_weight, taste_weight,
@@ -54,8 +54,8 @@ UPDATE taste_tuning
RETURNING *; RETURNING *;
-- name: UpsertDiscoverTuningDefaults :exec -- name: UpsertDiscoverTuningDefaults :exec
-- Boot reconcile for the Discover scope (#2377). Never overwrites -- Boot reconcile for the Discover scope. Never overwrites
-- operator-tuned values, same contract as the other two. -- admin-tuned values, same contract as the other two.
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days) INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, $1, $2) VALUES (true, $1, $2)
ON CONFLICT (singleton) DO NOTHING; ON CONFLICT (singleton) DO NOTHING;
@@ -77,7 +77,7 @@ INSERT INTO recommendation_tuning_audit (scope, action, changes)
VALUES ($1, $2, $3); VALUES ($1, $2, $3);
-- name: ListTuningAudit :many -- 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. -- knob turns on the timeline.
SELECT id, changed_at, scope, action, changes SELECT id, changed_at, scope, action, changes
FROM recommendation_tuning_audit FROM recommendation_tuning_audit
+1 -1
View File
@@ -111,7 +111,7 @@ SELECT * FROM requests
LIMIT 1; LIMIT 1;
-- name: RepointRequestsAlbumMbid :exec -- 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. -- that name it along, so the reconciler finds it.
UPDATE requests UPDATE requests
SET album_mbid = sqlc.arg(new_mbid), 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 -- name: InsertScanRun :one
-- Creates a new scan_runs row with started_at=now(). Stages are filled in -- 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; LIMIT 1;
-- name: UpdateScanRunArtistArtEnrich :exec -- 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 UPDATE scan_runs
SET artist_art_enrich = $2 SET artist_art_enrich = $2
WHERE id = $1; 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 -- name: GetAcquisitionSettings :one
SELECT * FROM acquisition_settings WHERE id = true; 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 -- Wants with no download running that were never searched or not since
-- @hours ago: open wants first (never-searched, then requests, then missing -- @hours ago: open wants first (never-searched, then requests, then missing
-- files, then followed artists' releases), then held albums wanted for an -- 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, 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(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types, COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
@@ -120,7 +120,7 @@ UPDATE grabs
-- name: ListReleaseBlocks :many -- name: ListReleaseBlocks :many
-- Every release set aside and still held back: a stalled one lifts by itself -- 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 SELECT * FROM release_blocks
WHERE expires_at IS NULL OR expires_at > now() WHERE expires_at IS NULL OR expires_at > now()
ORDER BY blocked_at DESC; 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; DELETE FROM sessions WHERE token_hash = $1;
-- name: DeleteSessionForUser :execrows -- 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. -- 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 -- execrows lets the handler answer 404 rather than a false 204 when the row
-- isn't theirs. -- isn't theirs.
+5 -5
View File
@@ -3,8 +3,8 @@
-- ListenBrainz has never answered for, or not in 30 days. Never-fetched first, -- 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 -- 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 -- 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 -- counts as fetched; reading the edges would let such seeds hold the head of
-- the head of the queue forever. -- the queue forever.
SELECT t.id, t.mbid SELECT t.id, t.mbid
FROM tracks t FROM tracks t
LEFT JOIN track_similarity_fetches f ON f.track_id = t.id 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 -- 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 -- 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 -- below it. LB's raw scores are counts, into the thousands, and the scorer
-- library) and reached the scorer, which reads similarity as 0 to 1, so one -- reads similarity as 0 to 1, so a raw edge would outweigh likes, skips and
-- edge outweighed likes, skips and recency together. The cache keeps the raw -- recency together. The cache keeps the raw
-- answer; only the edge is scaled. -- answer; only the edge is scaled.
-- --
-- Only edges of seeds the cache owns are removed, those with a fetch row. -- 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). -- song_key (migration 0076).
-- name: LinkTracksAsSong :one -- name: LinkTracksAsSong :one
@@ -41,7 +41,7 @@ UPDATE tracks SET song_id = NULL
-- name: ListTrackSongKeys :many -- name: ListTrackSongKeys :many
-- What the mix writer needs to place a track: its song, and whether it is a -- 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 SELECT id, song_key, (source_verdict IS NOT DISTINCT FROM 'suspect')::boolean AS held_back
FROM tracks WHERE id = ANY(sqlc.arg(ids)::uuid[]); 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 -- 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 -- 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 -- 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- -- producer caps + truncates and converts to rankedCandidate. Daily-
-- deterministic md5(id||dateStr) ordering (where used) keeps a mix -- deterministic md5(id||dateStr) ordering (where used) keeps a mix
-- stable within a day while rotating across days; shuffle-on-play -- stable within a day while rotating across days; shuffle-on-play
-- gives per-play variety on top. -- gives per-play variety on top.
-- name: ListDeepCutsTracks :many -- 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). -- has liked OR played heavily (>=5 non-skip plays across the artist).
-- Tracks the user has skipped twice or more don't qualify (#1257): -- Tracks the user has skipped twice or more don't qualify: counting only
-- eligibility used to count only unskipped plays, so the most actively -- unskipped plays would make the most actively rejected tracks read as
-- rejected tracks read as "barely heard" and kept being re-offered. -- "barely heard" and keep re-offering them. Threshold 2 so a single
-- Threshold 2 so a single accidental skip doesn't banish a track; -- accidental skip doesn't banish a track; passive signal only (there is no
-- passive-signal only (no dislike UI, rule #101). -- dislike button).
-- $1 user_id, $2 date string. -- $1 user_id, $2 date string.
WITH affinity_artists AS ( WITH affinity_artists AS (
SELECT artist_id FROM general_likes_artists WHERE user_id = $1 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 JOIN affinity_artists aa ON aa.artist_id = t.artist_id
LEFT JOIN play_counts pc ON pc.track_id = t.id LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.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(pc.c, 0) <= 2
AND COALESCE(sc.c, 0) < 2 AND COALESCE(sc.c, 0) < 2
AND NOT EXISTS ( AND NOT EXISTS (
@@ -51,11 +51,10 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200; LIMIT 200;
-- name: ListRediscoverTracks :many -- name: ListRediscoverTracks :many
-- #420 Rediscover: tracks the user played a lot but has drifted away -- Rediscover: tracks the user played a lot but has drifted away
-- from. One blended pool (issue #1246: the old two-tier UNION was -- from. One blended pool, not a two-tier UNION: the ">=6 months cold" tier is
-- all-or-nothing — a single ">=6 months cold" row suppressed the whole -- a strict subset of the ">=30 days" one, and a UNION that let one row of it
-- ">=30 days" tier, which is how a one-song playlist shipped; the 6mo -- suppress the wider tier would ship a one-song playlist. Eligibility is >=3
-- tier was also a strict subset of the 30d tier). Eligibility is >=3
-- non-skip plays and >=30 days cold; the bar sits at 3 rather than 5 -- 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 -- so a weeks-old library still fields a pool — on young histories the
-- >=5-play tracks are precisely the ones still in rotation. Ordering -- >=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 SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN stats s ON s.track_id = t.id 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.c >= 3
AND s.last_at <= now() - interval '30 days' AND s.last_at <= now() - interval '30 days'
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id 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 -- rediscover-sourced play is the user explicitly saying "I've moved
-- on" to the exact invitation this mix extends — such tracks sit -- on" to the exact invitation this mix extends — such tracks sit
-- out ~90 days instead of re-qualifying the next day. Passive -- out ~90 days instead of re-qualifying the next day. Passive
-- signal only (rule #101). -- signal only.
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM play_events rp SELECT 1 FROM play_events rp
WHERE rp.user_id = $1 WHERE rp.user_id = $1
@@ -97,14 +96,13 @@ SELECT t.id, t.album_id, t.artist_id
LIMIT 200; LIMIT 200;
-- name: ListNewForYouTracks :many -- name: ListNewForYouTracks :many
-- #421 New for you, tiered per project rule #131 (#1267). The old -- New for you, tiered so the mix degrades rather than vanishes. A
-- single hard rule (added <30d AND direct affinity) had two failure -- single hard rule (added <30d AND direct affinity) has no notion of
-- modes with one root: no notion of consumption — the album you -- consumption: the album you devoured in week one would crowd the mix
-- devoured in week one crowded the mix for three more weeks, and -- for three more weeks, and after a quiet month nothing would qualify.
-- after a quiet month nothing qualified at all.
-- --
-- "Consumed" = any track attempted for >=30s (the First Listens -- "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 -- no longer news. Applies to every tier — played albums leave the
-- mix the next build. -- 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 SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
FROM tracks t FROM tracks t
JOIN albums_tiered alt ON alt.album_id = t.album_id 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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id 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; LIMIT 300;
-- name: ListOnThisDayTracks :many -- 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 -- (±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 -- 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 -- they were played in those windows. The 30-day floor and ±10 window
-- and window ±7→±10 so it surfaces on a months-old library instead -- let it surface on a months-old library rather than needing a full
-- of needing a full year of history; still skips cleanly (no rows → -- year of history; it still skips cleanly (no rows → no playlist)
-- no playlist) when there's no qualifying history yet. -- when there's no qualifying history yet.
-- Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs -- Day-of-year distance is circular: plain ABS would read Dec 28 vs
-- Jan 3 read as 359 days apart, silently gutting the window for ~3 -- Jan 3 as 359 days apart, gutting the window for ~3 weeks around
-- weeks around every New Year — precisely when holiday nostalgia is -- every New Year — precisely when holiday nostalgia is the point.
-- the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of -- LEAST(d, 365-d) wraps the boundary; leap-year drift of ±1 is absorbed
-- ±1 is absorbed by the ±10 window. The build date ($2, already here -- by the ±10 window. The build date ($2, already here
-- for the md5 rotation) anchors "today" instead of now() so the -- for the md5 rotation) anchors "today" instead of now() so the
-- window is testable and consistent with the mix's daily determinism. -- window is testable and consistent with the mix's daily determinism.
-- $1 user_id, $2 date string. -- $1 user_id, $2 date string.
@@ -190,7 +188,7 @@ WITH windowed AS (
SELECT t.id, t.album_id, t.artist_id SELECT t.id, t.album_id, t.artist_id
FROM tracks t FROM tracks t
JOIN windowed w ON w.track_id = t.id 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 ( AND NOT EXISTS (
SELECT 1 FROM quarantine q SELECT 1 FROM quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id 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; LIMIT 200;
-- name: ListFirstListensTracks :many -- name: ListFirstListensTracks :many
-- #423 First Listens: songs the user has never heard or even -- First Listens: songs the user has never heard or even
-- attempted. "Attempted" is track-level with a >=30s listen threshold -- attempted. "Attempted" is track-level with a >=30s listen threshold:
-- (#1268, operator decision 2026-07-03) — the old version disqualified -- disqualifying a whole album on ANY play_event would let a 2-second
-- a whole album on ANY play_event, so a 2-second accidental brush -- accidental brush banish it permanently.
-- banished it permanently.
-- --
-- tier 1 the exact desire: albums with ZERO attempted tracks -- tier 1 the exact desire: albums with ZERO attempted tracks
-- tier 2 step back: barely-attempted albums (<=25% of 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 FROM tracks t
JOIN albums al ON al.id = t.album_id JOIN albums al ON al.id = t.album_id
JOIN albums_tiered alt ON alt.album_id = al.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 alt.tier IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id) AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
AND NOT EXISTS ( 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 -- One row per (user, playlist_kind). played_track_ids accumulates
-- tracks heard from that playlist in the current rotation; shuffle -- tracks heard from that playlist in the current rotation; shuffle
-- prefers ids NOT in this set, then resets when the snapshot is -- 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 -- seed or For-You candidate has to be something that can actually play. Note
-- this only affects newly GENERATED playlists — already-stored system -- this only affects newly GENERATED playlists — already-stored system
-- playlists keep their rows until the next daily rebuild, which is why the -- 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 -- also serves user-curated playlists, where hiding a track the user added
-- themselves would be wrong). -- themselves would be wrong).
-- M7 #352 slice 2: system-generated playlist queries. -- System-generated playlist queries.
-- name: ListActiveUsersForSystemPlaylists :many -- name: ListActiveUsersForSystemPlaylists :many
-- Active = had a play in the last 7 days. The cron iterates this list. -- 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 -- name: PickSeedArtists :many
-- Top-12 most-engaged distinct artist candidates, tiered so the -- Top-12 most-engaged distinct artist candidates, tiered so the
-- "Songs like X" mixes never silently vanish (#1255): the old hard -- "Songs like X" mixes never silently vanish: a hard 7-day window
-- 7-day window emptied the seed pool after a quiet week, and the -- would empty the seed pool after a quiet week, and the daily
-- daily atomic-replace build then deleted every existing mix until -- atomic-replace build would then delete every existing mix until the
-- the user played something again. Same fallback shape as -- user played something again. Same fallback shape as
-- PickTopPlayedTracksForUser (For You's seeds): -- PickTopPlayedTracksForUser (For You's seeds):
-- tier 0 engagement in the last 7 days -- tier 0 engagement in the last 7 days
-- tier 1 (only if tier 0 empty) last 30 days -- tier 1 (only if tier 0 empty) last 30 days
-- tier 2 (only if tiers 0+1 empty) all-time -- tier 2 (only if tiers 0+1 empty) all-time
-- tier 3 (only if 0-2 empty) liked artists with no play history -- tier 3 (only if 0-2 empty) liked artists with no play history
-- All returned rows share one tier; produceSeedMixes maps it onto the -- 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. -- can compare mixes seeded from fresh vs stale engagement.
-- Score = unskipped-play count + 5 if user has liked the artist. The -- Score = unskipped-play count + 5 if user has liked the artist. The
-- Go-side picker (pickSeedArtistsForDay) shuffles the 12 -- Go-side picker (pickSeedArtistsForDay) shuffles the 12
-- daily-deterministically and takes songsLikeSeedCount (6) so the mix -- 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 ( WITH liked AS (
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1 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 -- 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 -- 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. -- join.
liked AS ( liked AS (
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
@@ -206,7 +206,7 @@ SELECT COALESCE(
FROM tracks t FROM tracks t
JOIN albums a ON a.id = t.album_id JOIN albums a ON a.id = t.album_id
WHERE t.artist_id = $2 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, ORDER BY a.release_date DESC NULLS LAST,
t.disc_number NULLS LAST, t.disc_number NULLS LAST,
t.track_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 -- The tags.SettingsService consumes these — parallel to the cover-art
-- SettingsService (coverart_settings.sql). Keeping the two independent -- SettingsService (coverart_settings.sql). Keeping the two independent
-- means a new tag source is added without touching the art settings. -- 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 -- name: UpsertTagProviderSettings :exec
-- INSERT a default row for a newly-registered provider, or refresh an -- INSERT a default row for a newly-registered provider, or refresh an
-- existing row's display_order on boot. Does NOT overwrite enabled / -- 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) INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (provider_id) DO UPDATE 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 -- 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 -- name: ListPlayEngagementInputsForUser :many
-- One row per play in the decay-relevant window. completion is the play's -- One row per play in the decay-relevant window. completion is the play's
-- completion ratio (precomputed column when present, else duration_played / -- completion ratio (precomputed column when present, else duration_played /
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre -- 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 -- 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 SELECT
t.id AS track_id, t.id AS track_id,
t.artist_id, t.artist_id,
@@ -31,7 +31,7 @@ WHERE pe.user_id = $1
-- name: ListLikedTrackTasteInputsForUser :many -- name: ListLikedTrackTasteInputsForUser :many
-- (track_id, artist_id, genre, release_date) for each track the user has -- (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, -- 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 SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
FROM general_likes gl FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id 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); VALUES ($1, $2, $3);
-- name: ListTasteProfileArtistsForUser :many -- name: ListTasteProfileArtistsForUser :many
-- Top-weighted taste artists (phase-2 consumption + tests). -- Top-weighted taste artists, for the scorer and tests.
SELECT artist_id, weight SELECT artist_id, weight
FROM taste_profile_artists FROM taste_profile_artists
WHERE user_id = $1 WHERE user_id = $1
@@ -79,7 +79,7 @@ INSERT INTO taste_profile_eras (user_id, era, weight)
VALUES ($1, $2, $3); VALUES ($1, $2, $3);
-- name: ListTasteProfileErasForUser :many -- 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 SELECT era, weight
FROM taste_profile_eras FROM taste_profile_eras
WHERE user_id = $1 WHERE user_id = $1
@@ -94,7 +94,7 @@ INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3); VALUES ($1, $2, $3);
-- name: ListTasteProfileMoodsForUser :many -- 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 SELECT mood, weight
FROM taste_profile_moods FROM taste_profile_moods
WHERE user_id = $1 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 -- drains ListTracksMissingTags, fetches from the provider chain, and
-- writes the merged top-K via DeleteTrackTags + InsertTrackTag, then -- writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
-- stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape. -- stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
@@ -8,7 +8,7 @@
-- or previously settled 'none' under an older provider version. Returns -- or previously settled 'none' under an older provider version. Returns
-- the fields the provider chain needs — recording MBID (nullable) for -- the fields the provider chain needs — recording MBID (nullable) for
-- keyed lookups, title + artist name for name-based fallback, and the -- 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. -- $1 = current tag_sources_version, $2 = limit.
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
FROM tracks t FROM tracks t
@@ -58,7 +58,7 @@ SELECT tt.track_id, tt.tag, tt.weight
WHERE gl.user_id = $1; WHERE gl.user_id = $1;
-- name: ListTrackTagsForTracks :many -- 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, -- 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. -- tag); the caller filters to mood words via the internal/mood vocabulary.
SELECT tt.track_id, tt.tag SELECT tt.track_id, tt.tag
+28 -29
View File
@@ -1,7 +1,7 @@
-- name: UpsertTrack :one -- name: UpsertTrack :one
-- file_path is the canonical identity for library scan; mbid is secondary. -- 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) -- 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 (D4). -- survives a re-read that finds no tag id, and a tag id replaces it.
INSERT INTO tracks ( INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number, title, album_id, artist_id, track_number, disc_number,
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, 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, WHEN tracks.mbid_source = 'acoustid' THEN 'acoustid' END,
genre = EXCLUDED.genre, genre = EXCLUDED.genre,
-- Stamped on update too, so a tag-repair pass marks rows as done and the -- 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, tag_read_version = EXCLUDED.tag_read_version,
updated_at = now() updated_at = now()
RETURNING *; RETURNING *;
@@ -34,7 +34,7 @@ RETURNING *;
-- Track recording-MBID backfill: tracks with NULL mbid that still have -- Track recording-MBID backfill: tracks with NULL mbid that still have
-- a file to re-read. $1 caps the batch (mirrors the album backfill). -- 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 -- 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 SELECT id, file_path
FROM tracks FROM tracks
WHERE mbid IS NULL WHERE mbid IS NULL
@@ -92,7 +92,7 @@ WHERE title ILIKE '%' || $1::text || '%'
); );
-- name: ListArtistTracksForUser :many -- 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 -- and artist_name joined. Honors per-user quarantine. Used by
-- /api/artists/{id}/tracks for the artist-card play affordance, which -- /api/artists/{id}/tracks for the artist-card play affordance, which
-- shuffles client-side. Ordering matches album/track natural order so -- 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; t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id;
-- name: ListRandomTracksForUser :many -- 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 -- source for "Shuffle all". N random tracks across the whole
-- library, per-user-quarantine filtered. $1 user_id, $2 limit. -- library, per-user-quarantine filtered. $1 user_id, $2 limit.
SELECT sqlc.embed(t), SELECT sqlc.embed(t),
@@ -135,7 +135,7 @@ LIMIT $2;
SELECT COUNT(*) FROM tracks WHERE artist_id = $1; SELECT COUNT(*) FROM tracks WHERE artist_id = $1;
-- name: DeleteTrack :one -- 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 / -- play_events / general_likes_tracks / quarantine /
-- quarantine_actions handles their cleanup. RETURNING gives us -- quarantine_actions handles their cleanup. RETURNING gives us
-- album_id + artist_id for the album-empty / artist-empty cascade -- 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 -- name: GetTracksByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads -- 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[]); SELECT * FROM tracks WHERE id = ANY($1::uuid[]);
-- name: FindMissingTrackByMbid :many -- 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, -- carrying a recording MBID we already have on a MISSING row is that recording,
-- moved — not a new track. -- moved — not a new track.
-- --
@@ -166,11 +166,11 @@ SELECT id, file_path FROM tracks
LIMIT 2; LIMIT 2;
-- name: FindMissingTrackByAudioHash :many -- 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 -- 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), -- retag — anything short of a re-encode. (file_size, duration_ms) would pair two
-- which could pair two unrelated files that happened to share a byte count and a -- unrelated files that share a byte count and a duration, and miss a file
-- duration, and missed a file retagged in place, whose size changes. -- retagged in place, whose size changes.
-- --
-- Same missing-only constraint and same LIMIT 2 rationale as the MBID variant. -- Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
SELECT t.id, t.file_path SELECT t.id, t.file_path
@@ -196,7 +196,7 @@ UPDATE tracks
-- name: ListTrackPathsForReconcile :many -- name: ListTrackPathsForReconcile :many
-- Every row's path + current missing mark, for the scanner's reconcile pass -- 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 -- 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 -- outside it drift forever. Three narrow columns keep it cheap even on a
-- library of a few hundred thousand tracks. -- library of a few hundred thousand tracks.
@@ -217,10 +217,10 @@ UPDATE tracks
AND missing_since IS NULL; AND missing_since IS NULL;
-- name: ClearTrackMbids :exec -- name: ClearTrackMbids :exec
-- Forgets the recording MBID of rows whose file was another song's (M498's -- Forgets the recording MBID of rows whose file was another song's (the
-- wrong-slot removal). The MBID came from that file, so it names the song kept -- duplicate resolver's wrong-slot removal). The MBID came from that file, so it
-- elsewhere on the album, and missing-pair folding would fold the emptied slot -- names the song kept elsewhere on the album, and missing-pair folding would
-- into it: twelve slots went that way on 2026-10-20 and were never wanted back. -- fold the emptied slot into it, so it would never be wanted back.
-- mbid_source goes with it (tracks_mbid_source_pair). -- mbid_source goes with it (tracks_mbid_source_pair).
UPDATE tracks UPDATE tracks
SET mbid = NULL, mbid_source = NULL SET mbid = NULL, mbid_source = NULL
@@ -245,19 +245,18 @@ UPDATE tracks
AND missing_since IS NOT NULL; AND missing_since IS NOT NULL;
-- name: ListMissingTracks :many -- 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, -- 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 -- because the unit an admin actually reasons about is a FOLDER: a reorganised
-- this was built for was three whole albums that had been reorganised, and a -- album, listed flat by timestamp, reads as dozens of unrelated decisions.
-- flat list ordered by timestamp presents that as forty unrelated decisions.
-- Grouping happens in the handler; the ordering here is what makes a group -- Grouping happens in the handler; the ordering here is what makes a group
-- contiguous, so a page boundary splits a directory at worst. -- 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 -- 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, -- plays stays in the result with NULL. It is here because "gone six months,
-- never played" and "gone yesterday, played 200 times" deserve opposite -- 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, SELECT t.id,
t.title, t.title,
t.file_path, t.file_path,
@@ -285,7 +284,7 @@ SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL;
-- name: ListSuspectSourceTracks :many -- name: ListSuspectSourceTracks :many
-- The admin report of present tracks whose filename looks like a video rip -- 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 -- 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 -- 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,
t.source_verdict_at, t.source_verdict_at,
-- The album's place on the wanted list: wanted for a clean copy, and -- 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, COALESCE(w.upgrade, '')::text AS want_upgrade,
w.searched_at AS searched_at w.searched_at AS searched_at
FROM tracks t FROM tracks t
@@ -330,8 +329,8 @@ SELECT COUNT(*) FROM tracks t
AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text; AND regexp_replace(t.file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: FlagSuspectSourceTracks :execrows -- name: FlagSuspectSourceTracks :execrows
-- Holds back present tracks whose basename carries a video-rip marker (M498 -- Holds back present tracks whose basename carries a video-rip marker, the
-- #5439), the same pattern the report lists. A track the operator called fine -- same pattern the report lists. A track an admin called fine
-- keeps that verdict. -- keeps that verdict.
UPDATE tracks UPDATE tracks
SET source_verdict = 'suspect', source_verdict_at = now() SET source_verdict = 'suspect', source_verdict_at = now()
@@ -348,7 +347,7 @@ UPDATE tracks
AND NOT regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text; AND NOT regexp_replace(file_path, '^.*/', '') ~* sqlc.arg(pattern)::text;
-- name: SetTrackSourceVerdict :execrows -- 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. -- the mixes for good; 'suspect' holds it back again.
UPDATE tracks UPDATE tracks
SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now() SET source_verdict = sqlc.arg(verdict)::text, source_verdict_at = now()
@@ -357,7 +356,7 @@ UPDATE tracks
-- name: ListStillMissingTrackLabels :many -- name: ListStillMissingTrackLabels :many
-- Which of the tracks a scan marked missing still are once it has adopted -- 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. -- about, by name. A quality upgrade folds into its replacement and drops out.
SELECT ar.name AS artist_name, t.title SELECT ar.name AS artist_name, t.title
FROM tracks t FROM tracks t
+1 -1
View File
@@ -37,7 +37,7 @@ SELECT count(*) FROM users;
-- name: SetSubsonicPassword :exec -- name: SetSubsonicPassword :exec
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used -- 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 -- 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; UPDATE users SET subsonic_password = $2 WHERE id = $1;
-- name: GetUserByID :one -- 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 -- name: ListReleaseTypeProfiles :many
SELECT p.*, 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 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, 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 -- 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 -- 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 -- on disc 1, so a missing track 6 with no disc beside a present 1/6
-- nothing, over 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 ( COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM tracks p SELECT 1 FROM tracks p
WHERE p.album_id = a.id AND p.missing_since IS NULL 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; WHERE id = sqlc.arg(id) AND fulfilled_at IS NULL;
-- name: RepointWant :exec -- 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; UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1;
-- name: DeleteWant :execrows -- name: DeleteWant :execrows
@@ -180,13 +180,13 @@ DELETE FROM wants WHERE id = $1;
-- name: ListWants :many -- name: ListWants :many
-- The Wanted page: open wants (all but missing ones, or only missing ones), -- 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, -- 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 -- 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 -- 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. -- 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, 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, 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, w.upgrade, w.held_quality,
-- A request names an album before the catalogue has read it: the -- A request names an album before the catalogue has read it: the
-- catalogue's type and date fill in once it has. -- 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, u.username AS requested_by,
la.id AS library_artist_id, la.id AS library_artist_id,
COUNT(*) OVER () AS total, 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, 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 -- 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.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.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, 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' ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
END END
AND (sqlc.arg(artist_mbid)::text = '' OR w.artist_mbid = sqlc.arg(artist_mbid)::text) 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 (COALESCE(cardinality(sqlc.arg(want_ids)::uuid[]), 0) = 0 OR w.id = ANY(sqlc.arg(want_ids)::uuid[]))
AND (sqlc.arg(q)::text = '' AND (sqlc.arg(q)::text = ''
OR strpos(lower(w.artist_name), lower(sqlc.arg(q)::text)) > 0 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); LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: ListWantArtists :many -- 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 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; -- 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. -- 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); LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
-- name: GetWantAlbum :one -- 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; SELECT id, release_group_mbid, album_id FROM wants WHERE id = $1;
-- name: ListLibraryAlbumTracks :many -- 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, 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 NULL AND reason = 'missing')::bigint AS missing,
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held, 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(*) FILTER (WHERE fulfilled_at IS NOT NULL AND upgrade <> '')::bigint AS upgrades,
COUNT(DISTINCT artist_mbid)::bigint AS artists, 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 (SELECT COUNT(*) FROM library_album_matches m
WHERE m.outcome = 'unmatched' WHERE m.outcome = 'unmatched'
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = m.album_id))::bigint AS 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, (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. -- waiting on a person, and releases set aside.
(SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity, (SELECT COUNT(*) FROM grabs WHERE ended_at IS NULL)::bigint AS activity,
(SELECT COUNT(*) FROM grabs (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 WHERE b.expires_at IS NULL OR b.expires_at > now())::bigint AS blocked
FROM wants; FROM wants;
-- The one-time listing of the library (#5567). --------------------------- -- The one-time listing of the library. ---------------------------
-- name: GetLibraryListing :one -- name: GetLibraryListing :one
SELECT library_listing_started_at, library_listed_at, library_unlisted SELECT library_listing_started_at, library_listed_at, library_unlisted
@@ -332,7 +332,7 @@ ON CONFLICT (release_group_mbid) DO NOTHING;
-- name: RecordListedAlbums :exec -- name: RecordListedAlbums :exec
-- The albums ListLibraryAlbums takes are decided as listed by their tags, -- 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) INSERT INTO library_album_matches (album_id, outcome, method, release_group_mbid)
SELECT a.id, 'listed', 'tags', a.release_group_mbid SELECT a.id, 'listed', 'tags', a.release_group_mbid
FROM albums a FROM albums a
@@ -346,9 +346,9 @@ ON CONFLICT (album_id) DO NOTHING;
-- name: ListAlbumsToMatch :many -- name: ListAlbumsToMatch :many
-- Library albums the listing has not decided, or that an older matcher -- Library albums the listing has not decided, or that an older matcher
-- refused, or whose tracks changed since they were refused: each is matched -- 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 -- 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. -- 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 SELECT a.id, a.title, a.mbid, a.release_group_mbid, ar.name AS artist_name, ar.mbid AS artist_mbid
FROM albums a FROM albums a
@@ -423,7 +423,7 @@ UPDATE acquisition_settings
RETURNING library_listed_at, library_unlisted; RETURNING library_listed_at, library_unlisted;
-- name: ListUnmatchedLibraryAlbums :many -- 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 -- 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. -- 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, 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); WHERE album_id = sqlc.arg(album_id) AND outcome = sqlc.arg(was);
-- Upgrades (#5524). ------------------------------------------------------- -- Upgrades. -------------------------------------------------------
-- name: ListHeldWantFormats :many -- name: ListHeldWantFormats :many
-- What each held album's copy is made of, for judging whether it is worth -- 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 -- album/artist IDs in Go (similarity roll-up + cold-start gate) and
-- atomic-replaces them here; /api/home reads them back hydrated. -- atomic-replaces them here; /api/home reads them back hydrated.