The scan has marked missing files since f6d1cf24 and every selection
path filters them out, so they cause no harm -- and are invisible. The
operator found out about the first batch only because an unrelated MBID
backfill logged "no such file or directory" forty times.
GET /api/admin/library/missing reports them, grouped by directory. The
grouping is the whole ergonomic argument: the case that produced #2523
was three reorganised albums, which a flat list renders as forty
unrelated problems and a folder list renders as three decisions.
ListMissingTracks orders by directory so the handler can fold runs
without a map, which also keeps the query's ordering instead of Go's
random map iteration.
Each row carries last_played_at, nullable, because "gone six months,
never played" and "gone yesterday, played 200 times" deserve opposite
reactions and a file path tells you neither. The correlated MAX needs
its ::timestamptz cast or sqlc infers interface{} and the Go layer
loses the type.
Read-only, deliberately. Nothing here deletes: a missing file keeps its
row, its play history and its likes because it may come back, and if it
comes back renamed the scanner adopts it (#2528). The route sits under
/library rather than /tracks so it can't be confused with the
destructive DELETE /admin/tracks/{id} beside it.
893 lines
25 KiB
Go
893 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 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 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
|
|
}
|