feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz release group. A want comes from an approved request, a new release by a followed artist, or an album whose files went missing, and is held once the library has a full edition of it. - Migration 0081: release_type_profiles (Standard seeded: Album, EP, Single, studio only), follows, follow_seen_groups and wants. lidarr_requests becomes requests with plain *_mbid columns; approved album and track requests move onto the wanted list, approved artist requests become follows, and the library's album artists start out followed. missing_reacquisitions and the retry settings go. - internal/wanted: follows are checked against MusicBrainz discographies daily. The first check sets a baseline, and only releases after it are wanted unless the follow asked for what already exists. Profiles filter by primary and secondary type, so live recordings stay out unless a profile ticks Live. - internal/requests (was lidarrrequests): approving puts the request on the wanted list, and an artist request can be approved on a chosen profile. A request completes when its want is held. - The re-acquisition sweeper wants a missing album back instead of asking Lidarr; searching for it is the search schedule's (#5521). - Web: an admin Wanted page (Wanted, Missing files, Held, Following), a follow control on artist pages, a release-type profiles card on Integrations, "Approve on a profile" on Requests, and want state on Missing files. The artist detail API returns the artist's MBID. - Android: the request body sends artist_mbid, album_mbid and track_mbid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,582 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: lidarr_requests.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const approveLidarrRequest = `-- name: ApproveLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'approved',
|
||||
quality_profile_id = $2,
|
||||
root_folder_path = $3,
|
||||
decided_at = now(),
|
||||
decided_by = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type ApproveLidarrRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
QualityProfileID *int32
|
||||
RootFolderPath *string
|
||||
DecidedBy pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) ApproveLidarrRequest(ctx context.Context, arg ApproveLidarrRequestParams) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, approveLidarrRequest,
|
||||
arg.ID,
|
||||
arg.QualityProfileID,
|
||||
arg.RootFolderPath,
|
||||
arg.DecidedBy,
|
||||
)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const cancelLidarrRequest = `-- name: CancelLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'rejected',
|
||||
notes = 'cancelled by user',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type CancelLidarrRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
DecidedBy pgtype.UUID
|
||||
}
|
||||
|
||||
// $2 serves as both the ownership guard (user_id = $2) and the
|
||||
// decided_by audit field. The generated parameter struct names it
|
||||
// DecidedBy — callers must pass the cancelling user's UUID, and
|
||||
// a wrong UUID will silently no-op (zero rows updated) rather than
|
||||
// erroring at the SQL layer.
|
||||
func (q *Queries) CancelLidarrRequest(ctx context.Context, arg CancelLidarrRequestParams) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, cancelLidarrRequest, arg.ID, arg.DecidedBy)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const completeLidarrRequest = `-- name: CompleteLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'completed',
|
||||
matched_track_id = $2,
|
||||
matched_album_id = $3,
|
||||
matched_artist_id = $4,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type CompleteLidarrRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
MatchedTrackID pgtype.UUID
|
||||
MatchedAlbumID pgtype.UUID
|
||||
MatchedArtistID pgtype.UUID
|
||||
}
|
||||
|
||||
// Reconciler transitions an approved request to completed when its
|
||||
// target track/album/artist has appeared in the library. Callers
|
||||
// should set ONLY the matched_*_id corresponding to the request's
|
||||
// kind and pass pgtype.UUID{} (Valid: false) for the others —
|
||||
// per-kind one-of-three is the contract, NULL for the others
|
||||
// preserves auditability if the matched row is later evicted.
|
||||
func (q *Queries) CompleteLidarrRequest(ctx context.Context, arg CompleteLidarrRequestParams) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, completeLidarrRequest,
|
||||
arg.ID,
|
||||
arg.MatchedTrackID,
|
||||
arg.MatchedAlbumID,
|
||||
arg.MatchedArtistID,
|
||||
)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createLidarrRequest = `-- name: CreateLidarrRequest :one
|
||||
INSERT INTO lidarr_requests (
|
||||
user_id, kind,
|
||||
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
|
||||
artist_name, album_title, track_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type CreateLidarrRequestParams struct {
|
||||
UserID pgtype.UUID
|
||||
Kind LidarrRequestKind
|
||||
LidarrArtistMbid string
|
||||
LidarrAlbumMbid *string
|
||||
LidarrTrackMbid *string
|
||||
ArtistName string
|
||||
AlbumTitle *string
|
||||
TrackTitle *string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateLidarrRequest(ctx context.Context, arg CreateLidarrRequestParams) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, createLidarrRequest,
|
||||
arg.UserID,
|
||||
arg.Kind,
|
||||
arg.LidarrArtistMbid,
|
||||
arg.LidarrAlbumMbid,
|
||||
arg.LidarrTrackMbid,
|
||||
arg.ArtistName,
|
||||
arg.AlbumTitle,
|
||||
arg.TrackTitle,
|
||||
)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getLidarrRequestByID = `-- name: GetLidarrRequestByID :one
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetLidarrRequestByID(ctx context.Context, id pgtype.UUID) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, getLidarrRequestByID, id)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = $1)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = $1))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Returns the oldest non-terminal (pending/approved/completed) request
|
||||
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
// uses this to dedupe: a re-request for something already in flight
|
||||
// returns the existing row instead of inserting a duplicate. MBIDs are
|
||||
// globally unique per entity type, so the cross-column OR cannot
|
||||
// false-match (an artist MBID never collides with an album/track MBID).
|
||||
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const hasNonTerminalRequestForMBID = `-- name: HasNonTerminalRequestForMBID :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = $1)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = $1)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = $1))
|
||||
)
|
||||
`
|
||||
|
||||
// Returns true if any user has a pending/approved/completed request
|
||||
// whose MBID matches at the given level. Used to set the `requested`
|
||||
// flag on /api/lidarr/search responses. Terminal-status (rejected,
|
||||
// failed) rows do not count. The same MBID parameter is checked
|
||||
// against artist/album/track columns based on kind.
|
||||
func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, mbid)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const listApprovedLidarrRequestsForReconcile = `-- name: ListApprovedLidarrRequestsForReconcile :many
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE status = 'approved'
|
||||
ORDER BY decided_at ASC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
func (q *Queries) ListApprovedLidarrRequestsForReconcile(ctx context.Context, limit int32) ([]LidarrRequest, error) {
|
||||
rows, err := q.db.Query(ctx, listApprovedLidarrRequestsForReconcile, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []LidarrRequest
|
||||
for rows.Next() {
|
||||
var i LidarrRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLidarrRequestsByStatus = `-- name: ListLidarrRequestsByStatus :many
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListLidarrRequestsByStatusParams struct {
|
||||
Status LidarrRequestStatus
|
||||
Limit int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListLidarrRequestsByStatus(ctx context.Context, arg ListLidarrRequestsByStatusParams) ([]LidarrRequest, error) {
|
||||
rows, err := q.db.Query(ctx, listLidarrRequestsByStatus, arg.Status, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []LidarrRequest
|
||||
for rows.Next() {
|
||||
var i LidarrRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLidarrRequestsForUser = `-- name: ListLidarrRequestsForUser :many
|
||||
SELECT id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at FROM lidarr_requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListLidarrRequestsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListLidarrRequestsForUser(ctx context.Context, arg ListLidarrRequestsForUserParams) ([]LidarrRequest, error) {
|
||||
rows, err := q.db.Query(ctx, listLidarrRequestsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []LidarrRequest
|
||||
for rows.Next() {
|
||||
var i LidarrRequest
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markLidarrRequestAddConfirmed = `-- name: MarkLidarrRequestAddConfirmed :exec
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_add_confirmed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'approved'
|
||||
AND lidarr_add_confirmed_at IS NULL
|
||||
`
|
||||
|
||||
// Idempotently records that Lidarr accepted the add for an approved
|
||||
// request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
|
||||
// reconciler retry a no-op rather than bumping updated_at every tick.
|
||||
func (q *Queries) MarkLidarrRequestAddConfirmed(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, markLidarrRequestAddConfirmed, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const rejectLidarrRequest = `-- name: RejectLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'rejected',
|
||||
notes = $2,
|
||||
decided_at = now(),
|
||||
decided_by = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid, artist_name, album_title, track_title, quality_profile_id, root_folder_path, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at, lidarr_add_confirmed_at
|
||||
`
|
||||
|
||||
type RejectLidarrRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
Notes *string
|
||||
DecidedBy pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) RejectLidarrRequest(ctx context.Context, arg RejectLidarrRequestParams) (LidarrRequest, error) {
|
||||
row := q.db.QueryRow(ctx, rejectLidarrRequest, arg.ID, arg.Notes, arg.DecidedBy)
|
||||
var i LidarrRequest
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.LidarrArtistMbid,
|
||||
&i.LidarrAlbumMbid,
|
||||
&i.LidarrTrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.QualityProfileID,
|
||||
&i.RootFolderPath,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.LidarrAddConfirmedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const setLidarrRequestAlbumMbid = `-- name: SetLidarrRequestAlbumMbid :exec
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_album_mbid = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type SetLidarrRequestAlbumMbidParams struct {
|
||||
ID pgtype.UUID
|
||||
LidarrAlbumMbid *string
|
||||
}
|
||||
|
||||
// #5241: repoint a request at the album's release group. Requests made before
|
||||
// the release-group read named albums by MusicBrainz release id, which Lidarr
|
||||
// does not index; the reconciler rewrites them once it has resolved the group.
|
||||
func (q *Queries) SetLidarrRequestAlbumMbid(ctx context.Context, arg SetLidarrRequestAlbumMbidParams) error {
|
||||
_, err := q.db.Exec(ctx, setLidarrRequestAlbumMbid, arg.ID, arg.LidarrAlbumMbid)
|
||||
return err
|
||||
}
|
||||
@@ -233,24 +233,6 @@ func (q *Queries) MergeRepointContextualLikes(ctx context.Context, arg MergeRepo
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const mergeRepointLidarrRequests = `-- name: MergeRepointLidarrRequests :execrows
|
||||
UPDATE lidarr_requests SET matched_track_id = $1::uuid
|
||||
WHERE matched_track_id = $2::uuid
|
||||
`
|
||||
|
||||
type MergeRepointLidarrRequestsParams struct {
|
||||
SurvivorID pgtype.UUID
|
||||
LoserID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) MergeRepointLidarrRequests(ctx context.Context, arg MergeRepointLidarrRequestsParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, mergeRepointLidarrRequests, arg.SurvivorID, arg.LoserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const mergeRepointPlayEvents = `-- name: MergeRepointPlayEvents :execrows
|
||||
|
||||
UPDATE play_events SET track_id = $1::uuid WHERE track_id = $2::uuid
|
||||
@@ -321,6 +303,24 @@ func (q *Queries) MergeRepointPlaylistTracks(ctx context.Context, arg MergeRepoi
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const mergeRepointRequests = `-- name: MergeRepointRequests :execrows
|
||||
UPDATE requests SET matched_track_id = $1::uuid
|
||||
WHERE matched_track_id = $2::uuid
|
||||
`
|
||||
|
||||
type MergeRepointRequestsParams struct {
|
||||
SurvivorID pgtype.UUID
|
||||
LoserID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) MergeRepointRequests(ctx context.Context, arg MergeRepointRequestsParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, mergeRepointRequests, arg.SurvivorID, arg.LoserID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const mergeRepointSkipEvents = `-- name: MergeRepointSkipEvents :execrows
|
||||
UPDATE skip_events SET track_id = $1::uuid WHERE track_id = $2::uuid
|
||||
`
|
||||
|
||||
+105
-77
@@ -99,92 +99,92 @@ func (ns NullLidarrQuarantineReason) Value() (driver.Value, error) {
|
||||
return string(ns.LidarrQuarantineReason), nil
|
||||
}
|
||||
|
||||
type LidarrRequestKind string
|
||||
type RequestKind string
|
||||
|
||||
const (
|
||||
LidarrRequestKindArtist LidarrRequestKind = "artist"
|
||||
LidarrRequestKindAlbum LidarrRequestKind = "album"
|
||||
LidarrRequestKindTrack LidarrRequestKind = "track"
|
||||
RequestKindArtist RequestKind = "artist"
|
||||
RequestKindAlbum RequestKind = "album"
|
||||
RequestKindTrack RequestKind = "track"
|
||||
)
|
||||
|
||||
func (e *LidarrRequestKind) Scan(src interface{}) error {
|
||||
func (e *RequestKind) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = LidarrRequestKind(s)
|
||||
*e = RequestKind(s)
|
||||
case string:
|
||||
*e = LidarrRequestKind(s)
|
||||
*e = RequestKind(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for LidarrRequestKind: %T", src)
|
||||
return fmt.Errorf("unsupported scan type for RequestKind: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullLidarrRequestKind struct {
|
||||
LidarrRequestKind LidarrRequestKind
|
||||
Valid bool // Valid is true if LidarrRequestKind is not NULL
|
||||
type NullRequestKind struct {
|
||||
RequestKind RequestKind
|
||||
Valid bool // Valid is true if RequestKind is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullLidarrRequestKind) Scan(value interface{}) error {
|
||||
func (ns *NullRequestKind) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.LidarrRequestKind, ns.Valid = "", false
|
||||
ns.RequestKind, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.LidarrRequestKind.Scan(value)
|
||||
return ns.RequestKind.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullLidarrRequestKind) Value() (driver.Value, error) {
|
||||
func (ns NullRequestKind) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.LidarrRequestKind), nil
|
||||
return string(ns.RequestKind), nil
|
||||
}
|
||||
|
||||
type LidarrRequestStatus string
|
||||
type RequestStatus string
|
||||
|
||||
const (
|
||||
LidarrRequestStatusPending LidarrRequestStatus = "pending"
|
||||
LidarrRequestStatusApproved LidarrRequestStatus = "approved"
|
||||
LidarrRequestStatusRejected LidarrRequestStatus = "rejected"
|
||||
LidarrRequestStatusCompleted LidarrRequestStatus = "completed"
|
||||
LidarrRequestStatusFailed LidarrRequestStatus = "failed"
|
||||
RequestStatusPending RequestStatus = "pending"
|
||||
RequestStatusApproved RequestStatus = "approved"
|
||||
RequestStatusRejected RequestStatus = "rejected"
|
||||
RequestStatusCompleted RequestStatus = "completed"
|
||||
RequestStatusFailed RequestStatus = "failed"
|
||||
)
|
||||
|
||||
func (e *LidarrRequestStatus) Scan(src interface{}) error {
|
||||
func (e *RequestStatus) Scan(src interface{}) error {
|
||||
switch s := src.(type) {
|
||||
case []byte:
|
||||
*e = LidarrRequestStatus(s)
|
||||
*e = RequestStatus(s)
|
||||
case string:
|
||||
*e = LidarrRequestStatus(s)
|
||||
*e = RequestStatus(s)
|
||||
default:
|
||||
return fmt.Errorf("unsupported scan type for LidarrRequestStatus: %T", src)
|
||||
return fmt.Errorf("unsupported scan type for RequestStatus: %T", src)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NullLidarrRequestStatus struct {
|
||||
LidarrRequestStatus LidarrRequestStatus
|
||||
Valid bool // Valid is true if LidarrRequestStatus is not NULL
|
||||
type NullRequestStatus struct {
|
||||
RequestStatus RequestStatus
|
||||
Valid bool // Valid is true if RequestStatus is not NULL
|
||||
}
|
||||
|
||||
// Scan implements the Scanner interface.
|
||||
func (ns *NullLidarrRequestStatus) Scan(value interface{}) error {
|
||||
func (ns *NullRequestStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
ns.LidarrRequestStatus, ns.Valid = "", false
|
||||
ns.RequestStatus, ns.Valid = "", false
|
||||
return nil
|
||||
}
|
||||
ns.Valid = true
|
||||
return ns.LidarrRequestStatus.Scan(value)
|
||||
return ns.RequestStatus.Scan(value)
|
||||
}
|
||||
|
||||
// Value implements the driver Valuer interface.
|
||||
func (ns NullLidarrRequestStatus) Value() (driver.Value, error) {
|
||||
func (ns NullRequestStatus) Value() (driver.Value, error) {
|
||||
if !ns.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
return string(ns.LidarrRequestStatus), nil
|
||||
return string(ns.RequestStatus), nil
|
||||
}
|
||||
|
||||
type AcoustidSetting struct {
|
||||
@@ -384,6 +384,24 @@ type FingerprintSetting struct {
|
||||
AutoResolve bool
|
||||
}
|
||||
|
||||
type Follow struct {
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
ProfileID pgtype.UUID
|
||||
FollowedBy pgtype.UUID
|
||||
FollowedAt pgtype.Timestamptz
|
||||
BaselineAt pgtype.Timestamptz
|
||||
WantExisting bool
|
||||
CheckedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type FollowSeenGroup struct {
|
||||
ArtistMbid string
|
||||
ReleaseGroupMbid string
|
||||
SeenAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type GeneralLike struct {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
@@ -443,31 +461,6 @@ type LidarrQuarantineAction struct {
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type LidarrRequest struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Status LidarrRequestStatus
|
||||
Kind LidarrRequestKind
|
||||
LidarrArtistMbid string
|
||||
LidarrAlbumMbid *string
|
||||
LidarrTrackMbid *string
|
||||
ArtistName string
|
||||
AlbumTitle *string
|
||||
TrackTitle *string
|
||||
QualityProfileID *int32
|
||||
RootFolderPath *string
|
||||
DecidedAt pgtype.Timestamptz
|
||||
DecidedBy pgtype.UUID
|
||||
Notes *string
|
||||
CompletedAt pgtype.Timestamptz
|
||||
MatchedTrackID pgtype.UUID
|
||||
MatchedAlbumID pgtype.UUID
|
||||
MatchedArtistID pgtype.UUID
|
||||
RequestedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
LidarrAddConfirmedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ListenbrainzSimilarRecording struct {
|
||||
SeedTrackID pgtype.UUID
|
||||
RecordingMbid string
|
||||
@@ -535,16 +528,6 @@ type MbReleaseTrack struct {
|
||||
RecordingMbid string
|
||||
}
|
||||
|
||||
type MissingReacquisition struct {
|
||||
AlbumID pgtype.UUID
|
||||
Attempts int32
|
||||
LastAttemptAt pgtype.Timestamptz
|
||||
LastRequestID pgtype.UUID
|
||||
GaveUpAt pgtype.Timestamptz
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type NetworkSetting struct {
|
||||
ID bool
|
||||
TrustedProxyHops int32
|
||||
@@ -636,14 +619,10 @@ type PlaylistTrack struct {
|
||||
}
|
||||
|
||||
type ReacquisitionSetting struct {
|
||||
ID bool
|
||||
Enabled bool
|
||||
GraceHours int32
|
||||
BackoffBaseHours int32
|
||||
BackoffMaxHours int32
|
||||
MaxAttempts int32
|
||||
MaxPerPass int32
|
||||
AutoApprove bool
|
||||
ID bool
|
||||
Enabled bool
|
||||
GraceHours int32
|
||||
MaxPerPass int32
|
||||
}
|
||||
|
||||
type RecommendationTuningAudit struct {
|
||||
@@ -673,6 +652,38 @@ type RegistrationSetting struct {
|
||||
Mode string
|
||||
}
|
||||
|
||||
type ReleaseTypeProfile struct {
|
||||
ID pgtype.UUID
|
||||
Name string
|
||||
PrimaryTypes []string
|
||||
SecondaryTypes []string
|
||||
IsDefault bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Status RequestStatus
|
||||
Kind RequestKind
|
||||
ArtistMbid string
|
||||
AlbumMbid *string
|
||||
TrackMbid *string
|
||||
ArtistName string
|
||||
AlbumTitle *string
|
||||
TrackTitle *string
|
||||
DecidedAt pgtype.Timestamptz
|
||||
DecidedBy pgtype.UUID
|
||||
Notes *string
|
||||
CompletedAt pgtype.Timestamptz
|
||||
MatchedTrackID pgtype.UUID
|
||||
MatchedAlbumID pgtype.UUID
|
||||
MatchedArtistID pgtype.UUID
|
||||
RequestedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ScanRun struct {
|
||||
ID pgtype.UUID
|
||||
StartedAt pgtype.Timestamptz
|
||||
@@ -950,6 +961,23 @@ type UserNotificationPref struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Want struct {
|
||||
ID pgtype.UUID
|
||||
ReleaseGroupMbid string
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
Title string
|
||||
PrimaryType string
|
||||
FirstReleaseDate string
|
||||
Reason string
|
||||
AlbumID pgtype.UUID
|
||||
CreatedBy pgtype.UUID
|
||||
WantedAt pgtype.Timestamptz
|
||||
FulfilledAt pgtype.Timestamptz
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type YouMightLikeAlbum struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
|
||||
@@ -11,27 +11,6 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const clearRecoveredReacquisitions = `-- name: ClearRecoveredReacquisitions :execrows
|
||||
DELETE FROM missing_reacquisitions r
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tracks
|
||||
WHERE tracks.album_id = r.album_id
|
||||
AND tracks.missing_since IS NOT NULL
|
||||
)
|
||||
`
|
||||
|
||||
// Drops state for albums that no longer have any missing track — the files
|
||||
// came back, or the scanner adopted them at a new path (#2528). Deleting
|
||||
// rather than resetting counters means a future loss starts from a clean
|
||||
// budget, which is right: it is a new problem, not a continuation.
|
||||
func (q *Queries) ClearRecoveredReacquisitions(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, clearRecoveredReacquisitions)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const countAlbumsMissingWithoutMbid = `-- name: CountAlbumsMissingWithoutMbid :one
|
||||
SELECT COUNT(DISTINCT albums.id)::bigint
|
||||
FROM albums
|
||||
@@ -41,10 +20,9 @@ SELECT COUNT(DISTINCT albums.id)::bigint
|
||||
AND (albums.mbid IS NULL OR artists.mbid IS NULL)
|
||||
`
|
||||
|
||||
// Albums with missing files that can never be auto-requested because nothing
|
||||
// identifies them to MusicBrainz. Surfaced on the admin card so the gap is
|
||||
// visible: silently doing nothing for these would read as the feature being
|
||||
// broken.
|
||||
// Albums with missing files that can never be wanted automatically because
|
||||
// nothing identifies them to MusicBrainz. Surfaced on the admin card so the
|
||||
// gap is visible: silently doing nothing for these would read as broken.
|
||||
func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countAlbumsMissingWithoutMbid)
|
||||
var column_1 int64
|
||||
@@ -52,29 +30,56 @@ func (q *Queries) CountAlbumsMissingWithoutMbid(ctx context.Context) (int64, err
|
||||
return column_1, err
|
||||
}
|
||||
|
||||
const getReacquisitionForAlbums = `-- name: GetReacquisitionForAlbums :many
|
||||
SELECT album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at FROM missing_reacquisitions WHERE album_id = ANY($1::uuid[])
|
||||
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
|
||||
|
||||
SELECT id, enabled, grace_hours, max_per_pass FROM reacquisition_settings WHERE id = true
|
||||
`
|
||||
|
||||
// State for the admin missing-files surface, so each directory group can say
|
||||
// whether a re-acquisition is in flight, waiting, or given up.
|
||||
func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]MissingReacquisition, error) {
|
||||
rows, err := q.db.Query(ctx, getReacquisitionForAlbums, albumIds)
|
||||
// Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
|
||||
// The unit is the album: an album whose files went missing becomes a want for
|
||||
// its release group, and the search schedule (#5521) looks for it.
|
||||
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
|
||||
row := q.db.QueryRow(ctx, getReacquisitionSettings)
|
||||
var i ReacquisitionSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Enabled,
|
||||
&i.GraceHours,
|
||||
&i.MaxPerPass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWantsForAlbums = `-- name: GetWantsForAlbums :many
|
||||
SELECT a.id AS album_id, w.id AS want_id, w.wanted_at, w.fulfilled_at
|
||||
FROM albums a
|
||||
JOIN wants w ON w.album_id = a.id OR w.release_group_mbid = a.release_group_mbid
|
||||
WHERE a.id = ANY($1::uuid[])
|
||||
ORDER BY w.wanted_at DESC
|
||||
`
|
||||
|
||||
type GetWantsForAlbumsRow struct {
|
||||
AlbumID pgtype.UUID
|
||||
WantID pgtype.UUID
|
||||
WantedAt pgtype.Timestamptz
|
||||
FulfilledAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// What the wanted list says about each album on the missing-files page.
|
||||
func (q *Queries) GetWantsForAlbums(ctx context.Context, albumIds []pgtype.UUID) ([]GetWantsForAlbumsRow, error) {
|
||||
rows, err := q.db.Query(ctx, getWantsForAlbums, albumIds)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []MissingReacquisition
|
||||
var items []GetWantsForAlbumsRow
|
||||
for rows.Next() {
|
||||
var i MissingReacquisition
|
||||
var i GetWantsForAlbumsRow
|
||||
if err := rows.Scan(
|
||||
&i.AlbumID,
|
||||
&i.Attempts,
|
||||
&i.LastAttemptAt,
|
||||
&i.LastRequestID,
|
||||
&i.GaveUpAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.WantID,
|
||||
&i.WantedAt,
|
||||
&i.FulfilledAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -86,67 +91,35 @@ func (q *Queries) GetReacquisitionForAlbums(ctx context.Context, albumIds []pgty
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getReacquisitionSettings = `-- name: GetReacquisitionSettings :one
|
||||
|
||||
SELECT id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve FROM reacquisition_settings WHERE id = true
|
||||
`
|
||||
|
||||
// Auto re-acquisition of missing files (milestone #290). The unit is the
|
||||
// album: Lidarr acquires releases, and grouping collapses "40 missing files"
|
||||
// into "3 albums to ask for".
|
||||
func (q *Queries) GetReacquisitionSettings(ctx context.Context) (ReacquisitionSetting, error) {
|
||||
row := q.db.QueryRow(ctx, getReacquisitionSettings)
|
||||
var i ReacquisitionSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Enabled,
|
||||
&i.GraceHours,
|
||||
&i.BackoffBaseHours,
|
||||
&i.BackoffMaxHours,
|
||||
&i.MaxAttempts,
|
||||
&i.MaxPerPass,
|
||||
&i.AutoApprove,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listAlbumsDueReacquisition = `-- name: ListAlbumsDueReacquisition :many
|
||||
SELECT albums.id AS album_id,
|
||||
albums.title AS album_title,
|
||||
albums.mbid AS album_mbid,
|
||||
SELECT albums.id AS album_id,
|
||||
albums.title AS album_title,
|
||||
albums.mbid AS album_mbid,
|
||||
albums.release_group_mbid AS album_release_group_mbid,
|
||||
artists.id AS artist_id,
|
||||
artists.name AS artist_name,
|
||||
artists.mbid AS artist_mbid,
|
||||
COUNT(tracks.id)::bigint AS missing_track_count,
|
||||
COALESCE(r.attempts, 0)::int AS attempts
|
||||
artists.name AS artist_name,
|
||||
artists.mbid AS artist_mbid,
|
||||
COUNT(tracks.id)::bigint AS missing_track_count,
|
||||
MAX(tracks.missing_since)::timestamptz AS last_missing_since
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
JOIN tracks ON tracks.album_id = albums.id
|
||||
LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id
|
||||
WHERE tracks.missing_since IS NOT NULL
|
||||
AND tracks.missing_since <= now() - make_interval(hours => $1::int)
|
||||
AND albums.mbid IS NOT NULL
|
||||
AND artists.mbid IS NOT NULL
|
||||
AND (r.gave_up_at IS NULL)
|
||||
AND (
|
||||
r.last_attempt_at IS NULL
|
||||
OR r.last_attempt_at <= now() - make_interval(hours => LEAST(
|
||||
($2::int
|
||||
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
|
||||
$3::int))
|
||||
)
|
||||
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
|
||||
artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at
|
||||
ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title
|
||||
LIMIT $4
|
||||
artists.name, artists.mbid
|
||||
HAVING NOT EXISTS (
|
||||
SELECT 1 FROM wants w
|
||||
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
|
||||
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
|
||||
ORDER BY MIN(tracks.missing_since), albums.sort_title
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListAlbumsDueReacquisitionParams struct {
|
||||
GraceHours int32
|
||||
BackoffBaseHours int32
|
||||
BackoffMaxHours int32
|
||||
PageLimit int32
|
||||
GraceHours int32
|
||||
PageLimit int32
|
||||
}
|
||||
|
||||
type ListAlbumsDueReacquisitionRow struct {
|
||||
@@ -154,37 +127,24 @@ type ListAlbumsDueReacquisitionRow struct {
|
||||
AlbumTitle string
|
||||
AlbumMbid *string
|
||||
AlbumReleaseGroupMbid *string
|
||||
ArtistID pgtype.UUID
|
||||
ArtistName string
|
||||
ArtistMbid *string
|
||||
MissingTrackCount int64
|
||||
Attempts int32
|
||||
LastMissingSince pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// The sweeper's selection. An album qualifies when:
|
||||
//
|
||||
// - it still has at least one track whose file has been missing longer than
|
||||
// the grace window. Measured on missing_since, which reconcile never
|
||||
// re-stamps (#2523), so it is a genuine "gone since" clock rather than
|
||||
// "when we last noticed";
|
||||
// - MusicBrainz can name it. Both the album and its artist MBID are
|
||||
// required — Create rejects an album-kind request without them, and there
|
||||
// is nothing to ask Lidarr for anyway. Albums failing this are counted
|
||||
// separately (CountAlbumsMissingWithoutMbid) rather than vanishing;
|
||||
// - it has not spent its attempt budget (gave_up_at IS NULL);
|
||||
// - its backoff has elapsed: base * 2^(attempts-1) hours since the last
|
||||
// attempt, clamped to the configured maximum. First attempt (no row, or
|
||||
// last_attempt_at NULL) is always due.
|
||||
//
|
||||
// Oldest attempt first, never-attempted first, so a large loss drains in a
|
||||
// stable order across passes instead of re-picking the same head each time.
|
||||
// re-stamps (#2523), so it is a genuine "gone since" clock;
|
||||
// - MusicBrainz can name it: the album and its artist have MBIDs. Albums
|
||||
// failing this are counted separately (CountAlbumsMissingWithoutMbid);
|
||||
// - no want already covers it: none open, and none held since its files
|
||||
// went missing. A want held after the loss means the library has a full
|
||||
// copy despite the missing rows (#5245), so asking again would loop.
|
||||
func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbumsDueReacquisitionParams) ([]ListAlbumsDueReacquisitionRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition,
|
||||
arg.GraceHours,
|
||||
arg.BackoffBaseHours,
|
||||
arg.BackoffMaxHours,
|
||||
arg.PageLimit,
|
||||
)
|
||||
rows, err := q.db.Query(ctx, listAlbumsDueReacquisition, arg.GraceHours, arg.PageLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -197,11 +157,10 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
|
||||
&i.AlbumTitle,
|
||||
&i.AlbumMbid,
|
||||
&i.AlbumReleaseGroupMbid,
|
||||
&i.ArtistID,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
&i.MissingTrackCount,
|
||||
&i.Attempts,
|
||||
&i.LastMissingSince,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -213,101 +172,83 @@ func (q *Queries) ListAlbumsDueReacquisition(ctx context.Context, arg ListAlbums
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markReacquisitionGaveUp = `-- name: MarkReacquisitionGaveUp :exec
|
||||
UPDATE missing_reacquisitions
|
||||
SET gave_up_at = now(),
|
||||
updated_at = now()
|
||||
WHERE album_id = $1
|
||||
AND gave_up_at IS NULL
|
||||
`
|
||||
|
||||
// Stamped when the attempt budget is spent. Stored as a timestamp rather than
|
||||
// inferred from `attempts >= max_attempts` so the verdict survives an operator
|
||||
// later raising the maximum, and so the admin surface can say when.
|
||||
func (q *Queries) MarkReacquisitionGaveUp(ctx context.Context, albumID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, markReacquisitionGaveUp, albumID)
|
||||
return err
|
||||
}
|
||||
|
||||
const recordReacquisitionAttempt = `-- name: RecordReacquisitionAttempt :one
|
||||
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
|
||||
VALUES ($1, 1, now(), $2)
|
||||
ON CONFLICT (album_id) DO UPDATE
|
||||
SET attempts = missing_reacquisitions.attempts + 1,
|
||||
last_attempt_at = now(),
|
||||
last_request_id = COALESCE(EXCLUDED.last_request_id,
|
||||
missing_reacquisitions.last_request_id),
|
||||
updated_at = now()
|
||||
RETURNING album_id, attempts, last_attempt_at, last_request_id, gave_up_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type RecordReacquisitionAttemptParams struct {
|
||||
AlbumID pgtype.UUID
|
||||
LastRequestID pgtype.UUID
|
||||
}
|
||||
|
||||
// Bumps the attempt counter and stamps the clock the backoff measures from.
|
||||
// Upsert because the first attempt has no row yet.
|
||||
func (q *Queries) RecordReacquisitionAttempt(ctx context.Context, arg RecordReacquisitionAttemptParams) (MissingReacquisition, error) {
|
||||
row := q.db.QueryRow(ctx, recordReacquisitionAttempt, arg.AlbumID, arg.LastRequestID)
|
||||
var i MissingReacquisition
|
||||
err := row.Scan(
|
||||
&i.AlbumID,
|
||||
&i.Attempts,
|
||||
&i.LastAttemptAt,
|
||||
&i.LastRequestID,
|
||||
&i.GaveUpAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateReacquisitionSettings = `-- name: UpdateReacquisitionSettings :one
|
||||
UPDATE reacquisition_settings
|
||||
SET enabled = $1,
|
||||
grace_hours = $2,
|
||||
backoff_base_hours = $3,
|
||||
backoff_max_hours = $4,
|
||||
max_attempts = $5,
|
||||
max_per_pass = $6,
|
||||
auto_approve = $7
|
||||
SET enabled = $1,
|
||||
grace_hours = $2,
|
||||
max_per_pass = $3
|
||||
WHERE id = true
|
||||
RETURNING id, enabled, grace_hours, backoff_base_hours, backoff_max_hours, max_attempts, max_per_pass, auto_approve
|
||||
RETURNING id, enabled, grace_hours, max_per_pass
|
||||
`
|
||||
|
||||
type UpdateReacquisitionSettingsParams struct {
|
||||
Enabled bool
|
||||
GraceHours int32
|
||||
BackoffBaseHours int32
|
||||
BackoffMaxHours int32
|
||||
MaxAttempts int32
|
||||
MaxPerPass int32
|
||||
AutoApprove bool
|
||||
Enabled bool
|
||||
GraceHours int32
|
||||
MaxPerPass int32
|
||||
}
|
||||
|
||||
// Whole-row write from the admin card; the CHECKs in migration 0056 are the
|
||||
// validation, so a bad value fails loudly rather than being clamped silently.
|
||||
func (q *Queries) UpdateReacquisitionSettings(ctx context.Context, arg UpdateReacquisitionSettingsParams) (ReacquisitionSetting, error) {
|
||||
row := q.db.QueryRow(ctx, updateReacquisitionSettings,
|
||||
arg.Enabled,
|
||||
arg.GraceHours,
|
||||
arg.BackoffBaseHours,
|
||||
arg.BackoffMaxHours,
|
||||
arg.MaxAttempts,
|
||||
arg.MaxPerPass,
|
||||
arg.AutoApprove,
|
||||
)
|
||||
row := q.db.QueryRow(ctx, updateReacquisitionSettings, arg.Enabled, arg.GraceHours, arg.MaxPerPass)
|
||||
var i ReacquisitionSetting
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Enabled,
|
||||
&i.GraceHours,
|
||||
&i.BackoffBaseHours,
|
||||
&i.BackoffMaxHours,
|
||||
&i.MaxAttempts,
|
||||
&i.MaxPerPass,
|
||||
&i.AutoApprove,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const wantMissingAlbum = `-- name: WantMissingAlbum :one
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
|
||||
VALUES ($1, $2, $3,
|
||||
$4, 'missing', $5)
|
||||
ON CONFLICT (release_group_mbid) DO UPDATE
|
||||
SET reason = 'missing',
|
||||
album_id = EXCLUDED.album_id,
|
||||
wanted_at = now(),
|
||||
fulfilled_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE wants.fulfilled_at IS NOT NULL
|
||||
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type WantMissingAlbumParams struct {
|
||||
ReleaseGroupMbid string
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
Title string
|
||||
AlbumID pgtype.UUID
|
||||
}
|
||||
|
||||
// Records an album with missing files as wanted. A held want for the same
|
||||
// group is reopened; an open one is left as it is, and no row comes back.
|
||||
func (q *Queries) WantMissingAlbum(ctx context.Context, arg WantMissingAlbumParams) (Want, error) {
|
||||
row := q.db.QueryRow(ctx, wantMissingAlbum,
|
||||
arg.ReleaseGroupMbid,
|
||||
arg.ArtistMbid,
|
||||
arg.ArtistName,
|
||||
arg.Title,
|
||||
arg.AlbumID,
|
||||
)
|
||||
var i Want
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.Title,
|
||||
&i.PrimaryType,
|
||||
&i.FirstReleaseDate,
|
||||
&i.Reason,
|
||||
&i.AlbumID,
|
||||
&i.CreatedBy,
|
||||
&i.WantedAt,
|
||||
&i.FulfilledAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -1142,9 +1142,9 @@ contributions AS (
|
||||
JOIN seeds ON seeds.artist_id = u.seed_artist_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests r
|
||||
SELECT 1 FROM requests r
|
||||
WHERE r.user_id = $1
|
||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||
AND r.artist_mbid = u.candidate_mbid
|
||||
AND r.status NOT IN ('rejected', 'failed')
|
||||
)
|
||||
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: requests.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const approveRequest = `-- name: ApproveRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'approved',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
`
|
||||
|
||||
type ApproveRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
DecidedBy pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) ApproveRequest(ctx context.Context, arg ApproveRequestParams) (Request, error) {
|
||||
row := q.db.QueryRow(ctx, approveRequest, arg.ID, arg.DecidedBy)
|
||||
var i Request
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const cancelRequest = `-- name: CancelRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'rejected',
|
||||
notes = 'cancelled by user',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
`
|
||||
|
||||
type CancelRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
DecidedBy pgtype.UUID
|
||||
}
|
||||
|
||||
// $2 serves as both the ownership guard (user_id = $2) and the
|
||||
// decided_by audit field. The generated parameter struct names it
|
||||
// DecidedBy — callers must pass the cancelling user's UUID, and
|
||||
// a wrong UUID will silently no-op (zero rows updated) rather than
|
||||
// erroring at the SQL layer.
|
||||
func (q *Queries) CancelRequest(ctx context.Context, arg CancelRequestParams) (Request, error) {
|
||||
row := q.db.QueryRow(ctx, cancelRequest, arg.ID, arg.DecidedBy)
|
||||
var i Request
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const completeRequest = `-- name: CompleteRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'completed',
|
||||
matched_track_id = $2,
|
||||
matched_album_id = $3,
|
||||
matched_artist_id = $4,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
`
|
||||
|
||||
type CompleteRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
MatchedTrackID pgtype.UUID
|
||||
MatchedAlbumID pgtype.UUID
|
||||
MatchedArtistID pgtype.UUID
|
||||
}
|
||||
|
||||
// Reconciler transitions an approved request to completed when its
|
||||
// target track/album/artist has appeared in the library. Callers
|
||||
// should set ONLY the matched_*_id corresponding to the request's
|
||||
// kind and pass pgtype.UUID{} (Valid: false) for the others —
|
||||
// per-kind one-of-three is the contract, NULL for the others
|
||||
// preserves auditability if the matched row is later evicted.
|
||||
func (q *Queries) CompleteRequest(ctx context.Context, arg CompleteRequestParams) (Request, error) {
|
||||
row := q.db.QueryRow(ctx, completeRequest,
|
||||
arg.ID,
|
||||
arg.MatchedTrackID,
|
||||
arg.MatchedAlbumID,
|
||||
arg.MatchedArtistID,
|
||||
)
|
||||
var i Request
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createRequest = `-- name: CreateRequest :one
|
||||
INSERT INTO requests (
|
||||
user_id, kind,
|
||||
artist_mbid, album_mbid, track_mbid,
|
||||
artist_name, album_title, track_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
`
|
||||
|
||||
type CreateRequestParams struct {
|
||||
UserID pgtype.UUID
|
||||
Kind RequestKind
|
||||
ArtistMbid string
|
||||
AlbumMbid *string
|
||||
TrackMbid *string
|
||||
ArtistName string
|
||||
AlbumTitle *string
|
||||
TrackTitle *string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRequest(ctx context.Context, arg CreateRequestParams) (Request, error) {
|
||||
row := q.db.QueryRow(ctx, createRequest,
|
||||
arg.UserID,
|
||||
arg.Kind,
|
||||
arg.ArtistMbid,
|
||||
arg.AlbumMbid,
|
||||
arg.TrackMbid,
|
||||
arg.ArtistName,
|
||||
arg.AlbumTitle,
|
||||
arg.TrackTitle,
|
||||
)
|
||||
var i Request
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getNonTerminalRequestForMBID = `-- name: GetNonTerminalRequestForMBID :one
|
||||
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND artist_mbid = $1)
|
||||
OR (kind = 'album' AND album_mbid = $1)
|
||||
OR (kind = 'track' AND track_mbid = $1))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// Returns the oldest non-terminal (pending/approved/completed) request
|
||||
// whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
// uses this to dedupe: a re-request for something already in flight
|
||||
// returns the existing row instead of inserting a duplicate.
|
||||
func (q *Queries) GetNonTerminalRequestForMBID(ctx context.Context, mbid string) (Request, error) {
|
||||
row := q.db.QueryRow(ctx, getNonTerminalRequestForMBID, mbid)
|
||||
var i Request
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getRequestByID = `-- name: GetRequestByID :one
|
||||
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetRequestByID(ctx context.Context, id pgtype.UUID) (Request, error) {
|
||||
row := q.db.QueryRow(ctx, getRequestByID, id)
|
||||
var i Request
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const hasNonTerminalRequestForMBID = `-- name: HasNonTerminalRequestForMBID :one
|
||||
SELECT (EXISTS (
|
||||
SELECT 1 FROM requests r
|
||||
WHERE r.status IN ('pending', 'approved', 'completed')
|
||||
AND ((r.kind = 'artist' AND r.artist_mbid = $1)
|
||||
OR (r.kind = 'album' AND r.album_mbid = $1)
|
||||
OR (r.kind = 'track' AND r.track_mbid = $1))
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM wants w WHERE w.release_group_mbid = $1 AND w.fulfilled_at IS NULL
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM follows f WHERE f.artist_mbid = $1
|
||||
))::bool AS requested
|
||||
`
|
||||
|
||||
// Whether something is already on its way: a pending, approved or completed
|
||||
// request at the given level, an open want for the release group, or a
|
||||
// follow of the artist. Sets the `requested` flag on catalogue search.
|
||||
// Terminal-status (rejected, failed) requests do not count. MBIDs are unique
|
||||
// per entity type, so checking one id against every column cannot
|
||||
// false-match.
|
||||
func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, mbid string) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, mbid)
|
||||
var requested bool
|
||||
err := row.Scan(&requested)
|
||||
return requested, err
|
||||
}
|
||||
|
||||
const listApprovedRequestsForReconcile = `-- name: ListApprovedRequestsForReconcile :many
|
||||
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
|
||||
WHERE status = 'approved'
|
||||
ORDER BY decided_at ASC
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
func (q *Queries) ListApprovedRequestsForReconcile(ctx context.Context, limit int32) ([]Request, error) {
|
||||
rows, err := q.db.Query(ctx, listApprovedRequestsForReconcile, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Request
|
||||
for rows.Next() {
|
||||
var i Request
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRequestsByStatus = `-- name: ListRequestsByStatus :many
|
||||
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListRequestsByStatusParams struct {
|
||||
Status RequestStatus
|
||||
Limit int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListRequestsByStatus(ctx context.Context, arg ListRequestsByStatusParams) ([]Request, error) {
|
||||
rows, err := q.db.Query(ctx, listRequestsByStatus, arg.Status, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Request
|
||||
for rows.Next() {
|
||||
var i Request
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRequestsForUser = `-- name: ListRequestsForUser :many
|
||||
SELECT id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at FROM requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListRequestsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListRequestsForUser(ctx context.Context, arg ListRequestsForUserParams) ([]Request, error) {
|
||||
rows, err := q.db.Query(ctx, listRequestsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Request
|
||||
for rows.Next() {
|
||||
var i Request
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const rejectRequest = `-- name: RejectRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'rejected',
|
||||
notes = $2,
|
||||
decided_at = now(),
|
||||
decided_by = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING id, user_id, status, kind, artist_mbid, album_mbid, track_mbid, artist_name, album_title, track_title, decided_at, decided_by, notes, completed_at, matched_track_id, matched_album_id, matched_artist_id, requested_at, updated_at
|
||||
`
|
||||
|
||||
type RejectRequestParams struct {
|
||||
ID pgtype.UUID
|
||||
Notes *string
|
||||
DecidedBy pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) RejectRequest(ctx context.Context, arg RejectRequestParams) (Request, error) {
|
||||
row := q.db.QueryRow(ctx, rejectRequest, arg.ID, arg.Notes, arg.DecidedBy)
|
||||
var i Request
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Status,
|
||||
&i.Kind,
|
||||
&i.ArtistMbid,
|
||||
&i.AlbumMbid,
|
||||
&i.TrackMbid,
|
||||
&i.ArtistName,
|
||||
&i.AlbumTitle,
|
||||
&i.TrackTitle,
|
||||
&i.DecidedAt,
|
||||
&i.DecidedBy,
|
||||
&i.Notes,
|
||||
&i.CompletedAt,
|
||||
&i.MatchedTrackID,
|
||||
&i.MatchedAlbumID,
|
||||
&i.MatchedArtistID,
|
||||
&i.RequestedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const repointRequestsAlbumMbid = `-- name: RepointRequestsAlbumMbid :exec
|
||||
UPDATE requests
|
||||
SET album_mbid = $1,
|
||||
updated_at = now()
|
||||
WHERE album_mbid = $2
|
||||
`
|
||||
|
||||
type RepointRequestsAlbumMbidParams struct {
|
||||
NewMbid *string
|
||||
OldMbid *string
|
||||
}
|
||||
|
||||
// A want moved from a release id to its group (#5241) takes the requests
|
||||
// that name it along, so the reconciler finds it.
|
||||
func (q *Queries) RepointRequestsAlbumMbid(ctx context.Context, arg RepointRequestsAlbumMbidParams) error {
|
||||
_, err := q.db.Exec(ctx, repointRequestsAlbumMbid, arg.NewMbid, arg.OldMbid)
|
||||
return err
|
||||
}
|
||||
@@ -239,43 +239,6 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOldestAdmin = `-- name: GetOldestAdmin :one
|
||||
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users
|
||||
WHERE is_admin = true
|
||||
ORDER BY created_at, id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The account a system-initiated action is attributed to (milestone #290).
|
||||
// lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
|
||||
// human, so the row is owned by the longest-standing admin: it keeps the
|
||||
// request auditable and puts it in the same admin queue as everything else,
|
||||
// without inventing a synthetic principal the rest of the schema would have
|
||||
// to understand. Ordered by id as a tiebreak so the choice is stable across
|
||||
// calls rather than depending on scan order.
|
||||
func (q *Queries) GetOldestAdmin(ctx context.Context) (User, error) {
|
||||
row := q.db.QueryRow(ctx, getOldestAdmin)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.PasswordHash,
|
||||
&i.IsAdmin,
|
||||
&i.CreatedAt,
|
||||
&i.SubsonicPassword,
|
||||
&i.ListenbrainzToken,
|
||||
&i.ListenbrainzEnabled,
|
||||
&i.DisplayName,
|
||||
&i.AutoApproveRequests,
|
||||
&i.Email,
|
||||
&i.Timezone,
|
||||
&i.TimezoneUpdatedAt,
|
||||
&i.DebugModeEnabled,
|
||||
&i.ApiTokenHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByAPITokenHash = `-- name: GetUserByAPITokenHash :one
|
||||
SELECT id, username, password_hash, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at, debug_mode_enabled, api_token_hash FROM users WHERE api_token_hash = $1
|
||||
`
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: wanted.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const clearDefaultReleaseTypeProfile = `-- name: ClearDefaultReleaseTypeProfile :exec
|
||||
UPDATE release_type_profiles SET is_default = false, updated_at = now() WHERE is_default
|
||||
`
|
||||
|
||||
func (q *Queries) ClearDefaultReleaseTypeProfile(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, clearDefaultReleaseTypeProfile)
|
||||
return err
|
||||
}
|
||||
|
||||
const countWants = `-- name: CountWants :one
|
||||
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
|
||||
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
|
||||
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
|
||||
(SELECT COUNT(*) FROM follows)::bigint AS following
|
||||
FROM wants
|
||||
`
|
||||
|
||||
type CountWantsRow struct {
|
||||
Wanted int64
|
||||
Missing int64
|
||||
Held int64
|
||||
Following int64
|
||||
}
|
||||
|
||||
func (q *Queries) CountWants(ctx context.Context) (CountWantsRow, error) {
|
||||
row := q.db.QueryRow(ctx, countWants)
|
||||
var i CountWantsRow
|
||||
err := row.Scan(
|
||||
&i.Wanted,
|
||||
&i.Missing,
|
||||
&i.Held,
|
||||
&i.Following,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createReleaseTypeProfile = `-- name: CreateReleaseTypeProfile :one
|
||||
INSERT INTO release_type_profiles (name, primary_types, secondary_types)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, primary_types, secondary_types, is_default, created_at, updated_at
|
||||
`
|
||||
|
||||
type CreateReleaseTypeProfileParams struct {
|
||||
Name string
|
||||
PrimaryTypes []string
|
||||
SecondaryTypes []string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateReleaseTypeProfile(ctx context.Context, arg CreateReleaseTypeProfileParams) (ReleaseTypeProfile, error) {
|
||||
row := q.db.QueryRow(ctx, createReleaseTypeProfile, arg.Name, arg.PrimaryTypes, arg.SecondaryTypes)
|
||||
var i ReleaseTypeProfile
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.PrimaryTypes,
|
||||
&i.SecondaryTypes,
|
||||
&i.IsDefault,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteFollow = `-- name: DeleteFollow :execrows
|
||||
DELETE FROM follows WHERE artist_mbid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteFollow(ctx context.Context, artistMbid string) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteFollow, artistMbid)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const deleteReleaseTypeProfile = `-- name: DeleteReleaseTypeProfile :execrows
|
||||
DELETE FROM release_type_profiles WHERE id = $1 AND NOT is_default
|
||||
`
|
||||
|
||||
// The default stays; one in use is refused by the follows foreign key.
|
||||
func (q *Queries) DeleteReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteReleaseTypeProfile, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const deleteWant = `-- name: DeleteWant :execrows
|
||||
DELETE FROM wants WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteWant(ctx context.Context, id pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, deleteWant, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const fulfillWant = `-- name: FulfillWant :exec
|
||||
UPDATE wants
|
||||
SET fulfilled_at = now(),
|
||||
album_id = $1,
|
||||
updated_at = now()
|
||||
WHERE id = $2 AND fulfilled_at IS NULL
|
||||
`
|
||||
|
||||
type FulfillWantParams struct {
|
||||
AlbumID pgtype.UUID
|
||||
ID pgtype.UUID
|
||||
}
|
||||
|
||||
func (q *Queries) FulfillWant(ctx context.Context, arg FulfillWantParams) error {
|
||||
_, err := q.db.Exec(ctx, fulfillWant, arg.AlbumID, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getDefaultReleaseTypeProfile = `-- name: GetDefaultReleaseTypeProfile :one
|
||||
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE is_default
|
||||
`
|
||||
|
||||
func (q *Queries) GetDefaultReleaseTypeProfile(ctx context.Context) (ReleaseTypeProfile, error) {
|
||||
row := q.db.QueryRow(ctx, getDefaultReleaseTypeProfile)
|
||||
var i ReleaseTypeProfile
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.PrimaryTypes,
|
||||
&i.SecondaryTypes,
|
||||
&i.IsDefault,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getFollow = `-- name: GetFollow :one
|
||||
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_by, f.followed_at, f.baseline_at, f.want_existing, f.checked_at, f.updated_at, p.name AS profile_name
|
||||
FROM follows f
|
||||
JOIN release_type_profiles p ON p.id = f.profile_id
|
||||
WHERE f.artist_mbid = $1
|
||||
`
|
||||
|
||||
type GetFollowRow struct {
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
ProfileID pgtype.UUID
|
||||
FollowedBy pgtype.UUID
|
||||
FollowedAt pgtype.Timestamptz
|
||||
BaselineAt pgtype.Timestamptz
|
||||
WantExisting bool
|
||||
CheckedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
ProfileName string
|
||||
}
|
||||
|
||||
func (q *Queries) GetFollow(ctx context.Context, artistMbid string) (GetFollowRow, error) {
|
||||
row := q.db.QueryRow(ctx, getFollow, artistMbid)
|
||||
var i GetFollowRow
|
||||
err := row.Scan(
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.ProfileID,
|
||||
&i.FollowedBy,
|
||||
&i.FollowedAt,
|
||||
&i.BaselineAt,
|
||||
&i.WantExisting,
|
||||
&i.CheckedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.ProfileName,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getReleaseTypeProfile = `-- name: GetReleaseTypeProfile :one
|
||||
SELECT id, name, primary_types, secondary_types, is_default, created_at, updated_at FROM release_type_profiles WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (ReleaseTypeProfile, error) {
|
||||
row := q.db.QueryRow(ctx, getReleaseTypeProfile, id)
|
||||
var i ReleaseTypeProfile
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.PrimaryTypes,
|
||||
&i.SecondaryTypes,
|
||||
&i.IsDefault,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getWantByReleaseGroup = `-- name: GetWantByReleaseGroup :one
|
||||
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants WHERE release_group_mbid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetWantByReleaseGroup(ctx context.Context, releaseGroupMbid string) (Want, error) {
|
||||
row := q.db.QueryRow(ctx, getWantByReleaseGroup, releaseGroupMbid)
|
||||
var i Want
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.Title,
|
||||
&i.PrimaryType,
|
||||
&i.FirstReleaseDate,
|
||||
&i.Reason,
|
||||
&i.AlbumID,
|
||||
&i.CreatedBy,
|
||||
&i.WantedAt,
|
||||
&i.FulfilledAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const libraryHoldsReleaseGroup = `-- name: LibraryHoldsReleaseGroup :one
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM albums a
|
||||
WHERE (a.release_group_mbid = $1::text
|
||||
OR a.mbid = $1
|
||||
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = $1)
|
||||
OR (lower(a.title) = lower($2::text)
|
||||
AND a.artist_id IN (SELECT ar.id FROM artists ar WHERE ar.mbid = $3::text)))
|
||||
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
|
||||
)
|
||||
`
|
||||
|
||||
type LibraryHoldsReleaseGroupParams struct {
|
||||
GroupMbid string
|
||||
Title string
|
||||
ArtistMbid string
|
||||
}
|
||||
|
||||
// Whether the library already has the group: an album tagged with it or
|
||||
// with one of its releases, or failing that an album of the artist's with
|
||||
// the same title. Only albums with a track on disk count.
|
||||
func (q *Queries) LibraryHoldsReleaseGroup(ctx context.Context, arg LibraryHoldsReleaseGroupParams) (bool, error) {
|
||||
row := q.db.QueryRow(ctx, libraryHoldsReleaseGroup, arg.GroupMbid, arg.Title, arg.ArtistMbid)
|
||||
var exists bool
|
||||
err := row.Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
const listFollowGroups = `-- name: ListFollowGroups :many
|
||||
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
|
||||
EXISTS (SELECT 1 FROM follow_seen_groups s
|
||||
WHERE s.artist_mbid = $1 AND s.release_group_mbid = g.mbid) AS seen
|
||||
FROM mb_artist_release_groups ag
|
||||
JOIN mb_release_groups g ON g.mbid = ag.release_group_mbid
|
||||
WHERE ag.artist_mbid = $1
|
||||
ORDER BY g.first_release_date, g.title
|
||||
`
|
||||
|
||||
type ListFollowGroupsRow struct {
|
||||
Mbid string
|
||||
Title string
|
||||
PrimaryType string
|
||||
SecondaryTypes []string
|
||||
FirstReleaseDate string
|
||||
Seen bool
|
||||
}
|
||||
|
||||
// An artist's release groups from the catalogue, each with whether the
|
||||
// follow has judged it before.
|
||||
func (q *Queries) ListFollowGroups(ctx context.Context, artistMbid string) ([]ListFollowGroupsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listFollowGroups, artistMbid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListFollowGroupsRow
|
||||
for rows.Next() {
|
||||
var i ListFollowGroupsRow
|
||||
if err := rows.Scan(
|
||||
&i.Mbid,
|
||||
&i.Title,
|
||||
&i.PrimaryType,
|
||||
&i.SecondaryTypes,
|
||||
&i.FirstReleaseDate,
|
||||
&i.Seen,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFollows = `-- name: ListFollows :many
|
||||
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_at, f.baseline_at, f.checked_at,
|
||||
p.name AS profile_name,
|
||||
(SELECT a.id FROM artists a WHERE a.mbid = f.artist_mbid) AS artist_id,
|
||||
(SELECT COUNT(*) FROM wants w
|
||||
WHERE w.artist_mbid = f.artist_mbid AND w.fulfilled_at IS NULL)::bigint AS open_wants
|
||||
FROM follows f
|
||||
JOIN release_type_profiles p ON p.id = f.profile_id
|
||||
ORDER BY lower(f.artist_name), f.artist_mbid
|
||||
`
|
||||
|
||||
type ListFollowsRow struct {
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
ProfileID pgtype.UUID
|
||||
FollowedAt pgtype.Timestamptz
|
||||
BaselineAt pgtype.Timestamptz
|
||||
CheckedAt pgtype.Timestamptz
|
||||
ProfileName string
|
||||
ArtistID pgtype.UUID
|
||||
OpenWants int64
|
||||
}
|
||||
|
||||
func (q *Queries) ListFollows(ctx context.Context) ([]ListFollowsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listFollows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListFollowsRow
|
||||
for rows.Next() {
|
||||
var i ListFollowsRow
|
||||
if err := rows.Scan(
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.ProfileID,
|
||||
&i.FollowedAt,
|
||||
&i.BaselineAt,
|
||||
&i.CheckedAt,
|
||||
&i.ProfileName,
|
||||
&i.ArtistID,
|
||||
&i.OpenWants,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listFollowsDue = `-- name: ListFollowsDue :many
|
||||
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_by, f.followed_at, f.baseline_at, f.want_existing, f.checked_at, f.updated_at
|
||||
FROM follows f
|
||||
WHERE f.baseline_at IS NULL OR f.checked_at IS NULL OR f.checked_at < $1
|
||||
ORDER BY f.baseline_at NULLS FIRST, f.checked_at NULLS FIRST, f.artist_mbid
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListFollowsDueParams struct {
|
||||
Before pgtype.Timestamptz
|
||||
MaxRows int32
|
||||
}
|
||||
|
||||
// Follows whose discography has not been applied since @before, never-read
|
||||
// first, so a new follow's baseline is not stuck behind daily re-checks.
|
||||
func (q *Queries) ListFollowsDue(ctx context.Context, arg ListFollowsDueParams) ([]Follow, error) {
|
||||
rows, err := q.db.Query(ctx, listFollowsDue, arg.Before, arg.MaxRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Follow
|
||||
for rows.Next() {
|
||||
var i Follow
|
||||
if err := rows.Scan(
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.ProfileID,
|
||||
&i.FollowedBy,
|
||||
&i.FollowedAt,
|
||||
&i.BaselineAt,
|
||||
&i.WantExisting,
|
||||
&i.CheckedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listOpenWants = `-- name: ListOpenWants :many
|
||||
SELECT id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at FROM wants
|
||||
WHERE fulfilled_at IS NULL
|
||||
ORDER BY wanted_at, id
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
func (q *Queries) ListOpenWants(ctx context.Context, limit int32) ([]Want, error) {
|
||||
rows, err := q.db.Query(ctx, listOpenWants, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Want
|
||||
for rows.Next() {
|
||||
var i Want
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.Title,
|
||||
&i.PrimaryType,
|
||||
&i.FirstReleaseDate,
|
||||
&i.Reason,
|
||||
&i.AlbumID,
|
||||
&i.CreatedBy,
|
||||
&i.WantedAt,
|
||||
&i.FulfilledAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listReleaseTypeProfiles = `-- name: ListReleaseTypeProfiles :many
|
||||
|
||||
SELECT p.id, p.name, p.primary_types, p.secondary_types, p.is_default, p.created_at, p.updated_at,
|
||||
(SELECT COUNT(*) FROM follows f WHERE f.profile_id = p.id)::bigint AS follow_count
|
||||
FROM release_type_profiles p
|
||||
ORDER BY p.is_default DESC, p.name
|
||||
`
|
||||
|
||||
type ListReleaseTypeProfilesRow struct {
|
||||
ID pgtype.UUID
|
||||
Name string
|
||||
PrimaryTypes []string
|
||||
SecondaryTypes []string
|
||||
IsDefault bool
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
FollowCount int64
|
||||
}
|
||||
|
||||
// The wanted list (M502 #5520): release-type profiles, follows and wants.
|
||||
func (q *Queries) ListReleaseTypeProfiles(ctx context.Context) ([]ListReleaseTypeProfilesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listReleaseTypeProfiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListReleaseTypeProfilesRow
|
||||
for rows.Next() {
|
||||
var i ListReleaseTypeProfilesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.PrimaryTypes,
|
||||
&i.SecondaryTypes,
|
||||
&i.IsDefault,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.FollowCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listWants = `-- name: ListWants :many
|
||||
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
|
||||
w.album_id, w.wanted_at, w.fulfilled_at,
|
||||
-- A request names an album before the catalogue has read it: the
|
||||
-- catalogue's type and date fill in once it has.
|
||||
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
|
||||
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
|
||||
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
|
||||
u.username AS requested_by,
|
||||
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
|
||||
FROM wants w
|
||||
LEFT JOIN users u ON u.id = w.created_by
|
||||
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
|
||||
WHERE CASE $1::text
|
||||
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
|
||||
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
|
||||
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
||||
END
|
||||
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
|
||||
LIMIT $3 OFFSET $2
|
||||
`
|
||||
|
||||
type ListWantsParams struct {
|
||||
State string
|
||||
Skip int32
|
||||
MaxRows int32
|
||||
}
|
||||
|
||||
type ListWantsRow struct {
|
||||
ID pgtype.UUID
|
||||
ReleaseGroupMbid string
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
Title string
|
||||
Reason string
|
||||
AlbumID pgtype.UUID
|
||||
WantedAt pgtype.Timestamptz
|
||||
FulfilledAt pgtype.Timestamptz
|
||||
PrimaryType string
|
||||
SecondaryTypes []string
|
||||
FirstReleaseDate string
|
||||
RequestedBy *string
|
||||
LibraryArtistID pgtype.UUID
|
||||
}
|
||||
|
||||
// The Wanted page: open wants (all but missing ones, or only missing ones),
|
||||
// or what was held most recently.
|
||||
func (q *Queries) ListWants(ctx context.Context, arg ListWantsParams) ([]ListWantsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listWants, arg.State, arg.Skip, arg.MaxRows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListWantsRow
|
||||
for rows.Next() {
|
||||
var i ListWantsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.Title,
|
||||
&i.Reason,
|
||||
&i.AlbumID,
|
||||
&i.WantedAt,
|
||||
&i.FulfilledAt,
|
||||
&i.PrimaryType,
|
||||
&i.SecondaryTypes,
|
||||
&i.FirstReleaseDate,
|
||||
&i.RequestedBy,
|
||||
&i.LibraryArtistID,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markFollowChecked = `-- name: MarkFollowChecked :exec
|
||||
UPDATE follows
|
||||
SET checked_at = now(),
|
||||
baseline_at = COALESCE(baseline_at, now()),
|
||||
want_existing = false,
|
||||
updated_at = now()
|
||||
WHERE artist_mbid = $1
|
||||
`
|
||||
|
||||
func (q *Queries) MarkFollowChecked(ctx context.Context, artistMbid string) error {
|
||||
_, err := q.db.Exec(ctx, markFollowChecked, artistMbid)
|
||||
return err
|
||||
}
|
||||
|
||||
const markGroupSeen = `-- name: MarkGroupSeen :exec
|
||||
INSERT INTO follow_seen_groups (artist_mbid, release_group_mbid)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
`
|
||||
|
||||
type MarkGroupSeenParams struct {
|
||||
ArtistMbid string
|
||||
ReleaseGroupMbid string
|
||||
}
|
||||
|
||||
func (q *Queries) MarkGroupSeen(ctx context.Context, arg MarkGroupSeenParams) error {
|
||||
_, err := q.db.Exec(ctx, markGroupSeen, arg.ArtistMbid, arg.ReleaseGroupMbid)
|
||||
return err
|
||||
}
|
||||
|
||||
const repointWant = `-- name: RepointWant :exec
|
||||
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1
|
||||
`
|
||||
|
||||
type RepointWantParams struct {
|
||||
ID pgtype.UUID
|
||||
ReleaseGroupMbid string
|
||||
}
|
||||
|
||||
// A want recorded under a release id before #5241 is moved to its group.
|
||||
func (q *Queries) RepointWant(ctx context.Context, arg RepointWantParams) error {
|
||||
_, err := q.db.Exec(ctx, repointWant, arg.ID, arg.ReleaseGroupMbid)
|
||||
return err
|
||||
}
|
||||
|
||||
const setDefaultReleaseTypeProfile = `-- name: SetDefaultReleaseTypeProfile :execrows
|
||||
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) SetDefaultReleaseTypeProfile(ctx context.Context, id pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, setDefaultReleaseTypeProfile, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const updateReleaseTypeProfile = `-- name: UpdateReleaseTypeProfile :one
|
||||
UPDATE release_type_profiles
|
||||
SET name = $2,
|
||||
primary_types = $3,
|
||||
secondary_types = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, primary_types, secondary_types, is_default, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateReleaseTypeProfileParams struct {
|
||||
ID pgtype.UUID
|
||||
Name string
|
||||
PrimaryTypes []string
|
||||
SecondaryTypes []string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateReleaseTypeProfile(ctx context.Context, arg UpdateReleaseTypeProfileParams) (ReleaseTypeProfile, error) {
|
||||
row := q.db.QueryRow(ctx, updateReleaseTypeProfile,
|
||||
arg.ID,
|
||||
arg.Name,
|
||||
arg.PrimaryTypes,
|
||||
arg.SecondaryTypes,
|
||||
)
|
||||
var i ReleaseTypeProfile
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.PrimaryTypes,
|
||||
&i.SecondaryTypes,
|
||||
&i.IsDefault,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertFollow = `-- name: UpsertFollow :one
|
||||
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by, want_existing)
|
||||
VALUES ($1, $2, $3,
|
||||
$4, $5)
|
||||
ON CONFLICT (artist_mbid) DO UPDATE
|
||||
SET artist_name = EXCLUDED.artist_name,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
want_existing = follows.want_existing OR EXCLUDED.want_existing,
|
||||
baseline_at = CASE WHEN EXCLUDED.want_existing THEN NULL ELSE follows.baseline_at END,
|
||||
updated_at = now()
|
||||
RETURNING artist_mbid, artist_name, profile_id, followed_by, followed_at, baseline_at, want_existing, checked_at, updated_at
|
||||
`
|
||||
|
||||
type UpsertFollowParams struct {
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
ProfileID pgtype.UUID
|
||||
FollowedBy pgtype.UUID
|
||||
WantExisting bool
|
||||
}
|
||||
|
||||
// Following an artist already followed changes its profile. want_existing
|
||||
// only ever turns on here; the worker turns it off once applied, and turning
|
||||
// it on again re-reads the baseline so the existing releases are judged.
|
||||
func (q *Queries) UpsertFollow(ctx context.Context, arg UpsertFollowParams) (Follow, error) {
|
||||
row := q.db.QueryRow(ctx, upsertFollow,
|
||||
arg.ArtistMbid,
|
||||
arg.ArtistName,
|
||||
arg.ProfileID,
|
||||
arg.FollowedBy,
|
||||
arg.WantExisting,
|
||||
)
|
||||
var i Follow
|
||||
err := row.Scan(
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.ProfileID,
|
||||
&i.FollowedBy,
|
||||
&i.FollowedAt,
|
||||
&i.BaselineAt,
|
||||
&i.WantExisting,
|
||||
&i.CheckedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const wantHoldings = `-- name: WantHoldings :many
|
||||
SELECT a.id AS album_id,
|
||||
COUNT(*) FILTER (WHERE t.missing_since IS NULL)::int AS present,
|
||||
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > $1::timestamptz), false)::bool AS arrived_since,
|
||||
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < $1::timestamptz), false)::bool AS missing_before,
|
||||
-- Missing tracks no track on disk stands in for, by position or
|
||||
-- title. A missing row whose song is back at a new path (#5245) is
|
||||
-- covered; a lost bonus track is not.
|
||||
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM tracks p
|
||||
WHERE p.album_id = a.id AND p.missing_since IS NULL
|
||||
AND ((p.track_number = t.track_number AND p.disc_number IS NOT DISTINCT FROM t.disc_number)
|
||||
OR lower(p.title) = lower(t.title))))::int AS uncovered_missing
|
||||
FROM albums a
|
||||
JOIN tracks t ON t.album_id = a.id
|
||||
WHERE a.release_group_mbid = $2::text
|
||||
OR a.mbid = $2
|
||||
OR a.id = $3
|
||||
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = $2)
|
||||
GROUP BY a.id
|
||||
ORDER BY present DESC, a.id
|
||||
`
|
||||
|
||||
type WantHoldingsParams struct {
|
||||
Since pgtype.Timestamptz
|
||||
GroupMbid string
|
||||
AlbumID pgtype.UUID
|
||||
}
|
||||
|
||||
type WantHoldingsRow struct {
|
||||
AlbumID pgtype.UUID
|
||||
Present int32
|
||||
ArrivedSince bool
|
||||
MissingBefore bool
|
||||
UncoveredMissing int32
|
||||
}
|
||||
|
||||
// Each library album that may answer a want, with what it has on disk:
|
||||
// tracks present, whether one arrived since the want, and whether a track
|
||||
// missing before the want is still missing.
|
||||
func (q *Queries) WantHoldings(ctx context.Context, arg WantHoldingsParams) ([]WantHoldingsRow, error) {
|
||||
rows, err := q.db.Query(ctx, wantHoldings, arg.Since, arg.GroupMbid, arg.AlbumID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []WantHoldingsRow
|
||||
for rows.Next() {
|
||||
var i WantHoldingsRow
|
||||
if err := rows.Scan(
|
||||
&i.AlbumID,
|
||||
&i.Present,
|
||||
&i.ArrivedSince,
|
||||
&i.MissingBefore,
|
||||
&i.UncoveredMissing,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const wantReleaseGroup = `-- name: WantReleaseGroup :one
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type,
|
||||
first_release_date, reason, created_by)
|
||||
VALUES ($1, $2, $3, $4,
|
||||
$5, $6, $7, $8)
|
||||
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
|
||||
RETURNING id, release_group_mbid, artist_mbid, artist_name, title, primary_type, first_release_date, reason, album_id, created_by, wanted_at, fulfilled_at, created_at, updated_at
|
||||
`
|
||||
|
||||
type WantReleaseGroupParams struct {
|
||||
ReleaseGroupMbid string
|
||||
ArtistMbid string
|
||||
ArtistName string
|
||||
Title string
|
||||
PrimaryType string
|
||||
FirstReleaseDate string
|
||||
Reason string
|
||||
CreatedBy pgtype.UUID
|
||||
}
|
||||
|
||||
// Wants a release group, or returns the want already recorded for it. A
|
||||
// held want is not reopened here: the library has it.
|
||||
func (q *Queries) WantReleaseGroup(ctx context.Context, arg WantReleaseGroupParams) (Want, error) {
|
||||
row := q.db.QueryRow(ctx, wantReleaseGroup,
|
||||
arg.ReleaseGroupMbid,
|
||||
arg.ArtistMbid,
|
||||
arg.ArtistName,
|
||||
arg.Title,
|
||||
arg.PrimaryType,
|
||||
arg.FirstReleaseDate,
|
||||
arg.Reason,
|
||||
arg.CreatedBy,
|
||||
)
|
||||
var i Want
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.ReleaseGroupMbid,
|
||||
&i.ArtistMbid,
|
||||
&i.ArtistName,
|
||||
&i.Title,
|
||||
&i.PrimaryType,
|
||||
&i.FirstReleaseDate,
|
||||
&i.Reason,
|
||||
&i.AlbumID,
|
||||
&i.CreatedBy,
|
||||
&i.WantedAt,
|
||||
&i.FulfilledAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
ALTER TABLE reacquisition_settings
|
||||
ADD COLUMN backoff_base_hours int NOT NULL DEFAULT 6,
|
||||
ADD COLUMN backoff_max_hours int NOT NULL DEFAULT 168,
|
||||
ADD COLUMN max_attempts int NOT NULL DEFAULT 3,
|
||||
ADD COLUMN auto_approve boolean NOT NULL DEFAULT true;
|
||||
|
||||
ALTER INDEX requests_user_id_idx RENAME TO lidarr_requests_user_id_idx;
|
||||
ALTER INDEX requests_status_idx RENAME TO lidarr_requests_status_idx;
|
||||
ALTER INDEX requests_artist_mbid_idx RENAME TO lidarr_requests_artist_mbid_idx;
|
||||
ALTER INDEX requests_album_mbid_idx RENAME TO lidarr_requests_album_mbid_idx;
|
||||
ALTER TABLE requests ADD COLUMN lidarr_add_confirmed_at timestamptz;
|
||||
ALTER TABLE requests ADD COLUMN root_folder_path text;
|
||||
ALTER TABLE requests ADD COLUMN quality_profile_id int;
|
||||
ALTER TABLE requests RENAME COLUMN track_mbid TO lidarr_track_mbid;
|
||||
ALTER TABLE requests RENAME COLUMN album_mbid TO lidarr_album_mbid;
|
||||
ALTER TABLE requests RENAME COLUMN artist_mbid TO lidarr_artist_mbid;
|
||||
ALTER TYPE request_kind RENAME TO lidarr_request_kind;
|
||||
ALTER TYPE request_status RENAME TO lidarr_request_status;
|
||||
ALTER TABLE requests RENAME TO lidarr_requests;
|
||||
|
||||
CREATE TABLE missing_reacquisitions (
|
||||
album_id uuid PRIMARY KEY REFERENCES albums (id) ON DELETE CASCADE,
|
||||
attempts int NOT NULL DEFAULT 0,
|
||||
last_attempt_at timestamptz,
|
||||
last_request_id uuid REFERENCES lidarr_requests (id) ON DELETE SET NULL,
|
||||
gave_up_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT missing_reacquisitions_attempts_nonneg CHECK (attempts >= 0)
|
||||
);
|
||||
CREATE INDEX missing_reacquisitions_due_idx
|
||||
ON missing_reacquisitions (last_attempt_at NULLS FIRST)
|
||||
WHERE gave_up_at IS NULL;
|
||||
|
||||
DROP TABLE wants;
|
||||
DROP TABLE follow_seen_groups;
|
||||
DROP TABLE follows;
|
||||
DROP TABLE release_type_profiles;
|
||||
@@ -0,0 +1,165 @@
|
||||
-- 0081_wanted.up.sql — the wanted list (Scribe milestone #502, #5520).
|
||||
--
|
||||
-- With Lidarr gone, Minstrel keeps its own record of what it is looking for:
|
||||
--
|
||||
-- release_type_profiles which kinds of release a follow wants: primary
|
||||
-- types (Album, EP, Single…) and the secondary types
|
||||
-- allowed (Live, Compilation, Remix…). An empty
|
||||
-- secondary list means studio releases only, which
|
||||
-- is how live recordings are left out.
|
||||
-- follows artists whose new releases are wanted, each on a
|
||||
-- profile.
|
||||
-- follow_seen_groups release groups a follow has already judged, so
|
||||
-- each is judged once.
|
||||
-- wants release groups wanted: a request, a followed
|
||||
-- artist's new release, or an album whose files went
|
||||
-- missing. A want is held once the library has a
|
||||
-- full copy (internal/wanted).
|
||||
--
|
||||
-- lidarr_requests becomes requests with no Lidarr columns: approving a request
|
||||
-- now records a want or a follow instead of sending an add to Lidarr. The
|
||||
-- missing-file sweeper's attempt bookkeeping goes too; retrying belongs to the
|
||||
-- search schedule (#5521), which works per want.
|
||||
|
||||
CREATE TABLE release_type_profiles (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL UNIQUE,
|
||||
primary_types text[] NOT NULL,
|
||||
secondary_types text[] NOT NULL DEFAULT '{}',
|
||||
is_default boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT release_type_profiles_primary_nonempty CHECK (cardinality(primary_types) > 0)
|
||||
);
|
||||
|
||||
-- At most one default; the service keeps exactly one by moving it.
|
||||
CREATE UNIQUE INDEX release_type_profiles_one_default
|
||||
ON release_type_profiles (is_default) WHERE is_default;
|
||||
|
||||
-- Lidarr's "Standard" metadata profile, which every followed artist used
|
||||
-- (note #5527): albums, EPs and singles, studio releases only.
|
||||
INSERT INTO release_type_profiles (name, primary_types, secondary_types, is_default)
|
||||
VALUES ('Standard', ARRAY['Album', 'EP', 'Single'], '{}', true);
|
||||
|
||||
CREATE TABLE follows (
|
||||
artist_mbid text PRIMARY KEY,
|
||||
artist_name text NOT NULL,
|
||||
-- RESTRICT: a profile in use cannot be deleted out from under a follow.
|
||||
profile_id uuid NOT NULL REFERENCES release_type_profiles (id) ON DELETE RESTRICT,
|
||||
followed_by uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
followed_at timestamptz NOT NULL DEFAULT now(),
|
||||
-- When the first discography read marked everything then listed as seen.
|
||||
-- NULL until then: a follow wants only what appears after it.
|
||||
baseline_at timestamptz,
|
||||
-- Whether the baseline also wants what the artist had already released
|
||||
-- that the profile passes and the library lacks: an approved artist
|
||||
-- request, or the operator ticking "also want…". Cleared once applied.
|
||||
want_existing boolean NOT NULL DEFAULT false,
|
||||
-- The discography read this follow last applied.
|
||||
checked_at timestamptz,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX follows_profile_idx ON follows (profile_id);
|
||||
|
||||
CREATE TABLE follow_seen_groups (
|
||||
artist_mbid text NOT NULL REFERENCES follows (artist_mbid) ON DELETE CASCADE,
|
||||
release_group_mbid text NOT NULL,
|
||||
seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (artist_mbid, release_group_mbid)
|
||||
);
|
||||
|
||||
CREATE TABLE wants (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
release_group_mbid text NOT NULL UNIQUE,
|
||||
artist_mbid text NOT NULL,
|
||||
artist_name text NOT NULL,
|
||||
title text NOT NULL,
|
||||
primary_type text NOT NULL DEFAULT '',
|
||||
first_release_date text NOT NULL DEFAULT '',
|
||||
reason text NOT NULL
|
||||
CONSTRAINT wants_reason_check CHECK (reason IN ('request', 'follow', 'missing')),
|
||||
-- The library album this want is about (a missing want), or the one that
|
||||
-- answered it.
|
||||
album_id uuid REFERENCES albums (id) ON DELETE SET NULL,
|
||||
created_by uuid REFERENCES users (id) ON DELETE SET NULL,
|
||||
-- When it was last wanted: set again when a held want is reopened, so
|
||||
-- "arrived since it was wanted" measures from the reopening.
|
||||
wanted_at timestamptz NOT NULL DEFAULT now(),
|
||||
fulfilled_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX wants_open_idx ON wants (wanted_at) WHERE fulfilled_at IS NULL;
|
||||
CREATE INDEX wants_artist_idx ON wants (artist_mbid);
|
||||
|
||||
-- Requests lose Lidarr.
|
||||
DROP TABLE missing_reacquisitions;
|
||||
|
||||
ALTER TABLE lidarr_requests RENAME TO requests;
|
||||
ALTER TYPE lidarr_request_status RENAME TO request_status;
|
||||
ALTER TYPE lidarr_request_kind RENAME TO request_kind;
|
||||
ALTER TABLE requests RENAME COLUMN lidarr_artist_mbid TO artist_mbid;
|
||||
ALTER TABLE requests RENAME COLUMN lidarr_album_mbid TO album_mbid;
|
||||
ALTER TABLE requests RENAME COLUMN lidarr_track_mbid TO track_mbid;
|
||||
ALTER TABLE requests DROP COLUMN quality_profile_id;
|
||||
ALTER TABLE requests DROP COLUMN root_folder_path;
|
||||
ALTER TABLE requests DROP COLUMN lidarr_add_confirmed_at;
|
||||
ALTER INDEX lidarr_requests_user_id_idx RENAME TO requests_user_id_idx;
|
||||
ALTER INDEX lidarr_requests_status_idx RENAME TO requests_status_idx;
|
||||
ALTER INDEX lidarr_requests_artist_mbid_idx RENAME TO requests_artist_mbid_idx;
|
||||
ALTER INDEX lidarr_requests_album_mbid_idx RENAME TO requests_album_mbid_idx;
|
||||
|
||||
-- Requests made before #5241 name albums by release id. A want is keyed by
|
||||
-- release group, so repoint every request the library can translate.
|
||||
UPDATE requests r
|
||||
SET album_mbid = a.release_group_mbid,
|
||||
updated_at = now()
|
||||
FROM albums a
|
||||
WHERE a.mbid = r.album_mbid
|
||||
AND a.release_group_mbid IS NOT NULL
|
||||
AND a.release_group_mbid <> r.album_mbid;
|
||||
|
||||
-- Approved album and track requests still waiting become wants, judged by
|
||||
-- the same test as any other (internal/wanted).
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, created_by, wanted_at)
|
||||
SELECT DISTINCT ON (r.album_mbid)
|
||||
r.album_mbid, r.artist_mbid, r.artist_name, COALESCE(r.album_title, ''), 'request', r.user_id, r.requested_at
|
||||
FROM requests r
|
||||
WHERE r.status = 'approved'
|
||||
AND r.kind IN ('album', 'track')
|
||||
AND r.album_mbid IS NOT NULL
|
||||
ORDER BY r.album_mbid, r.requested_at
|
||||
ON CONFLICT (release_group_mbid) DO NOTHING;
|
||||
|
||||
-- The library's album artists start out followed on the default profile
|
||||
-- (operator, 2026-10-09: "we already have artists in minstrel those start as
|
||||
-- the wanted artists"). Various Artists and MusicBrainz's bracketed
|
||||
-- placeholders ([unknown], [anonymous]…) are not artists anyone follows.
|
||||
INSERT INTO follows (artist_mbid, artist_name, profile_id)
|
||||
SELECT ar.mbid, ar.name, p.id
|
||||
FROM artists ar
|
||||
CROSS JOIN (SELECT id FROM release_type_profiles WHERE is_default) p
|
||||
WHERE ar.mbid IS NOT NULL AND ar.mbid <> ''
|
||||
AND ar.mbid <> '89ad4ac3-39f7-470e-963a-56509c546377'
|
||||
AND ar.name NOT LIKE '[%]'
|
||||
AND EXISTS (SELECT 1 FROM albums al WHERE al.artist_id = ar.id)
|
||||
ON CONFLICT (artist_mbid) DO NOTHING;
|
||||
|
||||
-- An approved artist request was an artist added to Lidarr: it is followed.
|
||||
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by)
|
||||
SELECT DISTINCT ON (r.artist_mbid) r.artist_mbid, r.artist_name, p.id, r.decided_by
|
||||
FROM requests r
|
||||
CROSS JOIN (SELECT id FROM release_type_profiles WHERE is_default) p
|
||||
WHERE r.status = 'approved' AND r.kind = 'artist'
|
||||
ORDER BY r.artist_mbid, r.requested_at
|
||||
ON CONFLICT (artist_mbid) DO NOTHING;
|
||||
|
||||
-- The sweeper now only records a want; how often to look again is the
|
||||
-- search schedule's business.
|
||||
ALTER TABLE reacquisition_settings
|
||||
DROP COLUMN backoff_base_hours,
|
||||
DROP COLUMN backoff_max_hours,
|
||||
DROP COLUMN max_attempts,
|
||||
DROP COLUMN auto_approve;
|
||||
@@ -1,130 +0,0 @@
|
||||
-- name: CreateLidarrRequest :one
|
||||
INSERT INTO lidarr_requests (
|
||||
user_id, kind,
|
||||
lidarr_artist_mbid, lidarr_album_mbid, lidarr_track_mbid,
|
||||
artist_name, album_title, track_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetLidarrRequestByID :one
|
||||
SELECT * FROM lidarr_requests WHERE id = $1;
|
||||
|
||||
-- name: ListLidarrRequestsForUser :many
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListLidarrRequestsByStatus :many
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListApprovedLidarrRequestsForReconcile :many
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE status = 'approved'
|
||||
ORDER BY decided_at ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: ApproveLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'approved',
|
||||
quality_profile_id = $2,
|
||||
root_folder_path = $3,
|
||||
decided_at = now(),
|
||||
decided_by = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: RejectLidarrRequest :one
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'rejected',
|
||||
notes = $2,
|
||||
decided_at = now(),
|
||||
decided_by = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CancelLidarrRequest :one
|
||||
-- $2 serves as both the ownership guard (user_id = $2) and the
|
||||
-- decided_by audit field. The generated parameter struct names it
|
||||
-- DecidedBy — callers must pass the cancelling user's UUID, and
|
||||
-- a wrong UUID will silently no-op (zero rows updated) rather than
|
||||
-- erroring at the SQL layer.
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'rejected',
|
||||
notes = 'cancelled by user',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CompleteLidarrRequest :one
|
||||
-- Reconciler transitions an approved request to completed when its
|
||||
-- target track/album/artist has appeared in the library. Callers
|
||||
-- should set ONLY the matched_*_id corresponding to the request's
|
||||
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
|
||||
-- per-kind one-of-three is the contract, NULL for the others
|
||||
-- preserves auditability if the matched row is later evicted.
|
||||
UPDATE lidarr_requests
|
||||
SET status = 'completed',
|
||||
matched_track_id = $2,
|
||||
matched_album_id = $3,
|
||||
matched_artist_id = $4,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
RETURNING *;
|
||||
|
||||
-- name: HasNonTerminalRequestForMBID :one
|
||||
-- Returns true if any user has a pending/approved/completed request
|
||||
-- whose MBID matches at the given level. Used to set the `requested`
|
||||
-- flag on /api/lidarr/search responses. Terminal-status (rejected,
|
||||
-- failed) rows do not count. The same MBID parameter is checked
|
||||
-- against artist/album/track columns based on kind.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
|
||||
);
|
||||
|
||||
-- name: GetNonTerminalRequestForMBID :one
|
||||
-- Returns the oldest non-terminal (pending/approved/completed) request
|
||||
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
-- uses this to dedupe: a re-request for something already in flight
|
||||
-- returns the existing row instead of inserting a duplicate. MBIDs are
|
||||
-- globally unique per entity type, so the cross-column OR cannot
|
||||
-- false-match (an artist MBID never collides with an album/track MBID).
|
||||
SELECT * FROM lidarr_requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND lidarr_artist_mbid = @mbid)
|
||||
OR (kind = 'album' AND lidarr_album_mbid = @mbid)
|
||||
OR (kind = 'track' AND lidarr_track_mbid = @mbid))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: MarkLidarrRequestAddConfirmed :exec
|
||||
-- Idempotently records that Lidarr accepted the add for an approved
|
||||
-- request. The lidarr_add_confirmed_at IS NULL guard makes a redundant
|
||||
-- reconciler retry a no-op rather than bumping updated_at every tick.
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_add_confirmed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
AND status = 'approved'
|
||||
AND lidarr_add_confirmed_at IS NULL;
|
||||
|
||||
-- name: SetLidarrRequestAlbumMbid :exec
|
||||
-- #5241: repoint a request at the album's release group. Requests made before
|
||||
-- the release-group read named albums by MusicBrainz release id, which Lidarr
|
||||
-- does not index; the reconciler rewrites them once it has resolved the group.
|
||||
UPDATE lidarr_requests
|
||||
SET lidarr_album_mbid = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1;
|
||||
@@ -35,8 +35,8 @@ UPDATE contextual_likes SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_i
|
||||
-- name: MergeRepointPlaybackErrors :execrows
|
||||
UPDATE playback_errors SET track_id = sqlc.arg(survivor_id)::uuid WHERE track_id = sqlc.arg(loser_id)::uuid;
|
||||
|
||||
-- name: MergeRepointLidarrRequests :execrows
|
||||
UPDATE lidarr_requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
|
||||
-- name: MergeRepointRequests :execrows
|
||||
UPDATE requests SET matched_track_id = sqlc.arg(survivor_id)::uuid
|
||||
WHERE matched_track_id = sqlc.arg(loser_id)::uuid;
|
||||
|
||||
-- name: MergeRepointPlaylistTracks :many
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
-- Auto re-acquisition of missing files (milestone #290). The unit is the
|
||||
-- album: Lidarr acquires releases, and grouping collapses "40 missing files"
|
||||
-- into "3 albums to ask for".
|
||||
-- Re-acquisition of missing files (milestone #290, reworked for M502 #5520).
|
||||
-- The unit is the album: an album whose files went missing becomes a want for
|
||||
-- its release group, and the search schedule (#5521) looks for it.
|
||||
|
||||
-- name: GetReacquisitionSettings :one
|
||||
SELECT * FROM reacquisition_settings WHERE id = true;
|
||||
@@ -9,13 +9,9 @@ SELECT * FROM reacquisition_settings WHERE id = true;
|
||||
-- Whole-row write from the admin card; the CHECKs in migration 0056 are the
|
||||
-- validation, so a bad value fails loudly rather than being clamped silently.
|
||||
UPDATE reacquisition_settings
|
||||
SET enabled = sqlc.arg(enabled),
|
||||
grace_hours = sqlc.arg(grace_hours),
|
||||
backoff_base_hours = sqlc.arg(backoff_base_hours),
|
||||
backoff_max_hours = sqlc.arg(backoff_max_hours),
|
||||
max_attempts = sqlc.arg(max_attempts),
|
||||
max_per_pass = sqlc.arg(max_per_pass),
|
||||
auto_approve = sqlc.arg(auto_approve)
|
||||
SET enabled = sqlc.arg(enabled),
|
||||
grace_hours = sqlc.arg(grace_hours),
|
||||
max_per_pass = sqlc.arg(max_per_pass)
|
||||
WHERE id = true
|
||||
RETURNING *;
|
||||
|
||||
@@ -24,54 +20,40 @@ RETURNING *;
|
||||
--
|
||||
-- * it still has at least one track whose file has been missing longer than
|
||||
-- the grace window. Measured on missing_since, which reconcile never
|
||||
-- re-stamps (#2523), so it is a genuine "gone since" clock rather than
|
||||
-- "when we last noticed";
|
||||
-- * MusicBrainz can name it. Both the album and its artist MBID are
|
||||
-- required — Create rejects an album-kind request without them, and there
|
||||
-- is nothing to ask Lidarr for anyway. Albums failing this are counted
|
||||
-- separately (CountAlbumsMissingWithoutMbid) rather than vanishing;
|
||||
-- * it has not spent its attempt budget (gave_up_at IS NULL);
|
||||
-- * its backoff has elapsed: base * 2^(attempts-1) hours since the last
|
||||
-- attempt, clamped to the configured maximum. First attempt (no row, or
|
||||
-- last_attempt_at NULL) is always due.
|
||||
--
|
||||
-- Oldest attempt first, never-attempted first, so a large loss drains in a
|
||||
-- stable order across passes instead of re-picking the same head each time.
|
||||
SELECT albums.id AS album_id,
|
||||
albums.title AS album_title,
|
||||
albums.mbid AS album_mbid,
|
||||
-- re-stamps (#2523), so it is a genuine "gone since" clock;
|
||||
-- * MusicBrainz can name it: the album and its artist have MBIDs. Albums
|
||||
-- failing this are counted separately (CountAlbumsMissingWithoutMbid);
|
||||
-- * no want already covers it: none open, and none held since its files
|
||||
-- went missing. A want held after the loss means the library has a full
|
||||
-- copy despite the missing rows (#5245), so asking again would loop.
|
||||
SELECT albums.id AS album_id,
|
||||
albums.title AS album_title,
|
||||
albums.mbid AS album_mbid,
|
||||
albums.release_group_mbid AS album_release_group_mbid,
|
||||
artists.id AS artist_id,
|
||||
artists.name AS artist_name,
|
||||
artists.mbid AS artist_mbid,
|
||||
COUNT(tracks.id)::bigint AS missing_track_count,
|
||||
COALESCE(r.attempts, 0)::int AS attempts
|
||||
artists.name AS artist_name,
|
||||
artists.mbid AS artist_mbid,
|
||||
COUNT(tracks.id)::bigint AS missing_track_count,
|
||||
MAX(tracks.missing_since)::timestamptz AS last_missing_since
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
JOIN tracks ON tracks.album_id = albums.id
|
||||
LEFT JOIN missing_reacquisitions r ON r.album_id = albums.id
|
||||
WHERE tracks.missing_since IS NOT NULL
|
||||
AND tracks.missing_since <= now() - make_interval(hours => sqlc.arg(grace_hours)::int)
|
||||
AND albums.mbid IS NOT NULL
|
||||
AND artists.mbid IS NOT NULL
|
||||
AND (r.gave_up_at IS NULL)
|
||||
AND (
|
||||
r.last_attempt_at IS NULL
|
||||
OR r.last_attempt_at <= now() - make_interval(hours => LEAST(
|
||||
(sqlc.arg(backoff_base_hours)::int
|
||||
* POWER(2, GREATEST(COALESCE(r.attempts, 0) - 1, 0)))::int,
|
||||
sqlc.arg(backoff_max_hours)::int))
|
||||
)
|
||||
GROUP BY albums.id, albums.title, albums.mbid, albums.release_group_mbid,
|
||||
artists.id, artists.name, artists.mbid, r.attempts, r.last_attempt_at
|
||||
ORDER BY r.last_attempt_at NULLS FIRST, albums.sort_title
|
||||
artists.name, artists.mbid
|
||||
HAVING NOT EXISTS (
|
||||
SELECT 1 FROM wants w
|
||||
WHERE (w.album_id = albums.id OR w.release_group_mbid = albums.release_group_mbid)
|
||||
AND (w.fulfilled_at IS NULL OR w.fulfilled_at > MAX(tracks.missing_since)))
|
||||
ORDER BY MIN(tracks.missing_since), albums.sort_title
|
||||
LIMIT sqlc.arg(page_limit);
|
||||
|
||||
-- name: CountAlbumsMissingWithoutMbid :one
|
||||
-- Albums with missing files that can never be auto-requested because nothing
|
||||
-- identifies them to MusicBrainz. Surfaced on the admin card so the gap is
|
||||
-- visible: silently doing nothing for these would read as the feature being
|
||||
-- broken.
|
||||
-- Albums with missing files that can never be wanted automatically because
|
||||
-- nothing identifies them to MusicBrainz. Surfaced on the admin card so the
|
||||
-- gap is visible: silently doing nothing for these would read as broken.
|
||||
SELECT COUNT(DISTINCT albums.id)::bigint
|
||||
FROM albums
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
@@ -79,42 +61,25 @@ SELECT COUNT(DISTINCT albums.id)::bigint
|
||||
WHERE tracks.missing_since IS NOT NULL
|
||||
AND (albums.mbid IS NULL OR artists.mbid IS NULL);
|
||||
|
||||
-- name: RecordReacquisitionAttempt :one
|
||||
-- Bumps the attempt counter and stamps the clock the backoff measures from.
|
||||
-- Upsert because the first attempt has no row yet.
|
||||
INSERT INTO missing_reacquisitions (album_id, attempts, last_attempt_at, last_request_id)
|
||||
VALUES (sqlc.arg(album_id), 1, now(), sqlc.narg(last_request_id))
|
||||
ON CONFLICT (album_id) DO UPDATE
|
||||
SET attempts = missing_reacquisitions.attempts + 1,
|
||||
last_attempt_at = now(),
|
||||
last_request_id = COALESCE(EXCLUDED.last_request_id,
|
||||
missing_reacquisitions.last_request_id),
|
||||
updated_at = now()
|
||||
-- name: WantMissingAlbum :one
|
||||
-- Records an album with missing files as wanted. A held want for the same
|
||||
-- group is reopened; an open one is left as it is, and no row comes back.
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, reason, album_id)
|
||||
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name),
|
||||
sqlc.arg(title), 'missing', sqlc.arg(album_id))
|
||||
ON CONFLICT (release_group_mbid) DO UPDATE
|
||||
SET reason = 'missing',
|
||||
album_id = EXCLUDED.album_id,
|
||||
wanted_at = now(),
|
||||
fulfilled_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE wants.fulfilled_at IS NOT NULL
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkReacquisitionGaveUp :exec
|
||||
-- Stamped when the attempt budget is spent. Stored as a timestamp rather than
|
||||
-- inferred from `attempts >= max_attempts` so the verdict survives an operator
|
||||
-- later raising the maximum, and so the admin surface can say when.
|
||||
UPDATE missing_reacquisitions
|
||||
SET gave_up_at = now(),
|
||||
updated_at = now()
|
||||
WHERE album_id = sqlc.arg(album_id)
|
||||
AND gave_up_at IS NULL;
|
||||
|
||||
-- name: ClearRecoveredReacquisitions :execrows
|
||||
-- Drops state for albums that no longer have any missing track — the files
|
||||
-- came back, or the scanner adopted them at a new path (#2528). Deleting
|
||||
-- rather than resetting counters means a future loss starts from a clean
|
||||
-- budget, which is right: it is a new problem, not a continuation.
|
||||
DELETE FROM missing_reacquisitions r
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM tracks
|
||||
WHERE tracks.album_id = r.album_id
|
||||
AND tracks.missing_since IS NOT NULL
|
||||
);
|
||||
|
||||
-- name: GetReacquisitionForAlbums :many
|
||||
-- State for the admin missing-files surface, so each directory group can say
|
||||
-- whether a re-acquisition is in flight, waiting, or given up.
|
||||
SELECT * FROM missing_reacquisitions WHERE album_id = ANY(sqlc.arg(album_ids)::uuid[]);
|
||||
-- name: GetWantsForAlbums :many
|
||||
-- What the wanted list says about each album on the missing-files page.
|
||||
SELECT a.id AS album_id, w.id AS want_id, w.wanted_at, w.fulfilled_at
|
||||
FROM albums a
|
||||
JOIN wants w ON w.album_id = a.id OR w.release_group_mbid = a.release_group_mbid
|
||||
WHERE a.id = ANY(sqlc.arg(album_ids)::uuid[])
|
||||
ORDER BY w.wanted_at DESC;
|
||||
|
||||
@@ -362,9 +362,9 @@ contributions AS (
|
||||
JOIN seeds ON seeds.artist_id = u.seed_artist_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests r
|
||||
SELECT 1 FROM requests r
|
||||
WHERE r.user_id = $1
|
||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||
AND r.artist_mbid = u.candidate_mbid
|
||||
AND r.status NOT IN ('rejected', 'failed')
|
||||
)
|
||||
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- name: CreateRequest :one
|
||||
INSERT INTO requests (
|
||||
user_id, kind,
|
||||
artist_mbid, album_mbid, track_mbid,
|
||||
artist_name, album_title, track_title
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetRequestByID :one
|
||||
SELECT * FROM requests WHERE id = $1;
|
||||
|
||||
-- name: ListRequestsForUser :many
|
||||
SELECT * FROM requests
|
||||
WHERE user_id = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListRequestsByStatus :many
|
||||
SELECT * FROM requests
|
||||
WHERE status = $1
|
||||
ORDER BY requested_at DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListApprovedRequestsForReconcile :many
|
||||
SELECT * FROM requests
|
||||
WHERE status = 'approved'
|
||||
ORDER BY decided_at ASC
|
||||
LIMIT $1;
|
||||
|
||||
-- name: ApproveRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'approved',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: RejectRequest :one
|
||||
UPDATE requests
|
||||
SET status = 'rejected',
|
||||
notes = $2,
|
||||
decided_at = now(),
|
||||
decided_by = $3,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CancelRequest :one
|
||||
-- $2 serves as both the ownership guard (user_id = $2) and the
|
||||
-- decided_by audit field. The generated parameter struct names it
|
||||
-- DecidedBy — callers must pass the cancelling user's UUID, and
|
||||
-- a wrong UUID will silently no-op (zero rows updated) rather than
|
||||
-- erroring at the SQL layer.
|
||||
UPDATE requests
|
||||
SET status = 'rejected',
|
||||
notes = 'cancelled by user',
|
||||
decided_at = now(),
|
||||
decided_by = $2,
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND user_id = $2 AND status = 'pending'
|
||||
RETURNING *;
|
||||
|
||||
-- name: CompleteRequest :one
|
||||
-- Reconciler transitions an approved request to completed when its
|
||||
-- target track/album/artist has appeared in the library. Callers
|
||||
-- should set ONLY the matched_*_id corresponding to the request's
|
||||
-- kind and pass pgtype.UUID{} (Valid: false) for the others —
|
||||
-- per-kind one-of-three is the contract, NULL for the others
|
||||
-- preserves auditability if the matched row is later evicted.
|
||||
UPDATE requests
|
||||
SET status = 'completed',
|
||||
matched_track_id = $2,
|
||||
matched_album_id = $3,
|
||||
matched_artist_id = $4,
|
||||
completed_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND status = 'approved'
|
||||
RETURNING *;
|
||||
|
||||
-- name: HasNonTerminalRequestForMBID :one
|
||||
-- Whether something is already on its way: a pending, approved or completed
|
||||
-- request at the given level, an open want for the release group, or a
|
||||
-- follow of the artist. Sets the `requested` flag on catalogue search.
|
||||
-- Terminal-status (rejected, failed) requests do not count. MBIDs are unique
|
||||
-- per entity type, so checking one id against every column cannot
|
||||
-- false-match.
|
||||
SELECT (EXISTS (
|
||||
SELECT 1 FROM requests r
|
||||
WHERE r.status IN ('pending', 'approved', 'completed')
|
||||
AND ((r.kind = 'artist' AND r.artist_mbid = @mbid)
|
||||
OR (r.kind = 'album' AND r.album_mbid = @mbid)
|
||||
OR (r.kind = 'track' AND r.track_mbid = @mbid))
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM wants w WHERE w.release_group_mbid = @mbid AND w.fulfilled_at IS NULL
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM follows f WHERE f.artist_mbid = @mbid
|
||||
))::bool AS requested;
|
||||
|
||||
-- name: GetNonTerminalRequestForMBID :one
|
||||
-- Returns the oldest non-terminal (pending/approved/completed) request
|
||||
-- whose own-kind MBID matches @mbid, or pgx.ErrNoRows when none. Create()
|
||||
-- uses this to dedupe: a re-request for something already in flight
|
||||
-- returns the existing row instead of inserting a duplicate.
|
||||
SELECT * FROM requests
|
||||
WHERE status IN ('pending', 'approved', 'completed')
|
||||
AND ((kind = 'artist' AND artist_mbid = @mbid)
|
||||
OR (kind = 'album' AND album_mbid = @mbid)
|
||||
OR (kind = 'track' AND track_mbid = @mbid))
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: RepointRequestsAlbumMbid :exec
|
||||
-- A want moved from a release id to its group (#5241) takes the requests
|
||||
-- that name it along, so the reconciler finds it.
|
||||
UPDATE requests
|
||||
SET album_mbid = sqlc.arg(new_mbid),
|
||||
updated_at = now()
|
||||
WHERE album_mbid = sqlc.arg(old_mbid);
|
||||
@@ -174,16 +174,3 @@ SELECT u.id, u.timezone FROM users u
|
||||
WHERE pe.user_id = u.id
|
||||
AND pe.started_at > now() - INTERVAL '7 days'
|
||||
);
|
||||
|
||||
-- name: GetOldestAdmin :one
|
||||
-- The account a system-initiated action is attributed to (milestone #290).
|
||||
-- lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
|
||||
-- human, so the row is owned by the longest-standing admin: it keeps the
|
||||
-- request auditable and puts it in the same admin queue as everything else,
|
||||
-- without inventing a synthetic principal the rest of the schema would have
|
||||
-- to understand. Ordered by id as a tiebreak so the choice is stable across
|
||||
-- calls rather than depending on scan order.
|
||||
SELECT * FROM users
|
||||
WHERE is_admin = true
|
||||
ORDER BY created_at, id
|
||||
LIMIT 1;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
-- The wanted list (M502 #5520): release-type profiles, follows and wants.
|
||||
|
||||
-- name: ListReleaseTypeProfiles :many
|
||||
SELECT p.*,
|
||||
(SELECT COUNT(*) FROM follows f WHERE f.profile_id = p.id)::bigint AS follow_count
|
||||
FROM release_type_profiles p
|
||||
ORDER BY p.is_default DESC, p.name;
|
||||
|
||||
-- name: GetReleaseTypeProfile :one
|
||||
SELECT * FROM release_type_profiles WHERE id = $1;
|
||||
|
||||
-- name: GetDefaultReleaseTypeProfile :one
|
||||
SELECT * FROM release_type_profiles WHERE is_default;
|
||||
|
||||
-- name: CreateReleaseTypeProfile :one
|
||||
INSERT INTO release_type_profiles (name, primary_types, secondary_types)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateReleaseTypeProfile :one
|
||||
UPDATE release_type_profiles
|
||||
SET name = $2,
|
||||
primary_types = $3,
|
||||
secondary_types = $4,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearDefaultReleaseTypeProfile :exec
|
||||
UPDATE release_type_profiles SET is_default = false, updated_at = now() WHERE is_default;
|
||||
|
||||
-- name: SetDefaultReleaseTypeProfile :execrows
|
||||
UPDATE release_type_profiles SET is_default = true, updated_at = now() WHERE id = $1;
|
||||
|
||||
-- name: DeleteReleaseTypeProfile :execrows
|
||||
-- The default stays; one in use is refused by the follows foreign key.
|
||||
DELETE FROM release_type_profiles WHERE id = $1 AND NOT is_default;
|
||||
|
||||
-- name: UpsertFollow :one
|
||||
-- Following an artist already followed changes its profile. want_existing
|
||||
-- only ever turns on here; the worker turns it off once applied, and turning
|
||||
-- it on again re-reads the baseline so the existing releases are judged.
|
||||
INSERT INTO follows (artist_mbid, artist_name, profile_id, followed_by, want_existing)
|
||||
VALUES (sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(profile_id),
|
||||
sqlc.narg(followed_by), sqlc.arg(want_existing))
|
||||
ON CONFLICT (artist_mbid) DO UPDATE
|
||||
SET artist_name = EXCLUDED.artist_name,
|
||||
profile_id = EXCLUDED.profile_id,
|
||||
want_existing = follows.want_existing OR EXCLUDED.want_existing,
|
||||
baseline_at = CASE WHEN EXCLUDED.want_existing THEN NULL ELSE follows.baseline_at END,
|
||||
updated_at = now()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetFollow :one
|
||||
SELECT f.*, p.name AS profile_name
|
||||
FROM follows f
|
||||
JOIN release_type_profiles p ON p.id = f.profile_id
|
||||
WHERE f.artist_mbid = $1;
|
||||
|
||||
-- name: DeleteFollow :execrows
|
||||
DELETE FROM follows WHERE artist_mbid = $1;
|
||||
|
||||
-- name: ListFollows :many
|
||||
SELECT f.artist_mbid, f.artist_name, f.profile_id, f.followed_at, f.baseline_at, f.checked_at,
|
||||
p.name AS profile_name,
|
||||
(SELECT a.id FROM artists a WHERE a.mbid = f.artist_mbid) AS artist_id,
|
||||
(SELECT COUNT(*) FROM wants w
|
||||
WHERE w.artist_mbid = f.artist_mbid AND w.fulfilled_at IS NULL)::bigint AS open_wants
|
||||
FROM follows f
|
||||
JOIN release_type_profiles p ON p.id = f.profile_id
|
||||
ORDER BY lower(f.artist_name), f.artist_mbid;
|
||||
|
||||
-- name: ListFollowsDue :many
|
||||
-- Follows whose discography has not been applied since @before, never-read
|
||||
-- first, so a new follow's baseline is not stuck behind daily re-checks.
|
||||
SELECT f.*
|
||||
FROM follows f
|
||||
WHERE f.baseline_at IS NULL OR f.checked_at IS NULL OR f.checked_at < sqlc.arg(before)
|
||||
ORDER BY f.baseline_at NULLS FIRST, f.checked_at NULLS FIRST, f.artist_mbid
|
||||
LIMIT sqlc.arg(max_rows);
|
||||
|
||||
-- name: MarkFollowChecked :exec
|
||||
UPDATE follows
|
||||
SET checked_at = now(),
|
||||
baseline_at = COALESCE(baseline_at, now()),
|
||||
want_existing = false,
|
||||
updated_at = now()
|
||||
WHERE artist_mbid = $1;
|
||||
|
||||
-- name: ListFollowGroups :many
|
||||
-- An artist's release groups from the catalogue, each with whether the
|
||||
-- follow has judged it before.
|
||||
SELECT g.mbid, g.title, g.primary_type, g.secondary_types, g.first_release_date,
|
||||
EXISTS (SELECT 1 FROM follow_seen_groups s
|
||||
WHERE s.artist_mbid = sqlc.arg(artist_mbid) AND s.release_group_mbid = g.mbid) AS seen
|
||||
FROM mb_artist_release_groups ag
|
||||
JOIN mb_release_groups g ON g.mbid = ag.release_group_mbid
|
||||
WHERE ag.artist_mbid = sqlc.arg(artist_mbid)
|
||||
ORDER BY g.first_release_date, g.title;
|
||||
|
||||
-- name: MarkGroupSeen :exec
|
||||
INSERT INTO follow_seen_groups (artist_mbid, release_group_mbid)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- name: LibraryHoldsReleaseGroup :one
|
||||
-- Whether the library already has the group: an album tagged with it or
|
||||
-- with one of its releases, or failing that an album of the artist's with
|
||||
-- the same title. Only albums with a track on disk count.
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM albums a
|
||||
WHERE (a.release_group_mbid = sqlc.arg(group_mbid)::text
|
||||
OR a.mbid = sqlc.arg(group_mbid)
|
||||
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
|
||||
OR (lower(a.title) = lower(sqlc.arg(title)::text)
|
||||
AND a.artist_id IN (SELECT ar.id FROM artists ar WHERE ar.mbid = sqlc.arg(artist_mbid)::text)))
|
||||
AND EXISTS (SELECT 1 FROM tracks t WHERE t.album_id = a.id AND t.missing_since IS NULL)
|
||||
);
|
||||
|
||||
-- name: WantReleaseGroup :one
|
||||
-- Wants a release group, or returns the want already recorded for it. A
|
||||
-- held want is not reopened here: the library has it.
|
||||
INSERT INTO wants (release_group_mbid, artist_mbid, artist_name, title, primary_type,
|
||||
first_release_date, reason, created_by)
|
||||
VALUES (sqlc.arg(release_group_mbid), sqlc.arg(artist_mbid), sqlc.arg(artist_name), sqlc.arg(title),
|
||||
sqlc.arg(primary_type), sqlc.arg(first_release_date), sqlc.arg(reason), sqlc.narg(created_by))
|
||||
ON CONFLICT (release_group_mbid) DO UPDATE SET updated_at = wants.updated_at
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetWantByReleaseGroup :one
|
||||
SELECT * FROM wants WHERE release_group_mbid = $1;
|
||||
|
||||
-- name: ListOpenWants :many
|
||||
SELECT * FROM wants
|
||||
WHERE fulfilled_at IS NULL
|
||||
ORDER BY wanted_at, id
|
||||
LIMIT $1;
|
||||
|
||||
-- name: WantHoldings :many
|
||||
-- Each library album that may answer a want, with what it has on disk:
|
||||
-- tracks present, whether one arrived since the want, and whether a track
|
||||
-- missing before the want is still missing.
|
||||
SELECT a.id AS album_id,
|
||||
COUNT(*) FILTER (WHERE t.missing_since IS NULL)::int AS present,
|
||||
COALESCE(bool_or(t.missing_since IS NULL AND t.added_at > sqlc.arg(since)::timestamptz), false)::bool AS arrived_since,
|
||||
COALESCE(bool_or(t.missing_since IS NOT NULL AND t.missing_since < sqlc.arg(since)::timestamptz), false)::bool AS missing_before,
|
||||
-- Missing tracks no track on disk stands in for, by position or
|
||||
-- title. A missing row whose song is back at a new path (#5245) is
|
||||
-- covered; a lost bonus track is not.
|
||||
COUNT(*) FILTER (WHERE t.missing_since IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM tracks p
|
||||
WHERE p.album_id = a.id AND p.missing_since IS NULL
|
||||
AND ((p.track_number = t.track_number AND p.disc_number IS NOT DISTINCT FROM t.disc_number)
|
||||
OR lower(p.title) = lower(t.title))))::int AS uncovered_missing
|
||||
FROM albums a
|
||||
JOIN tracks t ON t.album_id = a.id
|
||||
WHERE a.release_group_mbid = sqlc.arg(group_mbid)::text
|
||||
OR a.mbid = sqlc.arg(group_mbid)
|
||||
OR a.id = sqlc.narg(album_id)
|
||||
OR a.mbid IN (SELECT r.mbid FROM mb_releases r WHERE r.release_group_mbid = sqlc.arg(group_mbid))
|
||||
GROUP BY a.id
|
||||
ORDER BY present DESC, a.id;
|
||||
|
||||
-- name: FulfillWant :exec
|
||||
UPDATE wants
|
||||
SET fulfilled_at = now(),
|
||||
album_id = sqlc.arg(album_id),
|
||||
updated_at = now()
|
||||
WHERE id = sqlc.arg(id) AND fulfilled_at IS NULL;
|
||||
|
||||
-- name: RepointWant :exec
|
||||
-- A want recorded under a release id before #5241 is moved to its group.
|
||||
UPDATE wants SET release_group_mbid = $2, updated_at = now() WHERE id = $1;
|
||||
|
||||
-- name: DeleteWant :execrows
|
||||
DELETE FROM wants WHERE id = $1;
|
||||
|
||||
-- name: ListWants :many
|
||||
-- The Wanted page: open wants (all but missing ones, or only missing ones),
|
||||
-- or what was held most recently.
|
||||
SELECT w.id, w.release_group_mbid, w.artist_mbid, w.artist_name, w.title, w.reason,
|
||||
w.album_id, w.wanted_at, w.fulfilled_at,
|
||||
-- A request names an album before the catalogue has read it: the
|
||||
-- catalogue's type and date fill in once it has.
|
||||
COALESCE(NULLIF(w.primary_type, ''), g.primary_type, '')::text AS primary_type,
|
||||
COALESCE(g.secondary_types, '{}')::text[] AS secondary_types,
|
||||
COALESCE(NULLIF(w.first_release_date, ''), g.first_release_date, '')::text AS first_release_date,
|
||||
u.username AS requested_by,
|
||||
(SELECT ar.id FROM artists ar WHERE ar.mbid = w.artist_mbid) AS library_artist_id
|
||||
FROM wants w
|
||||
LEFT JOIN users u ON u.id = w.created_by
|
||||
LEFT JOIN mb_release_groups g ON g.mbid = w.release_group_mbid
|
||||
WHERE CASE sqlc.arg(state)::text
|
||||
WHEN 'held' THEN w.fulfilled_at IS NOT NULL
|
||||
WHEN 'missing' THEN w.fulfilled_at IS NULL AND w.reason = 'missing'
|
||||
ELSE w.fulfilled_at IS NULL AND w.reason <> 'missing'
|
||||
END
|
||||
ORDER BY COALESCE(w.fulfilled_at, w.wanted_at) DESC, w.id
|
||||
LIMIT sqlc.arg(max_rows) OFFSET sqlc.arg(skip);
|
||||
|
||||
-- name: CountWants :one
|
||||
SELECT COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason <> 'missing')::bigint AS wanted,
|
||||
COUNT(*) FILTER (WHERE fulfilled_at IS NULL AND reason = 'missing')::bigint AS missing,
|
||||
COUNT(*) FILTER (WHERE fulfilled_at IS NOT NULL)::bigint AS held,
|
||||
(SELECT COUNT(*) FROM follows)::bigint AS following
|
||||
FROM wants;
|
||||
Reference in New Issue
Block a user