Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}