Files
pipu-music/services/api/internal/playback/playlists.go
T
mrleo1nid 0a59059738
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
feat(playlists): share playlists with revocable read-only links
2026-10-22 14:57:14 +03:00

236 lines
7.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) {
return h.readPlaylist(ctx, user, id, false)
}
func (h *Handler) readPlaylist(ctx context.Context, user, id uuid.UUID, shared bool) (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)
filter := ` WHERE p.user_id=$1 AND p.id=$2`
args := []any{user, id}
if shared {
filter = ` WHERE p.share_id=$1 AND EXISTS (SELECT 1 FROM users u WHERE u.id=p.user_id AND NOT u.is_blocked)`
args = []any{id}
}
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+filter, args...))
if err != nil {
return out, err
}
var shareID *uuid.UUID
if !shared {
if err = tx.QueryRow(ctx, `SELECT share_id FROM playlists WHERE id=$1`, p.Id).Scan(&shareID); err != nil {
return out, err
}
}
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, p.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{ShareId: shareID, 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(&current)
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
}