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

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}