From d0523da520a97e757853b201d20896e38d74e849 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 30 Apr 2026 17:04:21 -0400 Subject: [PATCH] feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved) --- internal/library/delete.go | 49 +++++++++++++ internal/library/delete_test.go | 119 ++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 internal/library/delete.go create mode 100644 internal/library/delete_test.go diff --git a/internal/library/delete.go b/internal/library/delete.go new file mode 100644 index 00000000..133f6496 --- /dev/null +++ b/internal/library/delete.go @@ -0,0 +1,49 @@ +package library + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// ErrTrackNotFound is returned when DeleteTrackFile is called with an id +// that has no row in tracks. +var ErrTrackNotFound = errors.New("library: track not found") + +// DeleteTrackFile removes a track file from disk and its row from the +// tracks table. Album and artist rows are left untouched. +// +// Steps: +// 1. Look up the track to get its file_path. +// 2. Remove the file from disk. fs.ErrNotExist is OK — already gone. +// 3. Delete the tracks row. +// +// Order matters: file first, then DB. If the file delete fails (permission, +// I/O error), we leave the DB row alone so the admin can retry. +func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUID) error { + q := dbq.New(pool) + track, err := q.GetTrackByID(ctx, trackID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrTrackNotFound + } + return fmt.Errorf("get track: %w", err) + } + + if err := os.Remove(track.FilePath); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("remove file: %w", err) + } + + if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil { + return fmt.Errorf("delete row: %w", err) + } + return nil +} diff --git a/internal/library/delete_test.go b/internal/library/delete_test.go new file mode 100644 index 00000000..9952bffd --- /dev/null +++ b/internal/library/delete_test.go @@ -0,0 +1,119 @@ +package library + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "path/filepath" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/dbtest" +) + +func newPool(t *testing.T) *pgxpool.Pool { + t.Helper() + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil { + t.Fatalf("migrate: %v", err) + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Fatalf("pool: %v", err) + } + t.Cleanup(pool.Close) + dbtest.ResetDB(t, pool) + return pool +} + +func seedTrack(t *testing.T, pool *pgxpool.Pool, filePath string) (dbq.Track, dbq.Album, dbq.Artist) { + t.Helper() + q := dbq.New(pool) + artist, err := q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{ + Name: "Delete Test Artist", SortName: "Delete Test Artist", + }) + if err != nil { + t.Fatalf("artist: %v", err) + } + album, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "Delete Test Album", SortTitle: "Delete Test Album", + ArtistID: artist.ID, + }) + if err != nil { + t.Fatalf("album: %v", err) + } + track, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Delete Test Track", AlbumID: album.ID, ArtistID: artist.ID, + DurationMs: 1000, FilePath: filePath, FileSize: 100, FileFormat: "mp3", + }) + if err != nil { + t.Fatalf("track: %v", err) + } + return track, album, artist +} + +func TestDeleteTrackFile_HappyPath(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + + dir := t.TempDir() + path := filepath.Join(dir, "track.mp3") + if err := os.WriteFile(path, []byte("payload"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + track, album, _ := seedTrack(t, pool, path) + + if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { + t.Fatalf("DeleteTrackFile: %v", err) + } + + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Errorf("file still exists: %v", err) + } + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } + // Album row preserved (other tracks may reference it). + if _, err := q.GetAlbumByID(context.Background(), album.ID); err != nil { + t.Errorf("album row vanished: %v", err) + } +} + +func TestDeleteTrackFile_FileAlreadyGoneSucceeds(t *testing.T) { + pool := newPool(t) + q := dbq.New(pool) + + track, _, _ := seedTrack(t, pool, "/no/such/file/anywhere.mp3") + + if err := DeleteTrackFile(context.Background(), pool, track.ID); err != nil { + t.Fatalf("DeleteTrackFile with missing file: %v", err) + } + if _, err := q.GetTrackByID(context.Background(), track.ID); err == nil { + t.Errorf("track row still exists") + } +} + +func TestDeleteTrackFile_NotFoundReturnsErr(t *testing.T) { + pool := newPool(t) + + var bogus pgtype.UUID + bogus.Bytes = [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16} + bogus.Valid = true + + err := DeleteTrackFile(context.Background(), pool, bogus) + if !errors.Is(err, ErrTrackNotFound) { + t.Errorf("err = %v, want ErrTrackNotFound", err) + } +}