// 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 }