Files
pipu-music/services/api/internal/playback/handler.go
T
mrleo1nid e158558da3
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
refactor: isolate playback and upload use cases
2026-10-22 13:52:16 +03:00

513 lines
15 KiB
Go

package playback
import (
"context"
"encoding/base64"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/platform/sign"
)
// Коды ошибок.
const (
CodeLinkInvalid = "playback.link_invalid"
CodeUnavailable = "playback.track_unavailable"
CodeTranscodeFail = "playback.transcode_failed"
CodeTrackNotFound = "catalog.not_found"
maxEventAge = 30 * 24 * time.Hour
maxEventClockSkew = 5 * time.Minute
defaultCollectPage = 50
)
// Handler реализует playback- и collection-части gen.StrictServerInterface,
// персонализация каталога вынесена в Personalizer.
type Handler struct {
store *Store
catalog *library.Store
dto library.DTO
signer *sign.Signer
transcoder *Transcoder
// RootFor — каталог хранилища в файловой системе api (для отдачи файлов без nginx и транскода).
rootFor func(ctx context.Context, sourceID uuid.UUID) (string, error)
xAccel bool
log *slog.Logger
now func() time.Time
}
// NewHandler создаёт обработчик. dto — независимо собранный DTO каталога с персонализацией.
func NewHandler(store *Store, catalog *library.Store, dto library.DTO, signer *sign.Signer, tr *Transcoder,
rootFor func(context.Context, uuid.UUID) (string, error), xAccel bool, log *slog.Logger,
) *Handler {
return &Handler{
store: store, catalog: catalog, dto: dto, signer: signer, transcoder: tr, rootFor: rootFor, xAccel: xAccel,
log: log, now: func() time.Time { return time.Now().UTC() },
}
}
func principal(ctx context.Context) (auth.Principal, error) {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return auth.Principal{}, apperr.Unauthorized(auth.CodeUnauthorized, "not authenticated")
}
return p, nil
}
// --- стрим ---
// StreamTrack реализует gen.StrictServerInterface.
func (h *Handler) StreamTrack(ctx context.Context, req gen.StreamTrackRequestObject) (gen.StreamTrackResponseObject, error) {
prm := req.Params
if !VerifyStream(h.signer, prm.Sig, prm.U, req.Id, prm.Exp, h.now()) {
return nil, apperr.Forbidden(CodeLinkInvalid, "link is invalid or expired")
}
blocked, err := h.store.UserBlocked(ctx, prm.U)
if err != nil {
return nil, err
}
if blocked {
return nil, apperr.Forbidden(auth.CodeUserBlocked, "user is blocked")
}
f, err := h.store.StreamFile(ctx, req.Id)
if errors.Is(err, ErrNotFound) {
return nil, apperr.NotFound(CodeUnavailable, "track has no available file")
}
if err != nil {
return nil, err
}
if f.SourceStatus != "mounted" {
return nil, apperr.NotFound(CodeUnavailable, "storage is offline")
}
q := QualityAuto
if prm.Q != nil {
q = string(*prm.Q)
}
codec, bitrate := "", 0
if f.Codec != nil {
codec = *f.Codec
}
if f.Bitrate != nil {
bitrate = *f.Bitrate
}
target := Target(q, f.Format, codec, bitrate)
res := streamResponse{req: httpx.RequestFrom(ctx), xAccel: h.xAccel}
if target == "" {
res.contentType = ContentType(f.Format)
res.accel = accelPath(f)
if !h.xAccel {
root, err := h.rootFor(ctx, f.SourceID)
if err != nil {
return nil, err
}
res.file = filepath.Join(root, filepath.FromSlash(f.RelPath))
}
return res, nil
}
root, err := h.rootFor(ctx, f.SourceID)
if err != nil {
return nil, err
}
name := CacheName(f.FileID, f.Size, target)
path, err := h.transcoder.Ensure(ctx, filepath.Join(root, filepath.FromSlash(f.RelPath)), name, target)
if err != nil {
h.log.ErrorContext(ctx, "transcode failed", "track_id", req.Id, "err", err)
return nil, apperr.New(http.StatusBadGateway, CodeTranscodeFail, "transcoding failed")
}
res.contentType = "audio/ogg; codecs=opus"
res.accel = "/_internal/cache/" + name
res.file = path
return res, nil
}
// streamResponse — X-Accel-Redirect для nginx или сам файл с поддержкой Range.
type streamResponse struct {
req *http.Request
contentType string
accel string
file string
xAccel bool
}
// VisitStreamTrackResponse реализует gen.StreamTrackResponseObject.
func (s streamResponse) VisitStreamTrackResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", s.contentType)
w.Header().Set("Cache-Control", "private, max-age=43200")
if s.xAccel {
w.Header().Set("X-Accel-Redirect", s.accel)
w.WriteHeader(http.StatusOK)
return nil
}
f, err := os.Open(s.file)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return err
}
if s.req == nil {
return fmt.Errorf("playback: request missing in context")
}
http.ServeContent(w, s.req, "", info.ModTime(), f)
return nil
}
// Prewarm заранее транскодирует пачку волны (docs/playback.md, задача pretranscode).
func (h *Handler) Prewarm(ctx context.Context, ids []uuid.UUID) {
for _, id := range ids {
if ctx.Err() != nil {
return
}
f, err := h.store.StreamFile(ctx, id)
if err != nil || f.SourceStatus != "mounted" {
continue
}
codec, bitrate := "", 0
if f.Codec != nil {
codec = *f.Codec
}
if f.Bitrate != nil {
bitrate = *f.Bitrate
}
target := Target(QualityHigh, f.Format, codec, bitrate)
if target == "" {
continue
}
root, err := h.rootFor(ctx, f.SourceID)
if err != nil {
continue
}
name := CacheName(f.FileID, f.Size, target)
if _, err := h.transcoder.Ensure(ctx, filepath.Join(root, filepath.FromSlash(f.RelPath)), name, target); err != nil {
h.log.WarnContext(ctx, "pretranscode failed", "track_id", id, "err", err)
}
}
}
// --- треки пачкой ---
// GetTracksBatch реализует gen.StrictServerInterface.
func (h *Handler) GetTracksBatch(ctx context.Context, req gen.GetTracksBatchRequestObject) (gen.GetTracksBatchResponseObject, error) {
rows, err := h.catalog.TracksByIDs(ctx, req.Body.Ids)
if err != nil {
return nil, err
}
items, err := h.dto.Tracks(ctx, rows)
if err != nil {
return nil, err
}
return gen.GetTracksBatch200JSONResponse{Items: items}, nil
}
// --- события ---
// PostEvents реализует gen.StrictServerInterface.
func (h *Handler) PostEvents(ctx context.Context, req gen.PostEventsRequestObject) (gen.PostEventsResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
return (EventService{store: h.store, now: h.now}).Accept(ctx, p, req.Body.Events)
}
// --- состояние плеера ---
func stateDTO(s State) gen.PlaybackState {
out := gen.PlaybackState{
TrackId: s.TrackID, PositionMs: s.PositionMs, Queue: s.Queue, QueueIndex: s.QueueIndex, Shuffle: s.Shuffle,
RepeatMode: gen.PlaybackStateRepeatMode(s.RepeatMode), Volume: s.Volume, DeviceId: s.DeviceID, UpdatedAt: s.UpdatedAt,
}
if out.Queue == nil {
out.Queue = []uuid.UUID{}
}
if s.Context != nil {
c := gen.PlaybackContext{}
str := func(k string) *string {
if v, ok := s.Context[k].(string); ok && v != "" {
return &v
}
return nil
}
c.Type, c.Id, c.Label = str("type"), str("id"), str("label")
if w := str("wave_session_id"); w != nil {
if id, err := uuid.Parse(*w); err == nil {
c.WaveSessionId = &id
}
}
out.Context = &c
}
return out
}
// GetPlaybackState реализует gen.StrictServerInterface.
func (h *Handler) GetPlaybackState(ctx context.Context, _ gen.GetPlaybackStateRequestObject) (gen.GetPlaybackStateResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
s, err := h.store.GetState(ctx, p.UserID)
if err != nil {
return nil, err
}
return gen.GetPlaybackState200JSONResponse(stateDTO(s)), nil
}
// PutPlaybackState реализует gen.StrictServerInterface.
func (h *Handler) PutPlaybackState(ctx context.Context, req gen.PutPlaybackStateRequestObject) (gen.PutPlaybackStateResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
b := req.Body
s := State{
TrackID: b.TrackId, PositionMs: max(b.PositionMs, 0), Queue: b.Queue, QueueIndex: b.QueueIndex,
RepeatMode: "off", Volume: 1, DeviceID: b.DeviceId,
}
if s.QueueIndex >= len(s.Queue) {
s.QueueIndex = max(len(s.Queue)-1, 0)
}
if b.Shuffle != nil {
s.Shuffle = *b.Shuffle
}
if b.RepeatMode != nil {
s.RepeatMode = string(*b.RepeatMode)
}
if b.Volume != nil {
s.Volume = min(max(*b.Volume, 0), 1)
}
if c := b.Context; c != nil {
s.Context = map[string]any{}
for k, v := range map[string]*string{"type": c.Type, "id": c.Id, "label": c.Label} {
if v != nil {
s.Context[k] = *v
}
}
if c.WaveSessionId != nil {
s.Context["wave_session_id"] = c.WaveSessionId.String()
}
}
saved, err := h.store.PutState(ctx, p.UserID, s)
if err != nil {
return nil, err
}
return gen.PutPlaybackState200JSONResponse(stateDTO(saved)), nil
}
// --- коллекция ---
func encodeCursor(t time.Time, id uuid.UUID) string {
return base64.RawURLEncoding.EncodeToString([]byte(t.Format(time.RFC3339Nano) + "|" + id.String()))
}
func decodeCursor(c *string) (time.Time, uuid.UUID, bool, error) {
if c == nil || *c == "" {
return time.Time{}, uuid.Nil, false, nil
}
bad := apperr.Validation("cursor", "validation.cursor_invalid", "malformed cursor", nil)
raw, err := base64.RawURLEncoding.DecodeString(*c)
if err != nil {
return time.Time{}, uuid.Nil, false, bad
}
ts, idStr, ok := strings.Cut(string(raw), "|")
t, err1 := time.Parse(time.RFC3339Nano, ts)
id, err2 := uuid.Parse(idStr)
if !ok || err1 != nil || err2 != nil {
return time.Time{}, uuid.Nil, false, bad
}
return t, id, true, nil
}
func pageLimit(l *int) int {
if l == nil {
return defaultCollectPage
}
return min(max(*l, 1), 200)
}
// hydrate — треки каталога по id; удалённые пропускаются.
func (h *Handler) hydrate(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]gen.Track, error) {
rows, err := h.catalog.TracksByIDs(ctx, ids)
if err != nil {
return nil, err
}
dtos, err := h.dto.Tracks(ctx, rows)
if err != nil {
return nil, err
}
out := make(map[uuid.UUID]gen.Track, len(dtos))
for _, t := range dtos {
out[t.Id] = t
}
return out, nil
}
// GetHistory реализует gen.StrictServerInterface.
func (h *Handler) GetHistory(ctx context.Context, req gen.GetHistoryRequestObject) (gen.GetHistoryResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
t, id, ok, err := decodeCursor(req.Params.Cursor)
if err != nil {
return nil, err
}
var before *HistoryRow
if ok {
before = &HistoryRow{ID: id, PlayedAt: t}
}
limit := pageLimit(req.Params.Limit)
rows, err := h.store.History(ctx, p.UserID, before, limit+1)
if err != nil {
return nil, err
}
out := gen.GetHistory200JSONResponse{Items: []gen.HistoryItem{}}
if len(rows) > limit {
rows = rows[:limit]
c := encodeCursor(rows[limit-1].PlayedAt, rows[limit-1].ID)
out.NextCursor = &c
}
ids := make([]uuid.UUID, len(rows))
for i, r := range rows {
ids[i] = r.TrackID
}
tracks, err := h.hydrate(ctx, ids)
if err != nil {
return nil, err
}
for _, r := range rows {
if tr, ok := tracks[r.TrackID]; ok {
out.Items = append(out.Items, gen.HistoryItem{Id: r.ID, PlayedAt: r.PlayedAt, Track: tr})
}
}
return out, nil
}
// ClearHistory реализует gen.StrictServerInterface.
func (h *Handler) ClearHistory(ctx context.Context, _ gen.ClearHistoryRequestObject) (gen.ClearHistoryResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.store.ClearHistory(ctx, p.UserID); err != nil {
return nil, err
}
return gen.ClearHistory204Response{}, nil
}
func (h *Handler) reacted(ctx context.Context, reaction string, limitP *int, cursor *string) (gen.ReactedTrackPage, error) {
p, err := principal(ctx)
if err != nil {
return gen.ReactedTrackPage{}, err
}
t, id, ok, err := decodeCursor(cursor)
if err != nil {
return gen.ReactedTrackPage{}, err
}
var before *Reacted
if ok {
before = &Reacted{TrackID: id, CreatedAt: t}
}
limit := pageLimit(limitP)
rows, total, err := h.store.ReactedPage(ctx, p.UserID, reaction, before, limit+1)
if err != nil {
return gen.ReactedTrackPage{}, err
}
out := gen.ReactedTrackPage{Items: []gen.ReactedTrack{}, Total: total}
if len(rows) > limit {
rows = rows[:limit]
c := encodeCursor(rows[limit-1].CreatedAt, rows[limit-1].TrackID)
out.NextCursor = &c
}
ids := make([]uuid.UUID, len(rows))
for i, r := range rows {
ids[i] = r.TrackID
}
tracks, err := h.hydrate(ctx, ids)
if err != nil {
return gen.ReactedTrackPage{}, err
}
for _, r := range rows {
if tr, ok := tracks[r.TrackID]; ok {
out.Items = append(out.Items, gen.ReactedTrack{CreatedAt: r.CreatedAt, Track: tr})
}
}
return out, nil
}
// GetLikes реализует gen.StrictServerInterface.
func (h *Handler) GetLikes(ctx context.Context, req gen.GetLikesRequestObject) (gen.GetLikesResponseObject, error) {
page, err := h.reacted(ctx, "like", req.Params.Limit, req.Params.Cursor)
if err != nil {
return nil, err
}
return gen.GetLikes200JSONResponse(page), nil
}
// GetDislikes реализует gen.StrictServerInterface.
func (h *Handler) GetDislikes(ctx context.Context, req gen.GetDislikesRequestObject) (gen.GetDislikesResponseObject, error) {
page, err := h.reacted(ctx, "dislike", req.Params.Limit, req.Params.Cursor)
if err != nil {
return nil, err
}
return gen.GetDislikes200JSONResponse(page), nil
}
// SetTrackReaction реализует gen.StrictServerInterface.
func (h *Handler) SetTrackReaction(ctx context.Context, req gen.SetTrackReactionRequestObject) (gen.SetTrackReactionResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
err = h.store.SetReaction(ctx, p.UserID, req.Id, string(req.Body.Reaction))
if errors.Is(err, ErrNotFound) {
return nil, apperr.NotFound(CodeTrackNotFound, "track not found")
}
if err != nil {
return nil, err
}
return gen.SetTrackReaction204Response{}, nil
}
// ClearTrackReaction реализует gen.StrictServerInterface.
func (h *Handler) ClearTrackReaction(ctx context.Context, req gen.ClearTrackReactionRequestObject) (gen.ClearTrackReactionResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
if err := h.store.ClearReaction(ctx, p.UserID, req.Id); err != nil {
return nil, err
}
return gen.ClearTrackReaction204Response{}, nil
}
// impressionOutcome — исход показа волны по событию: complete | skip_early | skip | like | dislike.
func impressionOutcome(eventType string, o Outcome) string {
switch {
case eventType == "like" || eventType == "dislike":
return eventType
case o.Complete:
return "complete"
case o.EarlySkip:
return "skip_early"
case o.Skip:
return "skip"
}
return ""
}