DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
3 changed files with 82 additions and 12 deletions
Showing only changes of commit 1aba1a41e6 - Show all commits
+12 -9
View File
@@ -20,14 +20,13 @@ import (
"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.
// Action is the discriminator stored in audit_log.action. Every value is
// declared here, so callers never stringly-type an action and the vocabulary
// has one source.
type Action string
const (
// U1
// Accounts and invites.
ActionRegister Action = "register"
ActionPromoteAdmin Action = "promote_admin"
ActionDemoteAdmin Action = "demote_admin"
@@ -35,15 +34,13 @@ const (
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).
// Admin user management.
ActionCreateUserAdmin Action = "create_user_admin"
ActionDeleteUser Action = "delete_user"
ActionPasswordResetAdmin Action = "password_reset_admin"
ActionAutoApproveToggle Action = "auto_approve_toggle"
// U3
// Credentials.
ActionPasswordChangeSelf Action = "password_change_self"
ActionTokenRegenerate Action = "token_regenerate"
ActionForgotPasswordInit Action = "forgot_password_initiated"
@@ -65,6 +62,12 @@ const (
ActionDuplicateDismiss Action = "duplicate_dismiss"
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
// An admin removing a track: its file and row are deleted, with every
// like, play and playlist entry that hung off the row. The metadata names
// the track and its file, so the log can say what went after the row is
// gone.
ActionTrackRemove Action = "track_remove"
// Subsonic password: generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
+29 -3
View File
@@ -13,11 +13,13 @@ import (
"fmt"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
@@ -57,11 +59,32 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Servic
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
// deleted — see library.DeleteTrackFile for why that order is the contract.
//
// adminID is accepted for the audit log but not yet written to it.
// A removal is written to the audit log with adminID as the actor. The
// track's names are read before the delete, since the row, and perhaps its
// album and artist, are gone after it.
func (s *Service) RemoveTrack(
ctx context.Context,
trackID, adminID pgtype.UUID, //nolint:revive // adminID reserved for audit-log wiring in a follow-up
ctx context.Context, trackID, adminID pgtype.UUID,
) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrNotFound
}
return nil, nil, fmt.Errorf("get track: %w", err)
}
meta := map[string]any{
"track_id": syncpkg.FormatUUID(track.ID),
"title": track.Title,
"file_path": track.FilePath,
}
if artist, err := q.GetArtistByID(ctx, track.ArtistID); err == nil {
meta["artist"] = artist.Name
}
if album, err := q.GetAlbumByID(ctx, track.AlbumID); err == nil {
meta["album"] = album.Title
}
deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID)
if err != nil {
if errors.Is(err, library.ErrTrackNotFound) {
@@ -69,6 +92,9 @@ func (s *Service) RemoveTrack(
}
return nil, nil, fmt.Errorf("delete track: %w", err)
}
meta["album_deleted"] = deleted.AlbumID != nil
meta["artist_deleted"] = deleted.ArtistID != nil
audit.WriteOrLog(ctx, s.pool, s.logger, adminID, pgtype.UUID{}, audit.ActionTrackRemove, meta)
return deleted.AlbumID, deleted.ArtistID, nil
}
+41
View File
@@ -137,6 +137,47 @@ func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) {
}
}
func TestRemoveTrack_WritesAuditRow(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
if _, err := pool.Exec(context.Background(), `DELETE FROM audit_log`); err != nil {
t.Fatalf("reset audit_log: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "track.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, _, track := seedArtistAlbumTrack(t, pool, "Audited", "", path)
svc := NewService(pool, nil, "")
if _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID); err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
var actor pgtype.UUID
var title, artist, album, filePath string
var albumDeleted bool
err := pool.QueryRow(context.Background(), `
SELECT actor_id, metadata->>'title', metadata->>'artist', metadata->>'album',
metadata->>'file_path', (metadata->>'album_deleted')::boolean
FROM audit_log WHERE action = 'track_remove'`).
Scan(&actor, &title, &artist, &album, &filePath, &albumDeleted)
if err != nil {
t.Fatalf("read audit row: %v", err)
}
if actor != admin.ID {
t.Errorf("actor = %v, want the admin %v", actor, admin.ID)
}
if title != "Audited Track" || artist != "Audited Artist" || album != "Audited Album" || filePath != path {
t.Errorf("metadata = %q / %q / %q / %q", title, artist, album, filePath)
}
if !albumDeleted {
t.Errorf("album_deleted = false, want true: the track was its album's only one")
}
}
func TestRemoveTrack_FileAlreadyMissing(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")