// 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. // // The web track-kebab entry that called this was removed in f7278f24; the // endpoint was kept deliberately for a safer admin surface to rebind. package tracks import ( "context" "errors" "fmt" "log/slog" "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/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 // (#3918). // // adminID is currently unused — the cascade audit-log line that would // reference it isn't wired in this slice. It's threaded through the // signature so the upcoming admin_tracks handler doesn't have to // re-plumb when audit logging lands. func (s *Service) RemoveTrack( ctx context.Context, trackID, adminID pgtype.UUID, //nolint:revive // adminID reserved for audit-log wiring in a follow-up ) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err error) { 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) } 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 }