feat(api): streaming, transcoding, events, history and likes (M3)
ci / api / api (push) Successful in 29s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
ci / ts / ts (packages + web) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s

- tracks carry a stream_url signed for the user for 12 h and the
  user's reaction; /stream checks the signature, expiry and that the
  user is not blocked, then hands the file to nginx by X-Accel-Redirect
  (or serves it with Range support when X_ACCEL=false)
- original by default; formats browsers can't play and explicit
  high/medium/low go through Opus transcoding, never upwards: a lossy
  source under the target bitrate stays original; transcodes are
  cached per file and size, deduplicated in flight, limited by
  TRANSCODE_CONCURRENCY and trimmed hourly by LRU to the size limit
- /events takes up to 500 events with client UUIDs, drops repeats and
  events older than 30 days, counts plays (≥ half or 4 min, or
  finished), early skips and history entries (≥ 30 s)
- history with clearing, likes and dislikes with cursors and totals,
  track reactions, playback state with last-write-wins, tracks batch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 07:25:15 +03:00
1 parent 8b76aa3964
commit c05e523cbe
16 files changed
+3913 -155

No files matched your search

+35 -6
View File
@@ -28,12 +28,35 @@ type Handler struct {
}
// NewHandler создаёт обработчик.
func NewHandler(svc *Service, store *Store, signer *sign.Signer, covers *Covers, xAccel bool) *Handler {
return &Handler{DTO: DTO{Signer: signer}, svc: svc, store: store, covers: covers, xAccel: xAccel}
func NewHandler(svc *Service, store *Store, dto DTO, covers *Covers, xAccel bool) *Handler {
return &Handler{DTO: dto, svc: svc, store: store, covers: covers, xAccel: xAccel}
}
// DTO переводит строки каталога в схемы API; подписывает ссылки на обложки.
type DTO struct{ Signer *sign.Signer }
type DTO struct {
Signer *sign.Signer
// Personal дополняет треки данными текущего пользователя (ссылка на стрим, реакция).
Personal Personalizer
}
// Personalizer заполняет персональные поля треков для пользователя из контекста запроса.
type Personalizer interface {
Personalize(ctx context.Context, rows []TrackRow, out []gen.Track) error
}
// Tracks переводит треки в схему и добавляет персональные поля.
func (d DTO) Tracks(ctx context.Context, rows []TrackRow) ([]gen.Track, error) {
out := make([]gen.Track, 0, len(rows))
for _, t := range rows {
out = append(out, d.Track(t))
}
if d.Personal != nil && len(out) > 0 {
if err := d.Personal.Personalize(ctx, rows, out); err != nil {
return nil, err
}
}
return out, nil
}
func (h *Handler) sourceDTO(s Source) gen.Source {
out := gen.Source{
@@ -382,11 +405,13 @@ func (h *Handler) GetAlbum(ctx context.Context, req gen.GetAlbumRequestObject) (
}
out := gen.GetAlbum200JSONResponse{
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: h.coverURL(a), CoverColors: colors(a.CoverColors),
Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name}, Tracks: make([]gen.Track, 0, len(tracks)),
Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name},
}
for _, t := range tracks {
out.DurationMs += t.DurationMs
out.Tracks = append(out.Tracks, h.Track(t))
}
if out.Tracks, err = h.Tracks(ctx, tracks); err != nil {
return nil, err
}
return out, nil
}
@@ -397,7 +422,11 @@ func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (
if err != nil {
return nil, catalogErr(err)
}
return gen.GetTrack200JSONResponse(h.Track(t)), nil
dto, err := h.Tracks(ctx, []TrackRow{t})
if err != nil {
return nil, err
}
return gen.GetTrack200JSONResponse(dto[0]), nil
}
// GetCover реализует gen.StrictServerInterface: проверяет подпись и отдаёт WebP нужного размера.