The incremental scan's orphan cleanup and RemoveFromLibrary deleted audio_files rows without first filling the playlist phantom columns, so a track removed from the library folder outside YellowJacket (or removed from the library) became a permanently empty playlist row that nothing could re-link — the same bug #183 fixed on the full-rescan and retire paths, on the two paths it missed. Add a scoped PreservePlaylistPhantomsForFiles and run it in the same transaction as the deletes on both paths. Closes #246
178 lines
6.4 KiB
Go
178 lines
6.4 KiB
Go
package database
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
)
|
|
|
|
// 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 (or the scoped
|
|
// variant below) first, inside the same transaction as the delete.
|
|
// These paths have drifted before: 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 (#183). The incremental scan's orphan cleanup and
|
|
// RemoveFromLibrary drifted the same way and are #246.
|
|
//
|
|
// 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 {
|
|
return preservePlaylistPhantoms(ctx, tx, nil, logger)
|
|
}
|
|
|
|
// PreservePlaylistPhantomsForFiles is PreservePlaylistPhantoms scoped to
|
|
// the given audio file ids, for the two removal paths that delete a
|
|
// known subset of the table rather than all of it: the incremental
|
|
// scan's orphan cleanup and RemoveFromLibrary. A bulk pass there would
|
|
// rewrite every linked playlist row on every scan for nothing.
|
|
func PreservePlaylistPhantomsForFiles(
|
|
ctx context.Context, tx *sql.Tx, ids []int64, logger *slog.Logger,
|
|
) error {
|
|
return preservePlaylistPhantoms(ctx, tx, ids, logger)
|
|
}
|
|
|
|
func preservePlaylistPhantoms(
|
|
ctx context.Context, tx *sql.Tx, ids []int64, logger *slog.Logger,
|
|
) error {
|
|
clause, args, skip := phantomIDFilter(ids)
|
|
if skip {
|
|
return nil
|
|
}
|
|
|
|
if _, err := tx.ExecContext(
|
|
ctx, preservePhantomPathSQL+clause, args...,
|
|
); err != nil {
|
|
return fmt.Errorf(
|
|
"could not preserve playlist track file paths: %w", err,
|
|
)
|
|
}
|
|
|
|
if _, err := tx.ExecContext(
|
|
ctx, preservePhantomDisplaySQL+clause, args...,
|
|
); 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
|
|
}
|
|
|
|
// phantomIDFilter builds the extra WHERE terms and arguments that scope
|
|
// a preservation pass to a set of audio file ids. A nil ids returns the
|
|
// empty clause (a bulk run over every linked entry); an empty slice
|
|
// reports skip, since there is nothing to preserve.
|
|
func phantomIDFilter(ids []int64) (clause string, args []any, skip bool) {
|
|
switch {
|
|
case ids == nil:
|
|
return "", nil, false
|
|
case len(ids) == 0:
|
|
return "", nil, true
|
|
}
|
|
|
|
clause = " AND audio_file_id IN (" +
|
|
strings.Repeat("?,", len(ids)-1) + "?)"
|
|
|
|
args = make([]any, len(ids))
|
|
for i, id := range ids {
|
|
args[i] = id
|
|
}
|
|
|
|
return clause, args, false
|
|
}
|