// Package database provides SQLite database access. package database import ( "context" "database/sql" "embed" "fmt" "io" "io/fs" "log/slog" "os" "path" "path/filepath" "strings" "time" "github.com/BurntSushi/toml" _ "modernc.org/sqlite" // Register sqlite driver. "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/profiling" "yellowjacket/backend/system" ) //go:generate go tool sqlc generate //go:embed sql/schemas/*.sql var schemas embed.FS // DB wraps the SQLite database connection and queries. type DB struct { db *sql.DB Ctx context.Context Queries *sqlcgen.Queries logger *slog.Logger } // NewDB opens the database and applies schema migrations. func NewDB(logger *slog.Logger) (*DB, error) { defer profiling.TimeOp(logger, "database.NewDB")() dbCtx := context.Background() userDataDir, err := system.GetUserDataDirPath() if err != nil { return nil, fmt.Errorf("could not get user data directory: %w", err) } sqliteDBFilePath := path.Join(userDataDir, "yj.db") logger.Debug("opening sqlite database", "filepath", sqliteDBFilePath) db, err := sql.Open("sqlite", sqliteDBFilePath+"?_busy_timeout=5000&_journal_mode=WAL") if err != nil { return nil, fmt.Errorf("could not connect to sqlite database: %w", err) } db.SetMaxOpenConns(1) // SQLite only supports one writer at a time if err := applyPRAGMAs(dbCtx, db); err != nil { return nil, fmt.Errorf("could not apply PRAGMAs: %w", err) } // Execute SQL files from the embedded schemas directory logger.Debug("reading sql schema files from embedded directory") dirEntries, err := schemas.ReadDir("sql/schemas") if err != nil { return nil, fmt.Errorf("could not read schemas directory: %w", err) } logger.Debug("executing all sql schema files") for _, dirEntry := range dirEntries { if !dirEntry.IsDir() { filePath := path.Join("sql/schemas", dirEntry.Name()) sqlContent, err := fs.ReadFile(schemas, filePath) if err != nil { return nil, fmt.Errorf("could not read file %s: %w", filePath, err) } logger.Debug( "executing sql schema file", "filepath", filePath, "sql", string(sqlContent), ) _, err = db.ExecContext(dbCtx, string(sqlContent)) // Execute the SQL if err != nil { return nil, fmt.Errorf("error executing sql from file %s: %w", filePath, err) } } } // Run versioned schema migrations for columns that cannot be // added with CREATE TABLE IF NOT EXISTS on existing databases. if err := runMigrations(dbCtx, db, logger, sqliteDBFilePath); err != nil { return nil, fmt.Errorf( "could not run schema migrations: %w", err, ) } // Remove orphaned playlist_tracks left behind by past deletes // that ran without foreign key enforcement. orphanResult, err := db.ExecContext( dbCtx, "DELETE FROM playlist_tracks WHERE playlist_id NOT IN (SELECT id FROM playlists)", ) if err != nil { logger.Warn( "could not clean orphaned playlist tracks", "err", err, ) } else if n, _ := orphanResult.RowsAffected(); n > 0 { logger.Info( "Cleaned orphaned playlist tracks", "deleted", n, ) } // Get generated queries queries := sqlcgen.New(db) return &DB{ db: db, Ctx: dbCtx, Queries: queries, logger: logger, }, err } // BeginTx starts a new database transaction. func (d *DB) BeginTx() (*sql.Tx, error) { return d.db.BeginTx(d.Ctx, nil) } // ExecContext executes a query without returning any rows. func (d *DB) ExecContext(query string, args ...any) (sql.Result, error) { return d.db.ExecContext(d.Ctx, query, args...) } // QueryContext executes a query that returns rows. func (d *DB) QueryContext(query string, args ...any) (*sql.Rows, error) { return d.db.QueryContext(d.Ctx, query, args...) } // applyPRAGMAs configures SQLite connection settings. Called by both // NewDB and NewTestDB to ensure identical behavior. func applyPRAGMAs(ctx context.Context, db *sql.DB) error { pragmas := []string{ "PRAGMA foreign_keys = ON", "PRAGMA synchronous = NORMAL", "PRAGMA cache_size = -8000", "PRAGMA mmap_size = 67108864", } for _, pragma := range pragmas { if _, err := db.ExecContext(ctx, pragma); err != nil { return fmt.Errorf( "could not apply PRAGMA %q: %w", pragma, err, ) } } return nil } // runMigrations applies incremental schema changes using SQLite's // PRAGMA user_version as the version tracker. Each migration runs // once and bumps the version so it is never re-applied. func runMigrations( ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string, ) error { var version int if err := db.QueryRowContext( ctx, "PRAGMA user_version", ).Scan(&version); err != nil { return fmt.Errorf( "could not read user_version: %w", err, ) } logger.Debug( "current schema version", "user_version", version, ) // Migration 1: add audio-property columns to audio_files. if version < 1 { logger.Info("applying migration 1: audio file properties") cols := []string{ "sample_rate int NOT NULL DEFAULT 0", "bit_depth int NOT NULL DEFAULT 0", "channels int NOT NULL DEFAULT 0", "bitrate int NOT NULL DEFAULT 0", "file_size int NOT NULL DEFAULT 0", } for _, col := range cols { stmt := "ALTER TABLE audio_files ADD COLUMN " + col if _, err := db.ExecContext(ctx, stmt); err != nil { // Column may already exist on a fresh DB that // ran the updated CREATE TABLE. SQLite returns // "duplicate column name" in that case. if isDuplicateColumnErr(err) { continue } return fmt.Errorf( "migration 1 failed (%s): %w", col, err, ) } } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 1", ); err != nil { return fmt.Errorf( "could not set user_version to 1: %w", err, ) } } // Migration 2: add basename column and populate search index. if version < 2 { if err := migration2BasenameAndFTS( ctx, db, logger, ); err != nil { return err } } // Migration 3: add UNIQUE constraint to artist_credit_artist. if version < 3 { logger.Info( "applying migration 3: artist_credit_artist unique constraint", ) // Remove duplicates first (keep lowest ID per pair). if _, err := db.ExecContext(ctx, ` DELETE FROM artist_credit_artist WHERE id NOT IN ( SELECT MIN(id) FROM artist_credit_artist GROUP BY artist_id, credit_id ) `); err != nil { return fmt.Errorf( "migration 3: could not deduplicate: %w", err, ) } if _, err := db.ExecContext(ctx, ` CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_credit_artist_unique ON artist_credit_artist(artist_id, credit_id) `); err != nil { return fmt.Errorf( "migration 3: could not create unique index: %w", err, ) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 3", ); err != nil { return fmt.Errorf( "could not set user_version to 3: %w", err, ) } logger.Info("migration 3 complete") } // Migration 4: create track_metadata VIEW. if version < 4 { if err := migration4TrackMetadataView( ctx, db, logger, ); err != nil { return err } } // Migration 5: rebuild release_groups with composite unique // constraint on (name, album_artist_credit_id) instead of // name alone, so albums with the same name by different // artists are stored as separate rows. if version < 5 { if err := migration5ReleaseGroupCompositeUnique( ctx, db, logger, ); err != nil { return err } } // Migration 6: multi-library support. if version < 6 { if err := migration6MultiLibrary( ctx, db, logger, dbPath, ); err != nil { return err } } // Migration 7: add phantom_file_path to playlist_tracks // for automatic phantom resolution after library re-scans. if version < 7 { if err := migration7PhantomFilePath( ctx, db, logger, ); err != nil { return err } } // Migration 8: rebuild FTS5 search_index with // contentless_delete=1 so individual rows can be deleted. if version < 8 { if err := migration8ContentlessDelete( ctx, db, logger, ); err != nil { return err } } // Migration 9: add smart playlist columns to playlists. if version < 9 { if err := migration9SmartPlaylists( ctx, db, logger, ); err != nil { return err } } if version < 10 { if err := migration10PlayHistory( ctx, db, logger, ); err != nil { return err } } // Migration 11: explore_cache table for MusicBrainz/ListenBrainz // API response caching with TTL expiry and MBID lookups. if version < 11 { if err := migration11ExploreCache( ctx, db, logger, ); err != nil { return err } } // Migration 12: explore_index + FTS5 for the popularity search // index. Stores the top albums and tracks from the most popular // ListenBrainz artists for instant local search. if version < 12 { //nolint:mnd if err := migration12ExploreSearchIndex( ctx, db, logger, ); err != nil { return err } } // Migration 13: add MusicBrainz ID columns to artists, // release_groups, and recordings for library↔explore linking. if version < 13 { //nolint:mnd if err := migration13MBIDColumns( ctx, db, logger, ); err != nil { return err } } // Migration 14: add aliases column to explore_index and rebuild // the FTS5 virtual table with 3 searchable columns. if version < 14 { //nolint:mnd if err := migration14ExploreAliases( ctx, db, logger, ); err != nil { return err } } // Migration 15: add in_library and is_similar columns to // explore_index for personalized search ranking. if version < 15 { //nolint:mnd if err := migration15PersonalizationColumns( ctx, db, logger, ); err != nil { return err } } // Migration 16: artist_images table for multi-source artist photos. if version < 16 { //nolint:mnd if err := migration16ArtistImages( ctx, db, logger, ); err != nil { return err } } return nil } // migration2BasenameAndFTS adds the basename column to audio_files, // backfills it from file_path, creates the basename index, and // populates the FTS5 search_index table. func migration2BasenameAndFTS( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 2: basename column + FTS5 search index", ) // Add basename column (may already exist on fresh DBs). if _, err := db.ExecContext( ctx, "ALTER TABLE audio_files ADD COLUMN basename text NOT NULL DEFAULT ''", ); err != nil && !isDuplicateColumnErr(err) { return fmt.Errorf( "migration 2: could not add basename column: %w", err, ) } // Backfill basename from file_path for existing rows. // SQLite doesn't have a basename function, so we use // REPLACE to strip directories by finding everything // after the last '/'. if _, err := db.ExecContext(ctx, ` UPDATE audio_files SET basename = CASE WHEN INSTR(file_path, '/') > 0 THEN SUBSTR( file_path, LENGTH(file_path) - LENGTH( REPLACE(file_path, '/', '') ) + 1 ) ELSE file_path END WHERE basename = '' `); err != nil { return fmt.Errorf( "migration 2: could not backfill basename: %w", err, ) } // Create index (IF NOT EXISTS handles fresh DBs). if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_audio_files_basename ON audio_files(basename) `); err != nil { return fmt.Errorf( "migration 2: could not create basename index: %w", err, ) } // Populate FTS5 search index from existing data. if _, err := db.ExecContext(ctx, ` INSERT INTO search_index(rowid, file_path, title, artist, album) SELECT af.id, af.file_path, COALESCE(r.name, ''), COALESCE(ac.text, ''), COALESCE(rg.name, '') FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id `); err != nil { return fmt.Errorf( "migration 2: could not populate search index: %w", err, ) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 2", ); err != nil { return fmt.Errorf( "could not set user_version to 2: %w", err, ) } logger.Info("migration 2 complete") return nil } // migration4TrackMetadataView creates the track_metadata VIEW that // consolidates the 5-table JOIN used by FTS5 search queries. // Fresh databases get the VIEW from the embedded schema file; // this migration covers databases created before the VIEW existed. func migration4TrackMetadataView( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 4: track_metadata VIEW", ) if _, err := db.ExecContext(ctx, ` CREATE VIEW IF NOT EXISTS track_metadata AS SELECT af.id, af.file_path, af.length_milliseconds, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist_name, r.track_number, r.disc_number, COALESCE(rg.name, '') AS album, CAST(COALESCE( (SELECT GROUP_CONCAT(g.name, '||') FROM recording_genres rg_sub JOIN genres g ON rg_sub.genre_id = g.id WHERE rg_sub.recording_id = r.id), '' ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, COALESCE(r.composer, '') AS composer, COALESCE(ft.extension, '') AS file_type, af.sample_rate, af.bit_depth, af.channels, af.bitrate, af.file_size FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id `); err != nil { return fmt.Errorf( "migration 4: could not create track_metadata VIEW: %w", err, ) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 4", ); err != nil { return fmt.Errorf( "could not set user_version to 4: %w", err, ) } logger.Info("migration 4 complete") return nil } // migration5ReleaseGroupCompositeUnique rebuilds the release_groups // table with UNIQUE(name, album_artist_credit_id) instead of // UNIQUE(name). SQLite cannot ALTER a UNIQUE constraint, so we // must rebuild the table. // // SAFETY: Hand-crafted SQL for schema migration. func migration5ReleaseGroupCompositeUnique( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 5: release_groups composite unique constraint", ) // Temporarily disable FK checks for table rebuild. if _, err := db.ExecContext( ctx, "PRAGMA foreign_keys = OFF", ); err != nil { return fmt.Errorf( "migration 5: could not disable foreign keys: %w", err, ) } // Drop the track_metadata VIEW that references release_groups // so the table rebuild can proceed without SQLite complaining // about a dangling VIEW reference. if _, err := db.ExecContext( ctx, "DROP VIEW IF EXISTS track_metadata", ); err != nil { return fmt.Errorf( "migration 5: could not drop track_metadata VIEW: %w", err, ) } // Create new table with composite unique constraint. if _, err := db.ExecContext(ctx, ` CREATE TABLE release_groups_new ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, cover_art_id INTEGER, album_artist_credit_id INTEGER, year INTEGER, total_tracks INTEGER, total_discs INTEGER, FOREIGN KEY(cover_art_id) REFERENCES cover_art(id), FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id), UNIQUE(name, album_artist_credit_id) ) `); err != nil { return fmt.Errorf( "migration 5: could not create release_groups_new: %w", err, ) } // Copy all data. if _, err := db.ExecContext(ctx, ` INSERT INTO release_groups_new SELECT * FROM release_groups `); err != nil { return fmt.Errorf( "migration 5: could not copy data: %w", err, ) } // Drop old table. if _, err := db.ExecContext( ctx, "DROP TABLE release_groups", ); err != nil { return fmt.Errorf( "migration 5: could not drop old table: %w", err, ) } // Rename new table. if _, err := db.ExecContext(ctx, ` ALTER TABLE release_groups_new RENAME TO release_groups `); err != nil { return fmt.Errorf( "migration 5: could not rename table: %w", err, ) } // Recreate indexes. if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_release_groups_cover_art_id ON release_groups(cover_art_id) `); err != nil { return fmt.Errorf( "migration 5: could not create cover_art_id index: %w", err, ) } if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_release_groups_album_artist_credit_id ON release_groups(album_artist_credit_id) `); err != nil { return fmt.Errorf( "migration 5: could not create album_artist_credit_id index: %w", err, ) } // Recreate the track_metadata VIEW that was dropped above. // The definition must match the embedded schema file // (sql/schemas/track_metadata_view.sql) exactly. if _, err := db.ExecContext(ctx, ` CREATE VIEW IF NOT EXISTS track_metadata AS SELECT af.id, af.file_path, af.length_milliseconds, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist_name, r.track_number, r.disc_number, COALESCE(rg.name, '') AS album, CAST(COALESCE( (SELECT GROUP_CONCAT(g.name, '||') FROM recording_genres rg_sub JOIN genres g ON rg_sub.genre_id = g.id WHERE rg_sub.recording_id = r.id), '' ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, COALESCE(r.composer, '') AS composer, COALESCE(ft.extension, '') AS file_type, af.sample_rate, af.bit_depth, af.channels, af.bitrate, af.file_size FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id `); err != nil { return fmt.Errorf( "migration 5: could not recreate track_metadata VIEW: %w", err, ) } // Re-enable FK checks. if _, err := db.ExecContext( ctx, "PRAGMA foreign_keys = ON", ); err != nil { return fmt.Errorf( "migration 5: could not re-enable foreign keys: %w", err, ) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 5", ); err != nil { return fmt.Errorf( "could not set user_version to 5: %w", err, ) } logger.Info("migration 5 complete") return nil } // isDuplicateColumnErr returns true when the error is SQLite's // "duplicate column name" error from an ALTER TABLE ADD COLUMN // on a column that already exists. func isDuplicateColumnErr(err error) bool { return err != nil && strings.Contains( err.Error(), "duplicate column name", ) } // backupDatabase copies the database file to a timestamped backup // before running a destructive migration. Returns the backup path. func backupDatabase( dbPath string, logger *slog.Logger, ) (string, error) { backupPath := dbPath + ".bak." + time.Now().Format("20060102") src, err := os.Open(dbPath) if err != nil { return "", fmt.Errorf( "could not open database for backup: %w", err, ) } defer func() { _ = src.Close() }() dst, err := os.Create(backupPath) if err != nil { return "", fmt.Errorf( "could not create backup file: %w", err, ) } defer func() { _ = dst.Close() }() if _, err := io.Copy(dst, src); err != nil { return "", fmt.Errorf( "could not copy database to backup: %w", err, ) } logger.Info("database backup created", "path", backupPath) return backupPath, nil } // migration6MultiLibrary adds multi-library support: creates the // libraries table, adds library_id FK to audio_files, rebuilds // playlist_tracks with SET NULL FK and phantom metadata columns, // and recreates the track_metadata VIEW with library_id. // // SAFETY: Hand-crafted SQL for schema migration. func migration6MultiLibrary( ctx context.Context, db *sql.DB, logger *slog.Logger, dbPath string, ) error { logger.Info("applying migration 6: multi-library support") // 1. Backup database BEFORE any changes (skip for in-memory DBs). if dbPath != "" && dbPath != ":memory:" { if _, err := backupDatabase(dbPath, logger); err != nil { return fmt.Errorf( "migration 6: backup failed: %w", err, ) } } // 2. Read TOML config to get existing library directory. existingDir := readLibraryDirFromTOML(logger) // 3. Disable FK checks for table rebuild. // SAFETY: PRAGMA foreign_keys cannot run inside a transaction. if _, err := db.ExecContext( ctx, "PRAGMA foreign_keys = OFF", ); err != nil { return fmt.Errorf( "migration 6: could not disable foreign keys: %w", err, ) } // 4. Create libraries table. // SAFETY: Hand-crafted DDL for new table. if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS libraries ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL UNIQUE, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) `); err != nil { return fmt.Errorf( "migration 6: could not create libraries table: %w", err, ) } // 5. Insert default library from TOML (if existingDir is not empty). var defaultLibID int64 if existingDir != "" { libName := filepath.Base(existingDir) // SAFETY: Hand-crafted INSERT for migrated default library. result, err := db.ExecContext(ctx, "INSERT INTO libraries (name, path) VALUES (?, ?)", libName, existingDir, ) if err != nil { return fmt.Errorf( "migration 6: could not insert default library: %w", err, ) } defaultLibID, _ = result.LastInsertId() logger.Info("migrated existing library", "name", libName, "path", existingDir, "id", defaultLibID, ) } // 6. Add library_id column to audio_files. // SAFETY: ALTER TABLE ADD COLUMN with dynamic DEFAULT for backfill. stmt := fmt.Sprintf( "ALTER TABLE audio_files ADD COLUMN library_id INTEGER NOT NULL DEFAULT %d", defaultLibID, ) if _, err := db.ExecContext(ctx, stmt); err != nil { if !isDuplicateColumnErr(err) { return fmt.Errorf( "migration 6: could not add library_id column: %w", err, ) } } // 7. Create index on library_id. // SAFETY: Hand-crafted index for FK performance. if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_audio_files_library_id ON audio_files(library_id) `); err != nil { return fmt.Errorf( "migration 6: could not create library_id index: %w", err, ) } // 8. Drop track_metadata VIEW before table rebuild. if _, err := db.ExecContext( ctx, "DROP VIEW IF EXISTS track_metadata", ); err != nil { return fmt.Errorf( "migration 6: could not drop track_metadata VIEW: %w", err, ) } // 9. Rebuild playlist_tracks for SET NULL FK + phantom columns. // SAFETY: Table rebuild — playlist_tracks changes to SET NULL, // queue_tracks keeps CASCADE (ephemeral, not rebuilt). // SAFETY: Hand-crafted DDL for rebuilt playlist_tracks. if _, err := db.ExecContext(ctx, ` CREATE TABLE playlist_tracks_new ( id INTEGER PRIMARY KEY, playlist_id INTEGER NOT NULL, audio_file_id INTEGER, position INTEGER NOT NULL, phantom_title TEXT, phantom_artist TEXT, phantom_album TEXT, phantom_duration_ms INTEGER, phantom_genre TEXT, phantom_cover_art_path TEXT, FOREIGN KEY(playlist_id) REFERENCES playlists(id) ON DELETE CASCADE, FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE SET NULL ) `); err != nil { return fmt.Errorf( "migration 6: could not create playlist_tracks_new: %w", err, ) } // Copy existing data (phantom columns get NULL). // SAFETY: Hand-crafted INSERT-SELECT for data migration. if _, err := db.ExecContext(ctx, ` INSERT INTO playlist_tracks_new (id, playlist_id, audio_file_id, position) SELECT id, playlist_id, audio_file_id, position FROM playlist_tracks `); err != nil { return fmt.Errorf( "migration 6: could not copy playlist_tracks data: %w", err, ) } // Drop old table. if _, err := db.ExecContext( ctx, "DROP TABLE playlist_tracks", ); err != nil { return fmt.Errorf( "migration 6: could not drop old playlist_tracks: %w", err, ) } // Rename. if _, err := db.ExecContext(ctx, "ALTER TABLE playlist_tracks_new RENAME TO playlist_tracks", ); err != nil { return fmt.Errorf( "migration 6: could not rename playlist_tracks_new: %w", err, ) } // Recreate indexes. if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_playlist_tracks_playlist_id ON playlist_tracks(playlist_id) `); err != nil { return fmt.Errorf( "migration 6: could not create playlist_id index: %w", err, ) } if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_playlist_tracks_audio_file_id ON playlist_tracks(audio_file_id) `); err != nil { return fmt.Errorf( "migration 6: could not create audio_file_id index: %w", err, ) } // 10. Backfill phantom metadata on existing playlist_tracks // from audio_files JOINs. Eager population per user decision. // SAFETY: Hand-crafted UPDATE-FROM-SELECT for phantom backfill. if _, err := db.ExecContext(ctx, ` UPDATE playlist_tracks SET phantom_title = sub.title, phantom_artist = sub.artist, phantom_album = sub.album, phantom_duration_ms = sub.duration, phantom_genre = sub.genre, phantom_cover_art_path = sub.cover_art_path FROM ( SELECT pt.id AS pt_id, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist, COALESCE(rg.name, '') AS album, af.length_milliseconds AS duration, CAST(COALESCE( (SELECT GROUP_CONCAT(g.name, '||') FROM recording_genres rg_sub JOIN genres g ON rg_sub.genre_id = g.id WHERE rg_sub.recording_id = r.id), '' ) AS TEXT) AS genre, COALESCE(ca.file_path, '') AS cover_art_path FROM playlist_tracks pt JOIN audio_files af ON pt.audio_file_id = af.id LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id ) sub WHERE playlist_tracks.id = sub.pt_id `); err != nil { return fmt.Errorf( "migration 6: could not backfill phantom metadata: %w", err, ) } // 11. Recreate track_metadata VIEW with library_id. // SAFETY: Hand-crafted VIEW recreation matching schema file. if _, err := db.ExecContext(ctx, ` CREATE VIEW IF NOT EXISTS track_metadata AS SELECT af.id, af.file_path, af.length_milliseconds, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist_name, r.track_number, r.disc_number, COALESCE(rg.name, '') AS album, CAST(COALESCE( (SELECT GROUP_CONCAT(g.name, '||') FROM recording_genres rg_sub JOIN genres g ON rg_sub.genre_id = g.id WHERE rg_sub.recording_id = r.id), '' ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, COALESCE(r.composer, '') AS composer, COALESCE(ft.extension, '') AS file_type, af.sample_rate, af.bit_depth, af.channels, af.bitrate, af.file_size, af.library_id FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id `); err != nil { return fmt.Errorf( "migration 6: could not recreate track_metadata VIEW: %w", err, ) } // 12. Re-enable FK checks. if _, err := db.ExecContext( ctx, "PRAGMA foreign_keys = ON", ); err != nil { return fmt.Errorf( "migration 6: could not re-enable foreign keys: %w", err, ) } // 13. Remove DirectoryPath from TOML config (libraries table // is now the source of truth). if existingDir != "" { removeLibraryDirFromTOML(logger) } // 14. Set version. if _, err := db.ExecContext( ctx, "PRAGMA user_version = 6", ); err != nil { return fmt.Errorf( "could not set user_version to 6: %w", err, ) } logger.Info("migration 6 complete") return nil } // migration7PhantomFilePath adds the phantom_file_path column to // playlist_tracks so that phantom entries can be automatically // re-linked to audio_files after a library scan. func migration7PhantomFilePath( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 7: phantom_file_path column", ) // SAFETY: ALTER TABLE ADD COLUMN for new nullable column. if _, err := db.ExecContext(ctx, `ALTER TABLE playlist_tracks ADD COLUMN phantom_file_path TEXT`, ); err != nil { if !isDuplicateColumnErr(err) { return fmt.Errorf( "migration 7: could not add column: %w", err, ) } } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 7", ); err != nil { return fmt.Errorf( "could not set user_version to 7: %w", err, ) } logger.Info("migration 7 complete") return nil } // migration8ContentlessDelete rebuilds the FTS5 search_index with // contentless_delete=1 so that individual rows can be deleted. // This is a prerequisite for inline tag edit → DB sync in Phase 16. // // SAFETY: Hand-crafted SQL for FTS5 schema migration. func migration8ContentlessDelete( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 8: rebuilding FTS5 search_index with contentless_delete=1", ) // Drop the old contentless FTS5 table (content='' only). if _, err := db.ExecContext( ctx, `DROP TABLE IF EXISTS search_index`, ); err != nil { return fmt.Errorf( "migration 8: could not drop search_index: %w", err, ) } // Recreate with contentless_delete=1 added. // SAFETY: Must match backend/database/sql/schemas/search_index.sql exactly. if _, err := db.ExecContext(ctx, ` CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5( file_path, title, artist, album, content='', contentless_delete=1, tokenize='unicode61 remove_diacritics 2' ) `); err != nil { return fmt.Errorf( "migration 8: could not recreate search_index: %w", err, ) } // Repopulate from track_metadata VIEW. // SAFETY: FTS5 INSERT from VIEW; no user input. if _, err := db.ExecContext(ctx, ` INSERT INTO search_index(rowid, file_path, title, artist, album) SELECT id, file_path, title, artist_name, album FROM track_metadata `); err != nil { return fmt.Errorf( "migration 8: could not repopulate search_index: %w", err, ) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 8", ); err != nil { return fmt.Errorf( "migration 8: could not set user_version: %w", err, ) } logger.Info("migration 8 complete") return nil } // migration9SmartPlaylists adds the is_smart and smart_rules // columns to the playlists table for smart playlist support. func migration9SmartPlaylists( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 9: smart playlist columns", ) if _, err := db.ExecContext(ctx, `ALTER TABLE playlists ADD COLUMN is_smart INTEGER NOT NULL DEFAULT 0`, ); err != nil { if !isDuplicateColumnErr(err) { return fmt.Errorf( "migration 9: could not add is_smart column: %w", err, ) } } if _, err := db.ExecContext(ctx, `ALTER TABLE playlists ADD COLUMN smart_rules TEXT`, ); err != nil { if !isDuplicateColumnErr(err) { return fmt.Errorf( "migration 9: could not add smart_rules column: %w", err, ) } } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 9", ); err != nil { return fmt.Errorf( "could not set user_version to 9: %w", err, ) } logger.Info("migration 9 complete") return nil } // migration10PlayHistory adds play history tracking: // - play_history table for timestamped play log // - play_count and last_played columns on audio_files // - Recreates track_metadata VIEW to expose the new columns. func migration10PlayHistory( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 10: play history tracking", ) // 1. Create play_history table. if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS play_history ( id INTEGER PRIMARY KEY, audio_file_id INTEGER NOT NULL, played_at DATETIME NOT NULL DEFAULT (datetime('now')), FOREIGN KEY(audio_file_id) REFERENCES audio_files(id) ON DELETE CASCADE )`, ); err != nil { return fmt.Errorf( "migration 10: could not create play_history table: %w", err, ) } if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_play_history_audio_file_id ON play_history(audio_file_id)`, ); err != nil { return fmt.Errorf( "migration 10: could not create play_history index: %w", err, ) } // 2. Add play_count and last_played columns to audio_files. if _, err := db.ExecContext(ctx, `ALTER TABLE audio_files ADD COLUMN play_count INTEGER NOT NULL DEFAULT 0`, ); err != nil { if !isDuplicateColumnErr(err) { return fmt.Errorf( "migration 10: could not add play_count column: %w", err, ) } } if _, err := db.ExecContext(ctx, `ALTER TABLE audio_files ADD COLUMN last_played DATETIME`, ); err != nil { if !isDuplicateColumnErr(err) { return fmt.Errorf( "migration 10: could not add last_played column: %w", err, ) } } // 3. Recreate track_metadata VIEW to include play_count and last_played. if _, err := db.ExecContext( ctx, "DROP VIEW IF EXISTS track_metadata", ); err != nil { return fmt.Errorf( "migration 10: could not drop track_metadata VIEW: %w", err, ) } if _, err := db.ExecContext(ctx, ` CREATE VIEW IF NOT EXISTS track_metadata AS SELECT af.id, af.file_path, af.length_milliseconds, COALESCE(r.name, '') AS title, COALESCE(ac.text, '') AS artist_name, r.track_number, r.disc_number, COALESCE(rg.name, '') AS album, CAST(COALESCE( (SELECT GROUP_CONCAT(g.name, '||') FROM recording_genres rg_sub JOIN genres g ON rg_sub.genre_id = g.id WHERE rg_sub.recording_id = r.id), '' ) AS TEXT) AS genre, COALESCE(r.year, 0) AS year, COALESCE(r.composer, '') AS composer, COALESCE(ft.extension, '') AS file_type, af.sample_rate, af.bit_depth, af.channels, af.bitrate, af.file_size, af.library_id, af.play_count, af.last_played FROM audio_files af LEFT JOIN recordings r ON af.recording_id = r.id LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id LEFT JOIN ( SELECT recording_id, MIN(release_group_id) AS release_group_id FROM release_group_recordings GROUP BY recording_id ) rgr ON r.id = rgr.recording_id LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id LEFT JOIN file_types ft ON af.file_type_id = ft.id`, ); err != nil { return fmt.Errorf( "migration 10: could not create track_metadata VIEW: %w", err, ) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 10", ); err != nil { return fmt.Errorf( "could not set user_version to 10: %w", err, ) } logger.Info("migration 10 complete") return nil } // migration11ExploreCache creates the explore_cache table for // MusicBrainz and ListenBrainz API response caching. The table // stores raw JSON keyed by URL with TTL-based expiry and optional // MBID columns for future autotagging lookups. func migration11ExploreCache( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info( "applying migration 11: explore_cache table", ) if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS explore_cache ( url_key TEXT PRIMARY KEY, response TEXT NOT NULL, mbid TEXT, entity_type TEXT, expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) `); err != nil { return fmt.Errorf( "migration 11: could not create explore_cache table: %w", err, ) } if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_explore_cache_expires ON explore_cache(expires_at) `); err != nil { return fmt.Errorf( "migration 11: could not create expires index: %w", err, ) } if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_explore_cache_mbid ON explore_cache(mbid) `); err != nil { return fmt.Errorf( "migration 11: could not create mbid index: %w", err, ) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 11", ); err != nil { return fmt.Errorf( "could not set user_version to 11: %w", err, ) } logger.Info("migration 11 complete") return nil } // migration12ExploreSearchIndex creates the explore_index table, // the FTS5 virtual table for full-text search, sync triggers, and // the explore_index_meta table for build tracking. func migration12ExploreSearchIndex( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info("applying migration 12: explore search index") // Content table — slim denormalized rows for search. if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS explore_index ( id INTEGER PRIMARY KEY AUTOINCREMENT, entity_type TEXT NOT NULL, mbid TEXT NOT NULL, title TEXT NOT NULL, artist_name TEXT NOT NULL, artist_mbid TEXT NOT NULL, popularity INTEGER NOT NULL DEFAULT 0, extra_json TEXT ) `); err != nil { return fmt.Errorf("migration 12: create explore_index: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE UNIQUE INDEX IF NOT EXISTS idx_explore_index_mbid ON explore_index(entity_type, mbid) `); err != nil { return fmt.Errorf("migration 12: create mbid index: %w", err) } // FTS5 virtual table backed by the content table. if _, err := db.ExecContext(ctx, ` CREATE VIRTUAL TABLE IF NOT EXISTS explore_index_fts USING fts5( title, artist_name, content='explore_index', content_rowid='id' ) `); err != nil { return fmt.Errorf("migration 12: create FTS5 table: %w", err) } // Triggers to keep FTS in sync. if _, err := db.ExecContext(ctx, ` CREATE TRIGGER IF NOT EXISTS explore_index_ai AFTER INSERT ON explore_index BEGIN INSERT INTO explore_index_fts(rowid, title, artist_name) VALUES (new.id, new.title, new.artist_name); END `); err != nil { return fmt.Errorf("migration 12: create insert trigger: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE TRIGGER IF NOT EXISTS explore_index_ad AFTER DELETE ON explore_index BEGIN INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name) VALUES ('delete', old.id, old.title, old.artist_name); END `); err != nil { return fmt.Errorf("migration 12: create delete trigger: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE TRIGGER IF NOT EXISTS explore_index_au AFTER UPDATE ON explore_index BEGIN INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name) VALUES ('delete', old.id, old.title, old.artist_name); INSERT INTO explore_index_fts(rowid, title, artist_name) VALUES (new.id, new.title, new.artist_name); END `); err != nil { return fmt.Errorf("migration 12: create update trigger: %w", err) } // Metadata table for build tracking. if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS explore_index_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ) `); err != nil { return fmt.Errorf("migration 12: create meta table: %w", err) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 12", ); err != nil { return fmt.Errorf("could not set user_version to 12: %w", err) } logger.Info("migration 12 complete") return nil } // migration13MBIDColumns adds MusicBrainz ID columns to artists, // release_groups, and recordings for linking local library entities // to MusicBrainz/ListenBrainz explore data. func migration13MBIDColumns( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info("applying migration 13: MusicBrainz ID columns") alterStmts := []struct { table string column string }{ {"artists", "mbid"}, {"release_groups", "mbid"}, {"recordings", "mbid"}, } for _, s := range alterStmts { stmt := fmt.Sprintf( "ALTER TABLE %s ADD COLUMN %s TEXT", s.table, s.column, ) if _, err := db.ExecContext(ctx, stmt); err != nil { // Column may already exist from a partial migration. if !strings.Contains(err.Error(), "duplicate column") { return fmt.Errorf("migration 13: alter %s: %w", s.table, err) } } } // Partial indexes for MBID lookups (only index non-NULL rows). indexes := []string{ "CREATE INDEX IF NOT EXISTS idx_artists_mbid ON artists(mbid) WHERE mbid IS NOT NULL", "CREATE INDEX IF NOT EXISTS idx_release_groups_mbid ON release_groups(mbid) WHERE mbid IS NOT NULL", "CREATE INDEX IF NOT EXISTS idx_recordings_mbid ON recordings(mbid) WHERE mbid IS NOT NULL", } for _, stmt := range indexes { if _, err := db.ExecContext(ctx, stmt); err != nil { return fmt.Errorf("migration 13: create index: %w", err) } } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 13", ); err != nil { return fmt.Errorf("could not set user_version to 13: %w", err) } logger.Info("migration 13 complete") return nil } // migration14ExploreAliases adds an aliases column to explore_index // and rebuilds the FTS5 virtual table with three searchable columns // (title, artist_name, aliases) for alias-aware search. func migration14ExploreAliases( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info("applying migration 14: explore index aliases + FTS5 rebuild") // Add aliases column to content table. if _, err := db.ExecContext(ctx, "ALTER TABLE explore_index ADD COLUMN aliases TEXT DEFAULT ''", ); err != nil { if !strings.Contains(err.Error(), "duplicate column") { return fmt.Errorf("migration 14: alter explore_index: %w", err) } } // Drop old triggers. for _, name := range []string{ "explore_index_ai", "explore_index_ad", "explore_index_au", } { if _, err := db.ExecContext(ctx, "DROP TRIGGER IF EXISTS "+name, ); err != nil { return fmt.Errorf("migration 14: drop trigger %s: %w", name, err) } } // Drop and recreate FTS5 with 3 columns. if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS explore_index_fts", ); err != nil { return fmt.Errorf("migration 14: drop FTS5: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE VIRTUAL TABLE explore_index_fts USING fts5( title, artist_name, aliases, content='explore_index', content_rowid='id' ) `); err != nil { return fmt.Errorf("migration 14: create FTS5: %w", err) } // Recreate triggers with 3 columns. if _, err := db.ExecContext(ctx, ` CREATE TRIGGER explore_index_ai AFTER INSERT ON explore_index BEGIN INSERT INTO explore_index_fts(rowid, title, artist_name, aliases) VALUES (new.id, new.title, new.artist_name, new.aliases); END `); err != nil { return fmt.Errorf("migration 14: create insert trigger: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE TRIGGER explore_index_ad AFTER DELETE ON explore_index BEGIN INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases) VALUES ('delete', old.id, old.title, old.artist_name, old.aliases); END `); err != nil { return fmt.Errorf("migration 14: create delete trigger: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases) VALUES ('delete', old.id, old.title, old.artist_name, old.aliases); INSERT INTO explore_index_fts(rowid, title, artist_name, aliases) VALUES (new.id, new.title, new.artist_name, new.aliases); END `); err != nil { return fmt.Errorf("migration 14: create update trigger: %w", err) } // Rebuild FTS5 index from existing content table rows. if _, err := db.ExecContext(ctx, "INSERT INTO explore_index_fts(explore_index_fts) VALUES ('rebuild')", ); err != nil { return fmt.Errorf("migration 14: rebuild FTS5: %w", err) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 14", ); err != nil { return fmt.Errorf("could not set user_version to 14: %w", err) } // Clear the index build timestamp so the next build populates aliases. _, _ = db.ExecContext(ctx, "DELETE FROM explore_index_meta WHERE key IN ('tier1_built', 'discog_built')", ) logger.Info("migration 14 complete") return nil } // migration15PersonalizationColumns adds in_library and is_similar // columns to explore_index for personalized search ranking. func migration15PersonalizationColumns( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info("applying migration 15: personalization columns") for _, col := range []string{"in_library", "is_similar"} { stmt := fmt.Sprintf( "ALTER TABLE explore_index ADD COLUMN %s INTEGER NOT NULL DEFAULT 0", col, ) if _, err := db.ExecContext(ctx, stmt); err != nil { if !strings.Contains(err.Error(), "duplicate column") { return fmt.Errorf("migration 15: alter explore_index: %w", err) } } } // Backfill in_library for artists already in the library. if _, err := db.ExecContext(ctx, ` UPDATE explore_index SET in_library = 1 WHERE entity_type = 'artist' AND mbid IN (SELECT mbid FROM artists WHERE mbid IS NOT NULL AND mbid != '') `); err != nil { logger.Warn("migration 15: backfill in_library artists", "error", err) } // Backfill in_library for release groups already in the library. if _, err := db.ExecContext(ctx, ` UPDATE explore_index SET in_library = 1 WHERE entity_type = 'release_group' AND mbid IN (SELECT mbid FROM release_groups WHERE mbid IS NOT NULL AND mbid != '') `); err != nil { logger.Warn("migration 15: backfill in_library release_groups", "error", err) } // Clear discog_built so the next index build populates these flags. _, _ = db.ExecContext(ctx, "DELETE FROM explore_index_meta WHERE key = 'discog_built'", ) if _, err := db.ExecContext( ctx, "PRAGMA user_version = 15", ); err != nil { return fmt.Errorf("could not set user_version to 15: %w", err) } logger.Info("migration 15 complete") return nil } // migration16ArtistImages creates the artist_images table for // storing multiple artist photos from multiple sources, with // thumbnail generation for the primary image. func migration16ArtistImages( ctx context.Context, db *sql.DB, logger *slog.Logger, ) error { logger.Info("applying migration 16: artist_images table") if _, err := db.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS artist_images ( id INTEGER PRIMARY KEY AUTOINCREMENT, artist_mbid TEXT NOT NULL, source TEXT NOT NULL, source_url TEXT NOT NULL, file_path TEXT NOT NULL, is_primary INTEGER NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, width INTEGER, height INTEGER, file_size INTEGER, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) `); err != nil { return fmt.Errorf("migration 16: create artist_images: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE INDEX IF NOT EXISTS idx_artist_images_mbid ON artist_images(artist_mbid) `); err != nil { return fmt.Errorf("migration 16: create mbid index: %w", err) } if _, err := db.ExecContext(ctx, ` CREATE UNIQUE INDEX IF NOT EXISTS idx_artist_images_source ON artist_images(artist_mbid, source, source_url) `); err != nil { return fmt.Errorf("migration 16: create source index: %w", err) } if _, err := db.ExecContext( ctx, "PRAGMA user_version = 16", ); err != nil { return fmt.Errorf("could not set user_version to 16: %w", err) } logger.Info("migration 16 complete") return nil } // readLibraryDirFromTOML reads the TOML config file and returns // the Library.DirectoryPath value, or "" if not configured. func readLibraryDirFromTOML(logger *slog.Logger) string { configDir, err := system.GetUserConfigDirPath() if err != nil { logger.Debug( "could not get config dir for TOML read", "err", err, ) return "" } configPath := path.Join(configDir, "config.toml") data, err := os.ReadFile(configPath) if err != nil { logger.Debug( "could not read config.toml", "path", configPath, "err", err, ) return "" } // Minimal struct to extract only the Library.DirectoryPath field. var cfg struct { Library struct { DirectoryPath string `toml:"DirectoryPath"` } `toml:"Library"` } if _, err := toml.Decode(string(data), &cfg); err != nil { logger.Debug( "could not parse config.toml", "path", configPath, "err", err, ) return "" } return cfg.Library.DirectoryPath } // removeLibraryDirFromTOML reads the TOML config, removes the // Library.DirectoryPath field, and writes the config back. This // ensures the libraries table is the sole source of truth after // migration. func removeLibraryDirFromTOML(logger *slog.Logger) { configDir, err := system.GetUserConfigDirPath() if err != nil { logger.Warn( "could not get config dir for TOML cleanup", "err", err, ) return } configPath := path.Join(configDir, "config.toml") data, err := os.ReadFile(configPath) if err != nil { logger.Warn( "could not read config.toml for cleanup", "path", configPath, "err", err, ) return } // Parse the full config as a generic map to preserve all fields. var cfg map[string]any if _, err := toml.Decode(string(data), &cfg); err != nil { logger.Warn( "could not parse config.toml for cleanup", "err", err, ) return } // Remove DirectoryPath from [Library] section. if lib, ok := cfg["Library"].(map[string]any); ok { delete(lib, "DirectoryPath") // If Library section is now empty, remove it entirely. if len(lib) == 0 { delete(cfg, "Library") } } // Write updated config back. out, err := toml.Marshal(cfg) if err != nil { logger.Warn( "could not marshal updated config.toml", "err", err, ) return } if err := os.WriteFile(configPath, out, 0o644); err != nil { logger.Warn( "could not write updated config.toml", "path", configPath, "err", err, ) return } logger.Info( "removed Library.DirectoryPath from config.toml", "path", configPath, ) }