feat(library): AcoustID lookup worker fills the MBIDs tags leave empty (M401 #3920 #3921)
release / web (push) Successful in 1m44s
release / go (push) Successful in 2m1s
release / govulncheck (push) Successful in 17s
release / integration (push) Successful in 5m22s
release / android (push) Successful in 5m48s
release / Build signed APK (releases and dev) (push) Successful in 5m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m6s
release / Verify release artifacts (tag releases only) (push) Skipped

Migration 0069 adds tracks.mbid_source (tag | acoustid), a lookup state per
track (matched | ambiguous | no_match | failed) and the acoustid_settings
row (off, no key, min score 0.85).

The file's tag outranks a lookup (D4). UpsertTrack keeps a looked-up id
through a re-read that finds no tag id and replaces it as soon as one
appears. SetTrackMbidFromAcoustID refuses to write over a tag id.

The worker fingerprints each untagged track with fpcalc's compressed
print, looks it up and writes an id only when D5 settles it: one
recording at or above the threshold, or one left after matching title and
length. Ambiguous and no-match results write nothing. A key AcoustID
refuses, or the service being unreachable, stops the pass and is reported
in the worker's status. It never counts as a verdict on a track.

A changed file drops its lookup in the scan. The re-lookup takes back an
id that no longer matches.

Admin API: GET /api/admin/library/acoustid (settings, status, coverage by
source), PUT …/acoustid-settings (write-only key), POST …/acoustid/run,
GET …/acoustid/unsettled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:26:03 -04:00
co-authored by Claude Opus 5.5
parent f13da62797
commit 3c575b137c
24 changed files with 1743 additions and 29 deletions
+309
View File
@@ -0,0 +1,309 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: acoustid.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const clearTrackAcoustIDMbid = `-- name: ClearTrackAcoustIDMbid :exec
UPDATE tracks
SET mbid = NULL, mbid_source = NULL
WHERE id = $1
AND mbid_source = 'acoustid'
`
// A re-lookup of changed bytes that no longer matches takes back the id an
// earlier lookup wrote. A tag id is never touched.
func (q *Queries) ClearTrackAcoustIDMbid(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, clearTrackAcoustIDMbid, id)
return err
}
const deleteAcoustIDLookup = `-- name: DeleteAcoustIDLookup :exec
DELETE FROM track_acoustid_lookups WHERE track_id = $1
`
// The scan drops the lookup of bytes that are gone, so the worker checks the
// new ones.
func (q *Queries) DeleteAcoustIDLookup(ctx context.Context, trackID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteAcoustIDLookup, trackID)
return err
}
const getAcoustIDSettings = `-- name: GetAcoustIDSettings :one
SELECT id, enabled, api_key, min_score, updated_at FROM acoustid_settings WHERE id = true
`
func (q *Queries) GetAcoustIDSettings(ctx context.Context) (AcoustidSetting, error) {
row := q.db.QueryRow(ctx, getAcoustIDSettings)
var i AcoustidSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.ApiKey,
&i.MinScore,
&i.UpdatedAt,
)
return i, err
}
const getMbidCoverage = `-- name: GetMbidCoverage :one
SELECT count(*)::bigint AS total,
count(*) FILTER (WHERE t.mbid_source = 'tag')::bigint AS from_tag,
count(*) FILTER (WHERE t.mbid_source = 'acoustid')::bigint AS from_acoustid,
count(*) FILTER (WHERE l.state = 'ambiguous')::bigint AS ambiguous,
count(*) FILTER (WHERE l.state = 'no_match')::bigint AS no_match,
count(*) FILTER (WHERE l.state = 'failed')::bigint AS failed,
count(*) FILTER (
WHERE l.track_id IS NULL AND (t.mbid IS NULL OR t.mbid_source = 'acoustid')
)::bigint AS pending
FROM tracks t
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
WHERE t.missing_since IS NULL
`
type GetMbidCoverageRow struct {
Total int64
FromTag int64
FromAcoustid int64
Ambiguous int64
NoMatch int64
Failed int64
Pending int64
}
// The admin gauge: present tracks by where their MBID came from, and the
// lookup states of those a lookup has seen. pending is what the worker's
// queue holds.
func (q *Queries) GetMbidCoverage(ctx context.Context) (GetMbidCoverageRow, error) {
row := q.db.QueryRow(ctx, getMbidCoverage)
var i GetMbidCoverageRow
err := row.Scan(
&i.Total,
&i.FromTag,
&i.FromAcoustid,
&i.Ambiguous,
&i.NoMatch,
&i.Failed,
&i.Pending,
)
return i, err
}
const listTracksNeedingAcoustIDLookup = `-- name: ListTracksNeedingAcoustIDLookup :many
SELECT t.id, t.file_path, t.title, t.duration_ms
FROM tracks t
WHERE (t.mbid IS NULL OR t.mbid_source = 'acoustid')
AND t.missing_since IS NULL
AND t.id > $1::uuid
AND NOT EXISTS (SELECT 1 FROM track_acoustid_lookups l WHERE l.track_id = t.id)
ORDER BY t.id
LIMIT $2
`
type ListTracksNeedingAcoustIDLookupParams struct {
AfterID pgtype.UUID
BatchLimit int32
}
type ListTracksNeedingAcoustIDLookupRow struct {
ID pgtype.UUID
FilePath string
Title string
DurationMs int32
}
// AcoustID recording-id lookup (M401). 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
// gone (the file changed), so the new bytes are checked rather than trusted.
// Keyset-paged on id, as the loudness queue is, so a pass ends even when
// every attempt is inconclusive and writes nothing.
func (q *Queries) ListTracksNeedingAcoustIDLookup(ctx context.Context, arg ListTracksNeedingAcoustIDLookupParams) ([]ListTracksNeedingAcoustIDLookupRow, error) {
rows, err := q.db.Query(ctx, listTracksNeedingAcoustIDLookup, arg.AfterID, arg.BatchLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksNeedingAcoustIDLookupRow
for rows.Next() {
var i ListTracksNeedingAcoustIDLookupRow
if err := rows.Scan(
&i.ID,
&i.FilePath,
&i.Title,
&i.DurationMs,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listUnsettledAcoustIDLookups = `-- name: ListUnsettledAcoustIDLookups :many
SELECT t.id, t.title, t.file_path, ar.name AS artist_name, al.title AS album_title,
l.state, l.best_score, l.candidates, l.detail, l.looked_up_at
FROM track_acoustid_lookups l
JOIN tracks t ON t.id = l.track_id
JOIN artists ar ON ar.id = t.artist_id
JOIN albums al ON al.id = t.album_id
WHERE l.state IN ('ambiguous', 'failed')
AND t.missing_since IS NULL
ORDER BY ar.name, al.title, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT $1
`
type ListUnsettledAcoustIDLookupsRow struct {
ID pgtype.UUID
Title string
FilePath string
ArtistName string
AlbumTitle string
State string
BestScore *float32
Candidates int32
Detail *string
LookedUpAt pgtype.Timestamptz
}
// The admin list of tracks a lookup could not settle (ambiguous or failed),
// so they are findable rather than silently empty.
func (q *Queries) ListUnsettledAcoustIDLookups(ctx context.Context, rowLimit int32) ([]ListUnsettledAcoustIDLookupsRow, error) {
rows, err := q.db.Query(ctx, listUnsettledAcoustIDLookups, rowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListUnsettledAcoustIDLookupsRow
for rows.Next() {
var i ListUnsettledAcoustIDLookupsRow
if err := rows.Scan(
&i.ID,
&i.Title,
&i.FilePath,
&i.ArtistName,
&i.AlbumTitle,
&i.State,
&i.BestScore,
&i.Candidates,
&i.Detail,
&i.LookedUpAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recordAcoustIDLookup = `-- name: RecordAcoustIDLookup :exec
INSERT INTO track_acoustid_lookups (track_id, state, best_score, candidates, recording_mbid, detail)
VALUES ($1, $2, $3, $4,
$5, $6)
ON CONFLICT (track_id) DO UPDATE SET
state = EXCLUDED.state,
best_score = EXCLUDED.best_score,
candidates = EXCLUDED.candidates,
recording_mbid = EXCLUDED.recording_mbid,
detail = EXCLUDED.detail,
looked_up_at = now()
`
type RecordAcoustIDLookupParams struct {
TrackID pgtype.UUID
State string
BestScore *float32
Candidates int32
RecordingMbid *string
Detail *string
}
func (q *Queries) RecordAcoustIDLookup(ctx context.Context, arg RecordAcoustIDLookupParams) error {
_, err := q.db.Exec(ctx, recordAcoustIDLookup,
arg.TrackID,
arg.State,
arg.BestScore,
arg.Candidates,
arg.RecordingMbid,
arg.Detail,
)
return err
}
const setTrackMbidFromAcoustID = `-- name: SetTrackMbidFromAcoustID :execrows
UPDATE tracks
SET mbid = $1, mbid_source = 'acoustid'
WHERE id = $2
AND (mbid IS NULL OR mbid_source = 'acoustid')
`
type SetTrackMbidFromAcoustIDParams struct {
Mbid *string
ID pgtype.UUID
}
// Never over a tag id (D4): 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.
func (q *Queries) SetTrackMbidFromAcoustID(ctx context.Context, arg SetTrackMbidFromAcoustIDParams) (int64, error) {
result, err := q.db.Exec(ctx, setTrackMbidFromAcoustID, arg.Mbid, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const updateAcoustIDSettings = `-- name: UpdateAcoustIDSettings :one
UPDATE acoustid_settings
SET enabled = $1,
min_score = $2,
api_key = CASE WHEN $3::boolean
THEN NULLIF($4::text, '')
ELSE api_key END,
updated_at = now()
WHERE id = true
RETURNING id, enabled, api_key, min_score, updated_at
`
type UpdateAcoustIDSettingsParams struct {
Enabled bool
MinScore float32
SetApiKey bool
ApiKey string
}
// The key is write-only from the admin card: set_api_key false keeps the
// stored one, and an empty api_key with set_api_key true clears it.
func (q *Queries) UpdateAcoustIDSettings(ctx context.Context, arg UpdateAcoustIDSettingsParams) (AcoustidSetting, error) {
row := q.db.QueryRow(ctx, updateAcoustIDSettings,
arg.Enabled,
arg.MinScore,
arg.SetApiKey,
arg.ApiKey,
)
var i AcoustidSetting
err := row.Scan(
&i.ID,
&i.Enabled,
&i.ApiKey,
&i.MinScore,
&i.UpdatedAt,
)
return i, err
}
+2 -1
View File
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
}
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source FROM tracks t
JOIN play_events pe ON pe.track_id = t.id
WHERE pe.session_id = $1
AND pe.started_at < $2
@@ -307,6 +307,7 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
); err != nil {
return nil, err
}
+2 -1
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id,
pe.started_at,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM play_events pe
@@ -81,6 +81,7 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
+2 -1
View File
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
}
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since FROM tracks t
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source FROM tracks t
JOIN general_likes l ON l.track_id = t.id
WHERE l.user_id = $1
ORDER BY l.liked_at DESC
@@ -301,6 +301,7 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
); err != nil {
return nil, err
}
+1 -1
View File
@@ -196,7 +196,7 @@ func (q *Queries) MergeCopyTrackTags(ctx context.Context, arg MergeCopyTrackTags
const mergeInheritTrackMbid = `-- name: MergeInheritTrackMbid :exec
UPDATE tracks AS survivor
SET mbid = loser.mbid
SET mbid = loser.mbid, mbid_source = loser.mbid_source
FROM tracks AS loser
WHERE survivor.id = $1::uuid
AND loser.id = $2::uuid
+19
View File
@@ -187,6 +187,14 @@ func (ns NullLidarrRequestStatus) Value() (driver.Value, error) {
return string(ns.LidarrRequestStatus), nil
}
type AcoustidSetting struct {
ID bool
Enabled bool
ApiKey *string
MinScore float32
UpdatedAt pgtype.Timestamptz
}
type Album struct {
ID pgtype.UUID
Title string
@@ -720,6 +728,17 @@ type Track struct {
TagSourcesVersion int32
TagReadVersion int16
MissingSince pgtype.Timestamptz
MbidSource *string
}
type TrackAcoustidLookup struct {
TrackID pgtype.UUID
State string
BestScore *float32
Candidates int32
RecordingMbid *string
Detail *string
LookedUpAt pgtype.Timestamptz
}
type TrackFingerprint struct {
+8 -4
View File
@@ -208,7 +208,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -270,6 +270,7 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -290,7 +291,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM plays p
@@ -354,6 +355,7 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -691,7 +693,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -772,6 +774,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.Track.MbidSource,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
@@ -904,7 +907,7 @@ random_fill AS (
LIMIT $9
)
SELECT
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
@@ -1031,6 +1034,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.Track.MbidSource,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
+27 -12
View File
@@ -233,7 +233,7 @@ func (q *Queries) FindMissingTrackByMbid(ctx context.Context, mbid string) ([]Fi
}
const getTrackByID = `-- name: GetTrackByID :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = $1
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = $1
`
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
@@ -259,12 +259,13 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
)
return i, err
}
const getTrackByPath = `-- name: GetTrackByPath :one
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE file_path = $1
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE file_path = $1
`
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
@@ -290,12 +291,13 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
)
return i, err
}
const getTracksByIDs = `-- name: GetTracksByIDs :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks WHERE id = ANY($1::uuid[])
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks WHERE id = ANY($1::uuid[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
@@ -329,6 +331,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
); err != nil {
return nil, err
}
@@ -341,7 +344,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
}
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -401,6 +404,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -501,7 +505,7 @@ func (q *Queries) ListMissingTracks(ctx context.Context, arg ListMissingTracksPa
}
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since,
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version, t.missing_since, t.mbid_source,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
@@ -558,6 +562,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.Track.MbidSource,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -607,7 +612,7 @@ func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPa
}
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks
WHERE album_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -653,6 +658,7 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
); err != nil {
return nil, err
}
@@ -726,7 +732,7 @@ func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int
}
const searchTracks = `-- name: SearchTracks :many
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since FROM tracks
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version, missing_since, mbid_source FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -779,6 +785,7 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
); err != nil {
return nil, err
}
@@ -792,7 +799,7 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
const setTrackMbidIfNull = `-- name: SetTrackMbidIfNull :exec
UPDATE tracks
SET mbid = $2, updated_at = now()
SET mbid = $2, mbid_source = 'tag', updated_at = now()
WHERE id = $1 AND mbid IS NULL
`
@@ -812,8 +819,9 @@ const upsertTrack = `-- name: UpsertTrack :one
INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number,
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
tag_read_version
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
tag_read_version, mbid_source
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
CASE WHEN $11::text IS NOT NULL THEN 'tag' END)
ON CONFLICT (file_path) DO UPDATE SET
title = EXCLUDED.title,
album_id = EXCLUDED.album_id,
@@ -824,13 +832,17 @@ ON CONFLICT (file_path) DO UPDATE SET
file_size = EXCLUDED.file_size,
file_format = EXCLUDED.file_format,
bitrate = EXCLUDED.bitrate,
mbid = EXCLUDED.mbid,
-- Both read the row as it was before this statement.
mbid = CASE WHEN EXCLUDED.mbid IS NULL AND tracks.mbid_source = 'acoustid'
THEN tracks.mbid ELSE EXCLUDED.mbid END,
mbid_source = CASE WHEN EXCLUDED.mbid IS NOT NULL THEN 'tag'
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).
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
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
`
type UpsertTrackParams struct {
@@ -850,6 +862,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).
func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track, error) {
row := q.db.QueryRow(ctx, upsertTrack,
arg.Title,
@@ -887,6 +901,7 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
&i.MbidSource,
)
return i, err
}
@@ -0,0 +1,7 @@
DROP TABLE IF EXISTS acoustid_settings;
DROP TABLE IF EXISTS track_acoustid_lookups;
-- Looked-up ids go with the lookups; a tag id stays.
UPDATE tracks SET mbid = NULL WHERE mbid_source = 'acoustid';
ALTER TABLE tracks DROP CONSTRAINT IF EXISTS tracks_mbid_source_pair;
ALTER TABLE tracks DROP CONSTRAINT IF EXISTS tracks_mbid_source_check;
ALTER TABLE tracks DROP COLUMN IF EXISTS mbid_source;
@@ -0,0 +1,69 @@
-- 0069_acoustid_lookup.up.sql — recording MBIDs looked up through AcoustID
-- for tracks whose tags carry none (Scribe milestone #401).
--
-- A track with no recording MBID is invisible to the ListenBrainz similarity
-- arm in both directions, so this fills the ids the tags leave empty.
-- Where tracks.mbid came from. The file's own tag always outranks a lookup
-- (M401 D4): the scan's upsert keeps a looked-up id while the tag carries
-- none, and replaces it the moment the tag carries one.
ALTER TABLE tracks ADD COLUMN mbid_source text;
-- Every id stored so far was read from a tag (the scan or the tag backfill),
-- or inherited from a merged duplicate's tag.
UPDATE tracks SET mbid_source = 'tag' WHERE mbid IS NOT NULL;
ALTER TABLE tracks
ADD CONSTRAINT tracks_mbid_source_check CHECK (mbid_source IN ('tag', 'acoustid')),
ADD CONSTRAINT tracks_mbid_source_pair CHECK ((mbid IS NULL) = (mbid_source IS NULL));
-- What happened the last time a track was looked up. A table of its own, as
-- track_loudness is (0065): tracks is read with SELECT * on the hot path.
--
-- What a row means, which the lookup worker depends on:
-- no row never looked up, or the file changed since (the scan deletes
-- the row): look it up
-- matched one recording cleared the score threshold, or one was left
-- after disambiguating by title and length; recording_mbid is it
-- ambiguous several recordings cleared it and nothing told them apart.
-- Nothing is written: a wrong MBID would feed similarity the
-- wrong neighbours, which is worse than none (M401 D5)
-- no_match AcoustID linked nothing at or above the threshold
-- failed fpcalc rejected the file, or AcoustID rejected its print
-- Every state is settled until the file changes. A failure that says nothing
-- about the file (AcoustID unreachable, a timeout, a cancelled pass) writes no
-- row, so the worker tries again.
CREATE TABLE track_acoustid_lookups (
track_id uuid PRIMARY KEY REFERENCES tracks (id) ON DELETE CASCADE,
state text NOT NULL,
-- The best score any linked recording had; NULL when none was linked.
best_score real,
-- How many distinct recordings were linked, at any score.
candidates integer NOT NULL DEFAULT 0,
recording_mbid text,
-- Why a lookup failed, for the admin list.
detail text,
looked_up_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT track_acoustid_lookups_state_check
CHECK (state IN ('matched', 'ambiguous', 'no_match', 'failed')),
CONSTRAINT track_acoustid_lookups_matched_has_mbid
CHECK ((state = 'matched') = (recording_mbid IS NOT NULL))
);
CREATE INDEX track_acoustid_lookups_state ON track_acoustid_lookups (state);
-- AcoustID's knobs, in admin Settings (rule 25). Singleton in the style of
-- loudness_settings (0065).
CREATE TABLE acoustid_settings (
id boolean PRIMARY KEY DEFAULT true,
-- Off by default (rule 26): AcoustID needs a key the operator registers,
-- and the lookup sends fingerprints to a third party.
enabled boolean NOT NULL DEFAULT false,
api_key text,
-- The score a recording needs before it is considered at all. AcoustID
-- scores a true match close to 1; 0.85 leaves room for a lossy encode.
min_score real NOT NULL DEFAULT 0.85,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT acoustid_settings_singleton CHECK (id = true),
CONSTRAINT acoustid_settings_min_score_range CHECK (min_score >= 0.5 AND min_score <= 1)
);
INSERT INTO acoustid_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
+99
View File
@@ -0,0 +1,99 @@
-- AcoustID recording-id lookup (M401). Migration 0069 says what each
-- lookup state means.
-- name: ListTracksNeedingAcoustIDLookup :many
-- 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
-- gone (the file changed), so the new bytes are checked rather than trusted.
-- Keyset-paged on id, as the loudness queue is, so a pass ends even when
-- every attempt is inconclusive and writes nothing.
SELECT t.id, t.file_path, t.title, t.duration_ms
FROM tracks t
WHERE (t.mbid IS NULL OR t.mbid_source = 'acoustid')
AND t.missing_since IS NULL
AND t.id > sqlc.arg(after_id)::uuid
AND NOT EXISTS (SELECT 1 FROM track_acoustid_lookups l WHERE l.track_id = t.id)
ORDER BY t.id
LIMIT sqlc.arg(batch_limit);
-- name: RecordAcoustIDLookup :exec
INSERT INTO track_acoustid_lookups (track_id, state, best_score, candidates, recording_mbid, detail)
VALUES (sqlc.arg(track_id), sqlc.arg(state), sqlc.narg(best_score), sqlc.arg(candidates),
sqlc.narg(recording_mbid), sqlc.narg(detail))
ON CONFLICT (track_id) DO UPDATE SET
state = EXCLUDED.state,
best_score = EXCLUDED.best_score,
candidates = EXCLUDED.candidates,
recording_mbid = EXCLUDED.recording_mbid,
detail = EXCLUDED.detail,
looked_up_at = now();
-- name: SetTrackMbidFromAcoustID :execrows
-- Never over a tag id (D4): 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.
UPDATE tracks
SET mbid = sqlc.arg(mbid), mbid_source = 'acoustid'
WHERE id = sqlc.arg(id)
AND (mbid IS NULL OR mbid_source = 'acoustid');
-- name: ClearTrackAcoustIDMbid :exec
-- A re-lookup of changed bytes that no longer matches takes back the id an
-- earlier lookup wrote. A tag id is never touched.
UPDATE tracks
SET mbid = NULL, mbid_source = NULL
WHERE id = sqlc.arg(id)
AND mbid_source = 'acoustid';
-- name: DeleteAcoustIDLookup :exec
-- The scan drops the lookup of bytes that are gone, so the worker checks the
-- new ones.
DELETE FROM track_acoustid_lookups WHERE track_id = $1;
-- name: GetMbidCoverage :one
-- The admin gauge: present tracks by where their MBID came from, and the
-- lookup states of those a lookup has seen. pending is what the worker's
-- queue holds.
SELECT count(*)::bigint AS total,
count(*) FILTER (WHERE t.mbid_source = 'tag')::bigint AS from_tag,
count(*) FILTER (WHERE t.mbid_source = 'acoustid')::bigint AS from_acoustid,
count(*) FILTER (WHERE l.state = 'ambiguous')::bigint AS ambiguous,
count(*) FILTER (WHERE l.state = 'no_match')::bigint AS no_match,
count(*) FILTER (WHERE l.state = 'failed')::bigint AS failed,
count(*) FILTER (
WHERE l.track_id IS NULL AND (t.mbid IS NULL OR t.mbid_source = 'acoustid')
)::bigint AS pending
FROM tracks t
LEFT JOIN track_acoustid_lookups l ON l.track_id = t.id
WHERE t.missing_since IS NULL;
-- name: ListUnsettledAcoustIDLookups :many
-- The admin list of tracks a lookup could not settle (ambiguous or failed),
-- so they are findable rather than silently empty.
SELECT t.id, t.title, t.file_path, ar.name AS artist_name, al.title AS album_title,
l.state, l.best_score, l.candidates, l.detail, l.looked_up_at
FROM track_acoustid_lookups l
JOIN tracks t ON t.id = l.track_id
JOIN artists ar ON ar.id = t.artist_id
JOIN albums al ON al.id = t.album_id
WHERE l.state IN ('ambiguous', 'failed')
AND t.missing_since IS NULL
ORDER BY ar.name, al.title, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
LIMIT sqlc.arg(row_limit);
-- name: GetAcoustIDSettings :one
SELECT * FROM acoustid_settings WHERE id = true;
-- name: UpdateAcoustIDSettings :one
-- The key is write-only from the admin card: set_api_key false keeps the
-- stored one, and an empty api_key with set_api_key true clears it.
UPDATE acoustid_settings
SET enabled = sqlc.arg(enabled),
min_score = sqlc.arg(min_score),
api_key = CASE WHEN sqlc.arg(set_api_key)::boolean
THEN NULLIF(sqlc.arg(api_key)::text, '')
ELSE api_key END,
updated_at = now()
WHERE id = true
RETURNING *;
+1 -1
View File
@@ -88,7 +88,7 @@ ON CONFLICT (track_a_id, track_b_id, source) DO NOTHING;
-- A recording MBID is what the similarity pipeline keys on. If only the removed
-- copy carried one, the survivor takes it rather than going dark to similarity.
UPDATE tracks AS survivor
SET mbid = loser.mbid
SET mbid = loser.mbid, mbid_source = loser.mbid_source
FROM tracks AS loser
WHERE survivor.id = sqlc.arg(survivor_id)::uuid
AND loser.id = sqlc.arg(loser_id)::uuid
+11 -4
View File
@@ -1,10 +1,13 @@
-- 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).
INSERT INTO tracks (
title, album_id, artist_id, track_number, disc_number,
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
tag_read_version
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
tag_read_version, mbid_source
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
CASE WHEN $11::text IS NOT NULL THEN 'tag' END)
ON CONFLICT (file_path) DO UPDATE SET
title = EXCLUDED.title,
album_id = EXCLUDED.album_id,
@@ -15,7 +18,11 @@ ON CONFLICT (file_path) DO UPDATE SET
file_size = EXCLUDED.file_size,
file_format = EXCLUDED.file_format,
bitrate = EXCLUDED.bitrate,
mbid = EXCLUDED.mbid,
-- Both read the row as it was before this statement.
mbid = CASE WHEN EXCLUDED.mbid IS NULL AND tracks.mbid_source = 'acoustid'
THEN tracks.mbid ELSE EXCLUDED.mbid END,
mbid_source = CASE WHEN EXCLUDED.mbid IS NOT NULL THEN 'tag'
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).
@@ -39,7 +46,7 @@ SELECT id, file_path
-- Heal a track's recording MBID only while still NULL — idempotent, so
-- re-running the backfill is a no-op for already-healed rows.
UPDATE tracks
SET mbid = $2, updated_at = now()
SET mbid = $2, mbid_source = 'tag', updated_at = now()
WHERE id = $1 AND mbid IS NULL;
-- name: GetTrackByID :one