package db import ( "context" "database/sql" "errors" "fmt" "slices" "strings" ) var ftsTables = []string{"media_file_fts", "album_fts", "artist_fts"} var ftsTriggerSuffixes = []string{"_ai", "_ad", "_au"} // integrityCheckMaxIssues bounds the problems reported; IntegrityCheck asks the // pragma for one extra row, because it truncates without emitting any marker. const integrityCheckMaxIssues = 100 // IntegrityCheck runs PRAGMA integrity_check and returns the problems it reports, or // an empty slice when healthy. The second value marks a list that was cut short. func IntegrityCheck(ctx context.Context, database *sql.DB) ([]string, bool, error) { rows, err := database.QueryContext(ctx, fmt.Sprintf("PRAGMA integrity_check(%d)", integrityCheckMaxIssues+1)) if err != nil { return nil, false, fmt.Errorf("running integrity_check: %w", err) } defer rows.Close() var issues []string for rows.Next() { var line string if err := rows.Scan(&line); err != nil { return nil, false, fmt.Errorf("reading integrity_check results: %w", err) } issues = append(issues, line) } if err := rows.Err(); err != nil { return nil, false, fmt.Errorf("reading integrity_check results: %w", err) } if len(issues) == 1 && issues[0] == "ok" { return nil, false, nil } if len(issues) > integrityCheckMaxIssues { return issues[:integrityCheckMaxIssues], true, nil } return issues, false, nil } // FKViolation counts the rows in Table that reference missing rows in Parent. type FKViolation struct { Table string Parent string Count int64 } // ForeignKeyCheck runs PRAGMA foreign_key_check, aggregated per (table, parent) pair // because the raw pragma emits one row per orphan, unbounded on a large library. func ForeignKeyCheck(ctx context.Context, database *sql.DB) ([]FKViolation, error) { rows, err := database.QueryContext(ctx, `SELECT "table", "parent", count(*) FROM pragma_foreign_key_check GROUP BY "table", "parent"`) if err != nil { return nil, fmt.Errorf("running foreign_key_check: %w", err) } defer rows.Close() var violations []FKViolation for rows.Next() { var v FKViolation if err := rows.Scan(&v.Table, &v.Parent, &v.Count); err != nil { return nil, fmt.Errorf("reading foreign_key_check results: %w", err) } violations = append(violations, v) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("reading foreign_key_check results: %w", err) } return violations, nil } // IsFTSCorruptionOnly reports whether every integrity issue refers to one of the // FTS5 search tables, meaning RebuildFTS can fully repair the database. func IsFTSCorruptionOnly(issues []string) bool { if len(issues) == 0 { return false } for _, line := range issues { if !slices.ContainsFunc(ftsTables, func(table string) bool { return strings.Contains(line, table) }) { return false } } return true } // execer is the subset of *sql.DB and *sql.Tx that verifyFTS needs. type execer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } // VerifyFTS runs the FTS5 'integrity-check' command on each search table. Unlike a // full PRAGMA integrity_check, it reads only the FTS indexes, not the whole database. func VerifyFTS(ctx context.Context, database *sql.DB) error { return verifyFTS(ctx, database) } func verifyFTS(ctx context.Context, database execer) error { for _, table := range ftsTables { stmt := fmt.Sprintf("INSERT INTO %[1]s(%[1]s) VALUES('integrity-check')", table) //nolint:gosec // fixed table list if _, err := database.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("verifying %s: %w", table, err) } } return nil } const ftsSearchMigration int64 = 20260220173400 var errNotMigrated = errors.New("the FTS search migration has not been applied yet; start Navidrome once to migrate the database first") // requireFTSMigration fails unless the FTS search migration has run. The goose table // is probed separately because a query against a missing table fails at prepare time. func requireFTSMigration(ctx context.Context, database *sql.DB) error { migrated, err := hasGooseTable(ctx, database) if err != nil { return fmt.Errorf("checking FTS migration status: %w", err) } if !migrated { return errNotMigrated } var applied int if err := database.QueryRowContext(ctx, "SELECT count(*) FROM goose_db_version WHERE version_id = ?", ftsSearchMigration).Scan(&applied); err != nil { return fmt.Errorf("checking FTS migration status: %w", err) } if applied == 0 { return errNotMigrated } return nil } // RebuildFTS drops the FTS5 search tables and their triggers, recreates them from the // base tables, and verifies the result before committing. The tables are contentless, // so no user data is lost. It needs only the FTS migration, not a fully migrated // schema, because a corrupted DB often cannot run pending migrations. func RebuildFTS(ctx context.Context, database *sql.DB) error { if err := requireFTSMigration(ctx, database); err != nil { return err } tx, err := database.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("starting FTS rebuild transaction: %w", err) } defer func() { _ = tx.Rollback() }() var stmts []string for _, table := range ftsTables { for _, suffix := range ftsTriggerSuffixes { stmts = append(stmts, "DROP TRIGGER IF EXISTS "+table+suffix) } stmts = append(stmts, "DROP TABLE IF EXISTS "+table) } stmts = append(stmts, ftsSchemaDDL...) for _, stmt := range stmts { if _, err := tx.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("rebuilding FTS schema: %w", err) } } if err := verifyFTS(ctx, tx); err != nil { return fmt.Errorf("the rebuilt search index did not verify: %w", err) } if err := tx.Commit(); err != nil { return fmt.Errorf("committing FTS rebuild: %w", err) } return nil } // ftsSchemaDDL must reproduce what the full migration chain produces, not what any // single migration does; the schema comparison in repair_test.go guards the drift. var ftsSchemaDDL = []string{ ` CREATE VIRTUAL TABLE IF NOT EXISTS media_file_fts USING fts5( title, album, artist, album_artist, sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, disc_subtitle, search_participants, search_normalized, content='', content_rowid='rowid', tokenize='unicode61 remove_diacritics 2' ) `, ` CREATE VIRTUAL TABLE IF NOT EXISTS album_fts USING fts5( name, sort_album_name, album_artist, search_participants, discs, catalog_num, album_version, search_normalized, content='', content_rowid='rowid', tokenize='unicode61 remove_diacritics 2' ) `, ` CREATE VIRTUAL TABLE IF NOT EXISTS artist_fts USING fts5( name, sort_artist_name, search_normalized, content='', content_rowid='rowid', tokenize='unicode61 remove_diacritics 2' ) `, ` INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, disc_subtitle, search_participants, search_normalized) SELECT rowid, title, album, artist, album_artist, sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, COALESCE(disc_subtitle, ''), COALESCE(search_participants, ''), COALESCE(search_normalized, '') FROM media_file `, ` INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, search_participants, discs, catalog_num, album_version, search_normalized) SELECT rowid, name, COALESCE(sort_album_name, ''), COALESCE(album_artist, ''), COALESCE(search_participants, ''), COALESCE(discs, ''), COALESCE(catalog_num, ''), COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') FROM json_each(album.tags, '$.albumversion') AS je), ''), COALESCE(search_normalized, '') FROM album `, ` INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) SELECT rowid, name, COALESCE(sort_artist_name, ''), COALESCE(search_normalized, '') FROM artist `, ` CREATE TRIGGER media_file_fts_ai AFTER INSERT ON media_file BEGIN INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, disc_subtitle, search_participants, search_normalized) VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), COALESCE(NEW.search_normalized, '')); END `, ` CREATE TRIGGER media_file_fts_ad AFTER DELETE ON media_file BEGIN INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, disc_subtitle, search_participants, search_normalized) VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), COALESCE(OLD.search_normalized, '')); END `, ` CREATE TRIGGER media_file_fts_au AFTER UPDATE ON media_file WHEN OLD.title IS NOT NEW.title OR OLD.album IS NOT NEW.album OR OLD.artist IS NOT NEW.artist OR OLD.album_artist IS NOT NEW.album_artist OR OLD.sort_title IS NOT NEW.sort_title OR OLD.sort_album_name IS NOT NEW.sort_album_name OR OLD.sort_artist_name IS NOT NEW.sort_artist_name OR OLD.sort_album_artist_name IS NOT NEW.sort_album_artist_name OR OLD.disc_subtitle IS NOT NEW.disc_subtitle OR OLD.search_participants IS NOT NEW.search_participants OR OLD.search_normalized IS NOT NEW.search_normalized BEGIN INSERT INTO media_file_fts(media_file_fts, rowid, title, album, artist, album_artist, sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, disc_subtitle, search_participants, search_normalized) VALUES ('delete', OLD.rowid, OLD.title, OLD.album, OLD.artist, OLD.album_artist, OLD.sort_title, OLD.sort_album_name, OLD.sort_artist_name, OLD.sort_album_artist_name, COALESCE(OLD.disc_subtitle, ''), COALESCE(OLD.search_participants, ''), COALESCE(OLD.search_normalized, '')); INSERT INTO media_file_fts(rowid, title, album, artist, album_artist, sort_title, sort_album_name, sort_artist_name, sort_album_artist_name, disc_subtitle, search_participants, search_normalized) VALUES (NEW.rowid, NEW.title, NEW.album, NEW.artist, NEW.album_artist, NEW.sort_title, NEW.sort_album_name, NEW.sort_artist_name, NEW.sort_album_artist_name, COALESCE(NEW.disc_subtitle, ''), COALESCE(NEW.search_participants, ''), COALESCE(NEW.search_normalized, '')); END `, ` CREATE TRIGGER album_fts_ai AFTER INSERT ON album BEGIN INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, search_participants, discs, catalog_num, album_version, search_normalized) VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), COALESCE(NEW.catalog_num, ''), COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') FROM json_each(NEW.tags, '$.albumversion') AS je), ''), COALESCE(NEW.search_normalized, '')); END `, ` CREATE TRIGGER album_fts_ad AFTER DELETE ON album BEGIN INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, search_participants, discs, catalog_num, album_version, search_normalized) VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), COALESCE(OLD.catalog_num, ''), COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') FROM json_each(OLD.tags, '$.albumversion') AS je), ''), COALESCE(OLD.search_normalized, '')); END `, ` CREATE TRIGGER album_fts_au AFTER UPDATE ON album WHEN OLD.name IS NOT NEW.name OR OLD.sort_album_name IS NOT NEW.sort_album_name OR OLD.album_artist IS NOT NEW.album_artist OR OLD.search_participants IS NOT NEW.search_participants OR OLD.discs IS NOT NEW.discs OR OLD.catalog_num IS NOT NEW.catalog_num OR OLD.tags IS NOT NEW.tags OR OLD.search_normalized IS NOT NEW.search_normalized BEGIN INSERT INTO album_fts(album_fts, rowid, name, sort_album_name, album_artist, search_participants, discs, catalog_num, album_version, search_normalized) VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_album_name, ''), COALESCE(OLD.album_artist, ''), COALESCE(OLD.search_participants, ''), COALESCE(OLD.discs, ''), COALESCE(OLD.catalog_num, ''), COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') FROM json_each(OLD.tags, '$.albumversion') AS je), ''), COALESCE(OLD.search_normalized, '')); INSERT INTO album_fts(rowid, name, sort_album_name, album_artist, search_participants, discs, catalog_num, album_version, search_normalized) VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_album_name, ''), COALESCE(NEW.album_artist, ''), COALESCE(NEW.search_participants, ''), COALESCE(NEW.discs, ''), COALESCE(NEW.catalog_num, ''), COALESCE((SELECT group_concat(json_extract(je.value, '$.value'), ' ') FROM json_each(NEW.tags, '$.albumversion') AS je), ''), COALESCE(NEW.search_normalized, '')); END `, ` CREATE TRIGGER artist_fts_ai AFTER INSERT ON artist BEGIN INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), COALESCE(NEW.search_normalized, '')); END `, ` CREATE TRIGGER artist_fts_ad AFTER DELETE ON artist BEGIN INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), COALESCE(OLD.search_normalized, '')); END `, ` CREATE TRIGGER artist_fts_au AFTER UPDATE ON artist WHEN OLD.name IS NOT NEW.name OR OLD.sort_artist_name IS NOT NEW.sort_artist_name OR OLD.search_normalized IS NOT NEW.search_normalized BEGIN INSERT INTO artist_fts(artist_fts, rowid, name, sort_artist_name, search_normalized) VALUES ('delete', OLD.rowid, OLD.name, COALESCE(OLD.sort_artist_name, ''), COALESCE(OLD.search_normalized, '')); INSERT INTO artist_fts(rowid, name, sort_artist_name, search_normalized) VALUES (NEW.rowid, NEW.name, COALESCE(NEW.sort_artist_name, ''), COALESCE(NEW.search_normalized, '')); END `, }