queue.gno
15.77 Kb · 443 lines
1package radio
2
3import (
4 "chain"
5 "chain/runtime"
6 "crypto/ed25519"
7 "encoding/hex"
8 "strconv"
9
10 "gno.land/p/nym-alexiscolin000/gnoradio/safe/v0"
11 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
12 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
13 "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
14 "gno.land/r/nym-alexiscolin000/gnoradio/data"
15)
16
17// ---- Programming ----
18
19// Queue programs a track on a station and adds it to Listeners' choice. It
20// starts after the last track a listener programmed, or when the song on air
21// ends: nothing is cut. On Main it starts at once over the hour's relay (the
22// app crossfades), which resumes after it.
23// Limits per station: one waiting slot per listener, two per artist, a track
24// at most once, 30 upcoming.
25func Queue(cur realm, stationID, trackID int) {
26 queue(cur, userCaller(cur), stationID, trackID, false, false, "", 0)
27}
28
29// QueueAt is Queue for a chosen time: at (unix, UTC) between 15 minutes and
30// 24 hours from now; 0 is Queue. The pick starts at the first track boundary
31// at or after at (on Main, at at: the relay's track fades out as for any
32// pick), unless listener picks fill that moment, then right after them. It
33// keeps its time: later picks go before it only if they end by then, else
34// after it. Every Queue rule applies, the 3-hour replay gap around at; the
35// 2-hour airtime cap does not, but at most 4 picks are booked per station and
36// clock hour and at most 15 booked picks wait per station.
37func QueueAt(cur realm, stationID, trackID int, at int64) {
38 queue(cur, userCaller(cur), stationID, trackID, false, false, "", at)
39}
40
41// QueueWithNote is Queue with a dedication shown on air ("for Marie!"). The
42// moderation robot judges it before the transaction (word filter and a
43// moderation model, app/netlify/functions/dedication.mts) and signs
44// NoteMessage(note, expires): the listener sends that certificate and pays
45// their own gas, so a dedication shows at once and costs GnoRadio nothing. It
46// must also pass p/gnoradio/safe (plain text, no link, no blocked word).
47// Listeners can report it: three reports hide it and give its author a strike;
48// a second hidden dedication within a week pauses their dedications for a
49// week (ReportNote). The pause is per wallet: an author can dedicate again
50// from a fresh one, which reports hide and pause the same way (accepted
51// residual; the robot and the word filter still judge every note).
52func QueueWithNote(cur realm, stationID, trackID int, note string, expires int64, sigHex string) {
53 queueWithNote(cur, stationID, trackID, 0, note, expires, sigHex)
54}
55
56// QueueWithNoteAt is QueueWithNote for a chosen time (see QueueAt; 0: now).
57func QueueWithNoteAt(cur realm, stationID, trackID int, at int64, note string, expires int64, sigHex string) {
58 queueWithNote(cur, stationID, trackID, at, note, expires, sigHex)
59}
60
61func queueWithNote(cur realm, stationID, trackID int, at int64, note string, expires int64, sigHex string) {
62 caller := userCaller(cur)
63 if until, ok := data.Get(cMuted, caller.String()); ok && now() < text.MustAtoi64(until) {
64 panic("radio: your dedications are paused after reports, pick without one")
65 }
66 if why := safe.Note(note, maxNote); why != "" {
67 panic("radio: dedication: " + why)
68 }
69 checkNote(caller, stationID, note, expires, sigHex)
70 queue(cur, caller, stationID, trackID, false, false, note, at)
71}
72
73// NoteMessage is what the moderation robot signs to approve a dedication by
74// author on stationID until expires (unix seconds), on this chain and this
75// GnoRadio deployment (its data realm) only:
76// "gnoradio-note|<chain id>|<data realm>|<author>|<station>|<note>|<expires>".
77// A certificate seen in a public transaction serves no other wallet,
78// station or deployment.
79func NoteMessage(author address, stationID int, note string, expires int64) string {
80 return "gnoradio-note|" + runtime.ChainID() + "|" + dataPath + "|" + author.String() + "|" + text.Itoa(stationID) + "|" + note + "|" + text.Itoa64(expires)
81}
82
83func checkNote(author address, stationID int, note string, expires int64, sigHex string) {
84 v, _ := data.Get(cConfig, "modbot")
85 key, _ := hex.DecodeString(v)
86 if len(key) == 0 {
87 panic("radio: dedications are paused, pick without one")
88 }
89 if ts := now(); expires <= ts || expires > ts+noteCertLife {
90 panic("radio: the dedication check expired, send it again")
91 }
92 sig, err := hex.DecodeString(sigHex)
93 if err != nil || len(sig) != 64 || !ed25519.Verify(key, []byte(NoteMessage(author, stationID, note, expires)), sig) {
94 panic("radio: this dedication was not approved, send it again")
95 }
96}
97
98// CuratorQueue programs a track like Queue but without the per-listener and
99// per-artist quotas (the duplicate, genre and queue-size rules still apply).
100// Radio admin only.
101func CuratorQueue(cur realm, stationID, trackID int) {
102 onlyAdmin(cur)
103 queue(cur, cur.Previous().Address(), stationID, trackID, true, false, "", 0)
104}
105
106func queue(cur realm, caller address, stationID, trackID int, curator, sponsored bool, note string, at int64) {
107 noPayment()
108 t := rd()
109 st := t.station(stationID)
110 who := caller // own picks earn nothing: only a listener's needs the check
111 if curator || sponsored {
112 who = ""
113 }
114 artistID, genre, dur, playable, own := catalog.TrackBriefFor(trackID, who)
115 if !playable {
116 panic("radio: unknown track")
117 }
118 if st.genre != 0 && genre != st.genre {
119 panic("radio: this track does not belong to this station")
120 }
121 if t.dropped(st.id, trackID) { // a pick must not undo the admin's DropSlot
122 panic("radio: this track was removed from this station")
123 }
124 if st.id == NewStation && trackID <= t.synced()-newCap {
125 panic("radio: only new tracks can be picked on New this week")
126 }
127 ts := now()
128 airs := ts // when the pick is meant to air
129 if at != 0 {
130 if at < ts+bookMin || at > ts+bookMax {
131 panic("radio: book a time between 15 minutes and 24 hours from now")
132 }
133 airs = at
134 }
135 st.fold(ts)
136 start := st.place(ts, dur, at)
137 // Main's simulcast is not a slot: picking the track it plays would
138 // restart it over itself.
139 if st.id == MainStation && at == 0 {
140 if _, prog := st.playing(ts); !prog {
141 if tr, _, _, _ := st.between(ts); tr == trackID {
142 panic("radio: this track is already programmed")
143 }
144 }
145 }
146 // The replay gap counts from when the pick really airs: a pick for now
147 // may wait up to 2 hours behind others, a booked one keeps its time.
148 gapAt := airs
149 if at == 0 {
150 gapAt = start
151 }
152 // realign may still move a booked pick by up to a track: around one,
153 // the replay gap takes that much more.
154 gap := int64(replayGap)
155 if at != 0 {
156 gap += maxTrack
157 }
158 upcoming, kept, perArtist, booked, bookedUp := 0, 0, 0, 0, 0
159 var ahead int64 // listener airtime for now still to play
160 for _, s := range st.slots() {
161 // A slot of the track blocks it within the replay gap.
162 if s.Track == trackID && s.Start+s.Dur > ts {
163 g := gap
164 if s.At > 0 {
165 g = replayGap + maxTrack
166 }
167 if abs64(s.Start-gapAt) < g {
168 panic("radio: this track is already programmed")
169 }
170 }
171 // A booked pick enters lastpick when folded, an hour after it ends:
172 // until then its slot keeps the replay gap.
173 if s.Track == trackID && s.At > 0 && s.Start+s.Dur <= ts && !curator && gapAt-s.Start < replayGap {
174 panic("radio: this track was picked here less than 3 hours ago, pick another one")
175 }
176 kept++ // aired or not: a listener slot stays in the record until it folds
177 if at != 0 && s.At/3600 == at/3600 {
178 booked++ // aired ones too: at most maxBookedHour per clock hour
179 }
180 if s.Start+s.Dur <= ts {
181 continue
182 }
183 upcoming++
184 if s.At > 0 {
185 bookedUp++
186 }
187 if !curator && s.Start > ts && s.By == caller {
188 panic("radio: you already have a track waiting on this station")
189 }
190 if s.artist == artistID {
191 perArtist++
192 }
193 if s.At == 0 {
194 ahead += s.Start + s.Dur - max64(s.Start, ts)
195 }
196 }
197 if upcoming >= maxQueue || kept >= maxKept {
198 panic("radio: the queue is full, try again later")
199 }
200 // Bookings may cover the next 24 hours: they take at most half the
201 // queue, so they never leave picks for now without room.
202 if at != 0 && bookedUp >= maxBooked {
203 panic("radio: booked picks are full on this station, pick for now or later")
204 }
205 if booked >= maxBookedHour {
206 panic("radio: 4 picks are already booked for that hour here, choose another time")
207 }
208 // No track on a loop: many wallets cannot replay one song for hours.
209 if last, ok := st.lastPicked(trackID); ok && !curator && gapAt-last < gap {
210 panic("radio: this track was picked here less than 3 hours ago, pick another one")
211 }
212 if !curator {
213 if last := lastPickAt(t, caller, st.id); last > 0 && ts-last < queueCooldown {
214 panic("radio: you can queue one track per station per hour")
215 }
216 if at == 0 && ahead+dur > maxAheadAir {
217 panic("radio: this station is programmed two hours ahead, try again later")
218 }
219 }
220 if !curator && perArtist >= maxPerArtistQ {
221 panic("radio: this artist already has 2 tracks in the queue")
222 }
223 if sponsored {
224 sponsorQuota(st, artistID, dur, airs, gapAt)
225 }
226 if at == 0 {
227 st.setPicked(trackID, start) // a booked pick is in the schedule; fold records it once aired
228 }
229 var pay int64
230 if sponsored {
231 pay = reserve(cur, t, caller, st.id, trackID, start, dur, Slot{Start: start, Dur: dur, At: at}.held())
232 }
233 st.put(Slot{Track: trackID, Start: start, Dur: dur, By: caller, Note: note, Pay: pay, At: at, artist: artistID})
234 if st.full() {
235 panic("radio: this station is full for now, try again later")
236 }
237 // Dedications use only the room the station's other listener slots, their
238 // replay entries and a full curator top cannot need: plain picks always
239 // fit (TestChoiceRecordWorstCase).
240 if note != "" && st.size()+noteReserve(st, kept) > maxRecord-recordHeadroom {
241 panic("radio: dedications are full on this station for now, pick without one")
242 }
243 kind := "queue"
244 if curator {
245 kind = "curator"
246 } else if sponsored {
247 kind = "sponsored"
248 }
249 recordActivity(t, kind, caller, trackID, st.id, start)
250 // A sponsored pick is paid for by its artist: it feeds neither Listeners'
251 // choice nor the curator ranking, and only keeps the cooldown. Nor does
252 // an artist's (or a collaborator's) pick of their own track (an alt
253 // wallet remains a residual, as for any sybil pick).
254 switch {
255 case sponsored || own:
256 markPick(t, caller, st.id)
257 case !curator:
258 choose(t, trackID, dur, ts)
259 credit(t, caller, st.id, 1, 0, 0)
260 }
261 save(cur, t)
262 chain.Emit("Queued", "curator", strconv.FormatBool(curator), "station", text.Itoa(st.id), "track", text.Itoa(trackID), "by", caller.String(), "start", text.Itoa64(start), "sponsored", text.Itoa64(pay), "at", text.Itoa64(at))
263}
264
265// place says when a new pick of d seconds starts. A pick for now starts when
266// the song on air ends (at once on Main, over the relay), after the picks
267// for now already waiting; one booked for at starts at the first track
268// boundary at or after at. Both go around booked picks: before one only if
269// they end by its time, else after it.
270func (st *station) place(ts, d, at int64) int64 {
271 t := at
272 if at == 0 {
273 t = ts
274 if st.id != MainStation {
275 if _, _, off, slotDur, ok := st.rotationAt(ts); ok {
276 t = ts + slotDur - off
277 }
278 }
279 for _, s := range st.slots() {
280 // after the picks for now, and after a booked pick on air (the
281 // rotation is paused under it: its track would only play a
282 // fragment before the pick)
283 if end := s.Start + s.Dur; end > ts && (s.At == 0 || s.Start <= ts) {
284 t = end
285 }
286 }
287 } else {
288 t = st.boundary(at)
289 }
290 for _, s := range st.slots() {
291 if s.Start+s.Dur <= t {
292 continue
293 }
294 if s.Start <= t {
295 t = s.Start + s.Dur // playing then: wait for its end
296 continue
297 }
298 limit := s.Start
299 if s.At > 0 {
300 limit = s.At // a booked pick may come back to its own time
301 }
302 if t+d <= limit {
303 break
304 }
305 t = s.Start + s.Dur
306 }
307 return t
308}
309
310// boundary is the first track boundary at or after t outside listener
311// picks: t itself on Main (the relay fades out) or inside a programmed
312// slot (place waits for its end), else the end of the rotation track on air.
313func (st *station) boundary(t int64) int64 {
314 if st.id == MainStation {
315 return t
316 }
317 for _, s := range st.slots() {
318 if s.Start <= t && t < s.Start+s.Dur {
319 return t
320 }
321 }
322 if _, _, off, d, ok := st.rotationAt(t); ok && off > 0 {
323 return t + d - off
324 }
325 return t
326}
327
328// put puts a new listener slot in the schedule. Elsewhere than on Main the
329// slots after it are realigned: they moved with the rotation.
330func (st *station) put(n Slot) {
331 sl, i := insert(st.slots(), n)
332 st.setSlots(sl)
333 if st.id != MainStation {
334 st.realign(i)
335 }
336}
337
338// insert puts s in a schedule, sorted by start, and returns its index.
339func insert(sl []Slot, s Slot) ([]Slot, int) {
340 i := len(sl)
341 for i > 0 && sl[i-1].Start > s.Start {
342 i--
343 }
344 sl = append(sl, Slot{})
345 copy(sl[i+1:], sl[i:])
346 sl[i] = s
347 return sl, i
348}
349
350// realign re-times the listener slots after index i once the rotation moved
351// under them: a booked one to the first boundary at or after its time (or
352// right after the slot before it), a pick that followed the slot before it
353// still follows it, a pick for now that waited for a rotation track after a
354// gap waits for the new end of that track. Sponsored records, note reports and
355// the replay record move along.
356// ponytail: only queue realigns; Unqueue, DropSlot and rotation edits leave
357// booked slots in place (the rotation track then pauses around them, as
358// around any pick after a removed one); realign there too if it matters.
359func (st *station) realign(i int) {
360 sl := st.slots()
361 var paused int64 // airtime of the slots before k: they all ended by its time (paused, inline)
362 for _, s := range sl[:i+1] {
363 paused += s.Dur
364 }
365 total := st.total()
366 oldEnd := sl[i].Start + sl[i].Dur
367 var notes []int64 // the dedicated slots moved: old start, new start
368 for k := i + 1; k < len(sl); k++ {
369 prev, s := sl[k-1], sl[k]
370 end := prev.Start + prev.Dur
371 start := max64(s.Start, end)
372 t := s.At
373 if t == 0 && s.Start != oldEnd {
374 t = s.Start // a pick for now that started on a rotation boundary after a gap
375 }
376 if t > end && total > 0 { // the rotation plays until then: wait for the end of that track
377 start = t
378 p := (t - st.epoch - paused) % total
379 if p < 0 {
380 p += total
381 }
382 if slot, _, off, ok := st.find(p); ok && off > 0 {
383 _, d := st.rotSlot(slot)
384 start += d - off
385 }
386 } else if s.At > 0 || s.Start == oldEnd {
387 start = max64(s.At, end)
388 }
389 oldEnd = s.Start + s.Dur
390 paused += s.Dur
391 if start != s.Start {
392 if s.Note != "" {
393 notes = append(notes, s.Start, start)
394 }
395 st.move(k, start)
396 }
397 }
398 st.moveNotes(notes)
399}
400
401// moveNotes re-keys the reports of moved dedications: all read, then all
402// removed, then all written, so that a slot moved to where another one
403// started never takes or loses its reports.
404func (st *station) moveNotes(moves []int64) {
405 t := st.t
406 recs := make([]string, len(moves)/2)
407 for j := 0; j < len(moves); j += 2 {
408 recs[j/2] = t.val(cNotes, noteKey(st.id, moves[j]))
409 }
410 for j := 0; j < len(moves); j += 2 {
411 t.del(cNotes, noteKey(st.id, moves[j]))
412 }
413 for j := 0; j < len(moves); j += 2 {
414 if recs[j/2] != "" {
415 t.set(cNotes, noteKey(st.id, moves[j+1]), recs[j/2])
416 }
417 }
418}
419
420// move re-times slot k, with its sponsored record and its replay record
421// (its dedication's reports move with moveNotes).
422func (st *station) move(k int, start int64) {
423 t := st.t
424 s := &st.sched[k]
425 if s.Pay > 0 {
426 k := sponsorKey(st.id, s.By)
427 if rec, ok := t.get(cSponsor, k); ok && text.Atoi64(store.Field(rec, 0)) == s.Start {
428 f := store.Fields(rec)
429 t.set(cSponsor, k, store.Rec(text.Itoa64(start), f[1], text.Itoa64(start+s.Dur), f[3], f[4], f[5], f[6]))
430 }
431 }
432 if s.At == 0 {
433 st.setPicked(s.Track, start) // a pick for now: the replay gap counts from its airing
434 }
435 s.Start = start
436 st.dirty, st.pzOK = true, false
437}
438
439// noteReserve is the record room the other plain picks a station may still
440// take need: a slot and a replay entry each, plus its curator top at full.
441func noteReserve(st *station, kept int) int {
442 return (maxKept-kept-1)*(plainSlot+pickW) + topMax - len(st.top)
443}