DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
+12
-9
@@ -20,14 +20,13 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Action is the discriminator stored in audit_log.action. New values
|
// Action is the discriminator stored in audit_log.action. Every value is
|
||||||
// added in U2/U3 + future tasks; declare them here so callers don't
|
// declared here, so callers never stringly-type an action and the vocabulary
|
||||||
// stringly-type and so a future audit-search UI has a single source
|
// has one source.
|
||||||
// of truth for the vocabulary.
|
|
||||||
type Action string
|
type Action string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// U1
|
// Accounts and invites.
|
||||||
ActionRegister Action = "register"
|
ActionRegister Action = "register"
|
||||||
ActionPromoteAdmin Action = "promote_admin"
|
ActionPromoteAdmin Action = "promote_admin"
|
||||||
ActionDemoteAdmin Action = "demote_admin"
|
ActionDemoteAdmin Action = "demote_admin"
|
||||||
@@ -35,15 +34,13 @@ const (
|
|||||||
ActionInviteRedeem Action = "invite_redeem"
|
ActionInviteRedeem Action = "invite_redeem"
|
||||||
ActionInviteRevoke Action = "invite_revoke"
|
ActionInviteRevoke Action = "invite_revoke"
|
||||||
|
|
||||||
// U2 (declared early; callers in U1 don't use them yet, but
|
// Admin user management.
|
||||||
// having them here means U2's diff is purely additive on the
|
|
||||||
// caller side, not also touching this file).
|
|
||||||
ActionCreateUserAdmin Action = "create_user_admin"
|
ActionCreateUserAdmin Action = "create_user_admin"
|
||||||
ActionDeleteUser Action = "delete_user"
|
ActionDeleteUser Action = "delete_user"
|
||||||
ActionPasswordResetAdmin Action = "password_reset_admin"
|
ActionPasswordResetAdmin Action = "password_reset_admin"
|
||||||
ActionAutoApproveToggle Action = "auto_approve_toggle"
|
ActionAutoApproveToggle Action = "auto_approve_toggle"
|
||||||
|
|
||||||
// U3
|
// Credentials.
|
||||||
ActionPasswordChangeSelf Action = "password_change_self"
|
ActionPasswordChangeSelf Action = "password_change_self"
|
||||||
ActionTokenRegenerate Action = "token_regenerate"
|
ActionTokenRegenerate Action = "token_regenerate"
|
||||||
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
||||||
@@ -65,6 +62,12 @@ const (
|
|||||||
ActionDuplicateDismiss Action = "duplicate_dismiss"
|
ActionDuplicateDismiss Action = "duplicate_dismiss"
|
||||||
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
|
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.
|
// Subsonic password: generated in Settings for t/s-only clients.
|
||||||
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
||||||
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
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
|
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
|
||||||
// deleted — see library.DeleteTrackFile for why that order is the contract.
|
// 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(
|
func (s *Service) RemoveTrack(
|
||||||
ctx context.Context,
|
ctx context.Context, trackID, adminID pgtype.UUID,
|
||||||
trackID, adminID pgtype.UUID, //nolint:revive // adminID reserved for audit-log wiring in a follow-up
|
|
||||||
) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err error) {
|
) (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)
|
deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, library.ErrTrackNotFound) {
|
if errors.Is(err, library.ErrTrackNotFound) {
|
||||||
@@ -69,6 +92,9 @@ func (s *Service) RemoveTrack(
|
|||||||
}
|
}
|
||||||
return nil, nil, fmt.Errorf("delete track: %w", err)
|
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
|
return deleted.AlbumID, deleted.ArtistID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestRemoveTrack_FileAlreadyMissing(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
admin := seedAdmin(t, pool, "alice")
|
admin := seedAdmin(t, pool, "alice")
|
||||||
|
|||||||
Reference in New Issue
Block a user