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}