From b0418b4f3888eef6e606d5ca6b3582ba2be188c1 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 1 Jun 2026 21:58:21 +0200 Subject: [PATCH] feat: Add id column to song table and prep for UUID migration - Add id serial4 PK to song table (was composite PK) - Update queries to use soundtrack_id + path - Add UUID columns to soundtrack and song (nullable) - Add migration tracking table TODO: Run sqlc generate, then create backfill migration (000008) Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- internal/backend/music.go | 6 +- internal/backend/sync.go | 24 ++++--- .../migrations/000006_add_id_to_song.down.sql | 24 +++++++ .../migrations/000006_add_id_to_song.up.sql | 36 ++++++++++ internal/db/queries/song.sql | 11 +-- internal/db/repository/models.go | 43 ++++++----- internal/db/repository/song.sql.go | 71 ++++++++++++++----- internal/db/repository/soundtrack.sql.go | 9 ++- 8 files changed, 174 insertions(+), 50 deletions(-) create mode 100644 internal/db/migrations/000006_add_id_to_song.down.sql create mode 100644 internal/db/migrations/000006_add_id_to_song.up.sql diff --git a/internal/backend/music.go b/internal/backend/music.go index 359ec52..dff0a6a 100644 --- a/internal/backend/music.go +++ b/internal/backend/music.go @@ -142,7 +142,7 @@ func GetRandomSongClassic() string { gameData, err := BackendRepo().GetSoundtrackById(BackendCtx(), song.SoundtrackID) if err != nil { - BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), zap.String("game", gameData.SoundtrackName), @@ -154,7 +154,7 @@ func GetRandomSongClassic() string { openFile, err := os.Open(song.Path) if err != nil || (song.FileName != nil && gameData.Path+*song.FileName != song.Path) { //File not found - BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), zap.String("game", gameData.SoundtrackName), @@ -282,7 +282,7 @@ func getSongFromList(games []repository.Soundtrack) repository.Song { openFile, err := os.Open(song.Path) if err != nil || (song.FileName != nil && game.Path+*song.FileName != song.Path) || (song.FileName != nil && strings.HasSuffix(*song.FileName, ".wav")) { //File not found - BackendRepo().RemoveBrokenSong(BackendCtx(), song.Path) + BackendRepo().RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Song not found, removed from database", zap.String("song", song.SongName), zap.String("game", game.SoundtrackName), diff --git a/internal/backend/sync.go b/internal/backend/sync.go index 7aae292..b4522d8 100644 --- a/internal/backend/sync.go +++ b/internal/backend/sync.go @@ -39,7 +39,13 @@ var gamesChangedTitle map[string]string var gamesChangedContent []string var gamesRemoved []string var catchedErrors []string -var brokenSongs []string + +type brokenSong struct { + SoundtrackID int32 + Path string +} + +var brokenSongs []brokenSong var pool *ants.Pool var poolSong *ants.Pool @@ -262,8 +268,10 @@ func checkBrokenSongsNew() { }) } brokenWg.Wait() - err = repo.RemoveBrokenSongs(BackendCtx(), brokenSongs) - handleError("RemoveBrokenSongs", err, "") + for _, bs := range brokenSongs { + err = repo.RemoveBrokenSong(BackendCtx(), repository.RemoveBrokenSongParams{SoundtrackID: bs.SoundtrackID, Path: bs.Path}) + handleError("RemoveBrokenSong", err, "") + } } func checkBrokenSongNew(song repository.Song) { @@ -271,7 +279,7 @@ func checkBrokenSongNew(song repository.Song) { openFile, err := os.Open(song.Path) if err != nil { //File not found - brokenSongs = append(brokenSongs, song.Path) + brokenSongs = append(brokenSongs, brokenSong{SoundtrackID: song.SoundtrackID, Path: song.Path}) logging.GetLogger().Warn("Broken song found", zap.String("path", song.Path)) } else { err = openFile.Close() @@ -493,10 +501,10 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool { count, err := repo.CheckSongWithHash(BackendCtx(), songHash) handleError("CheckSongWithHash", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) if err != nil { - count2, err := repo.CheckSong(BackendCtx(), path) + count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path}) handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s\n", id, path, entry.Name(), songHash)) if count2 > 0 { - err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path}) + err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path}) handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) count, err = repo.CheckSongWithHash(BackendCtx(), songHash) handleError("CheckSongWithHash 2", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) @@ -508,10 +516,10 @@ func newCheckSong(entry os.DirEntry, gameDir string, id int32) bool { err = repo.UpdateSong(BackendCtx(), repository.UpdateSongParams{SongName: songName, FileName: &fileName, Path: path, Hash: songHash}) handleError("UpdateSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) } else { - count2, err := repo.CheckSong(BackendCtx(), path) + count2, err := repo.CheckSong(BackendCtx(), repository.CheckSongParams{SoundtrackID: id, Path: path}) handleError("CheckSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) if count2 > 0 { - err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, Path: path}) + err = repo.AddHashToSong(BackendCtx(), repository.AddHashToSongParams{Hash: songHash, SoundtrackID: id, Path: path}) handleError("AddHashToSong", err, fmt.Sprintf("SoundtrackID: %d | Path: %s | SongName: %s | SongHash: %s", id, path, entry.Name(), songHash)) } else { err = repo.AddSong(BackendCtx(), repository.AddSongParams{SoundtrackID: id, SongName: songName, Path: path, FileName: &fileName, Hash: songHash}) diff --git a/internal/db/migrations/000006_add_id_to_song.down.sql b/internal/db/migrations/000006_add_id_to_song.down.sql new file mode 100644 index 0000000..58628bf --- /dev/null +++ b/internal/db/migrations/000006_add_id_to_song.down.sql @@ -0,0 +1,24 @@ +-- Rollback: Remove id column and restore composite PK + +-- Step 1: Drop indexes created in up migration +DROP INDEX IF EXISTS idx_song_soundtrack_id; +DROP INDEX IF EXISTS idx_song_path; + +-- Step 2: Drop foreign key constraint +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; + +-- Step 3: Drop new primary key +ALTER TABLE song DROP CONSTRAINT song_pkey; + +-- Step 4: Drop unique constraint on id +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_id_unique; + +-- Step 5: Restore composite primary key +ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (soundtrack_id, path); + +-- Step 6: Drop the id column +ALTER TABLE song DROP COLUMN id; + +-- Step 7: Recreate original foreign key (soundtrack_id references soundtrack.id) +ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey + FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); diff --git a/internal/db/migrations/000006_add_id_to_song.up.sql b/internal/db/migrations/000006_add_id_to_song.up.sql new file mode 100644 index 0000000..ed1494d --- /dev/null +++ b/internal/db/migrations/000006_add_id_to_song.up.sql @@ -0,0 +1,36 @@ +-- Migration: Add id column to song table and change PK from composite to single column +-- This prepares the song table for eventual UUID migration + +-- Step 1: Add new id column (nullable initially) +ALTER TABLE song ADD COLUMN id serial4; + +-- Step 2: Create unique constraint on id (allows backfilling) +ALTER TABLE song ADD CONSTRAINT song_id_unique UNIQUE (id); + +-- Step 3: Backfill existing rows with sequential IDs +-- Use DEFAULT which pulls from the sequence +UPDATE song SET id = DEFAULT WHERE id IS NULL; + +-- Step 4: Verify all rows have an id +-- If this returns 0, backfill worked +-- SELECT COUNT(*) FROM song WHERE id IS NULL; + +-- Step 5: Drop the composite primary key (soundtrack_id, path) +ALTER TABLE song DROP CONSTRAINT song_pkey; + +-- Step 6: Add new primary key on id column +ALTER TABLE song ADD CONSTRAINT song_pkey PRIMARY KEY (id); + +-- Step 7: Ensure soundtrack_id remains a foreign key to soundtrack +-- First drop existing FK if it exists (from the rename migration) +ALTER TABLE song DROP CONSTRAINT IF EXISTS song_soundtrack_id_fkey; + +-- Then recreate it +ALTER TABLE song ADD CONSTRAINT song_soundtrack_id_fkey + FOREIGN KEY (soundtrack_id) REFERENCES soundtrack(id); + +-- Step 8: Create index on soundtrack_id for query performance +CREATE INDEX IF NOT EXISTS idx_song_soundtrack_id ON song(soundtrack_id); + +-- Step 9: Create index on path for lookups (previously part of PK) +CREATE INDEX IF NOT EXISTS idx_song_path ON song(path); diff --git a/internal/db/queries/song.sql b/internal/db/queries/song.sql index 98928ab..95064ca 100644 --- a/internal/db/queries/song.sql +++ b/internal/db/queries/song.sql @@ -8,7 +8,7 @@ DELETE FROM song WHERE soundtrack_id = $1; INSERT INTO song(soundtrack_id, song_name, path, file_name, hash) VALUES ($1, $2, $3, $4, $5); -- name: CheckSong :one -SELECT COUNT(*) FROM song WHERE path = $1; +SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2; -- name: CheckSongWithHash :one SELECT COUNT(*) FROM song WHERE hash = $1; @@ -20,7 +20,7 @@ SELECT * FROM song WHERE hash = $1; UPDATE song SET song_name=$1, file_name=$2, path=$3 where hash=$4; -- name: AddHashToSong :exec -UPDATE song SET hash=$1 where path=$2; +UPDATE song SET hash=$1 where soundtrack_id = $2 AND path = $3; -- name: FindSongsFromSoundtrack :many SELECT * @@ -34,8 +34,11 @@ WHERE soundtrack_id = $1 AND song_name = $2; -- name: FetchAllSongs :many SELECT * FROM song; +-- name: GetSongById :one +SELECT * FROM song WHERE id = $1; + -- name: RemoveBrokenSong :exec -DELETE FROM song WHERE path = $1; +DELETE FROM song WHERE soundtrack_id = $1 AND path = $2; -- name: RemoveBrokenSongs :exec -DELETE FROM song where path = any (sqlc.slice('paths')); +DELETE FROM song WHERE id = ANY($1); diff --git a/internal/db/repository/models.go b/internal/db/repository/models.go index 9e2cff2..82728e5 100644 --- a/internal/db/repository/models.go +++ b/internal/db/repository/models.go @@ -10,6 +10,14 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type IDMigrationStatus struct { + TableName string `json:"table_name"` + TotalRows int32 `json:"total_rows"` + MigratedRows int32 `json:"migrated_rows"` + Completed bool `json:"completed"` + StartedAt *time.Time `json:"started_at"` +} + type Session struct { Token string `json:"token"` IpAddress string `json:"ip_address"` @@ -20,12 +28,14 @@ type Session struct { } type Song struct { - SoundtrackID int32 `json:"soundtrack_id"` - SongName string `json:"song_name"` - Path string `json:"path"` - TimesPlayed int32 `json:"times_played"` - Hash string `json:"hash"` - FileName *string `json:"file_name"` + SoundtrackID int32 `json:"soundtrack_id"` + SongName string `json:"song_name"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + Hash string `json:"hash"` + FileName *string `json:"file_name"` + ID pgtype.Int4 `json:"id"` + Uuid pgtype.UUID `json:"uuid"` } type SongList struct { @@ -37,16 +47,17 @@ type SongList struct { } type Soundtrack struct { - ID int32 `json:"id"` - SoundtrackName string `json:"soundtrack_name"` - Added time.Time `json:"added"` - Deleted *time.Time `json:"deleted"` - LastChanged *time.Time `json:"last_changed"` - Path string `json:"path"` - TimesPlayed int32 `json:"times_played"` - LastPlayed *time.Time `json:"last_played"` - NumberOfSongs int32 `json:"number_of_songs"` - Hash string `json:"hash"` + ID int32 `json:"id"` + SoundtrackName string `json:"soundtrack_name"` + Added time.Time `json:"added"` + Deleted *time.Time `json:"deleted"` + LastChanged *time.Time `json:"last_changed"` + Path string `json:"path"` + TimesPlayed int32 `json:"times_played"` + LastPlayed *time.Time `json:"last_played"` + NumberOfSongs int32 `json:"number_of_songs"` + Hash string `json:"hash"` + Uuid pgtype.UUID `json:"uuid"` } type Vgmq struct { diff --git a/internal/db/repository/song.sql.go b/internal/db/repository/song.sql.go index 12025fd..5b79b4f 100644 --- a/internal/db/repository/song.sql.go +++ b/internal/db/repository/song.sql.go @@ -7,19 +7,22 @@ package repository import ( "context" + + "github.com/jackc/pgx/v5/pgtype" ) const addHashToSong = `-- name: AddHashToSong :exec -UPDATE song SET hash=$1 where path=$2 +UPDATE song SET hash=$1 where soundtrack_id = $2 AND path = $3 ` type AddHashToSongParams struct { - Hash string `json:"hash"` - Path string `json:"path"` + Hash string `json:"hash"` + SoundtrackID int32 `json:"soundtrack_id"` + Path string `json:"path"` } func (q *Queries) AddHashToSong(ctx context.Context, arg AddHashToSongParams) error { - _, err := q.db.Exec(ctx, addHashToSong, arg.Hash, arg.Path) + _, err := q.db.Exec(ctx, addHashToSong, arg.Hash, arg.SoundtrackID, arg.Path) return err } @@ -62,11 +65,16 @@ func (q *Queries) AddSongPlayed(ctx context.Context, arg AddSongPlayedParams) er } const checkSong = `-- name: CheckSong :one -SELECT COUNT(*) FROM song WHERE path = $1 +SELECT COUNT(*) FROM song WHERE soundtrack_id = $1 AND path = $2 ` -func (q *Queries) CheckSong(ctx context.Context, path string) (int64, error) { - row := q.db.QueryRow(ctx, checkSong, path) +type CheckSongParams struct { + SoundtrackID int32 `json:"soundtrack_id"` + Path string `json:"path"` +} + +func (q *Queries) CheckSong(ctx context.Context, arg CheckSongParams) (int64, error) { + row := q.db.QueryRow(ctx, checkSong, arg.SoundtrackID, arg.Path) var count int64 err := row.Scan(&count) return count, err @@ -102,7 +110,7 @@ func (q *Queries) ClearSongsBySoundtrackId(ctx context.Context, soundtrackID int } const fetchAllSongs = `-- name: FetchAllSongs :many -SELECT soundtrack_id, song_name, path, times_played, hash, file_name FROM song +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song ` func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { @@ -121,6 +129,8 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { &i.TimesPlayed, &i.Hash, &i.FileName, + &i.ID, + &i.Uuid, ); err != nil { return nil, err } @@ -133,7 +143,7 @@ func (q *Queries) FetchAllSongs(ctx context.Context) ([]Song, error) { } const findSongsFromSoundtrack = `-- name: FindSongsFromSoundtrack :many -SELECT soundtrack_id, song_name, path, times_played, hash, file_name +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE soundtrack_id = $1 ` @@ -154,6 +164,8 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3 &i.TimesPlayed, &i.Hash, &i.FileName, + &i.ID, + &i.Uuid, ); err != nil { return nil, err } @@ -165,8 +177,28 @@ func (q *Queries) FindSongsFromSoundtrack(ctx context.Context, soundtrackID int3 return items, nil } +const getSongById = `-- name: GetSongById :one +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE id = $1 +` + +func (q *Queries) GetSongById(ctx context.Context, id pgtype.Int4) (Song, error) { + row := q.db.QueryRow(ctx, getSongById, id) + var i Song + err := row.Scan( + &i.SoundtrackID, + &i.SongName, + &i.Path, + &i.TimesPlayed, + &i.Hash, + &i.FileName, + &i.ID, + &i.Uuid, + ) + return i, err +} + const getSongWithHash = `-- name: GetSongWithHash :one -SELECT soundtrack_id, song_name, path, times_played, hash, file_name FROM song WHERE hash = $1 +SELECT soundtrack_id, song_name, path, times_played, hash, file_name, id, uuid FROM song WHERE hash = $1 ` func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error) { @@ -179,25 +211,32 @@ func (q *Queries) GetSongWithHash(ctx context.Context, hash string) (Song, error &i.TimesPlayed, &i.Hash, &i.FileName, + &i.ID, + &i.Uuid, ) return i, err } const removeBrokenSong = `-- name: RemoveBrokenSong :exec -DELETE FROM song WHERE path = $1 +DELETE FROM song WHERE soundtrack_id = $1 AND path = $2 ` -func (q *Queries) RemoveBrokenSong(ctx context.Context, path string) error { - _, err := q.db.Exec(ctx, removeBrokenSong, path) +type RemoveBrokenSongParams struct { + SoundtrackID int32 `json:"soundtrack_id"` + Path string `json:"path"` +} + +func (q *Queries) RemoveBrokenSong(ctx context.Context, arg RemoveBrokenSongParams) error { + _, err := q.db.Exec(ctx, removeBrokenSong, arg.SoundtrackID, arg.Path) return err } const removeBrokenSongs = `-- name: RemoveBrokenSongs :exec -DELETE FROM song where path = any ($1) +DELETE FROM song WHERE id = ANY($1) ` -func (q *Queries) RemoveBrokenSongs(ctx context.Context, paths []string) error { - _, err := q.db.Exec(ctx, removeBrokenSongs, paths) +func (q *Queries) RemoveBrokenSongs(ctx context.Context, id pgtype.Int4) error { + _, err := q.db.Exec(ctx, removeBrokenSongs, id) return err } diff --git a/internal/db/repository/soundtrack.sql.go b/internal/db/repository/soundtrack.sql.go index bc38704..b80cfd3 100644 --- a/internal/db/repository/soundtrack.sql.go +++ b/internal/db/repository/soundtrack.sql.go @@ -28,7 +28,7 @@ func (q *Queries) ClearSoundtracks(ctx context.Context) error { } const findAllSoundtracks = `-- name: FindAllSoundtracks :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid FROM soundtrack WHERE deleted IS NULL ORDER BY soundtrack_name @@ -54,6 +54,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) &i.LastPlayed, &i.NumberOfSongs, &i.Hash, + &i.Uuid, ); err != nil { return nil, err } @@ -66,7 +67,7 @@ func (q *Queries) FindAllSoundtracks(ctx context.Context) ([]Soundtrack, error) } const getAllSoundtracksIncludingDeleted = `-- name: GetAllSoundtracksIncludingDeleted :many -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid FROM soundtrack ORDER BY soundtrack_name ` @@ -91,6 +92,7 @@ func (q *Queries) GetAllSoundtracksIncludingDeleted(ctx context.Context) ([]Soun &i.LastPlayed, &i.NumberOfSongs, &i.Hash, + &i.Uuid, ); err != nil { return nil, err } @@ -114,7 +116,7 @@ func (q *Queries) GetIdBySoundtrackName(ctx context.Context, soundtrackName stri } const getSoundtrackById = `-- name: GetSoundtrackById :one -SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash +SELECT id, soundtrack_name, added, deleted, last_changed, path, times_played, last_played, number_of_songs, hash, uuid FROM soundtrack WHERE id = $1 AND deleted IS NULL @@ -134,6 +136,7 @@ func (q *Queries) GetSoundtrackById(ctx context.Context, id int32) (Soundtrack, &i.LastPlayed, &i.NumberOfSongs, &i.Hash, + &i.Uuid, ) return i, err }