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
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:
1 parent
c84f90e2cc
commit
e158558da3
56 files changed
+3337
-2196
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user