test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 3m45s
release / Build signed APK (releases and dev) (push) Successful in 4m52s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
A file that comes back renamed or moved keeps its track row, and with it its likes and play history, by being matched to the missing row it replaces (#2528). Untagged files were matched on (file_size, duration_ms), which was never a fingerprint. It could pair two unrelated files that happened to share a byte count and a duration, and it missed a file retagged in place, whose size changes. The only defence was requiring a unique match and otherwise giving up. Now there is a real identity. FindMissingTrackByAudioHash matches a missing track by the SHA-256 of its encoded audio (track_fingerprints, #3906). That survives a rename, a move and a retag, and only an identical recording can match it. adoptMovedTrack takes the new file's hash, which the scan already computes before adoption. The size and duration query and fallback are removed outright, with no second path (rule 22). Unchanged: - MBID first: it identifies the recording and survives a re-encode that even the hash does not - a unique match is still required - an absent hash is never looked up, so unhashable files cannot pair with each other The test fake answers the hash lookup only for the hash it holds, so the tests can tell adoption by identity apart from adoption by coincidence. That includes the case the old pair got wrong: different audio of equal size and duration is not adopted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
890 lines
25 KiB
Go
890 lines
25 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 countMissingTracks = `-- name: CountMissingTracks :one
|
|
SELECT COUNT(*) FROM tracks WHERE missing_since IS NOT NULL
|
|
`
|
|
|
|
// Total for the admin surface's badge and paging. Uses the same partial index
|
|
// (tracks_missing_since_idx) as the list above.
|
|
func (q *Queries) CountMissingTracks(ctx context.Context) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countMissingTracks)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
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 findMissingTrackByAudioHash = `-- name: FindMissingTrackByAudioHash :many
|
|
SELECT t.id, t.file_path
|
|
FROM tracks t
|
|
JOIN track_fingerprints f ON f.track_id = t.id
|
|
WHERE t.missing_since IS NOT NULL
|
|
AND f.audio_stream_sha256 = $1
|
|
LIMIT 2
|
|
`
|
|
|
|
type FindMissingTrackByAudioHashRow struct {
|
|
ID pgtype.UUID
|
|
FilePath string
|
|
}
|
|
|
|
// Move detection fallback for files with no MBID (#2528, #3914). 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.
|
|
//
|
|
// Same missing-only constraint and same LIMIT 2 rationale as the MBID variant.
|
|
func (q *Queries) FindMissingTrackByAudioHash(ctx context.Context, audioStreamSha256 []byte) ([]FindMissingTrackByAudioHashRow, error) {
|
|
rows, err := q.db.Query(ctx, findMissingTrackByAudioHash, audioStreamSha256)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []FindMissingTrackByAudioHashRow
|
|
for rows.Next() {
|
|
var i FindMissingTrackByAudioHashRow
|
|
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 listMissingTracks = `-- name: ListMissingTracks :many
|
|
SELECT t.id,
|
|
t.title,
|
|
t.file_path,
|
|
regexp_replace(t.file_path, '/[^/]*$', '') AS directory,
|
|
t.missing_since,
|
|
t.duration_ms,
|
|
albums.id AS album_id,
|
|
albums.title AS album_title,
|
|
artists.id AS artist_id,
|
|
artists.name AS artist_name,
|
|
-- Cast is load-bearing: without it sqlc infers the correlated
|
|
-- subquery as interface{} and the Go layer loses the timestamp type.
|
|
(SELECT MAX(pe.started_at) FROM play_events pe WHERE pe.track_id = t.id)::timestamptz AS last_played_at
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
WHERE t.missing_since IS NOT NULL
|
|
ORDER BY directory, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title
|
|
LIMIT $2 OFFSET $1
|
|
`
|
|
|
|
type ListMissingTracksParams struct {
|
|
PageOffset int32
|
|
PageLimit int32
|
|
}
|
|
|
|
type ListMissingTracksRow struct {
|
|
ID pgtype.UUID
|
|
Title string
|
|
FilePath string
|
|
Directory string
|
|
MissingSince pgtype.Timestamptz
|
|
DurationMs int32
|
|
AlbumID pgtype.UUID
|
|
AlbumTitle string
|
|
ArtistID pgtype.UUID
|
|
ArtistName string
|
|
LastPlayedAt pgtype.Timestamptz
|
|
}
|
|
|
|
// The admin review surface for files the scan could not find (#2527).
|
|
//
|
|
// 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.
|
|
// 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.
|
|
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 {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListMissingTracksRow
|
|
for rows.Next() {
|
|
var i ListMissingTracksRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Title,
|
|
&i.FilePath,
|
|
&i.Directory,
|
|
&i.MissingSince,
|
|
&i.DurationMs,
|
|
&i.AlbumID,
|
|
&i.AlbumTitle,
|
|
&i.ArtistID,
|
|
&i.ArtistName,
|
|
&i.LastPlayedAt,
|
|
); 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
|
|
}
|