feat(library): DeleteTrackFile (rm file + tracks row, album/artist preserved)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user