add internal/domain/playlist.go

This commit is contained in:
2026-09-30 21:59:31 +02:00
parent ad822138b6
commit c5fa413323
+118
View File
@@ -0,0 +1,118 @@
// Package domain holds the core radio model: tracks and playlist policy.
// No I/O, no dependencies — pure rules, fully unit-tested.
package domain
import (
"math"
"math/rand"
"sort"
"strconv"
"time"
)
// UnixTime is a time.Time that (un)marshals as a numeric Unix epoch,
// matching the generator app's Python `time.time()` float format.
type UnixTime struct{ time.Time }
func (u *UnixTime) UnmarshalJSON(b []byte) error {
s := string(b)
if s == "null" {
u.Time = time.Time{}
return nil
}
// quoted RFC3339 (defensive)
if len(s) > 1 && s[0] == '"' {
t, err := time.Parse(time.RFC3339, s[1:len(s)-1])
if err == nil {
u.Time = t
return nil
}
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return err
}
sec := math.Floor(f)
nsec := int64((f - sec) * 1e9)
u.Time = time.Unix(int64(sec), nsec)
return nil
}
func (u UnixTime) MarshalJSON() ([]byte, error) {
return []byte(strconv.FormatFloat(float64(u.UnixNano())/1e9, 'f', 3, 64)), nil
}
// Track is a generated song.
type Track struct {
ID string `json:"id"`
Genre string `json:"genre"`
Language string `json:"language"`
Title string `json:"title"`
Style string `json:"style"`
Filename string `json:"filename"` // relative to library dir, e.g. "synthwave/<id>.flac"
Duration float64 `json:"duration"`
CreatedAt UnixTime `json:"created_at"`
Source string `json:"source"` // generated | library
Rating int `json:"rating"` // 1 liked, -1 disliked, 0 unrated
}
// DisplayTitle is what the stereo display should show.
func (t Track) DisplayTitle() string {
if t.Title != "" {
return t.Title
}
return t.Style
}
// Playable reports whether the track may be broadcast.
func (t Track) Playable() bool { return t.Rating != -1 }
// FilterPlayable drops disliked tracks.
func FilterPlayable(tracks []Track) []Track {
out := make([]Track, 0, len(tracks))
for _, t := range tracks {
if t.Playable() {
out = append(out, t)
}
}
return out
}
// BuildPlaylist returns a newest-biased random sample of at most size tracks.
// Weight of the k-th newest track is 1/(k+bias): low bias = strongly favors
// fresh songs; bias >> pool size = near-uniform shuffle.
// seed makes the selection deterministic for tests.
func BuildPlaylist(tracks []Track, size, bias int, seed int64) []Track {
pool := FilterPlayable(tracks)
sort.Slice(pool, func(i, j int) bool { return pool[i].CreatedAt.After(pool[j].CreatedAt.Time) })
if size > len(pool) {
size = len(pool)
}
if size <= 0 {
return nil
}
rng := rand.New(rand.NewSource(seed))
remaining := make([]Track, len(pool))
copy(remaining, pool)
out := make([]Track, 0, size)
for len(out) < size && len(remaining) > 0 {
weights := make([]float64, len(remaining))
total := 0.0
for k := range remaining {
weights[k] = 1.0 / float64(k+bias)
total += weights[k]
}
r := rng.Float64() * total
acc := 0.0
for k := range remaining {
acc += weights[k]
if r <= acc {
out = append(out, remaining[k])
remaining = append(remaining[:k], remaining[k+1:]...)
break
}
}
}
return out
}