Answers the open fork on #2527's last slice: automatic, not a button. Until now missing_since was a dead end -- reconcile marks it, every selection path skips it, the admin surface lists it, and there it sits. Two decisions carry most of the safety, both at the design level rather than as rate limits bolted on afterwards. The unit is the ALBUM, not the track. Lidarr acquires releases; there is no meaningful "fetch me one track", and a track-kind request needs a recording MBID plenty of files lack. Grouping means the loss that produced #2523 -- three reorganised albums, ~40 missing files -- becomes three requests instead of forty. The flood problem mostly dissolves. And nothing is requested until a file has been missing longer than the grace window (24h default). A filesystem lies transiently: an unmounted volume, a container that started before its media mount attached, a NAS mid-reboot. Every one of those resolves itself well inside a day at no cost. missing_since is never re-stamped (#2523), so it is a true "gone since" clock to measure against, not "when we last noticed". This is the difference between automatic and trigger-happy. Then the backoff proper: 6h -> 12h -> 24h -> 48h per album, clamped to a week, three attempts before giving up, and a per-pass ceiling so a genuinely large loss trickles instead of dumping hundreds of rows into the queue. Giving up is stamped as a timestamp rather than inferred from attempts >= max, so the verdict survives an operator later raising the maximum and the surface can say when. A sweeper, not a hook inside reconcile. Reconcile runs inside a scan and has no business deciding to talk to a third-party service; it also re-runs often, which would make "attempt once, then back off" awkward to express. A worker paces itself, survives a restart, and retries without needing another scan. Recovered albums have their state deleted rather than reset -- a future loss is a new problem, not a continuation. Requests are attributed to the oldest admin: lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting human, so this keeps the row auditable and in the same queue as everything else without inventing a synthetic principal the schema would have to understand. Auto-approve defaults ON. Requests are created pending and nothing reaches Lidarr until approval, so with it off this would be a notification rather than an attempt. Lidarr disabled leaves the request pending rather than counting a failure -- the record of intent is still right and becomes actionable the moment Lidarr is configured. Albums with no MBID are counted, not silently skipped: nothing can be asked of Lidarr for a release MusicBrainz cannot name, and quietly doing nothing would read as the feature being broken. Settings are DB-backed per rule #25 with CHECK-guarded ranges, validated in Go as well so the API answers 400 rather than surfacing a constraint violation. The admin card and the state on the missing-files page are next; this is the engine.
189 lines
6.6 KiB
SQL
189 lines
6.6 KiB
SQL
-- name: CreateUser :one
|
|
INSERT INTO users (username, password_hash, api_token, 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, 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: GetUserByAPIToken :one
|
|
SELECT * FROM users WHERE api_token = $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.
|
|
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, 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 :one
|
|
-- Self-service: caller wants a new API token. Used by the /settings
|
|
-- API Token card's "Regenerate" button.
|
|
UPDATE users SET api_token = $2 WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- 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'
|
|
);
|
|
|
|
-- 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;
|