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/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create playlist: %w", err)
|
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)
|
full, err := q.GetPlaylist(ctx, row.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get-after-create: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("update playlist: %w", err)
|
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)
|
full, err := q.GetPlaylist(ctx, updated.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get-after-update: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("delete playlist: %w", err)
|
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 != "" {
|
if deleted.CoverPath != nil && *deleted.CoverPath != "" {
|
||||||
full := filepath.Join(s.dataDir, *deleted.CoverPath)
|
full := filepath.Join(s.dataDir, *deleted.CoverPath)
|
||||||
if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
|
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)
|
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 {
|
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
|
||||||
return fmt.Errorf("update rollups: %w", err)
|
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) }()
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
tq := dbq.New(tx)
|
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{
|
if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{
|
||||||
PlaylistID: playlistID,
|
PlaylistID: playlistID,
|
||||||
Position: position,
|
Position: position,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("delete track row: %w", err)
|
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{
|
if err := tq.RenumberPlaylistTracksAfter(ctx, dbq.RenumberPlaylistTracksAfterParams{
|
||||||
PlaylistID: playlistID,
|
PlaylistID: playlistID,
|
||||||
Position: position,
|
Position: position,
|
||||||
|
|||||||
Reference in New Issue
Block a user