Files
minstrel/internal/tracks/service.go
T
bvandeusenandClaude Opus 5.5 1aba1a41e6
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(tracks): removing a track writes a track_remove audit row (#6294)
RemoveTrack took the admin's id and dropped it, so a merge left an
audit trail and the more destructive removal did not. It now reads the
track's title, artist, album and file path before the delete and writes
them, with whether the album and artist went too, under the new
track_remove action.

The audit action groups lose their plan-era U1/U2/U3 headings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:16:41 -04:00

128 lines
4.6 KiB
Go

// Package tracks owns the track-level admin actions behind DELETE
// /api/admin/tracks/{id} and the duplicate merge. RemoveTrack's destructive
// part goes through library.DeleteTrackFile — the one path that deletes a
// track file.
//
// No client calls the endpoint today; it is kept for an admin surface to
// rebind.
package tracks
import (
"context"
"errors"
"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"
)
// ErrNotFound is returned when the track id doesn't resolve. Aliased
// to apierror.ErrNotFound so handlers can errors.Is against the shared
// sentinel; existing tracks.ErrNotFound callsites still resolve to the
// same pointer.
var ErrNotFound = apierror.ErrNotFound
// Service owns RemoveTrack and MergeDuplicates.
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string
}
// NewService constructs a Service. logger may be nil (defaults to
// slog.Default). dataDir is the on-disk root for cached artifacts; used to
// clean up artist-art on artist delete. Empty string disables the cleanup.
func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, logger: logger, dataDir: dataDir}
}
// RemoveTrack deletes the track's file and then its row, and tidies away an
// album or artist the delete empties.
//
// Returns:
// - deletedAlbumID: non-nil when removing the track left the album
// empty and the album row was deleted.
// - deletedArtistID: non-nil when both album AND artist were left
// empty (only set if deletedAlbumID is also set).
// - err: ErrNotFound, or a failure before anything was deleted. When the
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
// deleted — see library.DeleteTrackFile for why that order is the contract.
//
// 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,
) (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) {
return nil, nil, ErrNotFound
}
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
}
// MergeDuplicates merges a duplicate group into the copy to keep
// (library.MergeDuplicateGroup) and records the merge in the audit log.
func (s *Service) MergeDuplicates(
ctx context.Context, groupID, survivorID, actorID pgtype.UUID,
) (library.MergeResult, error) {
res, err := library.MergeDuplicateGroup(ctx, s.pool, s.logger, s.dataDir, groupID, survivorID)
if err != nil {
return res, err
}
removed := make([]map[string]string, 0, len(res.Removed))
for _, c := range res.Removed {
removed = append(removed, map[string]string{"track_id": syncpkg.FormatUUID(c.TrackID), "file_path": c.FilePath})
}
audit.WriteOrLog(ctx, s.pool, s.logger, actorID, pgtype.UUID{}, audit.ActionDuplicateMerge, map[string]any{
"group_id": syncpkg.FormatUUID(groupID),
"tier": res.Tier,
"survivor_track_id": syncpkg.FormatUUID(res.Survivor.TrackID),
"survivor_path": res.Survivor.FilePath,
"removed": removed,
"moved": map[string]any{
"play_events": res.PlayEvents, "skip_events": res.SkipEvents,
"likes": res.Likes, "playlist_entries": res.PlaylistEntries,
},
})
return res, nil
}