feat(server): scanner + DeleteTrackFile write library_changes

Wires sync.LogChange into the library mutation sites so /api/library/sync
reflects upserts and deletes.

Architectural pivot: LogChange's signature is now (ctx, dbq.DBTX, ...) so
it works with both *pgxpool.Pool and pgx.Tx. The scanner doesn't run
mutations in explicit transactions, so it pool-binds; delete.go matches.
Tx-bound callers (likes/playlists in subsequent commits) keep atomicity.

Also: sync.FormatUUID centralizes the pgtype.UUID → canonical string
conversion that both the scanner and the sync handler need; library_sync.go
now uses it instead of a local copy.

Best-effort logging on scanner failures (Warn, don't fail the scan): a
LogChange error after a successful upsert is rare and self-healing — the
next scan that touches the entity re-emits the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 22:39:58 -04:00
parent 6bd8a15c7a
commit 0fa7dc7982
5 changed files with 79 additions and 51 deletions
+2 -28
View File
@@ -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 {
+2 -13
View File
@@ -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)
}
}
+8
View File
@@ -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
}
+27 -3
View File
@@ -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,
+40 -7
View File
@@ -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.