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
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>
128 lines
4.6 KiB
Go
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
|
|
}
|