release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
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
Rewrites the internal/library comment blocks to rule 32. Task, milestone, decision and rule ids are gone; the incidents behind them are restated as the constraint they left (why a file is deleted before its row, why the missing mark is a mark, why duplicates block on duration). Counts and album names from one library are dropped from production comments; test comments keep the names their fixtures use. The tagReadVersion log keeps each version's before and after, without the measurements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
204 lines
7.5 KiB
Go
204 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 an admin 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 an admin 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 an admin 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.
|
|
//
|
|
// 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 would lose
|
|
// 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 would be destroyed while the file survived, and the next scan
|
|
// would re-import 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, selection paths filter on
|
|
// it, and a returning file is un-marked or adopted. 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.
|
|
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
|
|
}
|