records.gno
9.68 Kb · 350 lines
1package catalog
2
3import (
4 "strings"
5
6 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
7 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
8)
9
10// Records are store.Rec values: fields behind a length header, so reading a
11// field never scans the record. Hot fields come first. Id lists use store's
12// JSON-ready width and go straight into the JSON exports. The fields are
13// catalog v0's, ordered so the frequent writes touch the first ones.
14
15type Split struct {
16 To address
17 Pct int64 // percent of each tip
18}
19
20type Artist struct {
21 ID int
22 Owner address // "" while unclaimed (curated / Audius / Jamendo)
23 Kind string // artist | curated | audius | jamendo
24 Name string
25 Bio string
26 Source string // curated: source page; audius / jamendo: platform id (audius-user:<id>, jamendo-artist:<id>)
27 Joined int64
28 Tips int64
29 Followers int
30 Tracks []int
31 Albums []int
32 Verified bool
33 Hidden bool
34}
35
36type Track struct {
37 ID int
38 Artist int
39 Origin string
40 Title string
41 Genre int
42 Duration int64 // seconds
43 License string
44 CMO string // none | sacem-nc
45 Credits string
46 Audio string // ipfs://… ar://… audius:… jamendo:… https://…
47 AudioSHA string // sha256 hex, required for https
48 Cover string
49 CoverSHA string
50 SourceURL string
51 Attribution string
52 Splits []Split
53 Album int
54 Created int64
55 Likes int
56 Tips int64
57 Supporters int
58 Hidden bool
59 Early []address // first listeners to like it
60}
61
62type Album struct {
63 ID int
64 Artist int
65 Title string
66 Cover string
67 CoverSHA string
68 Year int
69 Tracks []int
70 Created int64
71 Hidden bool
72}
73
74type Playlist struct {
75 ID int
76 Owner address
77 Title string
78 Tracks []int
79 Updated int64
80 Hidden bool
81}
82
83// User is what a listener writes, so a follow only touches that listener's
84// own record. Follows are a sorted packed list (9 bytes each); likes and the
85// tipped tracks live apart, per range of 512 track ids.
86type User struct {
87 Follows string // sorted packed artist ids
88 Tipped int64
89 Playlists []int
90 Reports int // open reports filed by this user
91}
92
93type ReportInfo struct {
94 ID int
95 Kind string // track | album | artist | playlist
96 Target string
97 Reason string
98 By address
99 At int64
100}
101
102// ---- tracks: what the radio reads first, then what likes and tips change ----
103
104// Track record fields. Reading or replacing field i walks the i fields
105// before it, so the fields the frequent actions read and change come first.
106const (
107 fArtist = iota
108 fGenre
109 fDuration
110 fHidden
111 fLikes
112 fEarly // first listeners to like it, comma-joined
113 fTips
114 fSupporters
115 fAlbum
116 fCreated
117 fOrigin
118 fTitle
119 fLicense
120 fCMO
121 fCredits
122 fAudio
123 fAudioSHA
124 fCover
125 fCoverSHA
126 fSource
127 fAttribution
128 fSplits // "addr:pct,…"
129)
130
131func getTrack(t *tx, id int) *Track {
132 rec := t.rec(cTracks, id)
133 if rec == "" {
134 return nil
135 }
136 f := store.Fields(rec)
137 tr := &Track{
138 ID: id, Artist: text.Atoi(f[fArtist]), Genre: text.Atoi(f[fGenre]), Duration: text.Atoi64(f[fDuration]), Hidden: f[fHidden] != "",
139 Likes: text.Atoi(f[fLikes]), Tips: text.Atoi64(f[fTips]), Supporters: text.Atoi(f[fSupporters]), Album: text.Atoi(f[fAlbum]),
140 Created: text.Atoi64(f[fCreated]), Origin: f[fOrigin], Title: f[fTitle], License: f[fLicense], CMO: f[fCMO],
141 Credits: f[fCredits], Audio: f[fAudio], AudioSHA: f[fAudioSHA], Cover: f[fCover], CoverSHA: f[fCoverSHA],
142 SourceURL: f[fSource], Attribution: f[fAttribution],
143 }
144 if f[fSplits] != "" {
145 for _, p := range strings.Split(f[fSplits], ",") {
146 to, pct, _ := strings.Cut(p, ":")
147 tr.Splits = append(tr.Splits, Split{To: address(to), Pct: text.Atoi64(pct)})
148 }
149 }
150 if f[fEarly] != "" {
151 for _, a := range strings.Split(f[fEarly], ",") {
152 tr.Early = append(tr.Early, address(a))
153 }
154 }
155 return tr
156}
157
158func trackRecord(tr *Track) string {
159 var splits, early []string
160 for _, s := range tr.Splits {
161 splits = append(splits, s.To.String()+":"+text.Itoa64(s.Pct))
162 }
163 for _, a := range tr.Early {
164 early = append(early, a.String())
165 }
166 return store.Rec(text.Itoa(tr.Artist), text.Itoa(tr.Genre), text.Itoa64(tr.Duration), text.Flag(tr.Hidden), text.Itoa(tr.Likes),
167 strings.Join(early, ","), text.Itoa64(tr.Tips), text.Itoa(tr.Supporters), text.Itoa(tr.Album), text.Itoa64(tr.Created), tr.Origin,
168 tr.Title, tr.License, tr.CMO, tr.Credits, tr.Audio, tr.AudioSHA, tr.Cover, tr.CoverSHA, tr.SourceURL,
169 tr.Attribution, strings.Join(splits, ","))
170}
171
172func putTrack(t *tx, tr *Track) { t.setRec(cTracks, tr.ID, trackRecord(tr)) }
173
174// brief reads only what playback needs, without decoding the whole track.
175func brief(t *tx, id int) (artist, genre int, dur int64, hidden, ok bool) {
176 rec := t.rec(cTracks, id)
177 if rec == "" {
178 return 0, 0, 0, false, false
179 }
180 return text.Atoi(store.Field(rec, fArtist)), text.Atoi(store.Field(rec, fGenre)), text.Atoi64(store.Field(rec, fDuration)), store.Field(rec, fHidden) != "", true
181}
182
183// ---- artists: a small head record, the id lists apart ----
184
185// Artist head fields, in the same spirit: what tips and follows read and
186// change first. The permanent tickets/nft realm reads aHidden and aName (and
187// chunkSize) straight from the data realm: no release may move them.
188const (
189 aHidden = iota
190 aOwner
191 aVerified
192 aTips
193 aFollowers
194 aKind
195 aName
196 aBio
197 aSource
198 aJoined
199)
200
201// getArtist returns an artist's head; Tracks and Albums stay empty (see withLists).
202func getArtist(t *tx, id int) *Artist {
203 rec := t.rec(cArtists, id)
204 if rec == "" {
205 return nil
206 }
207 f := store.Fields(rec)
208 return &Artist{
209 ID: id, Hidden: f[aHidden] != "", Owner: address(f[aOwner]), Verified: f[aVerified] != "", Tips: text.Atoi64(f[aTips]),
210 Followers: text.Atoi(f[aFollowers]), Kind: f[aKind], Name: f[aName], Bio: f[aBio], Source: f[aSource], Joined: text.Atoi64(f[aJoined]),
211 }
212}
213
214func artistRecord(a *Artist) string {
215 return store.Rec(text.Flag(a.Hidden), a.Owner.String(), text.Flag(a.Verified), text.Itoa64(a.Tips), text.Itoa(a.Followers), a.Kind,
216 a.Name, a.Bio, a.Source, text.Itoa64(a.Joined))
217}
218
219func putArtist(t *tx, a *Artist) { t.setRec(cArtists, a.ID, artistRecord(a)) }
220
221// artistField reads one field of the head ("" for an unknown artist).
222func artistField(t *tx, id, i int) string {
223 if rec := t.rec(cArtists, id); rec != "" {
224 return store.Field(rec, i)
225 }
226 return ""
227}
228
229func artistVisible(t *tx, id int) bool {
230 rec := t.rec(cArtists, id)
231 return rec != "" && store.Field(rec, aHidden) == ""
232}
233
234// lists returns an artist's track and album id lists (made at its first publish).
235func lists(t *tx, artistID int) (trackIDs, albumIDs string) {
236 rec := t.val(cAlists, store.Pad(artistID))
237 if rec == "" {
238 return "", ""
239 }
240 f := store.Fields(rec)
241 return f[0], f[1]
242}
243
244func setLists(t *tx, artistID int, trackIDs, albumIDs string) {
245 t.set(cAlists, store.Pad(artistID), store.Rec(trackIDs, albumIDs))
246}
247
248// withLists fills an artist head's Tracks and Albums.
249func withLists(t *tx, a *Artist) *Artist {
250 tl, al := lists(t, a.ID)
251 a.Tracks, a.Albums = store.Ints(tl), store.Ints(al)
252 return a
253}
254
255func artistOf(t *tx, owner address) int { return text.Atoi(t.val(cOwners, owner.String())) }
256
257// ---- albums and playlists ----
258
259func getAlbum(t *tx, id int) *Album {
260 rec := t.rec(cAlbums, id)
261 if rec == "" {
262 return nil
263 }
264 f := store.Fields(rec)
265 return &Album{ID: id, Artist: text.Atoi(f[0]), Title: f[1], Cover: f[2], CoverSHA: f[3], Year: text.Atoi(f[4]),
266 Created: text.Atoi64(f[5]), Hidden: f[6] != "", Tracks: store.Ints(f[7])}
267}
268
269func albumRecord(al *Album) string {
270 return store.Rec(text.Itoa(al.Artist), al.Title, al.Cover, al.CoverSHA, text.Itoa(al.Year), text.Itoa64(al.Created),
271 text.Flag(al.Hidden), store.Pack(al.Tracks))
272}
273
274func putAlbum(t *tx, al *Album) { t.setRec(cAlbums, al.ID, albumRecord(al)) }
275
276func getPlaylist(t *tx, id int) *Playlist {
277 if id < 1 {
278 return nil
279 }
280 rec := t.val(cPlaylists, store.Pad(id))
281 if rec == "" {
282 return nil
283 }
284 f := store.Fields(rec)
285 return &Playlist{ID: id, Owner: address(f[0]), Title: f[1], Updated: text.Atoi64(f[2]), Hidden: f[3] != "",
286 Tracks: store.Ints(f[4])}
287}
288
289func putPlaylist(t *tx, p *Playlist) {
290 t.set(cPlaylists, store.Pad(p.ID), store.Rec(p.Owner.String(), p.Title, text.Itoa64(p.Updated), text.Flag(p.Hidden), store.Pack(p.Tracks)))
291}
292
293// ---- listeners ----
294
295// user returns a listener's record (an empty one if none); save it with putUser.
296func user(t *tx, addr address) *User {
297 rec := t.val(cUsers, addr.String())
298 if rec == "" {
299 return &User{}
300 }
301 f := store.Fields(rec)
302 return &User{Follows: f[0], Tipped: text.Atoi64(f[1]), Playlists: store.Ints(f[2]), Reports: text.Atoi(f[3])}
303}
304
305func putUser(t *tx, addr address, u *User) {
306 t.set(cUsers, addr.String(), store.Rec(u.Follows, text.Itoa64(u.Tipped), store.Pack(u.Playlists), text.Itoa(u.Reports)))
307}
308
309// ---- lookups shared by every file ----
310
311func visibleArtist(a *Artist) bool { return a != nil && !a.Hidden }
312
313func playable(t *tx, tr *Track) bool {
314 return tr != nil && !tr.Hidden && artistVisible(t, tr.Artist)
315}
316
317func mustArtist(t *tx, id int) *Artist {
318 a := getArtist(t, id)
319 if !visibleArtist(a) {
320 panic("catalog: unknown artist")
321 }
322 return a
323}
324
325func mustTrack(t *tx, id int) *Track {
326 tr := getTrack(t, id)
327 if !playable(t, tr) {
328 panic("catalog: unknown track")
329 }
330 return tr
331}
332
333// ownArtist returns the caller's visible artist profile: its id and head.
334func ownArtist(t *tx, caller address) (int, string) {
335 id := artistOf(t, caller)
336 arec := t.rec(cArtists, id)
337 if arec == "" || store.Field(arec, aHidden) != "" {
338 panic("catalog: create your artist profile first")
339 }
340 return id, arec
341}
342
343// mayEdit reports whether caller manages artist a: its owner, or the admin
344// while the artist is unclaimed.
345func mayEdit(caller address, a *Artist) bool {
346 if a.Owner != "" {
347 return caller == a.Owner
348 }
349 return caller != "" && caller == adminAddr()
350}