Files
minstrel/internal/db/dbq/tracks.sql.go
T
bvandeusen 24d330424f
test-go / test (push) Successful in 52s
test-go / integration (push) Successful in 5m0s
feat(library): adopt moved files instead of forking their history — #2528
Track identity was file_path, so a file that came back renamed or in a
different directory looked like a deletion plus an unrelated new track:
the old row kept the like and every play_event while a fresh zero-history
row appeared, and nothing connected them. A liked song read as unliked, its
play count reset, and Rediscover could offer it as a discovery — silently.
Renumbering an album was enough, which is what happened to the operator's
copy of Minutes to Midnight.

Adoption re-points the existing row's file_path at the new location and
clears its missing mark. The normal UpsertTrack then conflicts on file_path
and updates THAT row, so the track id survives and likes, plays and
playlist memberships travel with it — and clients see an update rather than
a delete-and-create, so no cache churn either.

Matching is MBID first (identifies the recording, so it survives a
re-encode), then file_size + duration_ms for untagged files. Both
fingerprint components must be non-zero: duration_ms is 0 when ffprobe
failed, and matching 0 against 0 would pair up unrelated broken files.
Only rows already marked missing are eligible — a row whose file is present
elsewhere is a duplicate, not a move, and re-pointing it would corrupt the
copy that still exists. An ambiguous match inserts fresh rather than
adopting one arbitrarily: a fork is recoverable later, a wrong merge isn't.

Scan is now three phases, and the order is the point. Adoption can only
claim a row that is ALREADY marked missing, but reconcile previously ran
after processing — so a rename performed while the server was down surfaced
the deletion and the addition in the same scan, the new path inserted first,
and the fork became permanent. Enumeration is therefore separated from
processing so reconcile can run between them: walk (paths only, no tag
reads or probes) -> reconcile -> process in walk order.

Consequence worth knowing: when reconcile refuses (an absent root, or a
reorganisation exceeding the 25% mark cap) adoption cannot fire and renamed
files fork as before. That's the pre-#2528 behaviour rather than a new
failure, and the warning now names it.

The old outer walk-error branch was unreachable — the callback always
returned nil, so WalkDir never surfaced an error — and verifyRootsPresent is
the real protection, so enumerate counts walk errors instead of pretending
to abort on them.
2026-08-06 15:56:07 -04:00

794 lines
22 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: tracks.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const adoptTrackPath = `-- name: AdoptTrackPath :execrows
UPDATE tracks
SET file_path = $1,
missing_since = NULL
WHERE id = $2
AND missing_since IS NOT NULL
`
type AdoptTrackPathParams struct {
FilePath string
ID pgtype.UUID
}
// Re-points a missing row at the path its file turned up on, and clears the
// mark. The caller's normal UpsertTrack then conflicts on file_path and updates
// THIS row in place, so the track id survives and its likes, play history and
// playlist memberships come with it.
//
// `missing_since IS NOT NULL` again, this time as a race guard: two files can't
// both adopt the same row, and :execrows reports 0 to whichever loses.
func (q *Queries) AdoptTrackPath(ctx context.Context, arg AdoptTrackPathParams) (int64, error) {
result, err := q.db.Exec(ctx, adoptTrackPath, arg.FilePath, arg.ID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const clearTracksMissing = `-- name: ClearTracksMissing :execrows
UPDATE tracks
SET missing_since = NULL
WHERE id = ANY($1::uuid[])
AND missing_since IS NOT NULL
`
// Clears the mark on rows whose file is back. Runs independently of the mtime
// skip check, so a file that reappears unchanged is un-marked even though the
// scanner skips re-reading its tags.
func (q *Queries) ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, clearTracksMissing, ids)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const countTracksByAlbum = `-- name: CountTracksByAlbum :one
SELECT count(*) FROM tracks WHERE album_id = $1
`
func (q *Queries) CountTracksByAlbum(ctx context.Context, albumID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countTracksByAlbum, albumID)
var count int64
err := row.Scan(&count)
return count, err
}
const countTracksByArtist = `-- name: CountTracksByArtist :one
SELECT COUNT(*) FROM tracks WHERE artist_id = $1
`
// Used by request-progress reporting to count tracks ingested under a
// matched artist (sum across all the artist's albums) while a request
// is still in flight.
func (q *Queries) CountTracksByArtist(ctx context.Context, artistID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countTracksByArtist, artistID)
var count int64
err := row.Scan(&count)
return count, err
}
const countTracksMatching = `-- name: CountTracksMatching :one
SELECT COUNT(*) FROM tracks
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
`
type CountTracksMatchingParams struct {
Column1 string
UserID pgtype.UUID
}
// $1 = title query, $2 = user_id (NULL to skip quarantine filter).
func (q *Queries) CountTracksMatching(ctx context.Context, arg CountTracksMatchingParams) (int64, error) {
row := q.db.QueryRow(ctx, countTracksMatching, arg.Column1, arg.UserID)
var count int64
err := row.Scan(&count)
return count, err
}
const deleteTrack = `-- name: DeleteTrack :one
DELETE FROM tracks WHERE id = $1
RETURNING id, album_id, artist_id, file_path, mbid
`
type DeleteTrackRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
FilePath string
Mbid *string
}
// M7 #372: hard delete with FK cascade. The CASCADE on track_id from
// play_events / general_likes_tracks / lidarr_quarantine /
// lidarr_quarantine_actions handles their cleanup. RETURNING gives us
// album_id + artist_id for the album-empty / artist-empty cascade
// checks the service does next.
func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackRow, error) {
row := q.db.QueryRow(ctx, deleteTrack, id)
var i DeleteTrackRow
err := row.Scan(
&i.ID,
&i.AlbumID,
&i.ArtistID,
&i.FilePath,
&i.Mbid,
)
return i, err
}
const findMissingTrackByFingerprint = `-- name: FindMissingTrackByFingerprint :many
SELECT id, file_path FROM tracks
WHERE missing_since IS NOT NULL
AND file_size = $1
AND duration_ms = $2
LIMIT 2
`
type FindMissingTrackByFingerprintParams struct {
FileSize int64
DurationMs int32
}
type FindMissingTrackByFingerprintRow struct {
ID pgtype.UUID
FilePath string
}
// Move detection fallback for files with no MBID (#2528). Exact byte size AND
// exact decoded duration is a strong pair: a plain move or rename preserves
// both, while a re-encode changes at least one — and a re-encode genuinely is a
// different file, so failing to match there is correct rather than a gap.
//
// Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
func (q *Queries) FindMissingTrackByFingerprint(ctx context.Context, arg FindMissingTrackByFingerprintParams) ([]FindMissingTrackByFingerprintRow, error) {
rows, err := q.db.Query(ctx, findMissingTrackByFingerprint, arg.FileSize, arg.DurationMs)
if err != nil {
return nil, err
}
defer rows.Close()
var items []FindMissingTrackByFingerprintRow
for rows.Next() {
var i FindMissingTrackByFingerprintRow
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const findMissingTrackByMbid = `-- name: FindMissingTrackByMbid :many
SELECT id, file_path FROM tracks
WHERE missing_since IS NOT NULL
AND mbid IS NOT NULL
AND mbid = $1::text
LIMIT 2
`
type FindMissingTrackByMbidRow struct {
ID pgtype.UUID
FilePath string
}
// Move detection, strongest signal (#2528). 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.
//
// `missing_since IS NOT NULL` is the safety constraint, not an optimisation: a
// row whose file is present elsewhere on disk is a DUPLICATE, and re-pointing
// its file_path would corrupt the copy that still exists.
//
// LIMIT 2 because the caller only needs to know "exactly one" vs "more than
// one" — an ambiguous match must not be adopted arbitrarily.
func (q *Queries) FindMissingTrackByMbid(ctx context.Context, mbid string) ([]FindMissingTrackByMbidRow, error) {
rows, err := q.db.Query(ctx, findMissingTrackByMbid, mbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []FindMissingTrackByMbidRow
for rows.Next() {
var i FindMissingTrackByMbidRow
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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
`
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
row := q.db.QueryRow(ctx, getTrackByID, id)
var i Track
err := row.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
)
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
`
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
row := q.db.QueryRow(ctx, getTrackByPath, filePath)
var i Track
err := row.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
)
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[])
`
// Batched lookup used by /api/library/sync to hydrate upsert payloads
// (#357). 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 {
return nil, err
}
defer rows.Close()
var items []Track
for rows.Next() {
var i Track
if err := rows.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.artist_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $2 AND q.track_id = t.id
)
ORDER BY albums.release_date NULLS LAST, albums.sort_title,
t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.id
`
type ListArtistTracksForUserParams struct {
ArtistID pgtype.UUID
UserID pgtype.UUID
}
type ListArtistTracksForUserRow struct {
Track Track
AlbumTitle string
ArtistName string
}
// M6a: every track for the artist across their albums, with album_title
// and artist_name joined. Honors per-user lidarr_quarantine. Used by
// /api/artists/{id}/tracks for the artist-card play affordance, which
// shuffles client-side. Ordering matches album/track natural order so
// the shuffle has a deterministic input.
func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTracksForUserParams) ([]ListArtistTracksForUserRow, error) {
rows, err := q.db.Query(ctx, listArtistTracksForUser, arg.ArtistID, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListArtistTracksForUserRow
for rows.Next() {
var i ListArtistTracksForUserRow
if err := rows.Scan(
&i.Track.ID,
&i.Track.Title,
&i.Track.AlbumID,
&i.Track.ArtistID,
&i.Track.TrackNumber,
&i.Track.DiscNumber,
&i.Track.DurationMs,
&i.Track.FilePath,
&i.Track.FileSize,
&i.Track.FileFormat,
&i.Track.Bitrate,
&i.Track.Mbid,
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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,
albums.title AS album_title,
artists.name AS artist_name
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
ORDER BY random()
LIMIT $2
`
type ListRandomTracksForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListRandomTracksForUserRow struct {
Track Track
AlbumTitle string
ArtistName string
}
// #427 S4: 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) {
rows, err := q.db.Query(ctx, listRandomTracksForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListRandomTracksForUserRow
for rows.Next() {
var i ListRandomTracksForUserRow
if err := rows.Scan(
&i.Track.ID,
&i.Track.Title,
&i.Track.AlbumID,
&i.Track.ArtistID,
&i.Track.TrackNumber,
&i.Track.DiscNumber,
&i.Track.DurationMs,
&i.Track.FilePath,
&i.Track.FileSize,
&i.Track.FileFormat,
&i.Track.Bitrate,
&i.Track.Mbid,
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.Track.TagReadVersion,
&i.Track.MissingSince,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTrackPathsForReconcile = `-- name: ListTrackPathsForReconcile :many
SELECT id, file_path, missing_since FROM tracks
`
type ListTrackPathsForReconcileRow struct {
ID pgtype.UUID
FilePath string
MissingSince pgtype.Timestamptz
}
// Every row's path + current missing mark, for the scanner's reconcile pass
// (#2523). 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.
func (q *Queries) ListTrackPathsForReconcile(ctx context.Context) ([]ListTrackPathsForReconcileRow, error) {
rows, err := q.db.Query(ctx, listTrackPathsForReconcile)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTrackPathsForReconcileRow
for rows.Next() {
var i ListTrackPathsForReconcileRow
if err := rows.Scan(&i.ID, &i.FilePath, &i.MissingSince); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
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
WHERE album_id = $1
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY disc_number NULLS LAST, track_number NULLS LAST
`
type ListTracksByAlbumParams struct {
AlbumID pgtype.UUID
UserID pgtype.UUID
}
// $1 = album_id, $2 = user_id. Pass pgtype.UUID{Valid: false} (NULL)
// to skip the per-user quarantine filter; the NOT EXISTS clause on
// a NULL user_id never matches a row, so every track passes through.
func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumParams) ([]Track, error) {
rows, err := q.db.Query(ctx, listTracksByAlbum, arg.AlbumID, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Track
for rows.Next() {
var i Track
if err := rows.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracksMissingMbidWithPath = `-- name: ListTracksMissingMbidWithPath :many
SELECT id, file_path
FROM tracks
WHERE mbid IS NULL
ORDER BY id
LIMIT $1
`
type ListTracksMissingMbidWithPathRow struct {
ID pgtype.UUID
FilePath string
}
// Track recording-MBID backfill: tracks with NULL mbid that still have
// a file to re-read. $1 caps the batch (mirrors the album backfill).
func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32) ([]ListTracksMissingMbidWithPathRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingMbidWithPath, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTracksMissingMbidWithPathRow
for rows.Next() {
var i ListTracksMissingMbidWithPathRow
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markTracksMissing = `-- name: MarkTracksMissing :execrows
UPDATE tracks
SET missing_since = now()
WHERE id = ANY($1::uuid[])
AND missing_since IS NULL
`
// Marks rows whose file the walk did not see. `missing_since IS NULL` in the
// predicate makes this idempotent: a row already marked keeps its ORIGINAL
// timestamp, so "how long has it been gone" survives repeated scans. Losing
// that would make any age-based cleanup policy meaningless.
//
// updated_at is deliberately NOT touched. It tracks content changes and gates
// the scanner's mtime skip; moving it here would make a returning file look
// newer than its own mtime and stop its tags being re-read.
func (q *Queries) MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error) {
result, err := q.db.Exec(ctx, markTracksMissing, ids)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
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
WHERE title ILIKE '%' || $1::text || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $2 AND q.track_id = tracks.id
)
ORDER BY title
LIMIT $3 OFFSET $4
`
type SearchTracksParams struct {
Column1 string
UserID pgtype.UUID
Limit int32
Offset int32
}
// $1 = title query, $2 = user_id (NULL to skip quarantine filter),
// $3 = limit, $4 = offset.
func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]Track, error) {
rows, err := q.db.Query(ctx, searchTracks,
arg.Column1,
arg.UserID,
arg.Limit,
arg.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Track
for rows.Next() {
var i Track
if err := rows.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setTrackMbidIfNull = `-- name: SetTrackMbidIfNull :exec
UPDATE tracks
SET mbid = $2, updated_at = now()
WHERE id = $1 AND mbid IS NULL
`
type SetTrackMbidIfNullParams struct {
ID pgtype.UUID
Mbid *string
}
// Heal a track's recording MBID only while still NULL — idempotent, so
// re-running the backfill is a no-op for already-healed rows.
func (q *Queries) SetTrackMbidIfNull(ctx context.Context, arg SetTrackMbidIfNullParams) error {
_, err := q.db.Exec(ctx, setTrackMbidIfNull, arg.ID, arg.Mbid)
return err
}
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)
ON CONFLICT (file_path) DO UPDATE SET
title = EXCLUDED.title,
album_id = EXCLUDED.album_id,
artist_id = EXCLUDED.artist_id,
track_number = EXCLUDED.track_number,
disc_number = EXCLUDED.disc_number,
duration_ms = EXCLUDED.duration_ms,
file_size = EXCLUDED.file_size,
file_format = EXCLUDED.file_format,
bitrate = EXCLUDED.bitrate,
mbid = EXCLUDED.mbid,
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
`
type UpsertTrackParams struct {
Title string
AlbumID pgtype.UUID
ArtistID pgtype.UUID
TrackNumber *int32
DiscNumber *int32
DurationMs int32
FilePath string
FileSize int64
FileFormat string
Bitrate *int32
Mbid *string
Genre *string
TagReadVersion int16
}
// file_path is the canonical identity for library scan; mbid is secondary.
func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track, error) {
row := q.db.QueryRow(ctx, upsertTrack,
arg.Title,
arg.AlbumID,
arg.ArtistID,
arg.TrackNumber,
arg.DiscNumber,
arg.DurationMs,
arg.FilePath,
arg.FileSize,
arg.FileFormat,
arg.Bitrate,
arg.Mbid,
arg.Genre,
arg.TagReadVersion,
)
var i Track
err := row.Scan(
&i.ID,
&i.Title,
&i.AlbumID,
&i.ArtistID,
&i.TrackNumber,
&i.DiscNumber,
&i.DurationMs,
&i.FilePath,
&i.FileSize,
&i.FileFormat,
&i.Bitrate,
&i.Mbid,
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
&i.TagReadVersion,
&i.MissingSince,
)
return i, err
}