Files
yellowjacket/backend/database/playlistphantoms.go
T
yonlu a3b5b43777 fix(database): preserve playlist phantoms across a stale audio_files retire
Retiring a stale audio_files dropped every playlist entry to an empty
row: ON DELETE SET NULL ran before the phantom_* columns were filled,
whereas the manual rescan path populates them first. Run the same
phantom population inside the retire transaction, before the drop, only
when audio_files is among the tables going, so
ResolvePhantomTracksAfterScan can re-link the entries.

Closes #183
2026-09-09 09:19:05 -04:00

126 lines
4.9 KiB
Go

package database
import (
"context"
"database/sql"
"fmt"
"log/slog"
)
// Preserving a playlist entry across the loss of its track is two
// statements, not one, and the split is not tidiness -- it is what
// makes the important half work in the situation that needs it most.
//
// `playlist_tracks.audio_file_id` is ON DELETE SET NULL, so an entry
// outlives its file as an id-less row that says nothing about what the
// user put in the playlist. The phantom_* columns carry the answer
// across and ResolvePhantomTracksAfterScan re-links them afterwards --
// but only if something fills them *before* the rows go.
//
// The two halves are not equally important and are not equally
// available:
//
// - **phantom_file_path is the one that matters.**
// ResolvePhantomTracksAfterScan matches it against
// `audio_files.file_path`, so without it an entry can never be
// re-linked and the playlist is empty for good. It comes straight
// off `audio_files`, whose `file_path` is the table's natural key
// and has been present in every shape it has ever had -- including
// the pre-013 stub of `(id, file_path, recording_id)`.
// - The rest is *display* for a phantom entry before a rescan
// re-links it, and it comes from the `track_metadata` view, which
// is the one definition of a track row and not worth restating.
//
// Reading the view is what cannot be relied on here, and that is the
// whole reason for the split. This runs *before* applySchema, which is
// precisely the moment the schema is inconsistent: the view is whatever
// the last launch's schema declared, while `audio_files` is whatever
// the launch before that left behind. A view over columns the table no
// longer has is not merely empty -- `pragma_table_info` on it *errors*,
// and so does selecting from it. `cmd/indexbuild`'s fixture is exactly
// that shape and is what caught this.
//
// COALESCE keeps an existing phantom value in both halves: an entry
// already phantom is one whose file went missing in an earlier pass,
// and its recorded metadata is the only copy left. Overwriting that
// from a NULL join erases the rows this exists to protect.
const (
preservePhantomPathSQL = `
UPDATE playlist_tracks
SET phantom_file_path = COALESCE(phantom_file_path, (
SELECT af.file_path FROM audio_files af
WHERE af.id = playlist_tracks.audio_file_id
))
WHERE audio_file_id IS NOT NULL
`
preservePhantomDisplaySQL = `
UPDATE playlist_tracks
SET
phantom_title = COALESCE(phantom_title, (
SELECT tm.title FROM track_metadata tm
WHERE tm.id = playlist_tracks.audio_file_id
)),
phantom_artist = COALESCE(phantom_artist, (
SELECT tm.artist_name FROM track_metadata tm
WHERE tm.id = playlist_tracks.audio_file_id
)),
phantom_album = COALESCE(phantom_album, (
SELECT tm.album FROM track_metadata tm
WHERE tm.id = playlist_tracks.audio_file_id
)),
phantom_duration_ms = COALESCE(phantom_duration_ms, (
SELECT af.length_milliseconds FROM audio_files af
WHERE af.id = playlist_tracks.audio_file_id
)),
phantom_genre = COALESCE(phantom_genre, (
SELECT tm.genre FROM track_metadata tm
WHERE tm.id = playlist_tracks.audio_file_id
)),
phantom_cover_art_path = COALESCE(phantom_cover_art_path, (
SELECT tm.cover_art_path FROM track_metadata tm
WHERE tm.id = playlist_tracks.audio_file_id
))
WHERE audio_file_id IS NOT NULL
`
)
// PreservePlaylistPhantoms records every linked playlist entry's track
// metadata on the entry itself, so the entry survives the rows being
// deleted underneath it.
//
// Every path that empties `audio_files` must call this first, inside
// the same transaction as the delete. There are two such paths and
// they had drifted: the full rescan in backend/library did this and the
// stale-shape retire in this package did not, so the *documented*
// repair ("delete and rescan") preserved playlists while the automatic
// one that exists to spare the user that work silently emptied them.
//
// The display half is skipped, with a warning, when `track_metadata`
// cannot answer -- see the note above. Skipping it costs a phantom
// entry its title until a rescan re-links it; skipping the path half
// would cost the entry outright, so that one is an error.
func PreservePlaylistPhantoms(
ctx context.Context, tx *sql.Tx, logger *slog.Logger,
) error {
if _, err := tx.ExecContext(ctx, preservePhantomPathSQL); err != nil {
return fmt.Errorf(
"could not preserve playlist track file paths: %w", err,
)
}
if _, err := tx.ExecContext(ctx, preservePhantomDisplaySQL); err != nil {
// A failed statement does not roll back a SQLite transaction,
// so the path half above stands and the entries remain
// re-linkable.
logger.Warn(
"could not record display metadata for playlist entries; "+
"they will be re-linked by the next scan but read as "+
"unknown until then",
"err", err,
)
}
return nil
}