diff --git a/db.go b/db.go index bc6edee..f43454e 100644 --- a/db.go +++ b/db.go @@ -31,6 +31,12 @@ func InitDB(dbPath string) error { return fmt.Errorf("failed to open database: %v", err) } + // Enable foreign key support in SQLite + _, err = DB.Exec("PRAGMA foreign_keys = ON") + if err != nil { + return fmt.Errorf("failed to enable foreign keys: %v", err) + } + if err = DB.Ping(); err != nil { return fmt.Errorf("failed to ping database: %v", err) } diff --git a/internal/repository/library.go b/internal/repository/library.go index 7693278..a82d719 100644 --- a/internal/repository/library.go +++ b/internal/repository/library.go @@ -78,8 +78,41 @@ func (r *LibraryRepository) UpdatePath(id int, path string) error { } func (r *LibraryRepository) Delete(id int) error { - _, err := r.db.Exec("DELETE FROM libraries WHERE id = ?", id) - return err + tx, err := r.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + // Delete the library — CASCADE removes media_files and songs automatically + _, err = tx.Exec("DELETE FROM libraries WHERE id = ?", id) + if err != nil { + return err + } + + // Clean up orphan albums (no songs reference them) + _, err = tx.Exec(` + DELETE FROM albums WHERE id NOT IN ( + SELECT DISTINCT album_id FROM songs WHERE album_id IS NOT NULL + ) + `) + if err != nil { + return err + } + + // Clean up orphan artists (no songs and no albums reference them) + _, err = tx.Exec(` + DELETE FROM artists WHERE id NOT IN ( + SELECT DISTINCT artist_id FROM songs WHERE artist_id IS NOT NULL + UNION + SELECT DISTINCT artist_id FROM albums WHERE artist_id IS NOT NULL + ) + `) + if err != nil { + return err + } + + return tx.Commit() } func (r *LibraryRepository) GetSongsByLibraryWithDetails(libraryID int) ([]SongDetail, error) { diff --git a/internal/repository/media_repo.go b/internal/repository/media_repo.go index 135dfb2..ac3e85b 100644 --- a/internal/repository/media_repo.go +++ b/internal/repository/media_repo.go @@ -12,15 +12,11 @@ func NewMediaRepository(db *sql.DB) *MediaRepository { func (r *MediaRepository) Get(id int) (MediaFile, error) { var m MediaFile - rows, err := r.db.Query("SELECT id, path, library_id FROM media_files WHERE id = ?", id) + err := r.db.QueryRow("SELECT id, path, library_id FROM media_files WHERE id = ?", id).Scan( + &m.ID, &m.Path, &m.LibraryID, + ) if err != nil { return MediaFile{}, err } - defer rows.Close() - if rows.Next() { - if err := rows.Scan(&m.ID, &m.Path, &m.LibraryID); err != nil { - return MediaFile{}, err - } - } return m, nil } diff --git a/internal/repository/song_repo.go b/internal/repository/song_repo.go index 5636a9b..769a3ff 100644 --- a/internal/repository/song_repo.go +++ b/internal/repository/song_repo.go @@ -55,13 +55,11 @@ func (r *SongRepository) GetAllWithDetails() ([]SongDetail, error) { func (r *SongRepository) Get(id int) (Song, error) { var song Song - rows, err := r.db.Query("SELECT id, title, artist_id, album_id, duration, media_file_id FROM songs WHERE id = ?", id) + err := r.db.QueryRow("SELECT id, title, artist_id, album_id, duration, media_file_id FROM songs WHERE id = ?", id).Scan( + &song.ID, &song.Title, &song.ArtistID, &song.AlbumID, &song.Duration, &song.MediaFileID, + ) if err != nil { return Song{}, err } - defer rows.Close() - if rows.Next() { - rows.Scan(&song.ID, &song.Title, &song.ArtistID, &song.AlbumID, &song.Duration, &song.MediaFileID) - } return song, nil } diff --git a/migrations/002_cascade_fk.go b/migrations/002_cascade_fk.go new file mode 100644 index 0000000..cf969d1 --- /dev/null +++ b/migrations/002_cascade_fk.go @@ -0,0 +1,142 @@ +package migrations + +import ( + "database/sql" +) + +func init() { + RegisterMigration( + 2, + "Add ON DELETE CASCADE to foreign keys", + migrateCascadeFKUp, + migrateCascadeFKDown, + ) +} + +func migrateCascadeFKUp(tx *sql.Tx) error { + // Enable foreign keys for this transaction + _, err := tx.Exec("PRAGMA foreign_keys = OFF") + if err != nil { + return err + } + + // 1. Rebuild media_files with FK + CASCADE + _, err = tx.Exec(` + CREATE TABLE media_files_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + library_id INTEGER NOT NULL, + FOREIGN KEY (library_id) REFERENCES libraries(id) ON DELETE CASCADE + ) + `) + if err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO media_files_new SELECT id, path, library_id FROM media_files`) + if err != nil { + return err + } + _, err = tx.Exec("DROP TABLE media_files") + if err != nil { + return err + } + _, err = tx.Exec("ALTER TABLE media_files_new RENAME TO media_files") + if err != nil { + return err + } + + // 2. Rebuild songs with FK + CASCADE on media_file_id + _, err = tx.Exec(` + CREATE TABLE songs_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + album_id INTEGER, + artist_id INTEGER, + duration INTEGER, + media_file_id INTEGER UNIQUE, + FOREIGN KEY (album_id) REFERENCES albums(id), + FOREIGN KEY (artist_id) REFERENCES artists(id), + FOREIGN KEY (media_file_id) REFERENCES media_files(id) ON DELETE CASCADE + ) + `) + if err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO songs_new SELECT id, title, album_id, artist_id, duration, media_file_id FROM songs`) + if err != nil { + return err + } + _, err = tx.Exec("DROP TABLE songs") + if err != nil { + return err + } + _, err = tx.Exec("ALTER TABLE songs_new RENAME TO songs") + if err != nil { + return err + } + + return nil +} + +func migrateCascadeFKDown(tx *sql.Tx) error { + _, err := tx.Exec("PRAGMA foreign_keys = OFF") + if err != nil { + return err + } + + // Revert songs + _, err = tx.Exec(` + CREATE TABLE songs_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + album_id INTEGER, + artist_id INTEGER, + duration INTEGER, + media_file_id INTEGER UNIQUE, + FOREIGN KEY (album_id) REFERENCES albums(id), + FOREIGN KEY (artist_id) REFERENCES artists(id), + FOREIGN KEY (media_file_id) REFERENCES media_files(id) + ) + `) + if err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO songs_old SELECT id, title, album_id, artist_id, duration, media_file_id FROM songs`) + if err != nil { + return err + } + _, err = tx.Exec("DROP TABLE songs") + if err != nil { + return err + } + _, err = tx.Exec("ALTER TABLE songs_old RENAME TO songs") + if err != nil { + return err + } + + // Revert media_files + _, err = tx.Exec(` + CREATE TABLE media_files_old ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT NOT NULL, + library_id INTEGER NOT NULL + ) + `) + if err != nil { + return err + } + _, err = tx.Exec(`INSERT INTO media_files_old SELECT id, path, library_id FROM media_files`) + if err != nil { + return err + } + _, err = tx.Exec("DROP TABLE media_files") + if err != nil { + return err + } + _, err = tx.Exec("ALTER TABLE media_files_old RENAME TO media_files") + if err != nil { + return err + } + + return nil +}