refactor: isolate playback and upload use cases
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

This commit is contained in:
mrleo1nid committed 2026-10-22 13:52:16 +03:00
1 parent c84f90e2cc
commit e158558da3
56 files changed
+3337 -2196

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
package playback
import (
"context"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
)
// EventService owns event acceptance and its transaction; HTTP only authenticates and maps DTOs.
type EventService struct {
store *Store
now func() time.Time
}
func (s EventService) Accept(ctx context.Context, p auth.Principal, events []gen.PlayEvent) (gen.PostEvents200JSONResponse, error) {
now := s.now()
ids := make([]uuid.UUID, 0, len(events))
for _, e := range events {
ids = append(ids, e.TrackId)
}
durations, err := s.store.TrackDurations(ctx, ids)
if err != nil {
return gen.PostEvents200JSONResponse{}, err
}
clientType := s.store.SessionClientType(ctx, p.SessionID)
out := gen.PostEvents200JSONResponse{}
tx, err := s.store.Begin(ctx)
if err != nil {
return gen.PostEvents200JSONResponse{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
for _, e := range 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 := s.store.InsertEvent(ctx, tx, p.UserID, clientType, ev)
if err != nil {
return gen.PostEvents200JSONResponse{}, 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 := s.store.ApplyOutcome(ctx, tx, p.UserID, ev, o); err != nil {
return gen.PostEvents200JSONResponse{}, 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 := s.store.MarkImpression(ctx, tx, sid, ev.TrackID, outcome); err != nil {
return gen.PostEvents200JSONResponse{}, err
}
}
}
}
if err := tx.Commit(ctx); err != nil {
return gen.PostEvents200JSONResponse{}, err
}
return out, nil
}
+7 -104
View File
@@ -34,11 +34,11 @@ const (
)
// Handler реализует playback- и collection-части gen.StrictServerInterface,
// а также library.Personalizer (ссылки на стрим и реакции в треках).
// персонализация каталога вынесена в Personalizer.
type Handler struct {
store *Store
catalog *library.Store
dto func() library.DTO
dto library.DTO
signer *sign.Signer
transcoder *Transcoder
// RootFor — каталог хранилища в файловой системе api (для отдачи файлов без nginx и транскода).
@@ -48,8 +48,8 @@ type Handler struct {
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,
// 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{
@@ -66,34 +66,6 @@ func principal(ctx context.Context) (auth.Principal, error) {
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.
@@ -236,7 +208,7 @@ func (h *Handler) GetTracksBatch(ctx context.Context, req gen.GetTracksBatchRequ
if err != nil {
return nil, err
}
items, err := h.dto().Tracks(ctx, rows)
items, err := h.dto.Tracks(ctx, rows)
if err != nil {
return nil, err
}
@@ -251,74 +223,7 @@ func (h *Handler) PostEvents(ctx context.Context, req gen.PostEventsRequestObjec
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
return (EventService{store: h.store, now: h.now}).Accept(ctx, p, req.Body.Events)
}
// --- состояние плеера ---
@@ -441,7 +346,7 @@ func (h *Handler) hydrate(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]g
if err != nil {
return nil, err
}
dtos, err := h.dto().Tracks(ctx, rows)
dtos, err := h.dto.Tracks(ctx, rows)
if err != nil {
return nil, err
}
@@ -591,8 +496,6 @@ func (h *Handler) ClearTrackReaction(ctx context.Context, req gen.ClearTrackReac
return gen.ClearTrackReaction204Response{}, nil
}
var _ library.Personalizer = (*Handler)(nil)
// impressionOutcome — исход показа волны по событию: complete | skip_early | skip | like | dislike.
func impressionOutcome(eventType string, o Outcome) string {
switch {
@@ -0,0 +1,58 @@
package playback
import (
"context"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/sign"
)
type ReactionReader interface {
Reactions(context.Context, uuid.UUID, []uuid.UUID) (map[uuid.UUID]string, error)
}
// Personalizer decorates catalog DTOs independently of HTTP handlers.
type Personalizer struct {
store ReactionReader
signer *sign.Signer
now func() time.Time
}
var _ library.Personalizer = (*Personalizer)(nil)
func NewPersonalizer(store ReactionReader, signer *sign.Signer) *Personalizer {
return &Personalizer{store: store, signer: signer, now: func() time.Time { return time.Now().UTC() }}
}
// Personalize реализует library.Personalizer.
func (h *Personalizer) 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
}
@@ -0,0 +1,55 @@
package playback
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/sign"
)
type reactionReaderFunc func(context.Context, uuid.UUID, []uuid.UUID) (map[uuid.UUID]string, error)
func (f reactionReaderFunc) Reactions(ctx context.Context, user uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]string, error) {
return f(ctx, user, ids)
}
func TestPersonalizerBatch(t *testing.T) {
user, first, second := uuid.New(), uuid.New(), uuid.New()
calls := 0
reader := reactionReaderFunc(func(_ context.Context, got uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]string, error) {
calls++
require.Equal(t, user, got)
require.Equal(t, []uuid.UUID{first, second}, ids)
return map[uuid.UUID]string{second: "dislike"}, nil
})
signer, err := sign.New("")
require.NoError(t, err)
p := NewPersonalizer(reader, signer)
now := time.Now().UTC()
p.now = func() time.Time { return now }
rows := []library.TrackRow{{ID: first}, {ID: second}}
out := []gen.Track{{Available: true}, {Available: false}}
require.NoError(t, p.Personalize(context.Background(), rows, out))
require.Zero(t, calls, "anonymous catalog does not query personal data")
ctx := auth.WithPrincipal(context.Background(), auth.Principal{UserID: user})
require.NoError(t, p.Personalize(ctx, rows, out))
require.Equal(t, 1, calls, "one reaction query for the entire batch")
require.Equal(t, StreamURL(signer, user, first, now), *out[0].StreamUrl)
require.Nil(t, out[1].StreamUrl, "unavailable tracks must not receive stream links")
require.Equal(t, gen.Reaction("dislike"), *out[1].Reaction)
}
func TestPersonalizerReadFailure(t *testing.T) {
failure := errors.New("database unavailable")
p := NewPersonalizer(reactionReaderFunc(func(context.Context, uuid.UUID, []uuid.UUID) (map[uuid.UUID]string, error) { return nil, failure }), nil)
ctx := auth.WithPrincipal(context.Background(), auth.Principal{UserID: uuid.New()})
require.ErrorIs(t, p.Personalize(ctx, nil, nil), failure)
}
+1 -1
View File
@@ -92,7 +92,7 @@ func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlis
if err != nil {
return out, err
}
dtos, err := h.dto().Tracks(ctx, tracks)
dtos, err := h.dto.Tracks(ctx, tracks)
if err != nil {
return out, err
}
@@ -42,7 +42,7 @@ func TestPlaylistPersistenceAndIsolation(t *testing.T) {
require.NoError(t, err)
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
st := NewStore(pool)
h := &Handler{store: st, catalog: library.NewStore(pool), dto: func() library.DTO { return library.DTO{} }}
h := &Handler{store: st, catalog: library.NewStore(pool), dto: library.DTO{}}
require.NoError(t, st.savePlaylist(ctx, owner, id, " Mix ", "Description", []uuid.UUID{first, second}, 0, true))
got, err := h.playlist(ctx, owner, id)
require.NoError(t, err)