tracks.gno
10.22 Kb · 255 lines
1package catalog
2
3import (
4 "strconv"
5 "strings"
6
7 "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
8 "gno.land/p/nym-alexiscolin000/gnoradio/store/v0"
9 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
10 "gno.land/r/nym-alexiscolin000/gnoradio/data"
11)
12
13// Tracks are added and edited through the radio realm only (radio.PublishTrack,
14// radio.ImportTrack, radio.EditTrack): it puts a new track in the station
15// rotations, and moves an edited one, in the same transaction. The radio
16// names the wallet that signed (caller, by); only the radio writer the data
17// realm names may call these.
18
19// PublishFor adds a track by caller's own artist profile.
20// duration is "m:ss"; cmo is "none" or "sacem-nc"; splits is optional,
21// "g1...:30,g1...:15" in percent (the artist keeps at least 10%);
22// rights must be "yes" (the caller accepts RightsTerms).
23// audio and cover are ipfs://, ar:// or https:// links (an allowed host,
24// with the file's sha256).
25func PublishFor(cur realm, caller address, title string, genre int, duration, license, cmo, credits, audio, audioSHA, cover, coverSHA, splits, rights string) int {
26 onlyRadio(cur)
27 noPayment()
28 t := rd()
29 aid, _ := ownArtist(t, caller)
30 if strings.ToLower(strings.TrimSpace(rights)) != "yes" {
31 panic("catalog: accept the rights declaration (answer yes)")
32 }
33 tr := &Track{Origin: OriginArtist, CMO: "none"}
34 setInfo(tr, title, genre, duration, credits, true)
35 mustRefAudio(tr, audio)
36 setLicense(tr, license, cmo)
37 setMedia(tr, audio, audioSHA, cover, coverSHA)
38 tr.Splits = parseSplits(splits, caller)
39 id := addTrack(t, caller, aid, tr)
40 save(cur, t)
41 return id
42}
43
44// ImportFor adds a curated (CC0 / CC BY / CC BY-SA / CC BY-NC / CC BY-NC-SA)
45// track, or a pointer to an Audius or Jamendo track, to an unclaimed artist.
46// by must be the admin. A pointer keeps only what the radio needs (audio
47// "audius:<id>" or "jamendo:<id>", genre, duration, licence): title, credits,
48// cover, source and attribution stay empty and the app reads them live from
49// the platform, whose API terms allow session caching only.
50func ImportFor(cur realm, by address, artistID int, title string, genre int, duration, license, credits, audio, audioSHA, cover, coverSHA, sourceURL, attribution string) int {
51 onlyRadio(cur)
52 noPayment()
53 if by == "" || by != adminAddr() {
54 panic("catalog: admin only")
55 }
56 t := rd()
57 a := mustArtist(t, artistID)
58 if a.Kind == OriginArtist {
59 panic("catalog: artists publish their own tracks")
60 }
61 tr := &Track{Origin: a.Kind, CMO: "none"}
62 setInfo(tr, title, genre, duration, credits, false)
63 mustRefAudio(tr, audio)
64 if tr.Origin == OriginAudius {
65 tr.License = AudiusLicense
66 } else if tr.License = curatedLicense(license); tr.License == "" {
67 panic("catalog: curated imports must be CC0, CC BY, BY-SA, BY-NC or BY-NC-SA")
68 }
69 setMedia(tr, audio, audioSHA, cover, coverSHA)
70 sourceURL, attribution = strings.TrimSpace(sourceURL), strings.TrimSpace(attribution)
71 if isRef(tr.Origin) {
72 if cover != "" || sourceURL != "" || attribution != "" {
73 panic("catalog: a pointer stores no cover, source or attribution")
74 }
75 } else {
76 if !text.HTTPS(sourceURL, 300) {
77 panic("catalog: sourceURL must be the track's https page at the source")
78 }
79 mustText("attribution", attribution, 2, maxCredits)
80 }
81 tr.SourceURL, tr.Attribution = sourceURL, attribution
82 if isRef(tr.Origin) { // one record per platform track (the key has a '-', no name skeleton does)
83 if _, dup := t.get(cNames, refKey+tr.Audio); dup {
84 panic("catalog: this pointer track is already imported (hide it and ReleaseName \"" + refKey + tr.Audio + "\" to import it again)")
85 }
86 }
87 id := addTrack(t, adminAddr(), a.ID, tr)
88 if isRef(tr.Origin) { // pointers imported before this key existed have none: fine on a fresh chain
89 t.set(cNames, refKey+tr.Audio, text.Itoa(id))
90 }
91 save(cur, t)
92 return id
93}
94
95// EditFor fixes a track's details. Its artist only (the admin while the
96// artist is unclaimed): caller is who signed.
97func EditFor(cur realm, caller address, trackID int, title string, genre int, duration, credits, audio, audioSHA, cover, coverSHA string) {
98 onlyRadio(cur)
99 noPayment()
100 t := rd()
101 tr := mustTrack(t, trackID)
102 a := getArtist(t, tr.Artist)
103 if !mayEdit(caller, a) {
104 panic("catalog: only the artist can edit this track")
105 }
106 mustRefAudio(tr, audio)
107 // A pointer keeps its platform id and stores no cover: only its genre,
108 // duration and (claimed owner) title change.
109 if isRef(tr.Origin) && (strings.TrimSpace(audio) != tr.Audio || strings.TrimSpace(cover) != "" || strings.TrimSpace(coverSHA) != "") {
110 panic("catalog: a pointer keeps its audio and stores no cover")
111 }
112 oldGenre := tr.Genre
113 // The owner of a claimed Audius or Jamendo profile may name their own
114 // tracks: the record then stores the title with the artist's consent.
115 setInfo(tr, title, genre, duration, credits, a.Owner != "")
116 if tr.Genre != oldGenre {
117 t.removeID(cGenres, store.Pad(oldGenre), tr.ID)
118 t.addID(cGenres, store.Pad(tr.Genre), tr.ID)
119 t.bump(mGenre+oldGenre, -1)
120 t.bump(mGenre+tr.Genre, 1)
121 }
122 setMedia(tr, audio, audioSHA, cover, coverSHA)
123 t.setRec(cTracks, tr.ID, fits(trackRecord(tr)))
124 save(cur, t)
125 emitEvent("TrackEdited", "id", text.Itoa(tr.ID), "audio", tr.Audio, "duration", text.Itoa64(tr.Duration), "genre", text.Itoa(tr.Genre))
126}
127
128func addTrack(t *tx, publisher address, artistID int, tr *Track) int {
129 trackIDs, albumIDs := lists(t, artistID)
130 if store.Count(trackIDs) >= maxPerArtist {
131 panic("catalog: tracks per artist limit reached")
132 }
133 t.bump(mGenre+tr.Genre, 1)
134 tr.ID, tr.Artist, tr.Created = int(t.bump(mTracks, 1)), artistID, now()
135 t.addRec(cTracks, tr.ID, fits(trackRecord(tr)))
136 setLists(t, artistID, trackIDs+store.Enc(tr.ID), albumIDs)
137 t.addID(cGenres, store.Pad(tr.Genre), tr.ID)
138 t.activity("publish", publisher, text.Itoa(tr.ID), text.Itoa(artistID), "0")
139 emitEvent("TrackPublished", "id", text.Itoa(tr.ID), "artist", text.Itoa(artistID), "origin", tr.Origin, "genre", text.Itoa(tr.Genre),
140 "duration", text.Itoa64(tr.Duration), "title", tr.Title, "audio", tr.Audio, "license", tr.License)
141 return tr.ID
142}
143
144// setInfo sets a track's text and timing. An Audius or Jamendo pointer stores
145// no title or credits unless named: its claimed artist's owner fills them in.
146// Without that, an edit may keep or clear the stored text (a legacy import), never set new text.
147func setInfo(tr *Track, title string, genre int, duration, credits string, named bool) {
148 title, credits = strings.TrimSpace(title), strings.TrimSpace(credits)
149 switch {
150 case isRef(tr.Origin) && (title == "" || title == tr.Title) && (credits == "" || credits == tr.Credits): // cleared or unchanged
151 case isRef(tr.Origin) && !named:
152 panic("catalog: a pointer stores no title or credits (the app reads them from the platform)")
153 default:
154 mustText("title", title, 1, maxTitle)
155 mustText("credits", credits, 0, maxCredits)
156 }
157 if genre < 1 || genre > NumGenres {
158 panic("catalog: genre must be 1.." + text.Itoa(NumGenres))
159 }
160 d, ok := parseClock(duration)
161 if !ok || d < minDuration || d > maxDuration {
162 panic("catalog: duration as m:ss, between 0:10 and 20:00")
163 }
164 tr.Title, tr.Genre, tr.Duration, tr.Credits = title, genre, d, credits
165}
166
167// refKey prefixes a pointer's audio in cNames (the '-' keeps it off every name skeleton).
168const refKey = "track-"
169
170// isRef reports whether tracks of this origin are pointers: read live from
171// Audius or Jamendo, with no title or credits stored on chain.
172func isRef(origin string) bool { return origin == OriginAudius || origin == OriginJamendo }
173
174// mustRefAudio keeps a pointer's audio on its platform ("audius:<id>",
175// "jamendo:<id>") and every other track off both.
176func mustRefAudio(tr *Track, audio string) {
177 prefix := strings.TrimSpace(audio)
178 if i := strings.IndexByte(prefix, ':'); i >= 0 {
179 prefix = prefix[:i]
180 }
181 if isRef(prefix) != isRef(tr.Origin) || isRef(tr.Origin) && prefix != tr.Origin {
182 panic("catalog: Audius and Jamendo tracks use audio audius:<id> or jamendo:<id>, other tracks neither")
183 }
184}
185
186// refLabel is what gnoweb shows for a pointer's empty title or name: the
187// platform it is read from ("Audius track", "Jamendo artist").
188func refLabel(origin, stored, what string) string {
189 if stored != "" || !isRef(origin) {
190 return stored
191 }
192 return strings.ToUpper(origin[:1]) + origin[1:] + " " + what
193}
194
195func setLicense(tr *Track, license, cmo string) {
196 lic := normLicense(license)
197 if lic == "" {
198 panic("catalog: unknown license, use one of " + strings.Join(licenses, ", "))
199 }
200 cmo = strings.ToLower(strings.TrimSpace(cmo))
201 switch cmo {
202 case "", "none":
203 cmo = "none"
204 case "sacem-nc":
205 if !strings.Contains(lic, "-NC") {
206 panic("catalog: SACEM members may only publish works declared under a CC NC license")
207 }
208 default:
209 panic("catalog: collective society must be none or sacem-nc")
210 }
211 tr.License, tr.CMO = lic, cmo
212}
213
214func setMedia(tr *Track, audio, audioSHA, cover, coverSHA string) {
215 audio, audioSHA = strings.TrimSpace(audio), strings.ToLower(strings.TrimSpace(audioSHA))
216 cover, coverSHA = strings.TrimSpace(cover), strings.ToLower(strings.TrimSpace(coverSHA))
217 mustMedia("audio", audio, audioSHA, true)
218 if coverSHA != "" && (cover == "" || !isSHA256(coverSHA)) { // coverSHA goes into the JSON as is
219 panic("catalog: cover sha256 must be 64 hex characters, with a cover")
220 }
221 if cover != "" {
222 mustMedia("cover", cover, coverSHA, false)
223 }
224 tr.Audio, tr.AudioSHA, tr.Cover, tr.CoverSHA = audio, audioSHA, cover, coverSHA
225}
226
227// parseSplits reads the collaborators: never the artist, never a GnoRadio realm.
228func parseSplits(s string, artist address) []Split {
229 s = strings.TrimSpace(s)
230 if s == "" {
231 return nil
232 }
233 parts := strings.Split(s, ",")
234 if len(parts) > maxSplits {
235 panic("catalog: at most 4 collaborators")
236 }
237 var out []Split
238 var sum int64
239 seen := map[string]bool{}
240 for _, p := range parts {
241 addrStr, pctStr, ok := strings.Cut(strings.TrimSpace(p), ":")
242 to := address(strings.TrimSpace(addrStr))
243 pct, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimSpace(pctStr), "%"), 10, 64)
244 if !ok || err != nil || !role.Canonical(to) || to == artist || data.IsGnoRadio(to) || seen[to.String()] || pct < 1 || pct > maxSplitPct {
245 panic("catalog: collaborators as g1...:30,g1...:15 (percent)")
246 }
247 seen[to.String()] = true
248 sum += pct
249 out = append(out, Split{To: to, Pct: pct})
250 }
251 if sum > maxSplitPct {
252 panic("catalog: collaborators may receive at most 90%")
253 }
254 return out
255}