ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
220 lines
7.1 KiB
Go
220 lines
7.1 KiB
Go
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
|
||
}
|