Every browse, discover and mix query filters missing_since, so a track
whose file vanished disappears from the places Minstrel chooses music.
A playlist is different: the entry is there because the user put it
there, and silently dropping it rewrites their list behind their back.
So playlists keep the row and mark it instead. ListPlaylistTracks now
carries missing_since (still deliberately unfiltered), the service
layer surfaces it as PlaylistTrack.Unavailable, and the wire gains
"unavailable" on each entry.
A missing entry also loses its stream_url. Refusing to hand out a URL
that cannot serve is stronger than trusting every client to honour the
flag, and "stream_url": null is a shape the clients already model --
PlaylistWire.streamUrl is documented nullable for the track-removed
case -- so an older build degrades to "present but not playable" with
no change.
Nothing is deleted here and nothing should be: the row, its play
history, its likes and its taste contribution all survive a file going
missing, because the file may come back (and #2528 will adopt it if it
comes back renamed).
Also corrects two comments that had drifted into lying. delete.go still
claimed the file-gone case was NOT auto-reconciled and told admins to
delete rows by hand -- untrue since f6d1cf24, and that exact staleness
is what produced drift #572. It now says what DeleteTrackFile really is:
the destructive admin action, which CASCADEs play_events and likes, and
is emphatically not the missing-file path. watcher.go claimed the
safety-net scan "covers anything missed"; the walk only covers
additions, and it is reconcile that covers removals.
73 lines
2.7 KiB
Go
73 lines
2.7 KiB
Go
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"
|
|
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")
|
|
|
|
// 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.
|
|
//
|
|
// The reverse failure mode — file gone, DB row still present — IS reconciled
|
|
// now, and not by this function: the scan's reconcile pass stamps
|
|
// tracks.missing_since (#2523), every selection path filters on it, and a file
|
|
// that returns is un-marked or adopted at its new path (#2528). That is the
|
|
// normal life of a vanished file and it is deliberately non-destructive: the
|
|
// row, its play history and its likes survive, because a missing file is a
|
|
// track Minstrel still knows about (#2527).
|
|
//
|
|
// So this function is NOT the missing-file path. It is the explicit admin
|
|
// action "remove this recording from disk and from the library", and it is
|
|
// irreversible: tracks CASCADEs to play_events, general_likes_tracks,
|
|
// contextual_likes, track_tags and playback_errors. Reach for it when the
|
|
// operator means to destroy the record, never to tidy up a row whose file
|
|
// merely went away.
|
|
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)
|
|
}
|
|
// Log the change after the delete succeeds. Best-effort: a Warn-level
|
|
// failure here would leave the cache index orphaned on offline clients
|
|
// until the next scan touches the surrounding album.
|
|
if err := syncpkg.LogChange(ctx, pool, syncpkg.EntityTrack,
|
|
syncpkg.FormatUUID(trackID), syncpkg.OpDelete); err != nil {
|
|
return fmt.Errorf("log change: %w", err)
|
|
}
|
|
return nil
|
|
}
|