Compare commits
1
Commits
main
..
4e5c6b9f7a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e5c6b9f7a |
@@ -779,7 +779,6 @@ func (yj *YellowJacketApp) startJanitor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
|
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
|
||||||
yj.janitor.Register(maintenance.StaleArtistMetadataJob(yj.database))
|
|
||||||
yj.janitor.Register(maintenance.StaleSearchClicksJob(yj.database))
|
yj.janitor.Register(maintenance.StaleSearchClicksJob(yj.database))
|
||||||
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
||||||
yj.database, coversDir, library.CoverArtFileSet,
|
yj.database, coversDir, library.CoverArtFileSet,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Preserving a playlist entry across the loss of its track is two
|
// Preserving a playlist entry across the loss of its track is two
|
||||||
@@ -90,14 +89,12 @@ const (
|
|||||||
// metadata on the entry itself, so the entry survives the rows being
|
// metadata on the entry itself, so the entry survives the rows being
|
||||||
// deleted underneath it.
|
// deleted underneath it.
|
||||||
//
|
//
|
||||||
// Every path that empties `audio_files` must call this (or the scoped
|
// Every path that empties `audio_files` must call this first, inside
|
||||||
// variant below) first, inside the same transaction as the delete.
|
// the same transaction as the delete. There are two such paths and
|
||||||
// These paths have drifted before: the full rescan in backend/library
|
// they had drifted: the full rescan in backend/library did this and the
|
||||||
// did this and the stale-shape retire in this package did not, so the
|
// stale-shape retire in this package did not, so the *documented*
|
||||||
// *documented* repair ("delete and rescan") preserved playlists while
|
// repair ("delete and rescan") preserved playlists while the automatic
|
||||||
// the automatic one that exists to spare the user that work silently
|
// one that exists to spare the user that work silently emptied them.
|
||||||
// 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`
|
// The display half is skipped, with a warning, when `track_metadata`
|
||||||
// cannot answer -- see the note above. Skipping it costs a phantom
|
// cannot answer -- see the note above. Skipping it costs a phantom
|
||||||
@@ -106,39 +103,13 @@ const (
|
|||||||
func PreservePlaylistPhantoms(
|
func PreservePlaylistPhantoms(
|
||||||
ctx context.Context, tx *sql.Tx, logger *slog.Logger,
|
ctx context.Context, tx *sql.Tx, logger *slog.Logger,
|
||||||
) error {
|
) error {
|
||||||
return preservePlaylistPhantoms(ctx, tx, nil, logger)
|
if _, err := tx.ExecContext(ctx, preservePhantomPathSQL); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
// 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(
|
return fmt.Errorf(
|
||||||
"could not preserve playlist track file paths: %w", err,
|
"could not preserve playlist track file paths: %w", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := tx.ExecContext(
|
if _, err := tx.ExecContext(ctx, preservePhantomDisplaySQL); err != nil {
|
||||||
ctx, preservePhantomDisplaySQL+clause, args...,
|
|
||||||
); err != nil {
|
|
||||||
// A failed statement does not roll back a SQLite transaction,
|
// A failed statement does not roll back a SQLite transaction,
|
||||||
// so the path half above stands and the entries remain
|
// so the path half above stands and the entries remain
|
||||||
// re-linkable.
|
// re-linkable.
|
||||||
@@ -152,26 +123,3 @@ func preservePlaylistPhantoms(
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestPreservePlaylistPhantomsForFilesScopesToTheRequestedIDs is the
|
|
||||||
// scoping half of the scoped variant: a run over one file's id must
|
|
||||||
// fill that file's playlist entries and leave every other entry alone,
|
|
||||||
// because the incremental scan calls this once per orphan batch and a
|
|
||||||
// pass that rewrote the whole table would touch every playlist row on
|
|
||||||
// every scan.
|
|
||||||
func TestPreservePlaylistPhantomsForFilesScopesToTheRequestedIDs(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
db := openRaw(t, t.TempDir())
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
|
||||||
t.Fatalf("pragma: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := applySchema(ctx, db); err != nil {
|
|
||||||
t.Fatalf("applySchema: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(ctx, `
|
|
||||||
INSERT INTO playlists (id, name) VALUES (1, 'keepme');
|
|
||||||
INSERT INTO libraries (id, name, path) VALUES (0, 'test', '/music');
|
|
||||||
INSERT INTO artists (id, name) VALUES (3, 'Aurora Fields');
|
|
||||||
INSERT INTO cover_art (id, file_path, mime_type)
|
|
||||||
VALUES (9, 'covers/7.jpg', 'image/jpeg');
|
|
||||||
INSERT INTO albums (id, name, artist_id, cover_art_id)
|
|
||||||
VALUES (4, 'Tideline', 3, 9);
|
|
||||||
INSERT INTO audio_files
|
|
||||||
(id, file_path, file_type_id, length_milliseconds,
|
|
||||||
title, artist_credit, artist_id, album_id)
|
|
||||||
VALUES
|
|
||||||
(7, '/music/a.flac', 1, 1000,
|
|
||||||
'Slack Water', 'Aurora Fields', 3, 4),
|
|
||||||
(8, '/music/b.flac', 1, 2000,
|
|
||||||
'Second Tide', 'Aurora Fields', 3, 4);
|
|
||||||
INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
|
||||||
VALUES (1, 7, 0), (1, 8, 1);
|
|
||||||
`); err != nil {
|
|
||||||
t.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("begin: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() { _ = tx.Rollback() }()
|
|
||||||
|
|
||||||
if err := PreservePlaylistPhantomsForFiles(
|
|
||||||
ctx, tx, []int64{7}, testLogger(),
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("preserve: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
t.Fatalf("commit: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var filled, untouched sql.NullString
|
|
||||||
|
|
||||||
if err := db.QueryRowContext(ctx,
|
|
||||||
"SELECT phantom_file_path FROM playlist_tracks WHERE audio_file_id = 7",
|
|
||||||
).Scan(&filled); err != nil {
|
|
||||||
t.Fatalf("read the requested entry: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if filled.String != "/music/a.flac" {
|
|
||||||
t.Errorf(
|
|
||||||
"requested entry phantom_file_path = %q, want %q",
|
|
||||||
filled.String, "/music/a.flac",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.QueryRowContext(ctx,
|
|
||||||
"SELECT phantom_file_path FROM playlist_tracks WHERE audio_file_id = 8",
|
|
||||||
).Scan(&untouched); err != nil {
|
|
||||||
t.Fatalf("read the untouched entry: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if untouched.Valid {
|
|
||||||
t.Errorf(
|
|
||||||
"untouched entry got phantom_file_path = %q, want NULL "+
|
|
||||||
"(a scoped run must not rewrite the whole table)",
|
|
||||||
untouched.String,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package library
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@@ -70,77 +69,6 @@ func CoverArtFileSet(coverPath string) []string {
|
|||||||
return paths
|
return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepOrphanedCoverArt deletes the cover_art rows no album references
|
|
||||||
// and returns their file paths, for the caller to remove from disk
|
|
||||||
// after the transaction commits. Cover art is referenced only by
|
|
||||||
// albums.cover_art_id, so an orphan is a cover whose album is gone —
|
|
||||||
// which is every album the caller just swept.
|
|
||||||
//
|
|
||||||
// One implementation because the scan path, RemoveFromLibrary and
|
|
||||||
// RemoveLibrary all reach this state, and the scan side used to skip it
|
|
||||||
// entirely while RemoveLibrary did it inline (#247).
|
|
||||||
func (l *Library) sweepOrphanedCoverArt(tx *sql.Tx) ([]string, error) {
|
|
||||||
const orphanSQL = `
|
|
||||||
SELECT file_path FROM cover_art WHERE id NOT IN (
|
|
||||||
SELECT DISTINCT cover_art_id FROM albums
|
|
||||||
WHERE cover_art_id IS NOT NULL
|
|
||||||
)`
|
|
||||||
|
|
||||||
rows, err := tx.QueryContext(l.ctx, orphanSQL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not query orphaned cover art: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var paths []string
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var filePath string
|
|
||||||
|
|
||||||
if err := rows.Scan(&filePath); err != nil {
|
|
||||||
l.logger.Warn("could not scan cover art path", "err", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
paths = append(paths, filePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close before the DELETE: the two run on the one writer connection.
|
|
||||||
if err := rows.Close(); err != nil {
|
|
||||||
l.logger.Warn("could not close cover art rows", "err", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(paths) > 0 {
|
|
||||||
if _, err := tx.ExecContext(l.ctx, `
|
|
||||||
DELETE FROM cover_art WHERE id NOT IN (
|
|
||||||
SELECT DISTINCT cover_art_id FROM albums
|
|
||||||
WHERE cover_art_id IS NOT NULL
|
|
||||||
)`); err != nil {
|
|
||||||
return nil, fmt.Errorf("could not delete orphaned cover_art: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeCoverArtFiles removes a cover original and its derived size
|
|
||||||
// variants. Only the original is stored in cover_art.file_path; the
|
|
||||||
// _sm/_md/_lg tiers are derived filenames beside it, so they have to be
|
|
||||||
// removed by name or they accumulate forever.
|
|
||||||
func (l *Library) removeCoverArtFiles(coverPaths []string) {
|
|
||||||
for _, coverPath := range coverPaths {
|
|
||||||
for _, path := range CoverArtFileSet(coverPath) {
|
|
||||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
||||||
l.logger.Warn(
|
|
||||||
"could not remove orphaned cover art file",
|
|
||||||
"path", path,
|
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// saveCoverArt saves embedded cover art to the cache directory.
|
// saveCoverArt saves embedded cover art to the cache directory.
|
||||||
// Returns the file path where the art was saved, or empty string
|
// Returns the file path where the art was saved, or empty string
|
||||||
// if no picture data. Timing is recorded in the provided metrics.
|
// if no picture data. Timing is recorded in the provided metrics.
|
||||||
|
|||||||
+50
-8
@@ -320,12 +320,43 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
|
|||||||
|
|
||||||
genresRemoved, _ := result.RowsAffected()
|
genresRemoved, _ := result.RowsAffected()
|
||||||
|
|
||||||
// Collect and delete orphaned cover_art rows before the commit. The
|
// 15. Collect orphaned cover_art file paths for post-commit cleanup.
|
||||||
// shared helper is the one place this sweep lives, so the scan path,
|
// SAFETY: Hand-crafted SELECT for orphaned cover art identification.
|
||||||
// RemoveFromLibrary and this removal cannot drift (#247).
|
// Parameterless.
|
||||||
orphanedCoverArtPaths, err := l.sweepOrphanedCoverArt(tx)
|
rows, err := tx.QueryContext(l.ctx,
|
||||||
|
`SELECT file_path FROM cover_art WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT cover_art_id FROM albums
|
||||||
|
WHERE cover_art_id IS NOT NULL
|
||||||
|
)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("could not query orphaned cover art: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var orphanedCoverArtPaths []string
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var filePath string
|
||||||
|
if err := rows.Scan(&filePath); err != nil {
|
||||||
|
l.logger.Warn("could not scan cover art path", "err", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
orphanedCoverArtPaths = append(orphanedCoverArtPaths, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
l.logger.Warn("could not close cover art rows", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Delete orphaned cover_art rows.
|
||||||
|
// SAFETY: Hand-crafted orphan cleanup SQL. Parameterless.
|
||||||
|
if _, err := tx.ExecContext(l.ctx,
|
||||||
|
`DELETE FROM cover_art WHERE id NOT IN (
|
||||||
|
SELECT DISTINCT cover_art_id FROM albums
|
||||||
|
WHERE cover_art_id IS NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
return nil, fmt.Errorf("could not delete orphaned cover_art: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 17. Delete the library's tagging queue. tagging_items holds a
|
// 17. Delete the library's tagging queue. tagging_items holds a
|
||||||
@@ -361,9 +392,20 @@ func (l *Library) RemoveLibrary(id int64) (*RemovalSummary, error) {
|
|||||||
// avoids a costly full re-index of all remaining tracks (~10s for
|
// avoids a costly full re-index of all remaining tracks (~10s for
|
||||||
// 25K tracks).
|
// 25K tracks).
|
||||||
|
|
||||||
// Post-commit: remove the orphaned cover art files and their sized
|
// 21. Post-commit: Delete orphaned cover art files and their sized
|
||||||
// variants.
|
// variants. Only the original is stored in cover_art.file_path; the
|
||||||
l.removeCoverArtFiles(orphanedCoverArtPaths)
|
// _sm/_md/_lg thumbnails are derived filenames beside it, so they
|
||||||
|
// have to be removed by name or they accumulate forever.
|
||||||
|
for _, coverPath := range orphanedCoverArtPaths {
|
||||||
|
for _, path := range CoverArtFileSet(coverPath) {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
l.logger.Warn("could not remove orphaned cover art file",
|
||||||
|
"path", path,
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 22. Post-commit: Compact queue.
|
// 22. Post-commit: Compact queue.
|
||||||
if l.removalHooks.CompactQueue != nil {
|
if l.removalHooks.CompactQueue != nil {
|
||||||
|
|||||||
+28
-103
@@ -911,96 +911,30 @@ func (l *Library) scanInternal(
|
|||||||
|
|
||||||
orphanStart := time.Now()
|
orphanStart := time.Now()
|
||||||
|
|
||||||
// Snapshot the orphan set first. The playlist-phantom
|
|
||||||
// preservation and the deletes are one transaction (the
|
|
||||||
// preservation has to land before the ON DELETE SET NULL, and
|
|
||||||
// both have to succeed or neither does), and the ids are what
|
|
||||||
// scope that preservation to just these files instead of
|
|
||||||
// rewriting every playlist row on a routine scan.
|
|
||||||
var (
|
|
||||||
orphans []sqlcgen.AudioFile
|
|
||||||
orphanPaths []string
|
|
||||||
)
|
|
||||||
|
|
||||||
existingPaths.Range(func(key, value any) bool {
|
existingPaths.Range(func(key, value any) bool {
|
||||||
orphanPaths = append(orphanPaths, key.(string))
|
path := key.(string)
|
||||||
orphans = append(orphans, value.(sqlcgen.AudioFile))
|
audioFile := value.(sqlcgen.AudioFile)
|
||||||
|
|
||||||
return true
|
l.logger.Debug(
|
||||||
})
|
"removing orphaned database entry",
|
||||||
|
"path", path, "id", audioFile.ID,
|
||||||
deleted := make([]bool, len(orphans))
|
|
||||||
|
|
||||||
if len(orphans) > 0 {
|
|
||||||
orphanIDs := make([]int64, len(orphans))
|
|
||||||
for i, f := range orphans {
|
|
||||||
orphanIDs[i] = f.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, beginErr := l.db.BeginTx()
|
|
||||||
if beginErr != nil {
|
|
||||||
metrics.addWarning("", "orphan", beginErr)
|
|
||||||
} else {
|
|
||||||
defer func() { _ = tx.Rollback() }() // no-op after commit
|
|
||||||
|
|
||||||
if err := database.PreservePlaylistPhantomsForFiles(
|
|
||||||
l.ctx, tx, orphanIDs, l.logger,
|
|
||||||
); err != nil {
|
|
||||||
// Deleting without the phantoms is exactly the
|
|
||||||
// playlist-emptying bug the preservation exists to
|
|
||||||
// prevent, so leave the rows for the next scan
|
|
||||||
// rather than empty the playlists now.
|
|
||||||
l.logger.Error(
|
|
||||||
"skipping orphan deletion: could not preserve "+
|
|
||||||
"playlist entries",
|
|
||||||
"err", err,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics.addWarning("", "orphan", err)
|
if err := l.db.Queries.DeleteAudioFile(
|
||||||
|
l.ctx, audioFile.ID,
|
||||||
_ = tx.Rollback()
|
|
||||||
} else {
|
|
||||||
txq := l.db.Queries.WithTx(tx)
|
|
||||||
|
|
||||||
for i, f := range orphans {
|
|
||||||
if err := txq.DeleteAudioFile(
|
|
||||||
l.ctx, f.ID,
|
|
||||||
); err != nil {
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to delete orphaned audio file",
|
"failed to delete orphaned audio file",
|
||||||
"path", orphanPaths[i],
|
"path", path,
|
||||||
"id", f.ID,
|
"id", audioFile.ID,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics.addWarning(orphanPaths[i], "orphan", err)
|
metrics.addWarning(path, "orphan", err)
|
||||||
|
|
||||||
continue
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted[i] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
l.logger.Error(
|
|
||||||
"could not commit orphan deletion",
|
|
||||||
"err", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
metrics.addWarning("", "orphan", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post-commit bookkeeping for the files that actually went.
|
|
||||||
for i, f := range orphans {
|
|
||||||
if !deleted[i] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
path := orphanPaths[i]
|
|
||||||
|
|
||||||
// Keep the file's tagging group in sync: drop the group's
|
// Keep the file's tagging group in sync: drop the group's
|
||||||
// track count and clear it out once empty, mirroring the
|
// track count and clear it out once empty, mirroring the
|
||||||
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
// bookkeeping maybeRebindTaggingGroup does for a group_key
|
||||||
@@ -1008,25 +942,25 @@ func (l *Library) scanInternal(
|
|||||||
// and replaced leaves a stale tagging_items row behind —
|
// and replaced leaves a stale tagging_items row behind —
|
||||||
// its track_count still counts the deleted files, and it
|
// its track_count still counts the deleted files, and it
|
||||||
// never clears from the autotag queue.
|
// never clears from the autotag queue.
|
||||||
if f.GroupKey != "" {
|
if audioFile.GroupKey != "" {
|
||||||
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
if err := l.db.Queries.DecrementTaggingItemTrackCount(
|
||||||
l.ctx, f.GroupKey,
|
l.ctx, audioFile.GroupKey,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to decrement tagging group for orphan",
|
"failed to decrement tagging group for orphan",
|
||||||
"path", path,
|
"path", path,
|
||||||
"group_key", f.GroupKey,
|
"group_key", audioFile.GroupKey,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics.addWarning(path, "orphan", err)
|
metrics.addWarning(path, "orphan", err)
|
||||||
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
} else if err := l.db.Queries.DeleteTaggingItemIfEmpty(
|
||||||
l.ctx, f.GroupKey,
|
l.ctx, audioFile.GroupKey,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to clean up emptied tagging group for orphan",
|
"failed to clean up emptied tagging group for orphan",
|
||||||
"path", path,
|
"path", path,
|
||||||
"group_key", f.GroupKey,
|
"group_key", audioFile.GroupKey,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1035,20 +969,24 @@ func (l *Library) scanInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove from FTS5 search index and the lyrics index.
|
// Remove from FTS5 search index and the lyrics index.
|
||||||
if err := l.db.DeleteSearchIndex(f.ID); err != nil {
|
if err := l.db.DeleteSearchIndex(
|
||||||
|
audioFile.ID,
|
||||||
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to delete FTS entry for orphan",
|
"failed to delete FTS entry for orphan",
|
||||||
"id", f.ID,
|
"id", audioFile.ID,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
metrics.addWarning(path, "orphan", err)
|
metrics.addWarning(path, "orphan", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := l.db.DeleteLyricsIndex(f.ID); err != nil {
|
if err := l.db.DeleteLyricsIndex(
|
||||||
|
audioFile.ID,
|
||||||
|
); err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"failed to delete lyrics index entry for orphan",
|
"failed to delete lyrics index entry for orphan",
|
||||||
"id", f.ID,
|
"id", audioFile.ID,
|
||||||
"err", err,
|
"err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1056,7 +994,9 @@ func (l *Library) scanInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
removed.Add(1)
|
removed.Add(1)
|
||||||
}
|
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
metrics.OrphanCleanup = time.Since(orphanStart)
|
metrics.OrphanCleanup = time.Since(orphanStart)
|
||||||
|
|
||||||
@@ -1265,32 +1205,17 @@ func (l *Library) pruneEmptyEntities() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cover art after albums: a cover whose album just went is
|
|
||||||
// unreferenced, and leaving the row behind keeps its files exempt
|
|
||||||
// from the janitor's covers sweep forever (#247).
|
|
||||||
orphanedCovers, err := l.sweepOrphanedCoverArt(tx)
|
|
||||||
if err != nil {
|
|
||||||
l.logger.Warn("could not sweep orphaned cover art", "err", err)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
l.logger.Warn("could not commit entity cleanup", "err", err)
|
l.logger.Warn("could not commit entity cleanup", "err", err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-commit: the rows are gone, so their files can go too.
|
if len(albumIDs) > 0 || len(artistIDs) > 0 || len(genreIDs) > 0 {
|
||||||
l.removeCoverArtFiles(orphanedCovers)
|
|
||||||
|
|
||||||
if len(albumIDs) > 0 || len(artistIDs) > 0 || len(genreIDs) > 0 ||
|
|
||||||
len(orphanedCovers) > 0 {
|
|
||||||
l.logger.Info("pruned empty library entities",
|
l.logger.Info("pruned empty library entities",
|
||||||
"albums", len(albumIDs),
|
"albums", len(albumIDs),
|
||||||
"artists", len(artistIDs),
|
"artists", len(artistIDs),
|
||||||
"genres", len(genreIDs),
|
"genres", len(genreIDs),
|
||||||
"covers", len(orphanedCovers),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,57 +94,6 @@ func countRows(
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveFromLibrary is the one path that empties a track *deliberately*:
|
|
||||||
// the file stays on disk but is excluded, so nothing re-imports it. The
|
|
||||||
// playlist entry must still survive as a re-linkable phantom rather than
|
|
||||||
// an empty row, because a later full rescan clears the exclusion and is
|
|
||||||
// what re-links the entry then (#246).
|
|
||||||
func TestRemoveFromLibrary_PreservesPlaylistPhantoms(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lib, db := setupTestLibrary(t)
|
|
||||||
|
|
||||||
seedRemovableLibrary(t, lib, "/nonexistent/cover.jpg")
|
|
||||||
|
|
||||||
if _, err := lib.db.ExecContext(
|
|
||||||
`INSERT INTO playlists (name) VALUES ('keepme')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed playlist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
playlistID := queryInt(
|
|
||||||
t, db, `SELECT id FROM playlists WHERE name = 'keepme'`,
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := lib.db.ExecContext(
|
|
||||||
`INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
|
||||||
SELECT ?, id, 0 FROM audio_files
|
|
||||||
WHERE file_path = '/music/song.mp3'`,
|
|
||||||
playlistID,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed playlist_tracks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := lib.RemoveFromLibrary([]string{"/music/song.mp3"}); err != nil {
|
|
||||||
t.Fatalf("RemoveFromLibrary: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
phantomPath := queryString(
|
|
||||||
t, db,
|
|
||||||
`SELECT phantom_file_path FROM playlist_tracks
|
|
||||||
WHERE playlist_id = ?`,
|
|
||||||
playlistID,
|
|
||||||
)
|
|
||||||
|
|
||||||
if phantomPath != "/music/song.mp3" {
|
|
||||||
t.Fatalf(
|
|
||||||
"phantom_file_path is %q, want %q -- the entry cannot be "+
|
|
||||||
"re-linked after a later full rescan",
|
|
||||||
phantomPath, "/music/song.mp3",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A library with tagging_items must still be removable. tagging_items
|
// A library with tagging_items must still be removable. tagging_items
|
||||||
// FK-references libraries with no ON DELETE clause, so leaving those
|
// FK-references libraries with no ON DELETE clause, so leaving those
|
||||||
// rows behind fails the DELETE and rolls back the entire removal.
|
// rows behind fails the DELETE and rolls back the entire removal.
|
||||||
@@ -249,53 +198,3 @@ func TestCoverArtFileSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removing the last track of an album must take the album's cover art
|
|
||||||
// with it — both the row and every derived file — or the row keeps its
|
|
||||||
// files exempt from the janitor's covers sweep forever (#247).
|
|
||||||
func TestRemoveFromLibrary_DeletesOrphanedCoverArt(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lib, _ := setupTestLibrary(t)
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
|
|
||||||
// The largest tier is what cover_art.file_path names; write every
|
|
||||||
// variant so the sweep has a real set to remove.
|
|
||||||
for _, tier := range thumbnailTiers {
|
|
||||||
p := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", tier.Suffix))
|
|
||||||
if err := os.WriteFile(p, []byte("img"), 0o600); err != nil {
|
|
||||||
t.Fatalf("write %s: %v", p, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cover := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", "_lg"))
|
|
||||||
|
|
||||||
seedRemovableLibrary(t, lib, cover)
|
|
||||||
|
|
||||||
// Link the album to the cover so it is not orphaned until the track
|
|
||||||
// (and with it the album) goes.
|
|
||||||
if _, err := lib.db.ExecContext(
|
|
||||||
`UPDATE albums SET cover_art_id =
|
|
||||||
(SELECT id FROM cover_art WHERE file_path = ?)
|
|
||||||
WHERE name = 'Test Album'`,
|
|
||||||
cover,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("link cover art: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := lib.RemoveFromLibrary([]string{"/music/song.mp3"}); err != nil {
|
|
||||||
t.Fatalf("RemoveFromLibrary: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := countRows(t, lib, "cover_art"); n != 0 {
|
|
||||||
t.Errorf("cover_art has %d rows after removal, want 0", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tier := range thumbnailTiers {
|
|
||||||
p := filepath.Join(dir, coverart.SizedFilename("abc123.jpg", tier.Suffix))
|
|
||||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
||||||
t.Errorf("cover art file still present: %s", filepath.Base(p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"yellowjacket/backend/database"
|
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,25 +57,6 @@ func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error)
|
|||||||
|
|
||||||
var result RemovalResult
|
var result RemovalResult
|
||||||
|
|
||||||
// Preserve the playlist entries before the rows go, so they survive
|
|
||||||
// as re-linkable phantoms rather than empty rows. The track is
|
|
||||||
// excluded and will not be re-imported on its own, but a later full
|
|
||||||
// rescan clears the exclusion and this is what lets the entry
|
|
||||||
// re-link then — the same preservation every other path that empties
|
|
||||||
// audio_files performs (#246).
|
|
||||||
rowIDs := make([]int64, len(rows))
|
|
||||||
for i, row := range rows {
|
|
||||||
rowIDs[i] = row.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := database.PreservePlaylistPhantomsForFiles(
|
|
||||||
l.ctx, tx, rowIDs, l.logger,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"could not preserve playlist entries for removal: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exclude every path the caller named, including one whose row has
|
// Exclude every path the caller named, including one whose row has
|
||||||
// already gone: the user asked for that file to stay out, and a row
|
// already gone: the user asked for that file to stay out, and a row
|
||||||
// that disappeared between the click and the commit is not a reason
|
// that disappeared between the click and the commit is not a reason
|
||||||
|
|||||||
@@ -231,98 +231,3 @@ func TestScan_MultipleDirectoriesDoNotCrossContaminate(t *testing.T) {
|
|||||||
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
|
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestScan_OrphanCleanupPreservesPlaylistPhantoms guards #246: a file
|
|
||||||
// deleted from the library folder *outside* YellowJacket is discovered
|
|
||||||
// as an orphan by the next scan, and its playlist entry must survive as
|
|
||||||
// a re-linkable phantom — the same preservation the full rescan and
|
|
||||||
// stale-retire paths already perform, scoped here to just the orphaned
|
|
||||||
// file. Before the fix the entry became an empty row (audio_file_id
|
|
||||||
// NULL and no phantom_file_path), which nothing can ever re-link.
|
|
||||||
func TestScan_OrphanCleanupPreservesPlaylistPhantoms(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
lib, db := setupTestLibrary(t)
|
|
||||||
|
|
||||||
root := t.TempDir()
|
|
||||||
track := filepath.Join(root, "gone.mp3")
|
|
||||||
|
|
||||||
writeTestTrack(t, track, 0)
|
|
||||||
|
|
||||||
library, err := db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{
|
|
||||||
Name: "orphans",
|
|
||||||
Path: root,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create library: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metrics := lib.scanInternal(library.ID, library.Name, library.Path); metrics == nil {
|
|
||||||
t.Fatal("first scan returned nil metrics")
|
|
||||||
}
|
|
||||||
|
|
||||||
trackID := queryInt(
|
|
||||||
t, db, "SELECT id FROM audio_files WHERE file_path = ?", track,
|
|
||||||
)
|
|
||||||
if trackID == 0 {
|
|
||||||
t.Fatal("first scan did not import the track")
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
`INSERT INTO playlists (name) VALUES ('keepme')`,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed playlist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
playlistID := queryInt(
|
|
||||||
t, db, "SELECT id FROM playlists WHERE name = 'keepme'",
|
|
||||||
)
|
|
||||||
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
`INSERT INTO playlist_tracks (playlist_id, audio_file_id, position)
|
|
||||||
VALUES (?, ?, 0)`,
|
|
||||||
playlistID, trackID,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed playlist_tracks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The file goes away outside the app.
|
|
||||||
if err := os.Remove(track); err != nil {
|
|
||||||
t.Fatalf("remove track: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metrics := lib.scanInternal(library.ID, library.Name, library.Path); metrics == nil {
|
|
||||||
t.Fatal("second scan returned nil metrics")
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := queryInt(
|
|
||||||
t, db, "SELECT COUNT(*) FROM audio_files WHERE file_path = ?", track,
|
|
||||||
); n != 0 {
|
|
||||||
t.Fatalf("audio_files still holds the removed path: %d rows", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n := queryInt(
|
|
||||||
t, db,
|
|
||||||
"SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = ? "+
|
|
||||||
"AND audio_file_id IS NULL",
|
|
||||||
playlistID,
|
|
||||||
); n != 1 {
|
|
||||||
t.Fatalf(
|
|
||||||
"playlist entry did not become a phantom: %d null-id rows, want 1",
|
|
||||||
n,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
phantomPath := queryString(
|
|
||||||
t, db,
|
|
||||||
"SELECT phantom_file_path FROM playlist_tracks WHERE playlist_id = ?",
|
|
||||||
playlistID,
|
|
||||||
)
|
|
||||||
if phantomPath != track {
|
|
||||||
t.Fatalf(
|
|
||||||
"phantom_file_path = %q, want %q -- the entry cannot be "+
|
|
||||||
"re-linked if the file comes back",
|
|
||||||
phantomPath, track,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -667,74 +667,6 @@ func TestExpiredHTTPCacheJob_TrimsToBudget(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStaleArtistMetadataJob pins the sweep's two keep rules: an owned
|
|
||||||
// artist's metadata survives, a browsed artist's survives while it still
|
|
||||||
// holds cached artwork, and everything else goes (#248).
|
|
||||||
func TestStaleArtistMetadataJob(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := database.NewTestDB(t)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ownedMBID = "11111111-1111-1111-1111-111111111111"
|
|
||||||
browsedMBID = "22222222-2222-2222-2222-222222222222"
|
|
||||||
staleMBID = "33333333-3333-3333-3333-333333333333"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The owned artist is in the library - which means a *file* says
|
|
||||||
// so. An artists row on its own is not ownership.
|
|
||||||
database.InsertTestTrack(t, db, database.TestTrack{
|
|
||||||
FilePath: "/music/owned.mp3",
|
|
||||||
Artist: "Owned",
|
|
||||||
ArtistMBID: ownedMBID,
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, mbid := range []string{ownedMBID, browsedMBID, staleMBID} {
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
`INSERT INTO artist_metadata (mbid, source, data, fetched_at)
|
|
||||||
VALUES (?, 'wikidata-p18', x'00', CURRENT_TIMESTAMP)`,
|
|
||||||
mbid,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed artist_metadata for %s: %v", mbid, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The browsed artist holds cached artwork, so its metadata is still
|
|
||||||
// referenced and must survive.
|
|
||||||
if _, err := db.ExecContext(
|
|
||||||
`INSERT INTO artist_images
|
|
||||||
(artist_mbid, source, source_url, file_path)
|
|
||||||
VALUES (?, 'test', 'http://x', '/art/primary.jpg')`,
|
|
||||||
browsedMBID,
|
|
||||||
); err != nil {
|
|
||||||
t.Fatalf("seed artist_images: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := StaleArtistMetadataJob(db).Run(context.Background()); err != nil {
|
|
||||||
t.Fatalf("run job: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
|
||||||
mbid string
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{ownedMBID, 1},
|
|
||||||
{browsedMBID, 1},
|
|
||||||
{staleMBID, 0},
|
|
||||||
} {
|
|
||||||
var n int
|
|
||||||
if err := db.QueryRowWriter(
|
|
||||||
"SELECT COUNT(*) FROM artist_metadata WHERE mbid = ?", tc.mbid,
|
|
||||||
).Scan(&n); err != nil {
|
|
||||||
t.Fatalf("count %s: %v", tc.mbid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n != tc.want {
|
|
||||||
t.Errorf("artist_metadata rows for %s = %d, want %d", tc.mbid, n, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStaleSearchClicksJob deletes only the clicks old enough to have
|
// TestStaleSearchClicksJob deletes only the clicks old enough to have
|
||||||
// left the retention window (#249).
|
// left the retention window (#249).
|
||||||
func TestStaleSearchClicksJob(t *testing.T) {
|
func TestStaleSearchClicksJob(t *testing.T) {
|
||||||
|
|||||||
@@ -629,40 +629,6 @@ func dirSize(dir string) (bytes, files int64) {
|
|||||||
return bytes, files
|
return bytes, files
|
||||||
}
|
}
|
||||||
|
|
||||||
// StaleArtistMetadataJob evicts long-lived artist metadata (bios, wiki
|
|
||||||
// leads, relationships) for artists the user no longer has any reason
|
|
||||||
// to keep around: not owned and holding no cached artwork.
|
|
||||||
//
|
|
||||||
// artist_metadata has no TTL by design — entity data changes rarely and
|
|
||||||
// re-fetching spends someone else's rate limit — so without a sweep it
|
|
||||||
// grows for the life of the install. This is the "swept when the
|
|
||||||
// artist is no longer referenced" contract the datamap always declared
|
|
||||||
// for it and nothing ever performed (#248).
|
|
||||||
func StaleArtistMetadataJob(db *database.DB) Job {
|
|
||||||
return Job{
|
|
||||||
Name: "artist-metadata-sweep",
|
|
||||||
MinInterval: dailyInterval,
|
|
||||||
Run: func(_ context.Context) (Result, error) {
|
|
||||||
res, err := db.ExecContext(
|
|
||||||
`DELETE FROM artist_metadata
|
|
||||||
WHERE mbid NOT IN (` + ownedArtistMBIDs + `)
|
|
||||||
AND mbid NOT IN (
|
|
||||||
SELECT artist_mbid FROM artist_images
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Result{}, fmt.Errorf(
|
|
||||||
"delete stale artist_metadata rows: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, _ := res.RowsAffected()
|
|
||||||
|
|
||||||
return Result{RowsDeleted: rows}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchClicksRetention is how long a search-click ranking signal stays
|
// searchClicksRetention is how long a search-click ranking signal stays
|
||||||
// useful. search_clicks is authored behavioural data — nothing that
|
// useful. search_clicks is authored behavioural data — nothing that
|
||||||
// owns a row ever drops it — so age is the ceiling that keeps the table
|
// owns a row ever drops it — so age is the ceiling that keeps the table
|
||||||
|
|||||||
@@ -56,16 +56,6 @@ export function CycleRepeat(): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(3510519482);
|
return $Call.ByID(3510519482);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DropSourceForPlaylist clears the queue's "Playing from" label when
|
|
||||||
* its source playlist is deleted. A link back to a playlist that no
|
|
||||||
* longer exists is worse than none, and the label otherwise survives
|
|
||||||
* the deletion until the next SetQueue (#249).
|
|
||||||
*/
|
|
||||||
export function DropSourceForPlaylist(playlistID: number): $CancellablePromise<void> {
|
|
||||||
return $Call.ByID(1435106374, playlistID);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmitCurrentState emits the current queue state to the frontend.
|
* EmitCurrentState emits the current queue state to the frontend.
|
||||||
* This is called after the frontend DOM is ready.
|
* This is called after the frontend DOM is ready.
|
||||||
|
|||||||
Reference in New Issue
Block a user