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

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}