feat: add personal playlists and shareable track links
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 23:37:53 +03:00
1 parent 3a097c15c8
commit fa79394869
46 files changed
+2307 -292

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
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(&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
}