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