feat(server): playlists service writes library_changes

Five sites wired:

- Create / Update / Delete playlist: pool-bound LogChange after success
- AppendTracks: tx-bound LogChange per inserted playlist_track row
- RemoveTrack: tx-bound LogChange after the delete + lookup the
  track_id pre-delete so the composite key is still resolvable

The two tx-bound paths (AppendTracks, RemoveTrack) treat LogChange as
required — failure rolls back the playlist mutation too. The pool-bound
paths Warn on failure to match the scanner / likes pattern (mutation
already committed; missed log row recovers on next mutation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 22:41:59 -04:00
parent e0c5789cee
commit 6fb6729beb
+37
View File
@@ -26,6 +26,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
// Typed errors. The API layer maps each to its wire status code; tests
@@ -131,6 +132,10 @@ func (s *Service) Create(ctx context.Context, userID pgtype.UUID, name, descript
if err != nil {
return nil, fmt.Errorf("create playlist: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
syncpkg.FormatUUID(row.ID), syncpkg.OpUpsert); err != nil {
s.logger.Warn("playlists: LogChange create failed", "playlist_id", row.ID, "err", err)
}
full, err := q.GetPlaylist(ctx, row.ID)
if err != nil {
return nil, fmt.Errorf("get-after-create: %w", err)
@@ -265,6 +270,10 @@ func (s *Service) Update(ctx context.Context, callerID, playlistID pgtype.UUID,
if err != nil {
return nil, fmt.Errorf("update playlist: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
syncpkg.FormatUUID(updated.ID), syncpkg.OpUpsert); err != nil {
s.logger.Warn("playlists: LogChange update failed", "playlist_id", updated.ID, "err", err)
}
full, err := q.GetPlaylist(ctx, updated.ID)
if err != nil {
return nil, fmt.Errorf("get-after-update: %w", err)
@@ -297,6 +306,10 @@ func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID)
if err != nil {
return fmt.Errorf("delete playlist: %w", err)
}
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityPlaylist,
syncpkg.FormatUUID(deleted.ID), syncpkg.OpDelete); err != nil {
s.logger.Warn("playlists: LogChange delete failed", "playlist_id", deleted.ID, "err", err)
}
if deleted.CoverPath != nil && *deleted.CoverPath != "" {
full := filepath.Join(s.dataDir, *deleted.CoverPath)
if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
@@ -377,6 +390,12 @@ func (s *Service) AppendTracks(ctx context.Context, callerID, playlistID pgtype.
}
return fmt.Errorf("append track: %w", ierr)
}
// Tx-bound: change row commits with the playlist_track row.
if lerr := syncpkg.LogChange(ctx, tx, syncpkg.EntityPlaylistTrack,
syncpkg.EncodePlaylistTrackID(syncpkg.FormatUUID(playlistID), syncpkg.FormatUUID(tid)),
syncpkg.OpUpsert); lerr != nil {
return fmt.Errorf("log change: %w", lerr)
}
}
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
return fmt.Errorf("update rollups: %w", err)
@@ -418,12 +437,30 @@ func (s *Service) RemoveTrack(ctx context.Context, callerID, playlistID pgtype.U
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
// Capture the track_id before we delete the row — we need it for the
// LogChange composite key after the delete commits.
var deletedTrackID pgtype.UUID
if err := tx.QueryRow(ctx,
`SELECT track_id FROM playlist_tracks WHERE playlist_id = $1 AND position = $2`,
playlistID, position,
).Scan(&deletedTrackID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("lookup track at position: %w", err)
}
if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{
PlaylistID: playlistID,
Position: position,
}); err != nil {
return fmt.Errorf("delete track row: %w", err)
}
if lerr := syncpkg.LogChange(ctx, tx, syncpkg.EntityPlaylistTrack,
syncpkg.EncodePlaylistTrackID(syncpkg.FormatUUID(playlistID), syncpkg.FormatUUID(deletedTrackID)),
syncpkg.OpDelete); lerr != nil {
return fmt.Errorf("log change: %w", lerr)
}
if err := tq.RenumberPlaylistTracksAfter(ctx, dbq.RenumberPlaylistTracksAfterParams{
PlaylistID: playlistID,
Position: position,