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

sponsor.gno

10.28 Kb · 257 lines
  1package radio
  2
  3import (
  4	"chain"
  5
  6	"gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
  7	"gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
  8	"gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
  9	"gno.land/r/nym-alexiscolin000/gnoradio/data"
 10)
 11
 12// Sponsored picks: an artist's promo budget (catalog/sponsor.gno) refunds a
 13// listener's pick of their track: by default what the pick and collecting
 14// its refund cost (0.2 GNOT, catalog/sponsor.gno). The listener
 15// chooses it (QueueSponsored) instead of a normal pick; the artist's refund is
 16// reserved at once in the data realm's vault, and the listener collects it
 17// (ClaimPickPayout, one signature, their gas) once the slot has aired in
 18// full. A slot dropped or cut before its end gives the reservation back; one
 19// not collected within 7 days lapses back to the budget by itself.
 20//
 21// Farming with throwaway wallets earns at most the pick cost back, and these
 22// rules keep it rare: a wallet needs a first pick 7 days old and 3 normal
 23// picks in the last two 15-day periods (two counters in the curator record: the
 24// current period and the one before, so the window is 15 to 30 days, never
 25// more);
 26// one sponsored pick per artist per station and hour; sponsored airtime at
 27// most a quarter of the 2 hours listeners may program; and per wallet one per
 28// artist and three a day (the catalog). They earn no curator point, do not
 29// join Listeners' choice and carry no dedication.
 30
 31const (
 32	repPeriod     = 15 * 24 * 3600 // reputation counters roll over every 15 days
 33	repAge        = 7 * 24 * 3600  // a wallet's first pick must be this old
 34	repPicks      = 3              // normal picks this period and the one before
 35	sponsorShare  = maxAheadAir / 4
 36	sponsorWindow = 3600
 37)
 38
 39// A sponsored pick not collected yet is radio/sponsor address/P(station) ->
 40// Rec(start, track, end, ugnot, held, reserved, gen): one open per
 41// wallet and station; held is the end the catalog reserved for, reserved
 42// when (unix), gen the budget's funding generation then.
 43//
 44// The vault pools an artist's holds per day, and a new owner's funding
 45// refunds the previous funder everything, holds included, and starts a new
 46// funding generation (catalog/sponsor.gno): a reservation made before is
 47// cancelled, never paid from (nor released into) a later listener's hold,
 48// even when the budget went back to the same funder.
 49
 50func sponsorKey(stationID int, addr address) string {
 51	return addr.String() + "/" + store.Pad(stationID)
 52}
 53
 54// QueueSponsored is Queue paid by the artist: once the pick has aired in
 55// full, ClaimPickPayout sends the picker the artist's refund (catalog
 56// PromoOffer). No dedication. A wallet has one sponsored pick open per
 57// station: it must be collected (or have lapsed) before the next.
 58func QueueSponsored(cur realm, stationID, trackID int) {
 59	queueSponsored(cur, stationID, trackID, 0)
 60}
 61
 62// QueueSponsoredAt is QueueSponsored for a chosen time (see QueueAt; 0: now).
 63// The refund lapses 7 days after the booked time plus the track's length.
 64func QueueSponsoredAt(cur realm, stationID, trackID int, at int64) {
 65	queueSponsored(cur, stationID, trackID, at)
 66}
 67
 68func queueSponsored(cur realm, stationID, trackID int, at int64) {
 69	caller := userCaller(cur)
 70	t := rd()
 71	if why := sponsorBlock(t, caller); why != "" {
 72		panic("radio: " + why)
 73	}
 74	mustStation(stationID)
 75	// One open per station until it lapses: collected, or nothing left to
 76	// pay (the catalog lapses it at the end of a UTC day, so a record past
 77	// end+7d may still pay).
 78	if rec, ok := data.Get(cSponsor, sponsorKey(stationID, caller)); ok {
 79		if payoutStatus(store.Fields(rec)) != "lapsed" {
 80			panic("radio: collect your last sponsored pick on this station first")
 81		}
 82	}
 83	queue(cur, caller, stationID, trackID, false, true, "", at)
 84}
 85
 86// ClaimPickPayout sends the caller the refund of their sponsored pick that
 87// started at start on stationID, once it has aired in full.
 88func ClaimPickPayout(cur realm, stationID int, start int64) {
 89	caller := userCaller(cur)
 90	noPayment()
 91	mustStation(stationID)
 92	t := rd()
 93	k := sponsorKey(stationID, caller)
 94	rec, ok := t.get(cSponsor, k)
 95	if !ok || text.MustAtoi64(store.Field(rec, 0)) != start {
 96		panic("radio: no sponsored pick of yours to collect at this time")
 97	}
 98	f := store.Fields(rec)
 99	track, amt, held := text.MustAtoi(f[1]), text.MustAtoi64(f[3]), text.MustAtoi64(f[4])
100	status := payoutStatus(f)
101	if status == "airing" {
102		panic("radio: your pick has not finished airing yet")
103	}
104	t.del(cSponsor, k)
105	// A removed track, a cancelled sponsorship or a lapsed refund leaves
106	// nothing to pay: the record goes, so the next sponsored pick is open.
107	if status == "lapsed" {
108		save(cur, t)
109		chain.Emit("PickPayoutVoid", "station", text.Itoa(stationID), "start", text.Itoa64(start), "track", text.Itoa(track), "to", caller.String())
110		return
111	}
112	catalog.PromoClaim(cross(cur), track, caller, held, amt, text.MustAtoi(f[6]))
113	c, sts := curRec(t, caller.String())
114	c[3] += amt
115	putCur(t, caller.String(), c, sts)
116	save(cur, t)
117	chain.Emit("PickPaid", "station", text.Itoa(stationID), "start", text.Itoa64(start), "track", text.Itoa(track), "to", caller.String(), "amount", text.Itoa64(amt))
118}
119
120// sponsorBlock says why a wallet may not take sponsored picks yet ("" when it may).
121func sponsorBlock(t *tx, addr address) string {
122	c, _ := curRec(t, addr.String())
123	ts := now()
124	if c[4] == 0 || ts-c[4] < repAge {
125		return "sponsored picks open 7 days after your first pick"
126	}
127	recent := int64(0)
128	switch ts / repPeriod {
129	case c[5]:
130		recent = c[6] + c[7]
131	case c[5] + 1:
132		recent = c[6]
133	}
134	if recent < repPicks {
135		return "sponsored picks need 3 normal picks in the last two 15-day periods"
136	}
137	return ""
138}
139
140// sponsorQuota: one sponsored pick per artist per station and hour, and
141// sponsored airtime at most a quarter of the listener window, both around
142// airs (now, or the time booked).
143// The share counts the listener window from airs; the per-artist hour is
144// checked around plays, when the pick really airs (review R31).
145func sponsorQuota(st *station, artistID int, dur, airs, plays int64) {
146	air := dur
147	for _, s := range st.slots() {
148		if s.Pay == 0 {
149			continue
150		}
151		if end := s.Start + s.Dur; end > airs && s.Start < airs+maxAheadAir {
152			air += end - max64(s.Start, airs)
153		}
154		if s.Start+s.Dur > plays-sponsorWindow && s.Start < plays+sponsorWindow && s.artist == artistID {
155			panic("radio: this artist already has a sponsored pick this hour here, pick it normally")
156		}
157	}
158	if air > sponsorShare {
159		panic("radio: sponsored picks are full on this station for now, pick normally")
160	}
161}
162
163// reserve books the artist's refund for a sponsored slot, lapsing after held.
164func reserve(cur realm, t *tx, picker address, stationID, trackID int, start, dur, held int64) int64 {
165	amt, gen := catalog.PromoReserve(cross(cur), trackID, picker, held)
166	t.set(cSponsor, sponsorKey(stationID, picker), store.Rec(text.Itoa64(start), text.Itoa(trackID), text.Itoa64(start+dur), text.Itoa64(amt), text.Itoa64(held), text.Itoa64(now()), text.Itoa(gen)))
167	return amt
168}
169
170// held is the end the catalog reserves a sponsored slot's refund for: a
171// booked slot keeps the one of its time even if it moves to a boundary.
172func (s Slot) held() int64 {
173	if s.At > 0 {
174		return s.At + s.Dur
175	}
176	return s.Start + s.Dur
177}
178
179// hold is a sponsored slot's reservation in the catalog: filed under the end
180// it was reserved for (the slot may have moved since). reserved is when it
181// was taken, 0 once the slot started airing: only a pick that never aired
182// gives its daily counts back.
183type hold struct {
184	track                int
185	picker               address
186	held, paid, reserved int64
187	gen                  int
188}
189
190// unsponsor forgets the open record of a sponsored slot cut at ts before it
191// started, and returns its reservation; ok is false when there was none. A
192// slot already on air keeps its record: whoever cut it (a hide, a genre edit,
193// a drop), the picker earned the refund and collects it once its end passes.
194func unsponsor(t *tx, stationID int, s Slot, ts int64) (hold, bool) {
195	k := sponsorKey(stationID, s.By)
196	rec, ok := t.get(cSponsor, k)
197	if !ok || s.Start < ts || text.MustAtoi64(store.Field(rec, 0)) != s.Start {
198		return hold{}, false
199	}
200	t.del(cSponsor, k)
201	f := store.Fields(rec)
202	return hold{s.Track, s.By, text.MustAtoi64(f[4]), s.Pay, text.MustAtoi64(f[5]), text.MustAtoi(f[6])}, true
203}
204
205// release gives the reservations of cut sponsored slots back to their
206// artists. While the catalog is paused it gives nothing back, so the radio
207// can still drop slots: each hold lapses back to its budget by itself.
208func release(cur realm, cut []hold) {
209	if data.Paused("catalog") {
210		return
211	}
212	for _, h := range cut {
213		catalog.PromoRelease(cross(cur), h.track, h.picker, h.held, h.paid, h.reserved, h.gen)
214	}
215}
216
217// PickPayoutStatus says what ClaimPickPayout would do for addr's sponsored
218// pick that started at start on stationID: "ok" (it pays), "airing" (not
219// finished yet), "lapsed" (nothing to pay: lapsed or cancelled; collecting only clears it) or
220// "none" (no such open pick).
221func PickPayoutStatus(addr address, stationID int, start int64) string {
222	rec, ok := data.Get(cSponsor, sponsorKey(stationID, addr))
223	if !ok || text.Atoi64(store.Field(rec, 0)) != start {
224		return "none"
225	}
226	return payoutStatus(store.Fields(rec))
227}
228
229func payoutStatus(f []string) string {
230	track, end, amt, held := text.MustAtoi(f[1]), text.MustAtoi64(f[2]), text.MustAtoi64(f[3]), text.MustAtoi64(f[4])
231	if now() < end {
232		return "airing"
233	}
234	// An aired pick pays whatever happened to its track since: hiding it
235	// must not let its artist take back a refund the picker earned.
236	if !catalog.PromoClaimable(track, held, amt, text.MustAtoi(f[6])) {
237		return "lapsed"
238	}
239	return "ok"
240}
241
242// SponsoredJSON lists a wallet's sponsored picks not collected yet, with
243// whether it may take new ones and each one's PickPayoutStatus:
244// {"block":"why not, or empty","open":[{"station","start","track","end","amount","status"}]}.
245func SponsoredJSON(addr address) string {
246	out := `{"block":` + text.Str(sponsorBlock(rd(), addr)) + `,"open":[`
247	p := addr.String() + "/"
248	keys, recs := store.Rows(data.Page(cSponsor, p, addr.String()+"0", numStations, false))
249	for i, rec := range recs {
250		f := store.Fields(rec)
251		if i > 0 {
252			out += ","
253		}
254		out += `{"station":` + text.Itoa(text.Atoi(keys[i][len(p):])) + `,"start":` + f[0] + `,"track":` + f[1] + `,"end":` + f[2] + `,"amount":` + f[3] + `,"status":"` + payoutStatus(f) + `"}`
255	}
256	return out + `]}`
257}