add internal/domain/playlist.go
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user