test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.
In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
lidarr_requests.matched_track_id and playlist_tracks. The last is
keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
would point a track at itself and keeping the kept copy's existing
edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
marks the group merged
- logs sync changes: track deletes, and like and playlist-track
delete/upsert pairs
The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.
tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.
POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.
On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
removes, with the consequence stated beside an opt-in Lidarr checkbox
Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
deduped at the earlier time, plays and skips counted, playlist
position unchanged, tags unioned, similarity rewritten with no
duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
103 lines
3.9 KiB
Go
103 lines
3.9 KiB
Go
// Package audit writes admin-driven user-management events to audit_log.
|
|
// Thin wrapper over the sqlc-generated WriteAuditLog query — the value
|
|
// is centralizing the action-name vocabulary and the metadata
|
|
// marshaling so callers don't repeat boilerplate.
|
|
//
|
|
// Audit writes are best-effort from the caller's perspective: a failed
|
|
// audit write must NOT fail the user-facing operation. Callers
|
|
// log-and-continue. The audit log is observability, not a transaction
|
|
// participant.
|
|
package audit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Action is the discriminator stored in audit_log.action. New values
|
|
// added in U2/U3 + future tasks; declare them here so callers don't
|
|
// stringly-type and so a future audit-search UI has a single source
|
|
// of truth for the vocabulary.
|
|
type Action string
|
|
|
|
const (
|
|
// U1
|
|
ActionRegister Action = "register"
|
|
ActionPromoteAdmin Action = "promote_admin"
|
|
ActionDemoteAdmin Action = "demote_admin"
|
|
ActionInviteCreate Action = "invite_create"
|
|
ActionInviteRedeem Action = "invite_redeem"
|
|
ActionInviteRevoke Action = "invite_revoke"
|
|
|
|
// U2 (declared early; callers in U1 don't use them yet, but
|
|
// having them here means U2's diff is purely additive on the
|
|
// caller side, not also touching this file).
|
|
ActionCreateUserAdmin Action = "create_user_admin"
|
|
ActionDeleteUser Action = "delete_user"
|
|
ActionPasswordResetAdmin Action = "password_reset_admin"
|
|
ActionAutoApproveToggle Action = "auto_approve_toggle"
|
|
|
|
// U3
|
|
ActionPasswordChangeSelf Action = "password_change_self"
|
|
ActionTokenRegenerate Action = "token_regenerate"
|
|
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
|
ActionPasswordResetByEmail Action = "password_reset_via_email"
|
|
|
|
// Active-sessions surface (#370). Worth auditing rather than silent:
|
|
// revoking sessions is what a user does when they think an account is
|
|
// compromised, so the audit trail is most useful precisely when it's
|
|
// exercised.
|
|
ActionSessionRevoke Action = "session_revoke"
|
|
ActionSessionRevokeOthers Action = "session_revoke_others"
|
|
|
|
// Duplicate merge (#3911). Irreversible: a copy's file and row are removed
|
|
// and its history moved onto the copy kept. The metadata names both, so the
|
|
// log can answer "where did that file go" long after the report is gone.
|
|
ActionDuplicateMerge Action = "duplicate_merge"
|
|
)
|
|
|
|
// Write inserts one audit_log row. metadata is marshaled as JSON;
|
|
// nil metadata writes SQL NULL. Errors are returned so callers can
|
|
// log them — but per package doc, callers should NOT fail user-facing
|
|
// operations on audit-write failures.
|
|
//
|
|
// actorID may be a zero/invalid pgtype.UUID for system actions
|
|
// (e.g. self-registration where the new user is both actor and
|
|
// target — pass them as the same id, or pass invalid for actor and
|
|
// the new user as target).
|
|
func Write(ctx context.Context, pool *pgxpool.Pool, actorID, targetID pgtype.UUID, action Action, metadata map[string]any) error {
|
|
q := dbq.New(pool)
|
|
var jsonMeta []byte
|
|
if metadata != nil {
|
|
b, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
jsonMeta = b
|
|
}
|
|
return q.WriteAuditLog(ctx, dbq.WriteAuditLogParams{
|
|
ActorID: actorID,
|
|
TargetID: targetID,
|
|
Action: string(action),
|
|
Metadata: jsonMeta,
|
|
})
|
|
}
|
|
|
|
// WriteOrLog writes the audit row; on error, logs at Warn and swallows
|
|
// (audit failures must not break user-facing operations — see package doc).
|
|
// Use this when the audit is observability, not gating; use Write directly
|
|
// when the caller needs strict semantics (e.g. tests).
|
|
func WriteOrLog(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, actorID, targetID pgtype.UUID, action Action, metadata map[string]any) {
|
|
if err := Write(ctx, pool, actorID, targetID, action, metadata); err != nil {
|
|
if logger != nil {
|
|
logger.Warn("audit failed", "action", string(action), "err", err)
|
|
}
|
|
}
|
|
}
|