station.gno
11.94 Kb · 447 lines
1package radio
2
3import (
4 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
5 "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
6)
7
8// ---- Station internals: the rotation loop and the schedule over it ----
9
10// paused is the airtime taken by programmed slots before ts. It reads an
11// undecoded schedule as it is, and keeps its result until the schedule
12// moves (setSlots, move).
13func (st *station) paused(ts int64) int64 {
14 if st.pzOK && st.pzAt == ts {
15 return st.pz
16 }
17 var p int64
18 if st.decoded {
19 for _, s := range st.sched {
20 if ts <= s.Start {
21 break
22 }
23 if ts >= s.Start+s.Dur {
24 p += s.Dur
25 } else {
26 p += ts - s.Start
27 }
28 }
29 } else {
30 for c := (cursor{s: st.raw}); c.next() && ts > c.start; {
31 if ts >= c.end {
32 p += c.end - c.start
33 } else {
34 p += ts - c.start
35 }
36 }
37 }
38 st.pz, st.pzAt, st.pzOK = p, ts, true
39 return p
40}
41
42// pos is the position in the rotation loop at ts.
43func (st *station) pos(ts int64) int64 {
44 total := st.total()
45 if total == 0 {
46 return 0
47 }
48 p := (ts - st.epoch - st.paused(ts)) % total
49 if p < 0 {
50 p += total
51 }
52 return p
53}
54
55// anchor sets the epoch so that the rotation is at position p at ts.
56func (st *station) anchor(ts, p int64) {
57 st.epoch, st.dirty = ts-st.paused(ts)-p, true
58}
59
60func (st *station) rotationAt(ts int64) (slot, track int, offset, dur int64, ok bool) {
61 if st.total() == 0 {
62 return 0, 0, 0, 0, false
63 }
64 slot, track, offset, ok = st.find(st.pos(ts))
65 if !ok {
66 return 0, 0, 0, 0, false
67 }
68 _, dur = st.rotSlot(slot)
69 return slot, track, offset, dur, true
70}
71
72// rotating is how many tracks play on the station and its loop length: on
73// Main, which simulcasts the genre stations, theirs added up.
74func (st *station) rotating() (live int, loop int64) {
75 if st.id != MainStation {
76 return st.live, st.total()
77 }
78 for g := 1; g <= NumGenres; g++ {
79 gs := st.t.station(g)
80 live, loop = live+gs.live, loop+gs.total()
81 }
82 return
83}
84
85// playing is the programmed slot on air at ts.
86func (st *station) playing(ts int64) (Slot, bool) {
87 for _, s := range st.slots() {
88 if ts >= s.Start && ts < s.Start+s.Dur {
89 return s, true
90 }
91 }
92 return Slot{}, false
93}
94
95// between is what a station plays at ts outside programmed slots: its
96// rotation, or on Main a simulcast of the hour's genre station (flow.gno):
97// what that station plays at ts, its own picks included, cut where it pauses
98// for a pick and at the hour (the app's top-of-hour jingle covers the cut).
99// The genre station never jumps (its edits re-anchor it), so neither does Main.
100func (st *station) between(ts int64) (track int, off, dur int64, ok bool) {
101 if st.id != MainStation {
102 _, track, off, dur, ok = st.rotationAt(ts)
103 return
104 }
105 g := hourGenre(st.t, ts)
106 if g == 0 {
107 return
108 }
109 gs := st.t.station(g)
110 if s, prog := gs.playing(ts); prog {
111 track, off, dur, ok = s.Track, ts-s.Start, s.Dur, true
112 } else if _, track, off, dur, ok = gs.rotationAt(ts); !ok {
113 return
114 } else {
115 for _, s := range gs.slots() { // the genre rotation pauses for its next pick
116 if s.Start > ts && s.Start < ts-off+dur {
117 dur = s.Start - (ts - off)
118 }
119 }
120 }
121 if end := ts - ts%3600 + 3600; ts-off+dur > end {
122 dur = end - (ts - off)
123 }
124 if st.t.dropped(MainStation, track) {
125 track = 0 // dropped from Main: silence (onAir and ScheduleJSON skip it)
126 }
127 return
128}
129
130// relay is the station Main simulcasts at ts (the hour's genre station), or st.
131func (st *station) relay(ts int64) *station {
132 if st.id == MainStation {
133 if g := hourGenre(st.t, ts); g != 0 {
134 return st.t.station(g)
135 }
136 }
137 return st
138}
139
140// pickAt is the listener pick on air at ts: the station's own or, on Main
141// between its own picks, the genre station's pick it simulcasts, so Main
142// shows, tips and reports it as that pick.
143func (st *station) pickAt(ts int64) (Slot, bool) {
144 if s, ok := st.playing(ts); ok || st.id != MainStation {
145 return s, ok
146 }
147 if r := st.relay(ts); r != st {
148 if s, ok := r.playing(ts); ok && !st.t.dropped(MainStation, s.Track) {
149 return s, true
150 }
151 }
152 return Slot{}, false
153}
154
155func (st *station) onAir(ts int64) (int, int64, bool) {
156 if s, ok := st.pickAt(ts); ok {
157 if _, _, _, ok := catalog.TrackBrief(s.Track); ok {
158 return s.Track, ts - s.Start, true
159 }
160 return 0, 0, false // a removed programmed slot plays silence until its end
161 }
162 track, off, _, ok := st.between(ts)
163 if !ok {
164 return 0, 0, false
165 }
166 if _, _, _, playable := catalog.TrackBrief(track); !playable {
167 return 0, 0, false // hidden but not refreshed yet: silence, never its title
168 }
169 return track, off, false
170}
171
172// queued counts the listener slots not ended yet.
173func (st *station) queued(ts int64) int {
174 n := 0
175 for _, s := range st.slots() {
176 if s.Start+s.Dur > ts {
177 n++
178 }
179 }
180 return n
181}
182
183// add appends a track at the end of the loop and returns its slot; listeners do not move.
184func (st *station) add(trackID int, dur int64, ts int64) int {
185 p := st.pos(ts)
186 i := st.appendRot(trackID, dur)
187 if dur > 0 {
188 st.addLive(1)
189 }
190 st.anchor(ts, p)
191 return i
192}
193
194// join puts a synced track in New or Choice, a ring of keep slots: once
195// full, the oldest slot is reused in place, so the rotation never outgrows
196// keep whatever the catalog size or the number of picks. The track it held
197// leaves the ring's index, so Refresh leaves it out. Listeners do not jump:
198// replace re-anchors around the change.
199func (st *station) join(trackID int, dur int64, ts int64, keep int) {
200 p, found := st.ringFind(trackID)
201 st.joinAt(p, found, trackID, dur, ts, keep)
202}
203
204// joinAt is join for a track at position p of the ring's index (ringFind).
205func (st *station) joinAt(p int, found bool, trackID int, dur int64, ts int64, keep int) {
206 if st.full() {
207 return // no room in the record now: the track misses this ring (Choice takes it again with a later pick or Sync)
208 }
209 i := st.ring % keep
210 st.ring++
211 st.dirty = true
212 if st.count() < keep {
213 st.ringIndex(p, found, trackID, st.add(trackID, dur, ts))
214 return
215 }
216 old, _ := st.rotSlot(i)
217 st.replace(i, trackID, dur, ts)
218 if q := st.ringUnindex(old, i); q >= 0 && q < p {
219 p--
220 } else if q == p && old == trackID {
221 found = false
222 }
223 st.ringIndex(p, found, trackID, i)
224}
225
226// choose adds a synced track to Listeners' choice unless it is already there.
227func choose(t *tx, trackID int, dur int64, ts int64) {
228 if trackID < 1 || trackID > t.synced() {
229 return // not ingested yet
230 }
231 cs := t.station(ChoiceStation)
232 p, in := cs.ringFind(trackID)
233 if in {
234 return
235 }
236 if t.dropped(ChoiceStation, trackID) {
237 dur = 0
238 }
239 cs.joinAt(p, false, trackID, dur, ts, choiceCap)
240}
241
242// slot returns a track's index in this station's rotation. Main has no
243// rotation (it simulcasts the genre stations); New and Choice keep an index;
244// genre stations look the slot up in homes.
245func (st *station) slot(trackID int) (int, bool) {
246 switch {
247 case st.id == MainStation:
248 return 0, false
249 case st.ringed():
250 return st.ringSlot(trackID)
251 }
252 return homeSlot(st.t.homes(trackID), st.id)
253}
254
255// setDuration changes slot i and re-anchors so the song on air keeps playing
256// (or, if it is the one removed, the next one starts now). O(n/Size + Size).
257func (st *station) setDuration(i int, d int64, ts int64) {
258 id, old := st.rotSlot(i)
259 if old == d {
260 return
261 }
262 p := st.pos(ts)
263 prefix := st.prefixBefore(i)
264 np := p
265 switch {
266 case st.total() == 0 || p < prefix:
267 // the change is ahead of the listeners
268 case p >= prefix+old:
269 np = p - old + d // the change is behind them
270 default:
271 // the slot on air changes
272 if p-prefix >= d {
273 np = prefix + d // cut short: the next track starts now
274 }
275 }
276 st.setRot(i, id, d)
277 if old == 0 && d > 0 {
278 st.addLive(1)
279 } else if old > 0 && d == 0 {
280 st.addLive(-1)
281 }
282 if total := st.total(); total > 0 {
283 st.anchor(ts, np%total)
284 }
285}
286
287// replace puts another track in slot i, as setDuration(i, 0), a new id,
288// then setDuration(i, d) would, in one rotation write: the old track stops
289// if on air (the next starts now), listeners do not jump.
290func (st *station) replace(i, id int, d int64, ts int64) {
291 _, old := st.rotSlot(i)
292 if old == 0 && d == 0 {
293 st.setRot(i, id, 0) // the old sequence left the epoch alone
294 return
295 }
296 p, prefix := st.pos(ts), st.prefixBefore(i)
297 np := p
298 switch {
299 case st.total() == 0 || p < prefix:
300 case p >= prefix+old:
301 np = p - old + d
302 default:
303 np = prefix + d // the slot on air ends now
304 }
305 st.setRot(i, id, d)
306 if old == 0 && d > 0 {
307 st.addLive(1)
308 } else if old > 0 && d == 0 {
309 st.addLive(-1)
310 }
311 if total := st.total(); total > 0 {
312 st.anchor(ts, np%total)
313 }
314}
315
316// fold moves programmed slots that ended long ago into the epoch.
317func (st *station) fold(ts int64) {
318 sl := st.slots()
319 i := 0
320 for i < len(sl) && sl[i].Start+sl[i].Dur <= ts-foldAfter {
321 s := sl[i]
322 st.epoch += s.Dur
323 if s.Note != "" { // only a dedication has reports
324 st.t.del(cNotes, noteKey(st.id, s.Start))
325 }
326 if s.At > 0 { // the replay gap counts from when a booked pick aired
327 st.setPicked(s.Track, s.Start)
328 }
329 i++
330 }
331 if i > 0 {
332 st.setSlots(sl[i:])
333 }
334}
335
336// dropSlots removes a track's programmed slots; one on air ends now. It
337// returns the sponsored ones, which no longer air in full: their payout goes
338// back to the artist (release).
339func (st *station) dropSlots(trackID int, ts int64) (cut []hold) {
340 var out []Slot
341 changed := false
342 for _, s := range st.slots() {
343 if s.Track == trackID && s.Pay > 0 && s.Start+s.Dur > ts {
344 if h, ok := unsponsor(st.t, st.id, s, ts); ok {
345 cut = append(cut, h)
346 }
347 }
348 if s.Track == trackID {
349 if s.Start >= ts {
350 // a later pick may reuse this start: its reports go too
351 if s.Note != "" {
352 st.t.del(cNotes, noteKey(st.id, s.Start))
353 }
354 changed = true
355 continue
356 }
357 if s.Start+s.Dur > ts {
358 s.Dur = ts - s.Start
359 changed = true
360 }
361 }
362 out = append(out, s)
363 }
364 if changed {
365 st.setSlots(out)
366 }
367 return cut
368}
369
370// The replay record of a station (in its record: a pick changes both) is
371// fixed-width entries, the track (4 digits) then the pick time (6), read by
372// slicing.
373const pickW = 10
374
375// lastPicked is when a track was last picked on the station, within replayGap.
376func (st *station) lastPicked(trackID int) (int64, bool) {
377 rec := st.picks
378 key := store.Fixed(int64(trackID), 4)
379 for i := 0; i+pickW <= len(rec); i += pickW {
380 if rec[i:i+4] == key {
381 return store.Num(rec, i+4, 6), true
382 }
383 }
384 return 0, false
385}
386
387// setPicked records a pick time (the later one wins) and drops the picks
388// older than replayGap, which no check reads: the record stays as short as
389// the picks of the last three hours.
390func (st *station) setPicked(trackID int, at int64) {
391 rec := st.picks
392 key := store.Fixed(int64(trackID), 4)
393 cut := now() - replayGap
394 out := ""
395 for i := 0; i+pickW <= len(rec); i += pickW {
396 when := store.Num(rec, i+4, 6)
397 if rec[i:i+4] == key {
398 if when > at {
399 at = when
400 }
401 } else if when > cut {
402 out += rec[i : i+pickW]
403 }
404 }
405 if at > cut {
406 out += key + store.Fixed(at, 6)
407 }
408 if out != rec {
409 st.picks, st.dirty = out, true
410 }
411}
412
413// shownNote is the dedication listeners see ("" once hidden).
414func (s Slot) shownNote() string {
415 if s.NoteHidden {
416 return ""
417 }
418 return s.Note
419}
420
421// picker is the listener whose pick of trackID plays at ts on the station,
422// or ended less than tipGrace ago, read from the stored schedule: "" when
423// none, a sponsored pick (its artist already refunds it) or the admin's (a
424// curator pick: GnoRadio takes no share), and the station the pick lives on
425// (on Main, the genre station whose pick it simulcast).
426func (st *station) picker(trackID int, ts int64, adm address) (address, int) {
427 var by address
428 for c := (cursor{s: st.raw}); c.next(); {
429 b := st.raw[c.b:c.i]
430 if c.start > ts || ts >= c.end+tipGrace || int(dec[b[3]]<<18|dec[b[4]]<<12|dec[b[5]]<<6|dec[b[6]]) != trackID || store.Num(b, 17, 4) != 0 {
431 continue
432 }
433 if p := address(b[23 : 23+int(dec[b[22]])]); p != adm {
434 by = p
435 }
436 }
437 if by == "" && st.id == MainStation && !st.t.dropped(MainStation, trackID) {
438 for _, at := range []int64{ts, ts - tipGrace} { // the pick may have ended in the hour before
439 if r := st.relay(at); r != st {
440 if by, _ = r.picker(trackID, ts, adm); by != "" {
441 return by, r.id
442 }
443 }
444 }
445 }
446 return by, st.id
447}