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

social.gno

11.26 Kb · 307 lines
  1package catalog
  2
  3import (
  4	"chain"
  5	"chain/banker"
  6	"strings"
  7
  8	"gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
  9	"gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
 10	"gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
 11	"gno.land/r/nym-alexiscolin000/gnoradio/data"
 12)
 13
 14// Like records a public like. The first listeners to like a track earn its
 15// early-listener badge. (Saving a track privately is done in the app, free.)
 16func Like(cur realm, trackID int) {
 17	caller := userCaller(cur)
 18	noPayment()
 19	// Likes and the early badges are updated in place: the most frequent write
 20	// never decodes the whole track.
 21	t := rd()
 22	rec, arec := playableRec(t, trackID)
 23	if rec == "" {
 24		panic("catalog: unknown track")
 25	}
 26	if address(store.Field(arec, aOwner)) == caller {
 27		panic("catalog: you cannot like your own track")
 28	}
 29	if !t.addID(cLikes, caller.String(), trackID) {
 30		panic("catalog: already liked")
 31	}
 32	n := text.Atoi(store.Field(rec, fLikes)) + 1
 33	rec = store.With(rec, fLikes, text.Itoa(n))
 34	if early := store.Field(rec, fEarly); !hasEarly(early, caller) && (len(early)+1)/(len(caller)+1) < earlyBadges {
 35		if early != "" {
 36			early += ","
 37		}
 38		rec = store.With(rec, fEarly, early+caller.String())
 39	}
 40	t.setRec(cTracks, trackID, rec)
 41	id := text.Itoa(trackID)
 42	t.rank(topLiked, id, int64(n), true)
 43	t.activityOnce("like", caller, id, store.Field(rec, fArtist))
 44	save(cur, t)
 45	emitEvent("Liked", "track", id, "by", caller.String())
 46}
 47
 48// hasEarly reports whether a comma-joined address list holds a (addresses
 49// share one length, so this compares slices instead of scanning bytes).
 50func hasEarly(list string, a address) bool {
 51	w := len(a) + 1
 52	for p := 0; p+len(a) <= len(list); p += w {
 53		if list[p:p+len(a)] == a.String() {
 54			return true
 55		}
 56	}
 57	return false
 58}
 59
 60// dropEarly takes a out of a comma-joined address list.
 61func dropEarly(list string, a address) string {
 62	var kept []string
 63	for _, x := range strings.Split(list, ",") {
 64		if x != a.String() {
 65			kept = append(kept, x)
 66		}
 67	}
 68	return strings.Join(kept, ",")
 69}
 70
 71// Unlike takes a like back, and its early-listener badge with it.
 72func Unlike(cur realm, trackID int) {
 73	caller := userCaller(cur)
 74	noPayment()
 75	t := rd()
 76	if !t.removeID(cLikes, caller.String(), trackID) {
 77		panic("catalog: not liked")
 78	}
 79	if rec := t.rec(cTracks, trackID); rec != "" {
 80		if n := text.Atoi(store.Field(rec, fLikes)); n > 0 {
 81			rec = store.With(rec, fLikes, text.Itoa(n-1))
 82			t.rank(topLiked, text.Itoa(trackID), int64(n-1), false)
 83		}
 84		// The early-listener badge goes with the like: like and unlike
 85		// cycles keep no badge, and free the slot for a real listener.
 86		if early := store.Field(rec, fEarly); hasEarly(early, caller) {
 87			rec = store.With(rec, fEarly, dropEarly(early, caller))
 88		}
 89		t.setRec(cTracks, trackID, rec)
 90	}
 91	save(cur, t)
 92	emitEvent("Unliked", "track", text.Itoa(trackID), "by", caller.String())
 93}
 94
 95// Tip forwards the attached ugnot to the track's artist and collaborators,
 96// split as declared at publish time, in this same transaction. Only verified
 97// artists receive tips (verify.gno). Verification proves control of a web
 98// host or an Audius account, not who the artist is: the proof host is public
 99// next to every ✓ (ProofHost), and the 72 h claim window (PendingClaims) is
100// there to catch an impersonator before tips open.
101func Tip(cur realm, trackID int) {
102	tip(cur, userCaller(cur), paid(), trackID, 0, "", "")
103}
104
105// TipWithSupport is Tip plus an optional contribution to GnoRadio: of the
106// attached total T, the artist side receives T*100/(100+supportPct) (split as
107// in Tip) and the rest goes to the GnoRadio treasury. supportPct is 0..50.
108func TipWithSupport(cur realm, trackID int, supportPct int) {
109	tip(cur, userCaller(cur), paid(), trackID, supportPct, "", "")
110}
111
112// tip pays total from this realm's balance for caller. The artist's promo
113// share (promo.gno) of the artist side goes to picker and ref, when given and
114// neither the tipper nor the artist, and only when the tipper did not pick
115// the track themselves; collaborators split what is left.
116func tip(cur realm, caller address, total int64, trackID, supportPct int, picker, ref address) (toPicker, toRef int64) {
117	if supportPct < 0 || supportPct > maxSupportPct {
118		panic("catalog: supportPct must be between 0 and 50")
119	}
120	// The track and artist records are changed in place, as Like does.
121	t := rd()
122	if supportPct > 0 && treasury(t) == "" {
123		panic("catalog: no treasury is set, a tip carries no support share")
124	}
125	rec, arec := playableRec(t, trackID)
126	if rec == "" {
127		panic("catalog: unknown track")
128	}
129	owner := address(store.Field(arec, aOwner))
130	if owner == "" || store.Field(arec, aVerified) == "" {
131		panic("catalog: this artist has not verified their profile yet")
132	}
133	if owner == caller {
134		panic("catalog: you cannot tip your own track")
135	}
136	if total > maxTip+maxTip/2 {
137		panic("catalog: a tip is between 0.1 and 1000000 GNOT")
138	}
139	amt := total * 100 / int64(100+supportPct)
140	platform := total - amt
141	if amt < minTip || amt > maxTip {
142		panic("catalog: a tip is between 0.1 and 1000000 GNOT")
143	}
144	// A tipper who picked the track themselves brings no listener: no promo
145	// share goes out, neither to the pick nor to a link they name.
146	selfPicked := picker == caller
147	if selfPicked || picker == owner || !role.Canonical(picker) || data.IsGnoRadio(picker) {
148		picker = ""
149	}
150	self := cur.Address()
151	bk := banker.NewBanker(banker.BankerTypeRealmSend, cur)
152	// This realm holds nothing between transactions: the tip is all it has.
153	if bk.GetCoins(self).AmountOf(denom) < total {
154		panic("catalog: the tip did not arrive")
155	}
156	if selfPicked || ref == caller || ref == owner || ref == picker || !role.Canonical(ref) || data.IsGnoRadio(ref) {
157		ref = ""
158	}
159	artistID := text.Atoi(store.Field(rec, fArtist))
160	toPicker, toRef = promoShares(amt, promoShare(t, artistID), picker, ref)
161	artistSide := amt - toPicker - toRef
162
163	rest := artistSide
164	var paidTo strings.Builder
165	if splits := store.Field(rec, fSplits); splits != "" {
166		for _, p := range strings.Split(splits, ",") {
167			to, pct, _ := strings.Cut(p, ":")
168			share := artistSide * text.Atoi64(pct) / 100
169			if share > 0 && !data.IsGnoRadio(address(to)) { // a realm of a later release: the owner keeps it
170				bk.SendCoins(self, address(to), chain.Coins{{Denom: denom, Amount: share}})
171				rest -= share
172				paidTo.WriteString(to + ":" + text.Itoa64(share) + ",")
173			}
174		}
175	}
176	bk.SendCoins(self, owner, chain.Coins{{Denom: denom, Amount: rest}})
177	paidTo.WriteString(owner.String() + ":" + text.Itoa64(rest))
178	if toPicker > 0 {
179		bk.SendCoins(self, picker, chain.Coins{{Denom: denom, Amount: toPicker}})
180	}
181	if toRef > 0 {
182		bk.SendCoins(self, ref, chain.Coins{{Denom: denom, Amount: toRef}})
183	}
184	if platform > 0 {
185		bk.SendCoins(self, treasury(t), chain.Coins{{Denom: denom, Amount: platform}})
186		recordSupport(t, caller, platform)
187	}
188
189	rec = store.With(rec, fTips, text.Itoa64(text.Atoi64(store.Field(rec, fTips))+artistSide))
190	if t.addID(cTipped, caller.String(), trackID) {
191		rec = store.With(rec, fSupporters, text.Itoa(text.Atoi(store.Field(rec, fSupporters))+1))
192	}
193	t.setRec(cTracks, trackID, rec)
194	artistTips := text.Atoi64(store.Field(arec, aTips)) + artistSide
195	t.setRec(cArtists, artistID, store.With(arec, aTips, text.Itoa64(artistTips)))
196	// The tipper's rank and feed line count what reached the artist side: a
197	// promo share may go back to a second wallet of the tipper's.
198	u := user(t, caller)
199	u.Tipped += artistSide
200	putUser(t, caller, u)
201	aid, id := store.Field(rec, fArtist), text.Itoa(trackID)
202	t.rank(topArtists, aid, artistTips, true)
203	t.rank(topFans, caller.String(), u.Tipped, true)
204	t.activity("tip", caller, id, aid, text.Itoa64(artistSide))
205	save(cur, t)
206	emitEvent("Tipped", "track", id, "by", caller.String(), "amount", text.Itoa64(amt), "paid", paidTo.String(), "support", text.Itoa64(platform),
207		"picker", picker.String(), "toPicker", text.Itoa64(toPicker), "ref", ref.String(), "toRef", text.Itoa64(toRef))
208	return toPicker, toRef
209}
210
211// ---- Albums ----
212
213// CreateAlbum groups tracks of one artist. The artist (or the admin for an
214// unclaimed artist) creates it; trackIDs is "12,13,14" in play order.
215func CreateAlbum(cur realm, artistID int, title, cover, coverSHA string, year int, trackIDs string) int {
216	caller := userCaller(cur)
217	noPayment()
218	t := rd()
219	a := mustArtist(t, artistID)
220	if !mayEdit(caller, a) {
221		panic("catalog: only the artist can create its albums")
222	}
223	title, cover, coverSHA = strings.TrimSpace(title), strings.TrimSpace(cover), strings.ToLower(strings.TrimSpace(coverSHA))
224	mustText("title", title, 1, maxTitle)
225	if cover != "" {
226		mustMedia("cover", cover, coverSHA, false)
227	}
228	if year < 1900 || year > 2100 {
229		panic("catalog: year must be between 1900 and 2100")
230	}
231	ids := parseIDs(trackIDs, maxAlbumTracks)
232	artistTracks, albumIDs := lists(t, a.ID)
233	if store.Count(albumIDs) >= maxPerArtist {
234		panic("catalog: albums per artist limit reached")
235	}
236	albumID := int(t.bump(mAlbums, 1))
237	for _, id := range ids {
238		tr := mustTrack(t, id)
239		if tr.Artist != a.ID || tr.Album != 0 {
240			panic("catalog: track " + text.Itoa(id) + " is not this artist's or already in an album")
241		}
242		tr.Album = albumID
243		putTrack(t, tr)
244	}
245	al := &Album{ID: albumID, Artist: a.ID, Title: title, Cover: cover, CoverSHA: coverSHA, Year: year, Tracks: ids, Created: now()}
246	t.addRec(cAlbums, al.ID, fits(albumRecord(al)))
247	setLists(t, a.ID, artistTracks, albumIDs+store.Enc(al.ID))
248	t.activity("album", caller, text.Itoa(al.ID), text.Itoa(a.ID), "0")
249	save(cur, t)
250	emitEvent("AlbumCreated", "id", text.Itoa(al.ID), "artist", text.Itoa(a.ID), "title", title)
251	return al.ID
252}
253
254// ---- Playlists ----
255
256// PublishPlaylist makes a public playlist in one signature; trackIDs is
257// "12,40,7". Private playlists live in the app and cost nothing.
258func PublishPlaylist(cur realm, title, trackIDs string) int {
259	caller := userCaller(cur)
260	noPayment()
261	title = strings.TrimSpace(title)
262	mustText("title", title, 1, maxTitle)
263	t := rd()
264	ids := checkedIDs(t, trackIDs)
265	u := user(t, caller)
266	if len(u.Playlists) >= maxUserLists {
267		panic("catalog: playlists per listener limit reached")
268	}
269	p := &Playlist{ID: int(t.bump(mPlaylists, 1)), Owner: caller, Title: title, Tracks: ids, Updated: now()}
270	u.Playlists = append(u.Playlists, p.ID)
271	putUser(t, caller, u)
272	putPlaylist(t, p)
273	t.activity("playlist", caller, text.Itoa(p.ID), "0", "0")
274	save(cur, t)
275	emitEvent("PlaylistPublished", "id", text.Itoa(p.ID), "owner", caller.String(), "tracks", text.Itoa(len(ids)))
276	return p.ID
277}
278
279// UpdatePlaylist replaces a playlist's title and tracks. Owner only.
280func UpdatePlaylist(cur realm, playlistID int, title, trackIDs string) {
281	caller := userCaller(cur)
282	noPayment()
283	t := rd()
284	p := getPlaylist(t, playlistID)
285	if p == nil || p.Hidden || p.Owner != caller {
286		panic("catalog: not your playlist")
287	}
288	title = strings.TrimSpace(title)
289	mustText("title", title, 1, maxTitle)
290	p.Title, p.Tracks, p.Updated = title, checkedIDs(t, trackIDs), now()
291	putPlaylist(t, p)
292	save(cur, t)
293	emitEvent("PlaylistUpdated", "id", text.Itoa(p.ID), "tracks", text.Itoa(len(p.Tracks)))
294}
295
296// checkedIDs parses a playlist's track ids. Only the id range and duplicates
297// are checked: loading every track would cost a read per id, and hidden or
298// removed tracks are skipped when the playlist is read.
299func checkedIDs(t *tx, s string) []int {
300	ids := parseIDs(s, maxListTracks)
301	for _, id := range ids {
302		if int64(id) > t.count(mTracks) {
303			panic("catalog: unknown track " + text.Itoa(id))
304		}
305	}
306	return ids
307}