test-web / test (push) Successful in 57s
test-go / test (push) Successful in 1m16s
test-go / integration (push) Successful in 3m39s
release / Build signed APK (releases and dev) (push) Successful in 4m46s
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
Merge keeps one copy of a duplicate group and removes the rest. Every
table that references tracks does so ON DELETE CASCADE, so deleting a
duplicate's row outright would silently destroy its likes, plays,
playlist entries and tags. The merge moves all of that onto the kept
copy first, then deletes the empty row.
In one transaction, holding a lock on the group:
- repoints play_events, skip_events, contextual_likes, playback_errors,
lidarr_requests.matched_track_id and playlist_tracks. The last is
keyed by position, so every entry stays where it was.
- merges general_likes one per user, dated to the earlier like
- takes the union of track_tags, keeping the kept copy's own weight on
a shared tag
- rewrites track_similarity onto the kept copy, dropping edges that
would point a track at itself and keeping the kept copy's existing
edge on a collision
- lets the kept copy take a recording MBID only the removed copy had
- deletes the removed copies' rows, tidies emptied albums and artists,
marks the group merged
- logs sync changes: track deletes, and like and playlist-track
delete/upsert pairs
The removed copies' files are deleted first, before any row changes,
through the same helper as DeleteTrackFile (now shared, along with the
album tidy-up). A merge that left the file behind would be undone by
the next scan re-importing it. An unwritable library answers 409
library_not_writable and nothing changes.
tracks.Service.MergeDuplicates wraps it with the opt-in Lidarr unmonitor
from RemoveTrack, skipped when the removed copy is a second file of the
kept copy's own album track: unmonitoring that would stop Lidarr
managing the kept file. It writes a duplicate_merge audit row after
commit, per the audit package's best-effort contract, naming both
paths.
POST /api/admin/library/duplicates/{id}/merge takes an optional
survivor_track_id (the report's proposal otherwise) and unmonitor.
On the report page:
- each copy gets a Keep choice, defaulting to the proposed one
- Merge needs a second click, on a button that says how many files it
removes, with the consequence stated beside an opt-in Lidarr checkbox
Integration tests cover:
- every piece of history landing on the kept copy exactly: likes
deduped at the earlier time, plays and skips counted, playlist
position unchanged, tags unioned, similarity rewritten with no
duplicate or self-edge, MBID inherited
- the removed file gone, and a second merge refused
- an unwritable file leaving likes, plays, row and group untouched
- a survivor outside the group refused
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
202 lines
7.5 KiB
Go
202 lines
7.5 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"syscall"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
|
)
|
|
|
|
// ErrTrackNotFound is returned when DeleteTrackFile is called with an id
|
|
// that has no row in tracks.
|
|
var ErrTrackNotFound = errors.New("library: track not found")
|
|
|
|
// removeFile is os.Remove behind a variable so a test can make removal fail the
|
|
// way a read-only mount or a wrongly-owned directory does. A chmod-based test
|
|
// cannot stand in for that: root ignores permission bits, so in a CI container
|
|
// running as root it would pass without ever exercising the failure.
|
|
var removeFile = os.Remove
|
|
|
|
// FileRemoveError reports that a track's file exists but could not be removed.
|
|
// When DeleteTrackFile returns one, NOTHING was deleted: the row, its likes, its
|
|
// play history and its playlist memberships are all intact.
|
|
type FileRemoveError struct {
|
|
Path string
|
|
// UID and GID are the identity the server process runs as — the half of a
|
|
// permission problem the operator cannot see from the host side.
|
|
UID, GID int
|
|
Err error
|
|
}
|
|
|
|
func (e *FileRemoveError) Error() string { return fmt.Sprintf("remove track file: %v", e.Err) }
|
|
|
|
func (e *FileRemoveError) Unwrap() error { return e.Err }
|
|
|
|
// Dir is the directory removal needs write access to. Unlinking a file writes to
|
|
// its PARENT, so a world-writable file inside a read-only directory still cannot
|
|
// be removed — naming the file's own permissions would send the operator to the
|
|
// wrong place.
|
|
func (e *FileRemoveError) Dir() string { return filepath.Dir(e.Path) }
|
|
|
|
// NotWritable reports whether the library is unwritable for this process — a
|
|
// read-only mount or a permission denial — rather than an I/O fault. It is the
|
|
// case the operator can fix, so callers answer it differently.
|
|
func (e *FileRemoveError) NotWritable() bool {
|
|
return errors.Is(e.Err, fs.ErrPermission) || errors.Is(e.Err, syscall.EROFS)
|
|
}
|
|
|
|
// Reason is the underlying cause without the path os.Remove already wrapped
|
|
// around it, for messages that name the directory themselves.
|
|
func (e *FileRemoveError) Reason() string {
|
|
var pathErr *fs.PathError
|
|
if errors.As(e.Err, &pathErr) {
|
|
return pathErr.Err.Error()
|
|
}
|
|
return e.Err.Error()
|
|
}
|
|
|
|
// DeletedTrack reports what a delete tidied away beyond the track itself.
|
|
type DeletedTrack struct {
|
|
// AlbumID is set when the track was its album's last, so the album went too.
|
|
AlbumID *pgtype.UUID
|
|
// ArtistID is set when that album was its artist's last, so the artist went too.
|
|
ArtistID *pgtype.UUID
|
|
}
|
|
|
|
// DeleteTrackFile removes a track's file from disk and then its row, tidying
|
|
// away an album or artist the delete leaves empty. It is the ONLY path that
|
|
// deletes a track file: the admin remove-track endpoint and quarantine's Delete
|
|
// file both come through here (#3918).
|
|
//
|
|
// Order is the whole contract. The file goes first, and if it cannot go — a
|
|
// read-only mount, a permission denial, an I/O error — nothing else happens and
|
|
// a *FileRemoveError comes back. Proceeding past that failure is how #3918 lost
|
|
// history: tracks CASCADEs to play_events, general_likes, contextual_likes,
|
|
// playlist_tracks, track_tags and playback_errors, so the row and everything
|
|
// hanging off it were destroyed while the file survived, and the next scan
|
|
// re-imported it as a brand-new track with none of it.
|
|
//
|
|
// A file that is already gone (fs.ErrNotExist) is not a failure; the row is
|
|
// removed as asked.
|
|
//
|
|
// This is NOT the missing-file path. That lifecycle is deliberately
|
|
// non-destructive: reconcile stamps missing_since (#2523), selection paths
|
|
// filter on it, and a returning file is un-marked or adopted (#2528). This is the
|
|
// explicit, irreversible "remove this recording", never the way to tidy up a row
|
|
// whose file merely went away.
|
|
//
|
|
// dataDir, when set, also clears the cached art of an artist the delete removed.
|
|
// logger may be nil.
|
|
func DeleteTrackFile(
|
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, dataDir string, trackID pgtype.UUID,
|
|
) (DeletedTrack, error) {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
q := dbq.New(pool)
|
|
track, err := q.GetTrackByID(ctx, trackID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return DeletedTrack{}, ErrTrackNotFound
|
|
}
|
|
return DeletedTrack{}, fmt.Errorf("get track: %w", err)
|
|
}
|
|
|
|
if err := removeTrackFileOnDisk(track.FilePath); err != nil {
|
|
return DeletedTrack{}, err
|
|
}
|
|
|
|
// The row and any album or artist it empties go together, so a failure
|
|
// partway cannot leave a deleted track with a ghost album behind it.
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return DeletedTrack{}, fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
|
tq := dbq.New(tx)
|
|
|
|
deleted, err := tq.DeleteTrack(ctx, trackID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Removed by someone else between the lookup and here.
|
|
return DeletedTrack{}, ErrTrackNotFound
|
|
}
|
|
return DeletedTrack{}, fmt.Errorf("delete track: %w", err)
|
|
}
|
|
|
|
out, err := tidyEmptiedAlbum(ctx, tq, deleted.AlbumID)
|
|
if err != nil {
|
|
return DeletedTrack{}, err
|
|
}
|
|
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return DeletedTrack{}, fmt.Errorf("commit: %w", err)
|
|
}
|
|
|
|
// Both of these run after the delete has committed, so neither may fail
|
|
// it: the recording is gone either way. An unlogged change leaves the track
|
|
// in offline clients' caches until the next scan touches its album; a
|
|
// leftover art directory is only disk.
|
|
if err := syncpkg.LogChange(ctx, pool, syncpkg.EntityTrack,
|
|
syncpkg.FormatUUID(trackID), syncpkg.OpDelete); err != nil {
|
|
logger.Warn("track delete: LogChange failed", "track_id", syncpkg.FormatUUID(trackID), "err", err)
|
|
}
|
|
if out.ArtistID != nil && dataDir != "" {
|
|
if err := coverart.CleanupArtistArt(dataDir, *out.ArtistID); err != nil {
|
|
logger.Warn("track delete: artist-art cleanup failed",
|
|
"artist_id", syncpkg.FormatUUID(*out.ArtistID), "err", err)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// removeTrackFileOnDisk is the one rule for removing a track's file, shared by
|
|
// DeleteTrackFile and the duplicate merge. A file already gone is fine; anything
|
|
// else comes back as a *FileRemoveError, and the caller must then change nothing
|
|
// in the database (#3918).
|
|
func removeTrackFileOnDisk(path string) error {
|
|
if err := removeFile(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
|
return &FileRemoveError{Path: path, UID: os.Getuid(), GID: os.Getgid(), Err: err}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// tidyEmptiedAlbum removes an album a track delete left with no tracks, and its
|
|
// artist if that album was the artist's last. It runs on the caller's
|
|
// transaction, so the tidy-up commits or rolls back with the delete itself.
|
|
func tidyEmptiedAlbum(ctx context.Context, tq *dbq.Queries, albumID pgtype.UUID) (DeletedTrack, error) {
|
|
var out DeletedTrack
|
|
album, err := tq.DeleteAlbumIfEmpty(ctx, albumID)
|
|
switch {
|
|
case err == nil:
|
|
id := album.ID
|
|
out.AlbumID = &id
|
|
artistID, aerr := tq.DeleteArtistIfEmpty(ctx, album.ArtistID)
|
|
switch {
|
|
case aerr == nil:
|
|
out.ArtistID = &artistID
|
|
case errors.Is(aerr, pgx.ErrNoRows):
|
|
// The artist still has other albums or stray tracks.
|
|
default:
|
|
return DeletedTrack{}, fmt.Errorf("delete artist if empty: %w", aerr)
|
|
}
|
|
case errors.Is(err, pgx.ErrNoRows):
|
|
// The album still has other tracks.
|
|
default:
|
|
return DeletedTrack{}, fmt.Errorf("delete album if empty: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|