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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user