Files
minstrel/internal/db/dbq/acoustid.sql.go
T
bvandeusenandClaude Opus 5.5 3c575b137c
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
feat(library): AcoustID lookup worker fills the MBIDs tags leave empty (M401 #3920 #3921)
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>
2026-10-06 23:26:03 -04:00

310 lines
8.7 KiB
Go

// 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
}