diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index f94956b6..acbdeb5b 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: albums.sql package dbq diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index 259efbc4..cafeb6b5 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: artists.sql package dbq diff --git a/internal/db/dbq/contextual_likes.sql.go b/internal/db/dbq/contextual_likes.sql.go index 6b932e5e..32bdb7a6 100644 --- a/internal/db/dbq/contextual_likes.sql.go +++ b/internal/db/dbq/contextual_likes.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: contextual_likes.sql package dbq diff --git a/internal/db/dbq/db.go b/internal/db/dbq/db.go index 8650b40f..1890abd0 100644 --- a/internal/db/dbq/db.go +++ b/internal/db/dbq/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 package dbq diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go index 7d3624b9..83a04dee 100644 --- a/internal/db/dbq/events.sql.go +++ b/internal/db/dbq/events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: events.sql package dbq diff --git a/internal/db/dbq/lidarr_config.sql.go b/internal/db/dbq/lidarr_config.sql.go new file mode 100644 index 00000000..a6fa50b3 --- /dev/null +++ b/internal/db/dbq/lidarr_config.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: lidarr_config.sql + +package dbq + +import ( + "context" +) + +const getLidarrConfig = `-- name: GetLidarrConfig :one +SELECT id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at +FROM lidarr_config +WHERE id = 1 +` + +func (q *Queries) GetLidarrConfig(ctx context.Context) (LidarrConfig, error) { + row := q.db.QueryRow(ctx, getLidarrConfig) + var i LidarrConfig + err := row.Scan( + &i.ID, + &i.Enabled, + &i.BaseUrl, + &i.ApiKey, + &i.DefaultQualityProfileID, + &i.DefaultRootFolderPath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateLidarrConfig = `-- name: UpdateLidarrConfig :one +UPDATE lidarr_config + SET enabled = $1, + base_url = $2, + api_key = $3, + default_quality_profile_id = $4, + default_root_folder_path = $5, + updated_at = now() + WHERE id = 1 + RETURNING id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at +` + +type UpdateLidarrConfigParams struct { + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultRootFolderPath *string +} + +func (q *Queries) UpdateLidarrConfig(ctx context.Context, arg UpdateLidarrConfigParams) (LidarrConfig, error) { + row := q.db.QueryRow(ctx, updateLidarrConfig, + arg.Enabled, + arg.BaseUrl, + arg.ApiKey, + arg.DefaultQualityProfileID, + arg.DefaultRootFolderPath, + ) + var i LidarrConfig + err := row.Scan( + &i.ID, + &i.Enabled, + &i.BaseUrl, + &i.ApiKey, + &i.DefaultQualityProfileID, + &i.DefaultRootFolderPath, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/db/dbq/lidarr_requests.sql.go b/internal/db/dbq/lidarr_requests.sql.go new file mode 100644 index 00000000..70676607 --- /dev/null +++ b/internal/db/dbq/lidarr_requests.sql.go @@ -0,0 +1,480 @@ +// 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 +` + +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, + ) + 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 +` + +type CancelLidarrRequestParams struct { + ID pgtype.UUID + DecidedBy pgtype.UUID +} + +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, + ) + 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 +` + +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. +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, + ) + 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 +` + +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, + ) + 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 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, + ) + 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. +func (q *Queries) HasNonTerminalRequestForMBID(ctx context.Context, lidarrArtistMbid string) (bool, error) { + row := q.db.QueryRow(ctx, hasNonTerminalRequestForMBID, lidarrArtistMbid) + 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 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, + ); 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 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, + ); 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 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, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +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 +` + +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, + ) + return i, err +} diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index 53533cc2..5510829b 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: likes.sql package dbq diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 5ce88a32..d0cbf1f2 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -1,13 +1,104 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 package dbq import ( + "database/sql/driver" + "fmt" + "github.com/jackc/pgx/v5/pgtype" ) +type LidarrRequestKind string + +const ( + LidarrRequestKindArtist LidarrRequestKind = "artist" + LidarrRequestKindAlbum LidarrRequestKind = "album" + LidarrRequestKindTrack LidarrRequestKind = "track" +) + +func (e *LidarrRequestKind) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrRequestKind(s) + case string: + *e = LidarrRequestKind(s) + default: + return fmt.Errorf("unsupported scan type for LidarrRequestKind: %T", src) + } + return nil +} + +type NullLidarrRequestKind struct { + LidarrRequestKind LidarrRequestKind + Valid bool // Valid is true if LidarrRequestKind is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrRequestKind) Scan(value interface{}) error { + if value == nil { + ns.LidarrRequestKind, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrRequestKind.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrRequestKind) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrRequestKind), nil +} + +type LidarrRequestStatus string + +const ( + LidarrRequestStatusPending LidarrRequestStatus = "pending" + LidarrRequestStatusApproved LidarrRequestStatus = "approved" + LidarrRequestStatusRejected LidarrRequestStatus = "rejected" + LidarrRequestStatusCompleted LidarrRequestStatus = "completed" + LidarrRequestStatusFailed LidarrRequestStatus = "failed" +) + +func (e *LidarrRequestStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = LidarrRequestStatus(s) + case string: + *e = LidarrRequestStatus(s) + default: + return fmt.Errorf("unsupported scan type for LidarrRequestStatus: %T", src) + } + return nil +} + +type NullLidarrRequestStatus struct { + LidarrRequestStatus LidarrRequestStatus + Valid bool // Valid is true if LidarrRequestStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullLidarrRequestStatus) Scan(value interface{}) error { + if value == nil { + ns.LidarrRequestStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.LidarrRequestStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullLidarrRequestStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.LidarrRequestStatus), nil +} + type Album struct { ID pgtype.UUID Title string @@ -65,6 +156,41 @@ type GeneralLikesArtist struct { LikedAt pgtype.Timestamptz } +type LidarrConfig struct { + ID int16 + Enabled bool + BaseUrl *string + ApiKey *string + DefaultQualityProfileID *int32 + DefaultRootFolderPath *string + CreatedAt pgtype.Timestamptz + UpdatedAt 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 +} + type PlayEvent struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 7da85f13..97a620df 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: recommendation.sql package dbq diff --git a/internal/db/dbq/scrobble.sql.go b/internal/db/dbq/scrobble.sql.go index 8c8da74c..5a538e5b 100644 --- a/internal/db/dbq/scrobble.sql.go +++ b/internal/db/dbq/scrobble.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: scrobble.sql package dbq diff --git a/internal/db/dbq/sessions.sql.go b/internal/db/dbq/sessions.sql.go index 3ac9741c..bc34671b 100644 --- a/internal/db/dbq/sessions.sql.go +++ b/internal/db/dbq/sessions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: sessions.sql package dbq diff --git a/internal/db/dbq/similarity.sql.go b/internal/db/dbq/similarity.sql.go index 5a92cecf..2673985c 100644 --- a/internal/db/dbq/similarity.sql.go +++ b/internal/db/dbq/similarity.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: similarity.sql package dbq diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 5254bb50..d17447c9 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: tracks.sql package dbq diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index d15f691e..d2c97bbc 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.27.0 +// sqlc v1.31.1 // source: users.sql package dbq diff --git a/internal/db/migrations/0010_lidarr.down.sql b/internal/db/migrations/0010_lidarr.down.sql new file mode 100644 index 00000000..a6aabe02 --- /dev/null +++ b/internal/db/migrations/0010_lidarr.down.sql @@ -0,0 +1,8 @@ +DROP INDEX IF EXISTS lidarr_requests_album_mbid_idx; +DROP INDEX IF EXISTS lidarr_requests_artist_mbid_idx; +DROP INDEX IF EXISTS lidarr_requests_status_idx; +DROP INDEX IF EXISTS lidarr_requests_user_id_idx; +DROP TABLE IF EXISTS lidarr_requests; +DROP TYPE IF EXISTS lidarr_request_kind; +DROP TYPE IF EXISTS lidarr_request_status; +DROP TABLE IF EXISTS lidarr_config; diff --git a/internal/db/migrations/0010_lidarr.up.sql b/internal/db/migrations/0010_lidarr.up.sql new file mode 100644 index 00000000..8efb7b03 --- /dev/null +++ b/internal/db/migrations/0010_lidarr.up.sql @@ -0,0 +1,63 @@ +-- M5a: Lidarr integration foundation. Two tables: +-- +-- lidarr_config — singleton (CHECK id=1) holding the operator's Lidarr +-- connection. enabled=false is the unconfigured state. +-- +-- lidarr_requests — per-request lifecycle row created by users at +-- /discover, transitioned by admin at /admin/requests, and matched +-- back to library tracks by the reconciler worker. Three matched_*_id +-- FKs (one per kind) instead of polymorphic — clean SQL, ON DELETE +-- SET NULL preserves audit even if the matched track is later removed. + +CREATE TABLE lidarr_config ( + id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1), + enabled boolean NOT NULL DEFAULT false, + base_url text, + api_key text, + default_quality_profile_id int, + default_root_folder_path text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO lidarr_config (id, enabled) VALUES (1, false); + +CREATE TYPE lidarr_request_status AS ENUM ( + 'pending', 'approved', 'rejected', 'completed', 'failed' +); +CREATE TYPE lidarr_request_kind AS ENUM ('artist', 'album', 'track'); + +CREATE TABLE lidarr_requests ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status lidarr_request_status NOT NULL DEFAULT 'pending', + kind lidarr_request_kind NOT NULL, + + lidarr_artist_mbid text NOT NULL, + lidarr_album_mbid text, + lidarr_track_mbid text, + artist_name text NOT NULL, + album_title text, + track_title text, + + quality_profile_id int, + root_folder_path text, + + decided_at timestamptz, + decided_by uuid REFERENCES users(id) ON DELETE SET NULL, + notes text, + + completed_at timestamptz, + matched_track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, + matched_album_id uuid REFERENCES albums(id) ON DELETE SET NULL, + matched_artist_id uuid REFERENCES artists(id) ON DELETE SET NULL, + + requested_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX lidarr_requests_user_id_idx ON lidarr_requests (user_id); +CREATE INDEX lidarr_requests_status_idx ON lidarr_requests (status); +CREATE INDEX lidarr_requests_artist_mbid_idx ON lidarr_requests (lidarr_artist_mbid); +CREATE INDEX lidarr_requests_album_mbid_idx ON lidarr_requests (lidarr_album_mbid) + WHERE lidarr_album_mbid IS NOT NULL; diff --git a/internal/db/queries/lidarr_config.sql b/internal/db/queries/lidarr_config.sql new file mode 100644 index 00000000..29f93172 --- /dev/null +++ b/internal/db/queries/lidarr_config.sql @@ -0,0 +1,17 @@ +-- name: GetLidarrConfig :one +SELECT id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at +FROM lidarr_config +WHERE id = 1; + +-- name: UpdateLidarrConfig :one +UPDATE lidarr_config + SET enabled = $1, + base_url = $2, + api_key = $3, + default_quality_profile_id = $4, + default_root_folder_path = $5, + updated_at = now() + WHERE id = 1 + RETURNING id, enabled, base_url, api_key, default_quality_profile_id, + default_root_folder_path, created_at, updated_at; diff --git a/internal/db/queries/lidarr_requests.sql b/internal/db/queries/lidarr_requests.sql new file mode 100644 index 00000000..9641581b --- /dev/null +++ b/internal/db/queries/lidarr_requests.sql @@ -0,0 +1,85 @@ +-- 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 +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. +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. +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)) +);