From c5fa413323dc10481715b5fcb24355934557fcff Mon Sep 17 00:00:00 2001 From: hermes Date: Wed, 30 Sep 2026 21:59:31 +0200 Subject: [PATCH] add internal/domain/playlist.go --- internal/domain/playlist.go | 118 ++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 internal/domain/playlist.go diff --git a/internal/domain/playlist.go b/internal/domain/playlist.go new file mode 100644 index 0000000..e074db6 --- /dev/null +++ b/internal/domain/playlist.go @@ -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/.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 +}