Files
minstrel/internal/db/queries/users.sql
T
bvandeusenandClaude Opus 5.5 04515a977c
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
feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
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>
2026-10-10 00:46:22 -04:00

177 lines
6.2 KiB
SQL

-- name: CreateUser :one
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: CreateUserFirstAdminRace :one
-- Inserts a new user; sets is_admin=true ONLY when no users currently
-- exist. The (SELECT NOT EXISTS ...) subquery is evaluated at INSERT
-- time within the same statement, so the result reflects committed
-- state at that moment.
--
-- Race semantics: two concurrent calls in an empty-users state may
-- both observe "no users" and both insert with is_admin=true. That's
-- benign — having two admins from the gate is fine; what matters is
-- that there's AT LEAST one. If the two calls happened to share the
-- same username, the unique constraint on users.username arbitrates
-- and the second caller's INSERT fails with a unique violation. The
-- caller (registration handler) can retry as a regular non-admin in
-- that case (or surface a "username taken" error to the user).
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES (
$1, $2, $3,
(SELECT NOT EXISTS (SELECT 1 FROM users)),
$4
)
RETURNING *;
-- name: GetUserByUsername :one
SELECT * FROM users WHERE username = $1;
-- name: GetUserByAPITokenHash :one
SELECT * FROM users WHERE api_token_hash = $1;
-- name: CountUsers :one
SELECT count(*) FROM users;
-- name: SetSubsonicPassword :exec
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003. Only
-- ever a server-generated value, never the login password (#5026).
UPDATE users SET subsonic_password = $2 WHERE id = $1;
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
-- name: SetListenBrainzToken :exec
UPDATE users
SET listenbrainz_token = $2,
listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END
WHERE id = $1;
-- name: SetListenBrainzEnabled :exec
UPDATE users
SET listenbrainz_enabled = $2
WHERE id = $1;
-- name: ListUsers :many
-- Admin user-management list. Sort newest-first.
SELECT id, username, display_name, is_admin, auto_approve_requests,
debug_mode_enabled, created_at
FROM users
ORDER BY created_at DESC;
-- name: CountAdmins :one
SELECT count(*) FROM users WHERE is_admin = true;
-- name: UpdateUserAdmin :one
-- Sets is_admin to the given value. Returns the updated row so the
-- handler can echo it back to the caller.
UPDATE users
SET is_admin = $2
WHERE id = $1
RETURNING *;
-- name: GetListenBrainzConfig :one
-- Returns the user's LB token + enabled flag and the most recent
-- play_events.scrobbled_at for last-scrobbled-at status.
SELECT
u.listenbrainz_token,
u.listenbrainz_enabled,
(SELECT MAX(pe.scrobbled_at)::timestamptz
FROM play_events pe
WHERE pe.user_id = u.id) AS last_scrobbled_at
FROM users u
WHERE u.id = $1;
-- name: CreateUserAdmin :one
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
-- the caller (an admin) supplies all five fields explicitly including is_admin,
-- so an admin can promote on creation. Used by POST /api/admin/users.
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
VALUES ($1, $2, $3, $4, $5)
RETURNING *;
-- name: DeleteUser :exec
-- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
-- handle plays/likes/sessions/etc. Caller must enforce the last-admin
-- guard at the HTTP layer (count admins, refuse if target is the only
-- admin). The lidarr_quarantine, general_likes*, play_events,
-- sessions, and other user-FK tables all have ON DELETE CASCADE so
-- this is a clean drop.
DELETE FROM users WHERE id = $1;
-- name: ResetUserPassword :exec
-- Admin-driven password reset: caller writes a new hashed password to
-- the target user's row. No knowledge of the old password is required
-- (this is the admin path; self-service "knows current password" is
-- a separate U3 query).
UPDATE users
SET password_hash = $2
WHERE id = $1;
-- name: UpdateUserAutoApprove :one
-- Toggle the per-user auto_approve_requests flag.
UPDATE users
SET auto_approve_requests = $2
WHERE id = $1
RETURNING *;
-- name: SetDebugMode :one
-- Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
-- from /admin (flip remotely while a bug is live) or self-driven OFF from
-- the client. Returns the updated row so the handler can echo it.
UPDATE users
SET debug_mode_enabled = $2
WHERE id = $1
RETURNING *;
-- name: ChangeUserPassword :exec
-- Self-service password change. Caller (HTTP handler) verifies the
-- current password before calling this. Distinct from
-- ResetUserPassword which is admin-driven (no current-password
-- check).
UPDATE users SET password_hash = $2 WHERE id = $1;
-- name: UpdateUserProfile :one
-- Self-service: set display name and/or email. Both fields are
-- nullable in the DB; pass NULL ptr to clear, non-NULL ptr to set.
UPDATE users
SET display_name = $2,
email = $3
WHERE id = $1
RETURNING *;
-- name: RegenerateApiToken :exec
-- Self-service: caller wants a new API token. Used by the /settings
-- API Token card's "Regenerate" button. Only the hash is stored; the
-- handler returns the raw key once.
UPDATE users SET api_token_hash = $2 WHERE id = $1;
-- name: GetUserByEmail :one
-- Used by forgot-password lookup. Lowercase comparison both sides
-- to keep the unique index happy and to make the lookup
-- case-insensitive.
SELECT * FROM users WHERE lower(email) = lower($1);
-- name: UpdateUserTimezone :exec
-- Sets the user's IANA timezone and bumps timezone_updated_at. The
-- handler validates the timezone string via time.LoadLocation before
-- calling this; the DB does not re-validate.
UPDATE users
SET timezone = $2,
timezone_updated_at = now()
WHERE id = $1;
-- name: ListActiveUsersWithTimezones :many
-- Returns (id, timezone) for every user with a play in the last 7
-- days. The system-playlist scheduler iterates this list at startup
-- and during its hourly reconciliation to discover newly-active /
-- no-longer-active users.
SELECT u.id, u.timezone FROM users u
WHERE EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = u.id
AND pe.started_at > now() - INTERVAL '7 days'
);