- 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>
80 lines
2.9 KiB
Go
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
|
|
}
|