radio.gno
12.81 Kb · 369 lines
1// Package radio is GnoRadio's radio rules, release 1: a main station that
2// plays the whole catalog through the genre stations, one station per genre,
3// "New this week" (the newest tracks) and "Listeners' choice" (the tracks
4// listeners picked).
5// Everyone tuned to a station hears the same track at the same second,
6// computed from block time; no streaming server is involved. Between picks
7// the main station simulcasts one genre station an hour, by the time of day
8// (flow.gno); it has no rotation of its own.
9//
10// Each station has a rotation (every playable track of its genre, looping)
11// stored in fixed-size blocks so that every operation costs the same
12// whatever the catalog size, and a short schedule of tracks programmed by
13// listeners. While a programmed track plays the rotation pauses, then
14// resumes where it was. Adding, removing or editing a track never makes
15// listeners jump.
16//
17// It keeps no state but its admin role. Every record lives in the data realm
18// (gno.land/r/nym-alexiscolin000/gnoradio/data), in the "radio/…" collections only the current
19// radio writer may change (db.gno). It reads the catalog through its value
20// getters; sponsored picks are paid from data's vault through the catalog.
21package radio
22
23import (
24 "chain"
25 "chain/runtime"
26 "chain/runtime/unsafe"
27 "strconv"
28 "time"
29
30 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
31 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
32 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
33 "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
34 "gno.land/r/nym-alexiscolin000/gnoradio/data"
35)
36
37// NumGenres is the catalog's: genre stations are 1..NumGenres.
38const NumGenres = catalog.NumGenres
39
40// Station ids are stored keys in the permanent data realm (schedules, drops,
41// notes, curator ranks): Main 0, genres 1..NumGenres, New, Choice. A later
42// release must keep them, and add any new station after Choice.
43const (
44 MainStation = 0
45 NewStation = NumGenres + 1 // the newest newCap ingested tracks
46 ChoiceStation = NumGenres + 2 // the last choiceCap distinct listener picks
47 numStations = ChoiceStation + 1
48 newCap = 300
49 choiceCap = 500
50 maxQueue = 30 // upcoming slots per station
51 maxPerArtistQ = 2 // upcoming slots per artist and station
52 maxSyncBatch = 200
53 maxRefreshBatch = 50
54 foldAfter = 3600 // programmed slots ended this long ago fold into the epoch
55 queueCooldown = 3600 // a listener may queue once per station per hour
56 replayGap = 3 * 3600 // a track picked on a station cannot be picked there again for 3 hours, by anyone
57 maxAheadAir = 7200 // listener-programmed airtime ahead on a station, seconds
58 maxNote = 40 // characters of a dedication
59 noteCertLife = 900 // seconds a moderation certificate may still have to live
60 hideAfter = 3 // distinct reputable listeners (sponsorBlock) reporting a dedication hide it
61 muteFor = 7 * 24 * 3600 // a second hidden dedication within this time mutes its author this long
62 bookMin = 15 * 60 // a pick booked for a time: at least this far ahead
63 bookMax = 24 * 3600 // and at most this far
64 maxBookedHour = 4 // booked picks per station and clock hour (UTC)
65 maxKept = 3 * maxQueue // listener slots a station keeps, aired ones included until they fold
66 maxBooked = maxQueue / 2 // booked picks waiting per station
67 maxTrack = 1200 // the catalog's longest track, seconds
68 recordHeadroom = 1024 // bytes a station record keeps free for the writes that follow a pick
69 plainSlot = 72 // record bytes of a listener slot without a dedication: 25, an address, at most 7 of gap
70)
71
72// dataPath is the data realm: certificates are bound to it.
73const dataPath = "gno.land/r/nym-alexiscolin000/gnoradio/data"
74
75// Slot is a track a listener (or a curator) programmed on a station. Dur is
76// captured when queued so later catalog edits cannot move the timeline.
77type Slot struct {
78 Track int
79 Start int64
80 Dur int64
81 By address
82 Note string // the picker's dedication, checked by p/gnoradio/safe and the moderation robot
83 NoteHidden bool // hidden by reports or the admin
84 Pay int64 // a sponsored pick: the ugnot its artist reserved to refund the picker (sponsor.gno)
85 At int64 // a booked pick: the time asked (unix); it starts at the first track boundary at or after it
86 artist int // a listener slot's artist (a track never changes artist): the per-artist rules read it
87 body string // the encoded slot without its start, reused while it does not change (db.gno)
88 bdur int64 // the duration body holds
89}
90
91var (
92 self string // this realm's path
93 admin *role.Role // the radio admin; nil when a release before renounced it
94)
95
96// init takes the admin over from the previous release (radio/config
97// "admin"), or names the deployer. While this realm is the radio writer
98// (release 1 at deploy) it also makes the radio's collections.
99func init(cur realm) {
100 self = cur.PkgPath()
101 holder, mirrored := data.Get(cConfig, "admin")
102 if !mirrored {
103 deployer := unsafe.OriginCaller()
104 if deployer == "" && runtime.ChainID() == "dev" {
105 deployer = chain.PackageAddress(self + "/dev") // gno test: a key nobody holds
106 }
107 holder = deployer.String()
108 }
109 if holder != "" {
110 admin = role.New("admin", address(holder))
111 }
112 if data.Writer("radio") == self {
113 setup(cur, address(holder))
114 }
115}
116
117// ---- Catalog ingestion (permissionless) ----
118
119// Sync ingests up to max catalog tracks not yet on the stations into the
120// main station, the station of their genre and New; the catalog's most
121// liked tracks join Listeners' choice. Main's flow needs no Sync (flow.gno).
122// Anyone may call it. PublishTrack and ImportTrack put each new track on the
123// air themselves (publish.gno), so this is a safety net: a track is never
124// ingested twice (the flow record counts the synced ones). A track that is
125// not playable yet is added silent (duration 0) and Refresh turns it on.
126func Sync(cur realm, max int) int {
127 noPayment()
128 max = store.Limit(max, maxSyncBatch)
129 t := rd()
130 ts := now()
131 n := ingest(t, ts, max, true)
132 synced, choice := t.synced(), t.station(ChoiceStation)
133 for _, r := range catalog.TopLiked() {
134 if id := text.Atoi(r.Key); id <= synced {
135 if _, in := choice.ringFind(id); in {
136 continue // there already: no need to read the catalog
137 }
138 if _, _, dur, ok := catalog.TrackBrief(id); ok {
139 choose(t, id, dur, ts)
140 }
141 }
142 }
143 save(cur, t)
144 return n
145}
146
147// ingest adds up to max newly published tracks to the stations, and to New
148// when joinNew (a launch-catalog import is not a new release).
149func ingest(t *tx, ts int64, max int, joinNew bool) int {
150 synced := t.synced()
151 from := synced + 1
152 last := catalog.TrackCount()
153 if last > synced+max {
154 last = synced + max
155 }
156 for id := from; id <= last; id++ {
157 _, genre, dur, playable := catalog.TrackBrief(id)
158 if !playable {
159 dur = 0
160 }
161 home := ""
162 if genre >= 1 && genre <= NumGenres {
163 home = homeEntry(genre, t.station(genre).add(id, dur, ts))
164 }
165 if joinNew {
166 t.station(NewStation).join(id, dur, ts, newCap)
167 }
168 t.appendHomes(id, home)
169 }
170 n := last - synced
171 if n > 0 {
172 t.setFlowAt(fSynced, 4, last)
173 chain.Emit("Synced", "from", text.Itoa(from), "to", text.Itoa(last))
174 }
175 return n
176}
177
178// Refresh re-reads a track from the catalog and updates its rotation slots:
179// a hidden track goes silent, a restored one comes back, an edited duration is
180// applied. Programmed slots of a track that is no longer playable are dropped.
181// Anyone may call it. A slot the admin dropped stays silent.
182func Refresh(cur realm, trackID int) {
183 noPayment()
184 t := rd()
185 refresh(cur, t, trackID)
186 save(cur, t)
187}
188
189// RefreshArtist refreshes up to maxRefreshBatch synced tracks of an artist,
190// from offset in the artist's track list, so hiding (or restoring) a whole
191// artist takes a few calls instead of one per track. It returns the offset to
192// pass next, or 0 when the list is done. Anyone may call it.
193func RefreshArtist(cur realm, artistID, offset int) int {
194 noPayment()
195 n := catalog.ArtistTrackCount(artistID)
196 if n == 0 && catalog.ArtistName(artistID) == "" {
197 panic("radio: unknown artist")
198 }
199 if offset < 0 || offset > n {
200 offset = 0
201 }
202 t := rd()
203 synced := t.synced()
204 for _, id := range catalog.ArtistTrackPage(artistID, offset, maxRefreshBatch) {
205 if id >= 1 && id <= synced {
206 refresh(cur, t, id)
207 }
208 }
209 save(cur, t)
210 if end := offset + maxRefreshBatch; end < n {
211 return end
212 }
213 return 0
214}
215
216func refresh(cur realm, t *tx, trackID int) {
217 if trackID < 1 || trackID > t.synced() {
218 panic("radio: track not synced yet")
219 }
220 _, genre, dur, playable := catalog.TrackBrief(trackID)
221 if !playable {
222 dur = 0
223 }
224 ts := now()
225 // Only the stations that hold the track in their rotation need a slot
226 // lookup (Main, New and Choice as they say, the genre stations listed in
227 // homes). Programmed slots are checked on every station: a genre edit
228 // must not leave a slot nobody can silence.
229 inGenre := false
230 for id := 0; id < numStations; id++ {
231 st := t.station(id)
232 belongs := st.genre == 0 || st.id == genre
233 if i, ok := st.slot(trackID); ok {
234 d := dur
235 if !belongs || t.dropped(st.id, trackID) {
236 d = 0
237 }
238 st.setDuration(i, d, ts)
239 if st.id == genre {
240 inGenre = true
241 }
242 }
243 if !playable || !belongs {
244 release(cur, st.dropSlots(trackID, ts))
245 }
246 }
247 if !inGenre && genre >= 1 && genre <= NumGenres && dur > 0 {
248 t.setHomes(trackID, t.homes(trackID)+homeEntry(genre, t.station(genre).add(trackID, dur, ts)))
249 }
250 chain.Emit("Refreshed", "track", text.Itoa(trackID), "playable", strconv.FormatBool(playable))
251}
252
253// ---- Reads ----
254
255// NowPlaying returns what a station plays at the last block: the track, the
256// offset into it in seconds, and whether a listener programmed it. trackID is
257// 0 when nothing plays. Clients should prefer ScheduleJSON and their clock.
258func NowPlaying(stationID int) (trackID int, offset int64, queued bool) {
259 return rd().station(stationID).onAir(now())
260}
261
262// OnAirNote returns the dedication of the programmed slot on air ("" when
263// none, or once reports hid it).
264func OnAirNote(stationID int) string {
265 if s, ok := rd().station(stationID).pickAt(now()); ok {
266 return s.shownNote()
267 }
268 return ""
269}
270
271// OnAirPay is the refund of the sponsored pick on air (0: none), for labels.
272func OnAirPay(stationID int) int64 {
273 if s, ok := rd().station(stationID).pickAt(now()); ok {
274 return s.Pay
275 }
276 return 0
277}
278
279// UpNext returns the slots listeners programmed on a station that have not
280// started yet.
281func UpNext(stationID int) []Slot {
282 st := rd().station(stationID)
283 ts := now()
284 var out []Slot
285 for _, s := range st.slots() {
286 if s.Start > ts {
287 if _, _, _, ok := catalog.TrackBrief(s.Track); ok {
288 s.Note = s.shownNote()
289 out = append(out, s)
290 }
291 }
292 }
293 return out
294}
295
296// StationCount returns the number of stations (ids 0..StationCount()-1).
297func StationCount() int { return numStations }
298
299// StationName is "Main", the genre name, "New this week" or "Listeners' choice".
300func StationName(stationID int) string {
301 switch mustStation(stationID) {
302 case MainStation:
303 return "Main"
304 case NewStation:
305 return "New this week"
306 case ChoiceStation:
307 return "Listeners' choice"
308 default:
309 return catalog.GenreName(stationID)
310 }
311}
312
313// StationInfo returns how many tracks rotate on a station and the loop length.
314func StationInfo(stationID int) (tracks int, loop int64, queued int) {
315 st := rd().station(stationID)
316 tracks, loop = st.rotating()
317 return tracks, loop, st.queued(now())
318}
319
320// Pending returns how many catalog tracks are not on the stations yet: 0, since
321// publishing puts a track on the air (Sync is the safety net).
322func Pending() int { return catalog.TrackCount() - rd().synced() }
323
324// PickRules returns the limits on listeners' picks, for pages that explain
325// them: one pick per cooldown per station, a track not again within replay,
326// at most ahead seconds and queue slots waiting, perArtist waiting per artist,
327// booked picks bookFrom to bookTo ahead, bookedPerHour per clock hour and
328// booked of the queue slots at most.
329func PickRules() (cooldown, replay, ahead int64, queue, perArtist int, bookFrom, bookTo int64, bookedPerHour, booked int) {
330 return queueCooldown, replayGap, maxAheadAir, maxQueue, maxPerArtistQ, bookMin, bookMax, maxBookedHour, maxBooked
331}
332
333// ---- helpers ----
334
335func now() int64 { return time.Now().Unix() }
336
337func mustStation(id int) int {
338 if id < 0 || id >= numStations {
339 panic("radio: unknown station")
340 }
341 return id
342}
343
344func userCaller(cur realm) address {
345 if !cur.Previous().IsUserCall() {
346 panic("radio: call this directly from your wallet")
347 }
348 return cur.Previous().Address()
349}
350
351func noPayment() {
352 if len(unsafe.OriginSend()) != 0 {
353 panic("radio: this action does not take coins")
354 }
355}
356
357func abs64(n int64) int64 {
358 if n < 0 {
359 return -n
360 }
361 return n
362}
363
364func max64(a, b int64) int64 {
365 if a > b {
366 return a
367 }
368 return b
369}