diff --git a/backend/tagwriter/dbsync.go b/backend/tagwriter/dbsync.go new file mode 100644 index 0000000..fc74c11 --- /dev/null +++ b/backend/tagwriter/dbsync.go @@ -0,0 +1,393 @@ +package tagwriter + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + + "yellowjacket/backend/database" + "yellowjacket/backend/database/sql/sqlcgen" + "yellowjacket/backend/metadata" +) + +// dbSyncParams holds the context needed by syncDatabase to update +// all database entities after a successful file tag write. +type dbSyncParams struct { + audioFileID int64 + recordingID int64 + filePath string + changes TagChanges + oldRecording sqlcgen.Recording + oldRGLinks []sqlcgen.ReleaseGroupRecording +} + +// syncDatabase runs all database updates for a tag write inside a +// single transaction: entity upsert-and-relink, FTS5 update, and +// orphan cleanup. If anything fails the entire transaction is +// rolled back, leaving the database at its previous state. +func syncDatabase( + ctx context.Context, + logger *slog.Logger, + db *database.DB, + params dbSyncParams, +) error { + tx, err := db.BeginTx() + if err != nil { + return fmt.Errorf("begin sync tx: %w", err) + } + + defer func() { _ = tx.Rollback() }() // no-op after commit + + txq := db.Queries.WithTx(tx) + + // ------------------------------------------------------------------ + // Track old entity IDs for orphan cleanup after relinking. + // ------------------------------------------------------------------ + oldArtistCreditID := params.oldRecording.ArtistCreditID + + oldRGIDs := make([]int64, 0, len(params.oldRGLinks)) + for _, link := range params.oldRGLinks { + oldRGIDs = append(oldRGIDs, link.ReleaseGroupID) + } + + // newArtistCreditID starts as old; overwritten if artist changes. + newArtistCreditID := oldArtistCreditID + + // ------------------------------------------------------------------ + // 1. Handle artist change. + // ------------------------------------------------------------------ + if v, ok := params.changes[FieldArtist].(string); ok { + newAC, acErr := txq.UpsertArtistCredit(ctx, v) + if acErr != nil { + return fmt.Errorf("upsert artist credit: %w", acErr) + } + + newArtistCreditID = newAC.ID + + newArtist, artErr := txq.UpsertArtist(ctx, v) + if artErr != nil { + return fmt.Errorf("upsert artist: %w", artErr) + } + + // Link artist → credit (INSERT OR IGNORE handles dupes). + if _, linkErr := txq.CreateArtistCreditArtist(ctx, + sqlcgen.CreateArtistCreditArtistParams{ + ArtistID: newArtist.ID, + CreditID: newAC.ID, + }, + ); linkErr != nil && !database.IsUniqueViolation(linkErr) { + return fmt.Errorf("link artist to credit: %w", linkErr) + } + } + + // ------------------------------------------------------------------ + // 2. Handle album change. + // ------------------------------------------------------------------ + if newAlbumName, ok := params.changes[FieldAlbum].(string); ok { + // Determine album-artist credit ID. + albumArtistCreditID := sql.NullInt64{ + Int64: newArtistCreditID, Valid: true, + } + + if aav, aaOK := params.changes[FieldAlbumArtist].(string); aaOK && aav != "" { + aaCredit, aaErr := txq.UpsertArtistCredit(ctx, aav) + if aaErr != nil { + return fmt.Errorf("upsert album artist credit: %w", aaErr) + } + + albumArtistCreditID = sql.NullInt64{ + Int64: aaCredit.ID, Valid: true, + } + + aaArtist, aaArtErr := txq.UpsertArtist(ctx, aav) + if aaArtErr != nil { + return fmt.Errorf("upsert album artist: %w", aaArtErr) + } + + if _, aaLinkErr := txq.CreateArtistCreditArtist(ctx, + sqlcgen.CreateArtistCreditArtistParams{ + ArtistID: aaArtist.ID, + CreditID: aaCredit.ID, + }, + ); aaLinkErr != nil && !database.IsUniqueViolation(aaLinkErr) { + return fmt.Errorf("link album artist to credit: %w", aaLinkErr) + } + } + + // Determine year value. + yearVal := params.oldRecording.Year + if yv, yOK := params.changes[FieldYear].(int); yOK { + yearVal = toNullInt64(yv) + } + + // Upsert new release group. + newRG, rgErr := txq.UpsertReleaseGroup(ctx, + sqlcgen.UpsertReleaseGroupParams{ + Name: newAlbumName, + AlbumArtistCreditID: albumArtistCreditID, + Year: yearVal, + }, + ) + if rgErr != nil { + return fmt.Errorf("upsert release group: %w", rgErr) + } + + // Unlink old release_group_recordings. + for _, oldLink := range params.oldRGLinks { + if unlinkErr := txq.DeleteReleaseGroupRecordingByFK(ctx, + sqlcgen.DeleteReleaseGroupRecordingByFKParams{ + ReleaseGroupID: oldLink.ReleaseGroupID, + RecordingID: params.recordingID, + }, + ); unlinkErr != nil { + return fmt.Errorf("unlink old rg recording: %w", unlinkErr) + } + } + + // Determine track/disc numbers. + trackNum := params.oldRecording.TrackNumber + if tn, tnOK := params.changes[FieldTrackNumber].(int); tnOK { + trackNum = toNullInt64(tn) + } + + discNum := params.oldRecording.DiscNumber + if dn, dnOK := params.changes[FieldDiscNumber].(int); dnOK { + discNum = toNullInt64(dn) + } + + // Create new link. + if _, linkErr := txq.CreateReleaseGroupRecording(ctx, + sqlcgen.CreateReleaseGroupRecordingParams{ + ReleaseGroupID: newRG.ID, + RecordingID: params.recordingID, + TrackNumber: trackNum, + DiscNumber: discNum, + }, + ); linkErr != nil { + return fmt.Errorf("create rg recording link: %w", linkErr) + } + } + + // ------------------------------------------------------------------ + // 3. Handle genre change. + // ------------------------------------------------------------------ + if newGenre, ok := params.changes[FieldGenre].(string); ok { + // Delete all existing recording_genres for this recording. + if delErr := txq.DeleteRecordingGenres(ctx, params.recordingID); delErr != nil { + return fmt.Errorf("delete recording genres: %w", delErr) + } + + // Parse and link new genres. + genres := metadata.ParseGenres(newGenre) + for _, gName := range genres { + g, gErr := txq.UpsertGenre(ctx, gName) + if gErr != nil { + return fmt.Errorf("upsert genre %q: %w", gName, gErr) + } + + if rgErr := txq.CreateRecordingGenre(ctx, + sqlcgen.CreateRecordingGenreParams{ + RecordingID: params.recordingID, + GenreID: g.ID, + }, + ); rgErr != nil { + return fmt.Errorf("create recording genre: %w", rgErr) + } + } + } + + // ------------------------------------------------------------------ + // 4. Handle cover art change (skipped in this plan — cover art + // save + thumbnail logic will be added when the UI sends + // cover art data, but the DB plumbing is ready). + // For now, cover art changes are a no-op in the DB sync. + // The file-level embed/clear is handled by the format writers. + // ------------------------------------------------------------------ + + // ------------------------------------------------------------------ + // 5. Update recording with all changed fields. + // ------------------------------------------------------------------ + rec := params.oldRecording + + newName := rec.Name + if v, ok := params.changes[FieldTitle].(string); ok { + newName = v + } + + newYear := rec.Year + if v, ok := params.changes[FieldYear].(int); ok { + newYear = toNullInt64(v) + } + + newTrackNum := rec.TrackNumber + if v, ok := params.changes[FieldTrackNumber].(int); ok { + newTrackNum = toNullInt64(v) + } + + newDiscNum := rec.DiscNumber + if v, ok := params.changes[FieldDiscNumber].(int); ok { + newDiscNum = toNullInt64(v) + } + + newGenreStr := rec.Genre + if v, ok := params.changes[FieldGenre].(string); ok { + newGenreStr = toNullString(v) + } + + newComposer := rec.Composer + if v, ok := params.changes[FieldComposer].(string); ok { + newComposer = toNullString(v) + } + + if updErr := txq.UpdateRecordingFull(ctx, sqlcgen.UpdateRecordingFullParams{ + Name: newName, + ArtistCreditID: newArtistCreditID, + TrackNumber: newTrackNum, + DiscNumber: newDiscNum, + Year: newYear, + Genre: newGenreStr, + Composer: newComposer, + Lyrics: rec.Lyrics, + Comment: rec.Comment, + ID: params.recordingID, + }); updErr != nil { + return fmt.Errorf("update recording: %w", updErr) + } + + // ------------------------------------------------------------------ + // 6. Update FTS5 search index (within the transaction). + // ------------------------------------------------------------------ + newTitle := newName + newArtist := params.changes[FieldArtist] + artistStr := "" + if newArtist != nil { + artistStr, _ = newArtist.(string) + } + + if artistStr == "" { + // Look up current artist credit text from the old recording + // if the artist hasn't changed. + ac, acErr := txq.GetArtistCredit(ctx, newArtistCreditID) + if acErr == nil { + artistStr = ac.Text + } + } + + newAlbum := "" + if v, ok := params.changes[FieldAlbum].(string); ok { + newAlbum = v + } else { + // Look up current album from release groups if unchanged. + if len(params.oldRGLinks) > 0 { + rg, rgErr := txq.GetReleaseGroup(ctx, params.oldRGLinks[0].ReleaseGroupID) + if rgErr == nil { + newAlbum = rg.Name + } + } + } + + // SAFETY: FTS5 DELETE on contentless_delete=1 table. Parameterized rowid. + if _, ftsDelErr := tx.ExecContext(ctx, + "DELETE FROM search_index WHERE rowid = ?", + params.audioFileID, + ); ftsDelErr != nil { + logger.Warn("FTS5 delete failed", "err", ftsDelErr, + "audioFileID", params.audioFileID) + } + + // SAFETY: FTS5 INSERT into contentless table. All values parameterized. + if _, ftsInsErr := tx.ExecContext(ctx, + `INSERT INTO search_index(rowid, file_path, title, artist, album) + VALUES (?, ?, ?, ?, ?)`, + params.audioFileID, params.filePath, newTitle, artistStr, newAlbum, + ); ftsInsErr != nil { + logger.Warn("FTS5 insert failed", "err", ftsInsErr, + "audioFileID", params.audioFileID) + } + + // ------------------------------------------------------------------ + // 7. Orphan cleanup (within same transaction). + // ------------------------------------------------------------------ + + // 7a. Artist credit orphan cleanup. + if newArtistCreditID != oldArtistCreditID { + refCount, refErr := txq.CountArtistCreditReferences(ctx, oldArtistCreditID) + if refErr != nil { + logger.Warn("count artist credit refs failed", "err", refErr) + } else if refCount == 0 { + // Delete artist_credit_artist entries for the orphaned credit, + // then the credit itself. + // SAFETY: Hand-crafted DELETE for orphan artist_credit_artist rows. + // Parameterized credit_id. No sqlc query exists for this specific + // delete-by-credit pattern. + if _, acaErr := tx.ExecContext(ctx, + "DELETE FROM artist_credit_artist WHERE credit_id = ?", + oldArtistCreditID, + ); acaErr != nil { + logger.Warn("delete orphan aca failed", "err", acaErr) + } + + if delErr := txq.DeleteArtistCredit(ctx, oldArtistCreditID); delErr != nil { + logger.Warn("delete orphan artist credit failed", "err", delErr) + } + } + } + + // 7b. Release group orphan cleanup. + if _, albumChanged := params.changes[FieldAlbum]; albumChanged { + for _, oldRGID := range oldRGIDs { + rgCount, rgErr := txq.CountReleaseGroupRecordings(ctx, oldRGID) + if rgErr != nil { + logger.Warn("count rg recordings failed", "err", rgErr, + "releaseGroupID", oldRGID) + + continue + } + + if rgCount == 0 { + if delErr := txq.DeleteReleaseGroup(ctx, oldRGID); delErr != nil { + logger.Warn("delete orphan release group failed", + "err", delErr, "releaseGroupID", oldRGID) + } + } + } + } + + // 7c. Genre orphan cleanup — delete genres with no remaining + // recording_genres references. This is safe because genres + // are only referenced via recording_genres. + if _, genreChanged := params.changes[FieldGenre]; genreChanged { + // SAFETY: Hand-crafted DELETE for orphan genres. No user input. + // Matches the global orphan pattern from library/crud.go. + if _, gErr := tx.ExecContext(ctx, + `DELETE FROM genres WHERE id NOT IN + (SELECT DISTINCT genre_id FROM recording_genres)`, + ); gErr != nil { + logger.Warn("genre orphan cleanup failed", "err", gErr) + } + } + + // ------------------------------------------------------------------ + // 8. Commit. + // ------------------------------------------------------------------ + return tx.Commit() +} + +// toNullInt64 converts an int to sql.NullInt64, treating 0 as null. +func toNullInt64(v int) sql.NullInt64 { + if v == 0 { + return sql.NullInt64{} + } + + return sql.NullInt64{Int64: int64(v), Valid: true} +} + +// toNullString converts a string to sql.NullString, treating empty as null. +func toNullString(v string) sql.NullString { + if v == "" { + return sql.NullString{} + } + + return sql.NullString{String: v, Valid: true} +}