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