Files
pipu-music/services/api/internal/playback/rules.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

80 lines
2.9 KiB
Go

// Package playback — стриминг, транскодинг, события прослушивания, история, реакции и
// состояние плеера (docs/playback.md).
package playback
// Качество стрима.
const (
QualityAuto = "auto"
QualityOriginal = "original"
QualityHigh = "high"
QualityMedium = "medium"
QualityLow = "low"
)
// Целевой битрейт Opus, кбит/с.
var opusKbps = map[string]int{QualityHigh: 256, QualityMedium: 128, QualityLow: 64}
// Форматы, которые браузеры не играют: их всегда отдаём транскодом.
var browserUnplayable = map[string]bool{"ape": true, "wv": true, "aiff": true, "aif": true}
var losslessCodecs = map[string]bool{"flac": true, "alac": true, "wavpack": true, "ape": true, "tta": true}
func lossless(codec string) bool {
return losslessCodecs[codec] || len(codec) > 4 && codec[:4] == "pcm_"
}
// Target решает, что отдать: "" — оригинал, иначе качество транскода.
// Перекодировать вверх бессмысленно: lossy-исходник не выше целевого битрейта отдаётся как есть.
func Target(q, format, codec string, bitrate int) string {
switch q {
case QualityHigh, QualityMedium, QualityLow:
default: // auto, original, пусто
if browserUnplayable[format] {
return QualityHigh
}
return ""
}
if lossless(codec) || browserUnplayable[format] {
return q
}
if bitrate > 0 && bitrate <= opusKbps[q]*1000*11/10 {
return ""
}
return q
}
// Пороги засчитывания (docs/playback.md#события-прослушивания).
const (
countCapMs = 4 * 60 * 1000 // засчитано после min(50% длительности, 4 мин)
historyMinMs = 30 * 1000 // в историю — от 30 с
earlySkipMs = 30 * 1000 // ранний скип — до 30 с
)
// Outcome — что значит событие окончания попытки (play_end / skip) для статистики.
type Outcome struct {
Counted bool // засчитанное прослушивание
Complete bool // доиграно до конца
Skip bool
EarlySkip bool
History bool // попадает в историю
PlayedMs int
}
// Judge оценивает событие окончания попытки. durationMs — длительность трека.
func Judge(eventType string, playedMs, durationMs int) Outcome {
o := Outcome{PlayedMs: max(playedMs, 0)}
switch eventType {
case "play_end":
o.Complete, o.Counted = true, true
case "skip":
o.Skip = true
o.EarlySkip = o.PlayedMs < earlySkipMs
threshold := min(durationMs/2, countCapMs)
o.Counted = durationMs > 0 && o.PlayedMs >= threshold
default:
return Outcome{}
}
o.History = o.PlayedMs >= historyMinMs || (durationMs > 0 && durationMs < historyMinMs && o.Complete)
return o
}