Files
pipu-music/services/api/internal/playback/stream.go
T
mrleo1nidandClaude Opus 5.5 c05e523cbe
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
feat(api): streaming, transcoding, events, history and likes (M3)
- 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>
2026-10-20 07:25:15 +03:00

203 lines
6.1 KiB
Go

package playback
import (
"context"
"fmt"
"io/fs"
"log/slog"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"pipu/api/internal/platform/sign"
)
// StreamTTL — срок жизни ссылки на стрим (ADR-006).
const StreamTTL = 12 * time.Hour
// StreamURL подписывает ссылку на стрим трека для пользователя. Качество клиент добавляет сам (&q=).
func StreamURL(s *sign.Signer, userID, trackID uuid.UUID, now time.Time) string {
exp := now.Add(StreamTTL).Unix()
e := strconv.FormatInt(exp, 10)
return "/api/v1/stream/" + trackID.String() + "?u=" + userID.String() + "&exp=" + e +
"&sig=" + s.Sign("stream", userID.String(), trackID.String(), e)
}
// VerifyStream проверяет подпись и срок.
func VerifyStream(s *sign.Signer, sig string, userID, trackID uuid.UUID, exp int64, now time.Time) bool {
if now.Unix() > exp {
return false
}
return s.Verify(sig, "stream", userID.String(), trackID.String(), strconv.FormatInt(exp, 10))
}
// MIME-типы оригиналов.
var mimeTypes = map[string]string{
"flac": "audio/flac", "mp3": "audio/mpeg", "m4a": "audio/mp4", "aac": "audio/aac", "ogg": "audio/ogg",
"opus": "audio/ogg; codecs=opus", "wav": "audio/wav", "aiff": "audio/aiff", "aif": "audio/aiff",
"wv": "audio/x-wavpack", "ape": "audio/x-ape",
}
// ContentType — MIME оригинала по формату.
func ContentType(format string) string {
if m, ok := mimeTypes[format]; ok {
return m
}
return "application/octet-stream"
}
// Transcoder перекодирует в Opus и хранит результат в кэше. Один файл в одном качестве
// кодируется не больше одного раза одновременно; общее число ffmpeg ограничено.
type Transcoder struct {
FFmpeg string
Dir string // /cache/transcode
LimitGB int
log *slog.Logger
sem chan struct{}
mu sync.Mutex
inflight map[string]*flight
}
type flight struct {
done chan struct{}
err error
}
// NewTranscoder создаёт транскодер с concurrency параллельными ffmpeg.
func NewTranscoder(ffmpeg, dir string, concurrency, limitGB int, log *slog.Logger) *Transcoder {
return &Transcoder{
FFmpeg: ffmpeg, Dir: dir, LimitGB: limitGB, log: log,
sem: make(chan struct{}, max(1, concurrency)), inflight: map[string]*flight{},
}
}
// CacheName — имя файла транскода: зависит от файла и его размера, чтобы изменённый файл
// не отдавался из устаревшего кэша.
func CacheName(fileID uuid.UUID, size int64, quality string) string {
return fmt.Sprintf("%s-%d-%s.opus", fileID, size, quality)
}
// Ensure возвращает путь к готовому транскоду, кодируя его при необходимости.
func (t *Transcoder) Ensure(ctx context.Context, src string, name, quality string) (string, error) {
dst := filepath.Join(t.Dir, name)
if _, err := os.Stat(dst); err == nil {
now := time.Now()
_ = os.Chtimes(dst, now, now) // LRU: отмечаем использование
return dst, nil
}
t.mu.Lock()
if f, ok := t.inflight[name]; ok {
t.mu.Unlock()
select {
case <-f.done:
return dst, f.err
case <-ctx.Done():
return "", ctx.Err()
}
}
f := &flight{done: make(chan struct{})}
t.inflight[name] = f
t.mu.Unlock()
f.err = t.encode(src, dst, quality)
close(f.done)
t.mu.Lock()
delete(t.inflight, name)
t.mu.Unlock()
return dst, f.err
}
// encode не зависит от контекста запроса: если слушатель ушёл, транскод всё равно пригодится.
func (t *Transcoder) encode(src, dst, quality string) error {
t.sem <- struct{}{}
defer func() { <-t.sem }()
if err := os.MkdirAll(t.Dir, 0o755); err != nil {
return err
}
tmp := dst + ".tmp"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
out, err := exec.CommandContext(ctx, t.FFmpeg, "-v", "error", "-y", "-i", src, "-map", "0:a:0",
"-c:a", "libopus", "-b:a", fmt.Sprintf("%dk", opusKbps[quality]), "-vbr", "on", "-f", "ogg", tmp).CombinedOutput()
if err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg opus: %s", strings.TrimSpace(string(out)))
}
return os.Rename(tmp, dst)
}
// RunCleanup раз в час удаляет давно не использованные транскоды сверх лимита.
func (t *Transcoder) RunCleanup(ctx context.Context) {
tick := time.NewTicker(time.Hour)
defer tick.Stop()
for {
t.cleanup()
select {
case <-ctx.Done():
return
case <-tick.C:
}
}
}
func (t *Transcoder) cleanup() {
type entry struct {
path string
size int64
at time.Time
}
var (
files []entry
total int64
)
_ = filepath.WalkDir(t.Dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(p, ".opus") {
return nil
}
if info, err := d.Info(); err == nil {
files = append(files, entry{p, info.Size(), info.ModTime()})
total += info.Size()
}
return nil
})
limit := int64(t.LimitGB) << 30
if total <= limit {
return
}
sort.Slice(files, func(i, j int) bool { return files[i].at.Before(files[j].at) })
removed := 0
for _, f := range files {
if total <= limit {
break
}
if os.Remove(f.path) == nil {
total -= f.size
removed++
}
}
t.log.Info("transcode cache trimmed", "removed", removed)
}
// accelPath — внутренний путь nginx к оригиналу (сегменты экранированы).
func accelPath(f StreamFile) string {
segs := strings.Split(f.RelPath, "/")
if f.SourceType == "local" && f.LocalPath != nil && *f.LocalPath != "" {
segs = append(strings.Split(*f.LocalPath, "/"), segs...)
}
for i, s := range segs {
segs[i] = url.PathEscape(s)
}
if f.SourceType == "local" {
return "/_internal/library-host/" + strings.Join(segs, "/")
}
return "/_internal/library/" + f.SourceID.String() + "/" + strings.Join(segs, "/")
}