diff --git a/internal/api/library_sync.go b/internal/api/library_sync.go index 0b84baaf..6bebad3b 100644 --- a/internal/api/library_sync.go +++ b/internal/api/library_sync.go @@ -168,7 +168,7 @@ func (h *handlers) hydrateUpserts( } } - userIDStr := uuidToString(userID) + userIDStr := syncpkg.FormatUUID(userID) if rows := ids[syncpkg.EntityLikeTrack]; len(rows) > 0 { out["like_track"] = scopedLikeRows(rows, userIDStr, "track_id") @@ -188,7 +188,7 @@ func (h *handlers) hydrateUpserts( } for _, p := range playlists { // Filter: only return playlists owned by this user OR public ones - if uuidToString(p.UserID) != userIDStr && !p.IsPublic { + if syncpkg.FormatUUID(p.UserID) != userIDStr && !p.IsPublic { continue } b, _ := json.Marshal(p) @@ -249,32 +249,6 @@ func stringsToUUIDs(strs []string) []pgtype.UUID { return out } -// uuidToString renders a pgtype.UUID as the canonical 8-4-4-4-12 hex -// form. Returns "" if the UUID is not valid. -func uuidToString(u pgtype.UUID) string { - if !u.Valid { - return "" - } - b := u.Bytes - return formatUUIDBytes(b) -} - -func formatUUIDBytes(b [16]byte) string { - const hex = "0123456789abcdef" - out := make([]byte, 36) - pos := 0 - for i := 0; i < 16; i++ { - if i == 4 || i == 6 || i == 8 || i == 10 { - out[pos] = '-' - pos++ - } - out[pos] = hex[b[i]>>4] - out[pos+1] = hex[b[i]&0x0f] - pos += 2 - } - return string(out) -} - // splitOnce returns [before, after] split at the first occurrence of sep, // or [s] if sep doesn't appear. Used for composite-key parsing. func splitOnce(s, sep string) []string { diff --git a/internal/api/library_sync_test.go b/internal/api/library_sync_test.go index 11ade966..1ff280b3 100644 --- a/internal/api/library_sync_test.go +++ b/internal/api/library_sync_test.go @@ -157,10 +157,10 @@ func TestLibrarySync_LikeRowScopedToUser(t *testing.T) { ctx := context.Background() tx, _ := pool.Begin(ctx) _ = sync.LogChange(ctx, tx, sync.EntityLikeTrack, - sync.EncodeLikeID(uuidToString(u.ID), "11111111-1111-1111-1111-111111111111"), + sync.EncodeLikeID(sync.FormatUUID(u.ID), "11111111-1111-1111-1111-111111111111"), sync.OpUpsert) _ = sync.LogChange(ctx, tx, sync.EntityLikeTrack, - sync.EncodeLikeID(uuidToString(other.ID), "22222222-2222-2222-2222-222222222222"), + sync.EncodeLikeID(sync.FormatUUID(other.ID), "22222222-2222-2222-2222-222222222222"), sync.OpUpsert) _ = tx.Commit(ctx) @@ -206,14 +206,3 @@ func TestSplitOnce(t *testing.T) { } } -func TestFormatUUIDBytes(t *testing.T) { - var b [16]byte - for i := range b { - b[i] = byte(i) - } - got := formatUUIDBytes(b) - want := "00010203-0405-0607-0809-0a0b0c0d0e0f" - if got != want { - t.Errorf("formatUUIDBytes = %q want %q", got, want) - } -} diff --git a/internal/library/delete.go b/internal/library/delete.go index 9f701a0d..532f9727 100644 --- a/internal/library/delete.go +++ b/internal/library/delete.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // ErrTrackNotFound is returned when DeleteTrackFile is called with an id @@ -48,5 +49,12 @@ func DeleteTrackFile(ctx context.Context, pool *pgxpool.Pool, trackID pgtype.UUI if _, err := pool.Exec(ctx, "DELETE FROM tracks WHERE id = $1", trackID); err != nil { return fmt.Errorf("delete row: %w", err) } + // Log the change after the delete succeeds. Best-effort: a Warn-level + // failure here would leave the cache index orphaned on offline clients + // until the next scan touches the surrounding album. + if err := syncpkg.LogChange(ctx, pool, syncpkg.EntityTrack, + syncpkg.FormatUUID(trackID), syncpkg.OpDelete); err != nil { + return fmt.Errorf("log change: %w", err) + } return nil } diff --git a/internal/library/scanner.go b/internal/library/scanner.go index 274ec0a3..be3476ad 100644 --- a/internal/library/scanner.go +++ b/internal/library/scanner.go @@ -20,6 +20,7 @@ import ( "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync" ) // audioExtensions is the v1 set. Duration extraction is not wired, so all of @@ -195,9 +196,16 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta params.Genre = &g } - if _, err := q.UpsertTrack(ctx, params); err != nil { + track, err := q.UpsertTrack(ctx, params) + if err != nil { return fmt.Errorf("upsert track: %w", err) } + if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityTrack, + syncpkg.FormatUUID(track.ID), syncpkg.OpUpsert); err != nil { + // Best-effort: log but don't fail the scan. The next scan that + // touches this track will re-emit the change. + s.logger.Warn("library scan: LogChange track upsert failed", "track_id", track.ID, "err", err) + } if knownTrack { stats.Updated++ @@ -236,7 +244,15 @@ func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid m := mbid params.Mbid = &m } - return q.UpsertArtist(ctx, params) + artist, err := q.UpsertArtist(ctx, params) + if err != nil { + return dbq.Artist{}, err + } + if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityArtist, + syncpkg.FormatUUID(artist.ID), syncpkg.OpUpsert); err != nil { + s.logger.Warn("library scan: LogChange artist upsert failed", "artist_id", artist.ID, "err", err) + } + return artist, nil } func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgtype.UUID, title string, year int, mbid string) (dbq.Album, error) { @@ -285,7 +301,15 @@ func (s *Scanner) resolveAlbum(ctx context.Context, q *dbq.Queries, artistID pgt m := mbid params.Mbid = &m } - return q.UpsertAlbum(ctx, params) + album, err := q.UpsertAlbum(ctx, params) + if err != nil { + return dbq.Album{}, err + } + if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityAlbum, + syncpkg.FormatUUID(album.ID), syncpkg.OpUpsert); err != nil { + s.logger.Warn("library scan: LogChange album upsert failed", "album_id", album.ID, "err", err) + } + return album, nil } // releaseDateFromYear converts a tag-supplied year into a Postgres date, diff --git a/internal/sync/changes.go b/internal/sync/changes.go index cfb22ec3..1734e611 100644 --- a/internal/sync/changes.go +++ b/internal/sync/changes.go @@ -4,20 +4,28 @@ import ( "context" "fmt" - "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) -// LogChange writes a row to library_changes inside the supplied transaction. -// Callers MUST pass the same tx that performed the underlying mutation — -// the change row and the mutation must commit or roll back together. +// LogChange writes a row to library_changes via the supplied DBTX. dbq.DBTX +// is satisfied by both *pgxpool.Pool and pgx.Tx, so callers can opt into +// transactional safety where they already hold a tx, or pool-bind for +// best-effort logging when no tx is available. +// +// Callers SHOULD pass a tx that owns the underlying mutation — the change +// row and the mutation then commit or roll back together. When pool-bound, +// the scanner-style "log after success" pattern keeps the log consistent +// with reality at the cost of a tiny race window: if the LogChange call +// itself fails after the mutation succeeded, that mutation won't appear in +// the change log until the entity is touched again. // // entityID is the string form of the row's primary key. For composite-key // entities (likes, playlist_tracks), use EncodeLikeID / EncodePlaylistTrackID -// to produce stable strings. -func LogChange(ctx context.Context, tx pgx.Tx, entityType EntityType, entityID string, op Op) error { - q := dbq.New(tx) +// to produce stable strings. For pgtype.UUID values, use FormatUUID. +func LogChange(ctx context.Context, dbtx dbq.DBTX, entityType EntityType, entityID string, op Op) error { + q := dbq.New(dbtx) if err := q.InsertLibraryChange(ctx, dbq.InsertLibraryChangeParams{ EntityType: string(entityType), EntityID: entityID, @@ -28,6 +36,31 @@ func LogChange(ctx context.Context, tx pgx.Tx, entityType EntityType, entityID s return nil } +// FormatUUID renders a pgtype.UUID as the canonical 8-4-4-4-12 hex form. +// Returns "" if the UUID is not valid. +func FormatUUID(u pgtype.UUID) string { + if !u.Valid { + return "" + } + return formatUUIDBytes(u.Bytes) +} + +func formatUUIDBytes(b [16]byte) string { + const hex = "0123456789abcdef" + out := make([]byte, 36) + pos := 0 + for i := 0; i < 16; i++ { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[pos] = '-' + pos++ + } + out[pos] = hex[b[i]>>4] + out[pos+1] = hex[b[i]&0x0f] + pos += 2 + } + return string(out) +} + // EncodeLikeID joins a user UUID and an entity UUID into the stable // composite identifier used for like_* rows in library_changes. Both // sides are UUID strings so no escaping is needed.