package playback import ( "context" "errors" "strings" "unicode/utf8" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "pipu/api/internal/api/gen" "pipu/api/internal/platform/apperr" ) func validatePlaylist(name, description string, ids []uuid.UUID) error { if n := utf8.RuneCountInString(strings.TrimSpace(name)); n < 1 || n > 120 { return apperr.Validation("name", "playlist.invalid_name", "name must contain 1–120 characters", nil) } if utf8.RuneCountInString(description) > 2000 || len(ids) > 2000 { return apperr.Validation("playlist", "playlist.too_large", "maximum 2000 description characters and tracks", nil) } seen := map[uuid.UUID]bool{} for _, id := range ids { if id == uuid.Nil || seen[id] { return apperr.Validation("track_ids", "playlist.invalid_tracks", "track IDs must be unique and nonzero", nil) } seen[id] = true } return nil } const playlistSelect = `SELECT p.id, p.name, p.description, p.revision, p.created_at, p.updated_at, (SELECT count(*) FROM playlist_tracks pt WHERE pt.playlist_id=p.id), COALESCE((SELECT sum(t.duration_ms) FROM playlist_tracks pt JOIN tracks t ON t.id=pt.track_id WHERE pt.playlist_id=p.id),0) FROM playlists p` func scanPlaylist(row pgx.Row) (gen.PlaylistSummary, error) { var p gen.PlaylistSummary err := row.Scan(&p.Id, &p.Name, &p.Description, &p.Revision, &p.CreatedAt, &p.UpdatedAt, &p.TrackCount, &p.DurationMs) if errors.Is(err, pgx.ErrNoRows) { return p, apperr.NotFound("playlist.not_found", "playlist not found") } return p, err } func (h *Handler) ListPlaylists(ctx context.Context, _ gen.ListPlaylistsRequestObject) (gen.ListPlaylistsResponseObject, error) { user, err := principal(ctx) if err != nil { return nil, err } rows, err := h.store.pool.Query(ctx, playlistSelect+` WHERE p.user_id=$1 ORDER BY p.updated_at DESC, p.id`, user.UserID) if err != nil { return nil, err } defer rows.Close() out := gen.PlaylistList{Items: []gen.PlaylistSummary{}} for rows.Next() { p, err := scanPlaylist(rows) if err != nil { return nil, err } out.Items = append(out.Items, p) } return gen.ListPlaylists200JSONResponse(out), rows.Err() } func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) { var out gen.Playlist tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly}) if err != nil { return out, err } defer tx.Rollback(ctx) p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+` WHERE p.user_id=$1 AND p.id=$2`, user, id)) if err != nil { return out, err } rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, id) if err != nil { return out, err } ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID]) if err != nil { return out, err } if err = tx.Commit(ctx); err != nil { return out, err } tracks, err := h.catalog.TracksByIDs(ctx, ids) if err != nil { return out, err } dtos, err := h.dto.Tracks(ctx, tracks) if err != nil { return out, err } if dtos == nil { dtos = []gen.Track{} } out = gen.Playlist{Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos} for _, t := range dtos { out.DurationMs += int64(t.DurationMs) } return out, nil } func (h *Handler) GetPlaylist(ctx context.Context, req gen.GetPlaylistRequestObject) (gen.GetPlaylistResponseObject, error) { user, err := principal(ctx) if err != nil { return nil, err } p, err := h.playlist(ctx, user.UserID, req.Id) if err != nil { return nil, err } return gen.GetPlaylist200JSONResponse(p), nil } // A row lock plus revision check prevents stale clients from overwriting each other. // Replacing ordered membership is atomic, including validation of all foreign keys. func (st *Store) savePlaylist(ctx context.Context, user, id uuid.UUID, name, description string, ids []uuid.UUID, revision int, create bool) error { if err := validatePlaylist(name, description, ids); err != nil { return err } tx, err := st.pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) if create { _, err = tx.Exec(ctx, `INSERT INTO playlists(id,user_id,name,description) VALUES($1,$2,$3,$4)`, id, user, strings.TrimSpace(name), description) } else { var current int err = tx.QueryRow(ctx, `SELECT revision FROM playlists WHERE id=$1 AND user_id=$2 FOR UPDATE`, id, user).Scan(¤t) if errors.Is(err, pgx.ErrNoRows) { return apperr.NotFound("playlist.not_found", "playlist not found") } if err != nil { return err } if current != revision { return apperr.Conflict("playlist.conflict", "playlist changed; reload before editing") } _, err = tx.Exec(ctx, `UPDATE playlists SET name=$3, description=$4, revision=revision+1, updated_at=now() WHERE id=$1 AND user_id=$2`, id, user, strings.TrimSpace(name), description) } if err != nil { return err } if _, err = tx.Exec(ctx, `DELETE FROM playlist_tracks WHERE playlist_id=$1`, id); err != nil { return err } if len(ids) > 0 { _, err = tx.Exec(ctx, `INSERT INTO playlist_tracks(playlist_id,track_id,position) SELECT $1, id, ord-1 FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id,ord)`, id, ids) if err != nil { var pgerr *pgconn.PgError if errors.As(err, &pgerr) && pgerr.Code == "23503" { return apperr.Validation("track_ids", "playlist.invalid_tracks", "track no longer exists", nil) } return err } } return tx.Commit(ctx) } func (h *Handler) CreatePlaylist(ctx context.Context, req gen.CreatePlaylistRequestObject) (gen.CreatePlaylistResponseObject, error) { user, err := principal(ctx) if err != nil { return nil, err } if req.Body == nil { return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil) } id := uuid.New() b := req.Body if err = h.store.savePlaylist(ctx, user.UserID, id, b.Name, b.Description, b.TrackIds, 0, true); err != nil { return nil, err } p, err := h.playlist(ctx, user.UserID, id) if err != nil { return nil, err } return gen.CreatePlaylist201JSONResponse(p), nil } func (h *Handler) UpdatePlaylist(ctx context.Context, req gen.UpdatePlaylistRequestObject) (gen.UpdatePlaylistResponseObject, error) { user, err := principal(ctx) if err != nil { return nil, err } if req.Body == nil { return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil) } b := req.Body if err = h.store.savePlaylist(ctx, user.UserID, req.Id, b.Name, b.Description, b.TrackIds, b.Revision, false); err != nil { return nil, err } p, err := h.playlist(ctx, user.UserID, req.Id) if err != nil { return nil, err } return gen.UpdatePlaylist200JSONResponse(p), nil } func (h *Handler) DeletePlaylist(ctx context.Context, req gen.DeletePlaylistRequestObject) (gen.DeletePlaylistResponseObject, error) { user, err := principal(ctx) if err != nil { return nil, err } result, err := h.store.pool.Exec(ctx, `DELETE FROM playlists WHERE id=$1 AND user_id=$2`, req.Id, user.UserID) if err != nil { return nil, err } if result.RowsAffected() == 0 { return nil, apperr.NotFound("playlist.not_found", "playlist not found") } return gen.DeletePlaylist204Response{}, nil }