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, // а также library.Personalizer (ссылки на стрим и реакции в треках). type Handler struct { store *Store catalog *library.Store dto func() 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 каталога с персонализацией (замыкание: DTO ссылается на сам Handler). func NewHandler(store *Store, catalog *library.Store, dto func() 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 } // Personalize реализует library.Personalizer. func (h *Handler) Personalize(ctx context.Context, rows []library.TrackRow, out []gen.Track) error { p, ok := auth.PrincipalFrom(ctx) if !ok { return nil } ids := make([]uuid.UUID, len(rows)) for i, r := range rows { ids[i] = r.ID } reactions, err := h.store.Reactions(ctx, p.UserID, ids) if err != nil { return err } now := h.now() for i := range out { if out[i].Available { u := StreamURL(h.signer, p.UserID, rows[i].ID, now) out[i].StreamUrl = &u } if r, ok := reactions[rows[i].ID]; ok { rr := gen.Reaction(r) out[i].Reaction = &rr } } return 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 } now := h.now() ids := make([]uuid.UUID, 0, len(req.Body.Events)) for _, e := range req.Body.Events { ids = append(ids, e.TrackId) } durations, err := h.store.TrackDurations(ctx, ids) if err != nil { return nil, err } clientType := h.store.SessionClientType(ctx, p.SessionID) out := gen.PostEvents200JSONResponse{} tx, err := h.store.Begin(ctx) if err != nil { return nil, err } defer func() { _ = tx.Rollback(ctx) }() for _, e := range req.Body.Events { dur, known := durations[e.TrackId] at := e.OccurredAt.UTC() if !known || at.Before(now.Add(-maxEventAge)) { out.Rejected++ continue } if at.After(now.Add(maxEventClockSkew)) { at = now // часы клиента спешат } ev := Event{ ID: e.Id, Type: string(e.Type), TrackID: e.TrackId, OccurredAt: at, PositionMs: e.PositionMs, PlayedMs: e.PlayedMs, ContextID: e.ContextId, WaveSessionID: e.WaveSessionId, Offline: e.Offline != nil && *e.Offline, } if e.ContextType != nil { ct := string(*e.ContextType) ev.ContextType = &ct } inserted, err := h.store.InsertEvent(ctx, tx, p.UserID, clientType, ev) if err != nil { return nil, err } if !inserted { out.Duplicates++ continue } out.Accepted++ played := 0 if e.PlayedMs != nil { played = *e.PlayedMs } o := Judge(ev.Type, played, dur) if o != (Outcome{}) { if err := h.store.ApplyOutcome(ctx, tx, p.UserID, ev, o); err != nil { return nil, err } } if outcome := impressionOutcome(ev.Type, o); outcome != "" && ev.ContextType != nil && *ev.ContextType == "wave" && ev.ContextID != nil { if sid, err := uuid.Parse(*ev.ContextID); err == nil { if err := h.store.MarkImpression(ctx, tx, sid, ev.TrackID, outcome); err != nil { return nil, err } } } } if err := tx.Commit(ctx); err != nil { return nil, err } return out, nil } // --- состояние плеера --- 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 } var _ library.Personalizer = (*Handler)(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 "" }