feat(10-01): implement migration 6 and pre-migration backup
- Add backupDatabase() for timestamped .db file backup before migration - Add migration6MultiLibrary() with all 14 steps: FK OFF, create libraries table, insert default library from TOML config, add library_id to audio_files, rebuild playlist_tracks with SET NULL FK and 6 phantom columns, backfill phantom metadata, recreate track_metadata VIEW with library_id, FK ON, clean TOML config - Add readLibraryDirFromTOML() and removeLibraryDirFromTOML() helpers - Update runMigrations signature to accept dbPath for backup - Add sentinel library row in NewTestDB for FK constraint satisfaction
This commit is contained in:
@@ -6,11 +6,16 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
_ "modernc.org/sqlite" // Register sqlite driver.
|
_ "modernc.org/sqlite" // Register sqlite driver.
|
||||||
|
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
@@ -93,7 +98,7 @@ func NewDB(logger *slog.Logger) (*DB, error) {
|
|||||||
|
|
||||||
// Run versioned schema migrations for columns that cannot be
|
// Run versioned schema migrations for columns that cannot be
|
||||||
// added with CREATE TABLE IF NOT EXISTS on existing databases.
|
// added with CREATE TABLE IF NOT EXISTS on existing databases.
|
||||||
if err := runMigrations(dbCtx, db, logger); err != nil {
|
if err := runMigrations(dbCtx, db, logger, sqliteDBFilePath); err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"could not run schema migrations: %w", err,
|
"could not run schema migrations: %w", err,
|
||||||
)
|
)
|
||||||
@@ -171,6 +176,7 @@ func runMigrations(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
db *sql.DB,
|
db *sql.DB,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
|
dbPath string,
|
||||||
) error {
|
) error {
|
||||||
var version int
|
var version int
|
||||||
|
|
||||||
@@ -297,6 +303,15 @@ func runMigrations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 6: multi-library support.
|
||||||
|
if version < 6 {
|
||||||
|
if err := migration6MultiLibrary(
|
||||||
|
ctx, db, logger, dbPath,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -657,3 +672,483 @@ func isDuplicateColumnErr(err error) bool {
|
|||||||
err.Error(), "duplicate column name",
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -57,10 +57,19 @@ func NewTestDB(t *testing.T) *DB {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := runMigrations(ctx, db, slog.Default()); err != nil {
|
if err := runMigrations(ctx, db, slog.Default(), ":memory:"); err != nil {
|
||||||
t.Fatalf("could not run migrations: %v", err)
|
t.Fatalf("could not run migrations: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert a sentinel library row at id=0 so audio_files inserts
|
||||||
|
// using the DEFAULT library_id=0 satisfy the FK constraint.
|
||||||
|
if _, err := db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
"INSERT INTO libraries (id, name, path) VALUES (0, 'Test', '/test')",
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("could not insert test library: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
queries := sqlcgen.New(db)
|
queries := sqlcgen.New(db)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = db.Close() })
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|||||||
Reference in New Issue
Block a user