home.gno
61.99 Kb · 1641 lines
1// Package home is the GnoRadio website on gnoweb, release 1. It owns no
2// data: every page is rendered from the catalog, radio and tickets realms,
3// and its two settings (app.gno) live in the data realm, "home/config".
4package home
5
6import (
7 "chain/runtime"
8 "chain/runtime/unsafe"
9 "strconv"
10 "strings"
11 "time"
12
13 roles "gno.land/p/nym-alexiscolin000/gnoradio/role/v0"
14 "gno.land/p/nym-alexiscolin000/gnoradio/safe/v0"
15 "gno.land/p/nym-alexiscolin000/gnoradio/text/v0"
16
17 "gno.land/p/moul/txlink/v0"
18 "gno.land/p/nt/markdown/sanitize/v0"
19
20 "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
21 "gno.land/r/nym-alexiscolin000/gnoradio/data"
22 "gno.land/r/nym-alexiscolin000/gnoradio/radio/v1"
23 "gno.land/r/nym-alexiscolin000/gnoradio/tickets/v1"
24)
25
26const (
27 catalogPath = "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v1"
28 radioPath = "gno.land/r/nym-alexiscolin000/gnoradio/radio/v1"
29 ticketsPath = "gno.land/r/nym-alexiscolin000/gnoradio/tickets/v1"
30 dataPath = "gno.land/r/nym-alexiscolin000/gnoradio/data"
31 maxCovers = 8
32 pageSize = 20
33 ugnot = 1_000_000
34)
35
36var (
37 catalogTx = txlink.Realm(catalogPath)
38 radioTx = txlink.Realm(radioPath)
39 ticketsTx = txlink.Realm(ticketsPath)
40 dataTx = txlink.Realm(dataPath)
41 self = unsafe.CurrentRealm().PkgPath()
42 base = strings.TrimPrefix(self, runtime.ChainDomain()) // gnoweb path of this realm
43)
44
45// Render serves:
46//
47// "" home: main station on air, stations, new releases, top, concerts
48// "stations" "station/<n>" radio stations and their schedule
49// "track/<id>" a track
50// "artist/<id|name>" an artist
51// "album/<id>" "playlist/<id>"
52// "catalog?p=N&g=G" "artists?p=N" "albums?p=N" "playlists?p=N"
53// "concerts?p=N" "event/<id>" "ticket/<id>"
54// "listener" "listener/<g1…>" listeners: top curators; one listener's likes, playlists, tickets, badges
55// "charts" "join" "about" "moderation?p=N"
56//
57// Bare entity segments ("track", "artist"…, linked by gnoweb's breadcrumb)
58// open the matching list. Entity pages with long lists take ?p=N too.
59//
60// Raw path input is never echoed: ids are parsed, names and addresses looked up.
61func Render(path string) string {
62 p, query, _ := strings.Cut(path, "?")
63 pg, genre := 1, 0
64 for _, kv := range strings.Split(query, "&") {
65 k, v, _ := strings.Cut(kv, "=")
66 switch n := atoi(v); k {
67 case "p":
68 if n > 1 && n < 100000 {
69 pg = n
70 }
71 case "g":
72 if n >= 1 && n <= catalog.NumGenres {
73 genre = n
74 }
75 }
76 }
77 out := route(p, pg, genre)
78 if out == "" {
79 out = header("Not found", "-") + "This page does not exist. [← Back to the radio](" + page("") + ")\n"
80 }
81 return banner() + out
82}
83
84// banner tells visitors what the data realm says of the release in force
85// (data.Writers): a newer site, a paused role, or a release on its way.
86func banner() string {
87 if s := data.Writer("home"); s != self {
88 return "> [!WARNING] GnoRadio has moved\n> The new version lives at [" + s + "](" +
89 strings.TrimPrefix(s, runtime.ChainDomain()) + ").\n\n"
90 }
91 for _, r := range [3]string{"catalog", "radio", "tickets"} {
92 if data.Paused(r) {
93 return "> [!CAUTION] GnoRadio is paused\n> Its owner or guardian stopped the " + r +
94 " for now. Pages stay readable, and promo budgets can still be withdrawn.\n\n"
95 }
96 }
97 if at := releaseAt(data.Writers()); at > 0 {
98 return "> [!NOTE] A new release takes over on " + date(at) + "\n> Its code is public until then: [see what changes](" +
99 strings.TrimPrefix(dataPath, runtime.ChainDomain()) + "$source&file=data.gno), `Writers()`.\n\n"
100 }
101 return ""
102}
103
104// releaseAt reads "at" of data.Writers(): when a ready release takes over, 0
105// when none is on its way.
106func releaseAt(js string) int64 {
107 _, v, ok := strings.Cut(js, `"at":`)
108 if !ok {
109 return 0
110 }
111 if i := strings.IndexByte(v, ','); i >= 0 {
112 v = v[:i]
113 }
114 n, _ := strconv.ParseInt(v, 10, 64)
115 return n
116}
117
118func route(p string, pg, genre int) string {
119 switch p {
120 case "":
121 return renderHome()
122 case "stations", "station":
123 return renderStations()
124 case "catalog", "track":
125 return renderCatalog(pg, genre)
126 case "artists", "artist":
127 return renderArtists(pg)
128 case "albums", "album":
129 return renderAlbums(pg)
130 case "playlists", "playlist":
131 return renderPlaylists(pg)
132 case "concerts", "event", "ticket":
133 return renderConcerts(pg)
134 case "listener":
135 return renderListeners()
136 case "charts":
137 return renderCharts()
138 case "join":
139 return renderJoin()
140 case "about":
141 return renderAbout()
142 case "moderation":
143 return renderModeration(pg)
144 }
145 kind, arg, ok := strings.Cut(p, "/")
146 if !ok {
147 return ""
148 }
149 switch kind {
150 case "station":
151 if n, err := strconv.Atoi(arg); err == nil && n >= 0 && n < radio.StationCount() {
152 return renderStation(n)
153 }
154 case "track":
155 if t, ok := catalog.GetTrack(atoi(arg)); ok {
156 return renderTrack(t)
157 }
158 case "artist":
159 id := atoi(arg)
160 if id == 0 {
161 id = catalog.ArtistByName(arg)
162 }
163 if a, ok := catalog.GetArtist(id); ok {
164 if a.Hidden {
165 return renderRemoved(catalog.HiddenNote("artist", a.ID))
166 }
167 return renderArtist(a, pg)
168 }
169 case "album":
170 if al, ok := catalog.GetAlbum(atoi(arg)); ok {
171 switch {
172 case al.Hidden:
173 return renderRemoved(catalog.HiddenNote("album", al.ID))
174 case !catalog.ArtistVisible(al.Artist):
175 return renderRemoved(catalog.HiddenNote("artist", al.Artist))
176 }
177 return renderAlbum(al)
178 }
179 case "playlist":
180 if pl, ok := catalog.GetPlaylist(atoi(arg)); ok {
181 if pl.Hidden {
182 return renderRemoved(catalog.HiddenNote("playlist", pl.ID))
183 }
184 return renderPlaylist(pl, pg)
185 }
186 case "event":
187 id := atoi(arg)
188 if e, ok := tickets.EventInfo(id); ok {
189 if !catalog.ArtistVisible(e.ArtistID) {
190 return renderRemoved(catalog.HiddenNote("artist", e.ArtistID))
191 }
192 return renderEvent(e)
193 }
194 if note := tickets.HiddenNote(id); note != "" {
195 return renderRemoved(note)
196 }
197 case "ticket":
198 if tk, ok := tickets.TicketInfo(atoi(arg)); ok {
199 return renderTicket(tk)
200 }
201 case "listener":
202 if addr := address(arg); roles.Canonical(addr) { // never the upper-case spelling of a wallet
203 return renderListener(addr, pg)
204 }
205 }
206 return ""
207}
208
209// ---- pages ----
210
211func renderHome() string {
212 var b strings.Builder
213 b.WriteString(header("GnoRadio", ""))
214 b.WriteString("**Community radio and open music player, 100% on-chain: every tip lands in the artist's wallet, in the same transaction.** " +
215 "[Read the code that guarantees it](" + sourceURL(catalogPath, "social.gno") + ").\n\n")
216 listen := page("station/0")
217 if u := appURL(); u != "" {
218 listen = strings.TrimRight(u, "/") + "/"
219 }
220 b.WriteString("**[▶ Listen now](" + listen + ")** · **[Pick a track](" + page("catalog") + ")** · **[◈ Tip an artist](" + page("artists") + ")**\n\n")
221 b.WriteString(onAir(0))
222
223 b.WriteString("<gno-columns>\n## Stations\n\n")
224 for _, s := range stationOrder()[:7] {
225 tracks, _, _ := radio.StationInfo(s)
226 b.WriteString("- " + stationLink(s) + " · " + plural(tracks, "track") + "\n")
227 }
228 b.WriteString("\n[All stations →](" + page("stations") + ")\n")
229 b.WriteString("<gno-columns-sep />\n## Most liked\n\n")
230 shown := 0
231 for _, r := range catalog.TopLiked() {
232 if t, ok := catalog.GetTrack(atoi(r.Key)); ok && playable(t) && shown < 6 {
233 shown++
234 b.WriteString(text.Itoa(shown) + ". " + trackLink(t) + " · ♥ " + text.Itoa(t.Likes) + "\n")
235 }
236 }
237 if shown == 0 {
238 b.WriteString("No likes yet.\n")
239 }
240 b.WriteString("<gno-columns-sep />\n## Concerts\n\n")
241 up := tickets.Upcoming(4)
242 if len(up) == 0 {
243 b.WriteString("No concert announced. [Announce yours](" + page("join") + ").\n")
244 }
245 for _, e := range up {
246 b.WriteString("- [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " + date(e.Start) + "\n")
247 }
248 b.WriteString("</gno-columns>\n\n## New releases\n\n")
249 var fresh []catalog.Track
250 for _, id := range catalog.TrackPage(0, 40, true) {
251 if t, ok := catalog.GetTrack(id); ok && playable(t) {
252 if fresh = append(fresh, t); len(fresh) == maxCovers {
253 break
254 }
255 }
256 }
257 switch {
258 case len(fresh) > 0:
259 case catalog.TrackCount() == 0:
260 b.WriteString("> [!INFO] The catalog is empty\n> [Publish the first track](" + page("join") + ").\n")
261 default:
262 b.WriteString("> [!INFO] No release to show among the newest tracks\n> [Browse the catalog](" + page("catalog") + ").\n")
263 }
264 b.WriteString(coverGrid(fresh))
265 b.WriteString("\n[Full catalog →](" + page("catalog") + ")\n\n")
266 if hasTreasury() {
267 b.WriteString("<gno-columns>\n" + supportBlock() + "<gno-columns-sep />\n## Recent activity\n\n" + activityFeed(8) + "</gno-columns>\n\n---\n\n" + stats())
268 } else {
269 b.WriteString("## Recent activity\n\n" + activityFeed(8) + "\n---\n\n" + stats())
270 }
271 return b.String()
272}
273
274// coverGrid lays covers out in columns, each with a "Title · Artist" line.
275func coverGrid(ts []catalog.Track) string {
276 if len(ts) == 0 {
277 return ""
278 }
279 var b strings.Builder
280 b.WriteString("<gno-columns>\n")
281 for i, t := range ts {
282 if i > 0 {
283 b.WriteString("<gno-columns-sep />\n")
284 }
285 link := page("track/" + text.Itoa(t.ID))
286 b.WriteString("[" + coverSVG(t.ID, t.Artist, t.Title, 150) + "](" + link + ")\n\n**[" + md(t.Title) + "](" + link + ")** · " + artistRef(t.Artist) + "\n")
287 }
288 return b.String() + "</gno-columns>\n"
289}
290
291// hasTreasury is false at the free launch: support and the tip share panic then.
292func hasTreasury() bool { return catalog.Treasury().IsValid() }
293
294// supportBlock shows GnoRadio's own funding: treasury, monthly goal, links.
295func supportBlock() string {
296 if !hasTreasury() {
297 return ""
298 }
299 to, total, supporters, monthTotal, goal := catalog.SupportInfo()
300 var b strings.Builder
301 b.WriteString("## Keep GnoRadio on air\n\n")
302 if goal > 0 {
303 b.WriteString("This month: **" + text.GNOT(monthTotal) + "** of " + text.GNOT(goal) + " \n")
304 } else {
305 b.WriteString("This month: **" + text.GNOT(monthTotal) + "** \n")
306 }
307 b.WriteString(text.GNOT(total) + " from " + plural(supporters, "supporter") + " since launch")
308 if to.IsValid() {
309 b.WriteString(", to the " + role(to, "GnoRadio treasury"))
310 }
311 b.WriteString(".\n\n**◈ Support:** [1 GNOT](" + supportURL(1) + ") · [5 GNOT](" + supportURL(5) + ") · [20 GNOT](" + supportURL(20) + ") \n" +
312 "Optional. GnoRadio pays nothing to run: listeners and artists sign their own transactions. Support funds the project's work; artists' tips never fund it. " +
313 "[Read the code](" + sourceURL(catalogPath, "support.gno") + ").\n")
314 return b.String()
315}
316
317// activityFeed merges the catalog's and the radio's latest public actions,
318// newest first, as the app does.
319func activityFeed(n int) string {
320 cat, rad := catalog.Activities(n), radio.Activities(n)
321 var b strings.Builder
322 for i, j, k := 0, 0, 0; k < n && (i < len(cat) || j < len(rad)); k++ {
323 if j >= len(rad) || i < len(cat) && cat[i].At >= rad[j].At {
324 b.WriteString(catalogLine(cat[i]))
325 i++
326 } else {
327 b.WriteString(radioLine(rad[j]))
328 j++
329 }
330 }
331 if b.Len() == 0 {
332 return "Nothing yet: like, tip, pick or publish to start the feed.\n"
333 }
334 return b.String()
335}
336
337func catalogLine(a catalog.Activity) string {
338 line := ""
339 switch a.Kind {
340 case "publish":
341 line = "published " + trackRef(a.Track)
342 case "like":
343 line = "liked " + trackRef(a.Track)
344 case "tip":
345 line = "tipped " + text.GNOT(a.Amount) + " on " + trackRef(a.Track)
346 case "support":
347 line = "supported GnoRadio with " + text.GNOT(a.Amount)
348 case "follow":
349 line = "followed " + artistRef(a.Artist)
350 case "playlist":
351 line = "published [a playlist](" + page("playlist/"+text.Itoa(a.Track)) + ")"
352 case "album":
353 line = "released [an album](" + page("album/"+text.Itoa(a.Track)) + ")"
354 case "claim":
355 return "- **Profile verified** · " + artistRef(a.Artist) + " · " + date(a.At) + "\n"
356 default:
357 line = md(a.Kind)
358 }
359 return "- " + who(a.By) + " " + line + " · " + date(a.At) + "\n"
360}
361
362func radioLine(a radio.Activity) string {
363 verb := "picked "
364 switch a.Kind {
365 case "curator":
366 verb = "programmed "
367 case "sponsored":
368 verb = "made a sponsored pick of "
369 }
370 where := ""
371 if a.Station >= 0 && a.Station < radio.StationCount() {
372 where = " on " + stationLink(a.Station)
373 }
374 return "- " + who(a.By) + " " + verb + trackRef(a.Track) + where + " · " + date(a.At) + "\n"
375}
376
377func onAir(st int) string {
378 id, off, queued := radio.NowPlaying(st)
379 t, ok := catalog.GetTrack(id)
380 if id == 0 || !ok || !playable(t) {
381 return onAirSVG(radio.StationName(st), "", "", 0, 0) + "\n\n"
382 }
383 src := "rotation"
384 if queued && radio.OnAirPay(st) > 0 {
385 src = "sponsored pick · paid by " + md(catalog.ArtistName(t.Artist))
386 } else if queued {
387 src = "queued by a listener"
388 if n := radio.OnAirNote(st); n != "" {
389 src += " · ♥ *" + md(n) + "*"
390 }
391 }
392 live := ""
393 if l := inApp("stations", "Go live in the app"); l != "" {
394 live = " · " + l
395 }
396 out := onAirSVG(radio.StationName(st), t.Title, catalog.ArtistName(t.Artist), off, t.Duration) + "\n\n" +
397 "**[▶ Listen from " + clock(off) + "](" + listenURL(t.ID, t.Audio, off) + ")** · " + trackLink(t) + " · " + src + live + "\n\n"
398 if tippable(catalog.ArtistOwner(t.Artist), catalog.ArtistVerified(t.Artist)) {
399 out += "**◈ Tip it on air:** [1 GNOT](" + tipOnAirURL(st, t.ID, 1) + ") · [5 GNOT](" + tipOnAirURL(st, t.ID, 5) + ") · [20 GNOT](" + tipOnAirURL(st, t.ID, 20) + ")"
400 if queued && radio.OnAirPay(st) == 0 {
401 if p := catalog.PromoShare(t.Artist); p > 0 {
402 out += " · " + text.Itoa(p) + "% goes to the listener who picked it"
403 }
404 }
405 out += "\n\n"
406 }
407 return out
408}
409
410func renderStations() string {
411 var b strings.Builder
412 b.WriteString(header("Stations", "stations"))
413 b.WriteString("Every station plays the same second for everyone. Pick one, or queue a track on it from any track page.")
414 if l := inApp("stations", "Listen in the app"); l != "" {
415 b.WriteString(" " + l + ".")
416 }
417 // One line per station rather than a table: it reads on a phone.
418 b.WriteString("\n\n")
419 for _, s := range stationOrder() {
420 tracks, _, queued := radio.StationInfo(s)
421 line := "- **" + stationLink(s) + "** · " + plural(tracks, "track")
422 if queued > 0 {
423 line += " · " + plural(queued, "pick") + " waiting"
424 }
425 if id, _, _ := radio.NowPlaying(s); id != 0 {
426 line += " · on air: " + md(catalog.TrackTitle(id))
427 }
428 b.WriteString(line + "\n")
429 }
430 if p := radio.Pending(); p > 0 {
431 b.WriteString("\n" + plural(p, "track") + " not on the stations yet: [add them](" + radioTx.NewLink("Sync").AddArgs("max", "200").URL() + ") (anyone can).\n")
432 }
433 b.WriteString("\n" + pickRules())
434 return b.String()
435}
436
437// pickRules explains the limits radio enforces, read from radio itself.
438func pickRules() string {
439 cooldown, replay, ahead, queue, perArtist, bookMin, bookMax, perHour, booked := radio.PickRules()
440 return "## How picks work\n\n" +
441 "- One pick per listener per station per " + strings.TrimPrefix(hours(cooldown), "1 ") + ", and one waiting per station.\n" +
442 "- " + text.Itoa(perArtist) + " waiting per artist on a station; a track is not picked again on a station within " + hours(replay) + ".\n" +
443 "- Genre stations take only their genre; New this week takes only new tracks.\n" +
444 "- At most " + hours(ahead) + " of picks ahead on a station, " + text.Itoa(queue) + " waiting at most.\n" +
445 "- A pick can be booked " + text.Itoa(int(bookMin/60)) + " minutes to " + hours(bookMax) + " ahead, " + text.Itoa(perHour) + " booked picks per station per hour (UTC) and " + text.Itoa(booked) + " of the " + text.Itoa(queue) + " waiting at most.\n" +
446 "- A pick starts when the song on air ends: nothing is cut.\n"
447}
448
449// hours says a whole number of hours in words: "1 hour", "3 hours".
450func hours(sec int64) string { return plural(int(sec/3600), "hour") }
451
452func renderStation(st int) string {
453 var b strings.Builder
454 b.WriteString(header(md(radio.StationName(st)), "-"))
455 tracks, _, _ := radio.StationInfo(st)
456 if tracks == 0 {
457 b.WriteString("> [!INFO] This station is empty for now\n> " + genreBrowse(st) + "\n\n")
458 return b.String()
459 }
460 b.WriteString(onAir(st))
461 b.WriteString("## Up next\n\n")
462 shown := 0
463 for _, s := range radio.UpNext(st) {
464 if shown == 10 {
465 break
466 }
467 t, ok := catalog.GetTrack(s.Track)
468 if !ok {
469 continue
470 }
471 shown++
472 line := "- " + hour(s.Start) + " UTC · " + trackLink(t) + " · queued by " + who(s.By)
473 if s.At > 0 {
474 line = "- " + day(s.Start) + hour(s.Start) + " UTC · " + trackLink(t) + " · booked by " + who(s.By)
475 }
476 if s.Pay > 0 {
477 line += " · sponsored pick, paid by " + md(catalog.ArtistName(t.Artist))
478 }
479 if s.Note != "" {
480 line += " · ♥ *" + md(s.Note) + "*"
481 }
482 b.WriteString(line + "\n")
483 }
484 if shown == 0 {
485 b.WriteString("Nothing queued: the rotation plays. " + genreBrowse(st) + "\n")
486 }
487 b.WriteString("\n" + topCurators(st) + flowLine(st, tracks))
488 return b.String()
489}
490
491// flowLine says how a station chooses what plays when nobody picked.
492func flowLine(st, tracks int) string {
493 if st != radio.MainStation {
494 return plural(tracks, "track") + " in rotation, looping; listeners' picks play first.\n"
495 }
496 hour := ""
497 if g := radio.HourGenre(time.Now().Unix()); g != 0 {
498 hour = "This hour: **" + md(catalog.GenreName(g)) + "**. "
499 }
500 return "Main follows the time of day: one genre an hour, calm at night and peaking in the evening. " + hour +
501 "Listeners' picks play first. " + plural(tracks, "track") + " in rotation.\n"
502}
503
504func renderTrack(t catalog.Track) string {
505 if !playable(t) {
506 note := catalog.HiddenNote("track", t.ID)
507 if note == "" {
508 note = catalog.HiddenNote("artist", t.Artist)
509 }
510 if note == "" {
511 note = "This track is no longer available."
512 }
513 return renderRemoved(note)
514 }
515 a, _ := catalog.GetArtist(t.Artist)
516 id := text.Itoa(t.ID)
517 var b strings.Builder
518 b.WriteString(header(md(t.Title), "-"))
519 b.WriteString("<gno-columns>\n" + coverSVG(t.ID, t.Artist, t.Title, 300) + "\n<gno-columns-sep />\n")
520 b.WriteString("### by " + artistLink(a) + "\n\n")
521 b.WriteString(md(catalog.GenreName(t.Genre)) + " · " + clock(t.Duration) + " · " + licenseLink(t.License))
522 if t.CMO == "sacem-nc" {
523 b.WriteString(" · declared to SACEM")
524 }
525 b.WriteString(" \n")
526 if t.Credits != "" {
527 b.WriteString("Credits: " + md(t.Credits) + " \n")
528 }
529 b.WriteString("♥ " + text.Itoa(t.Likes) + " · ◈ " + text.GNOT(t.Tips) + " from " + plural(t.Supporters, "supporter"))
530 if t.Album != 0 {
531 if al, ok := catalog.GetAlbum(t.Album); ok && !al.Hidden {
532 b.WriteString(" · from [" + md(al.Title) + "](" + page("album/"+text.Itoa(al.ID)) + ")")
533 }
534 }
535 // One primary action line; the rest waits in the folded block below.
536 b.WriteString("\n\n**[▶ Listen](" + listenURL(t.ID, t.Audio, 0) + ")** · **[♥ Like](" + catalogTx.NewLink("Like").AddArgs("trackID", id).URL() + ")** · " +
537 "**[Pick it on Main](" + queueURL(0, t.ID) + ")**")
538 if l := inApp("track/"+id, "Open in the app"); l != "" {
539 b.WriteString(" · " + l)
540 }
541 b.WriteString("\n\n" + support(t, a) + promoLines(a, t.ID, t.Genre))
542 b.WriteString("</gno-columns>\n\n")
543
544 switch t.Origin {
545 case catalog.OriginCurated:
546 b.WriteString("> [!NOTE] Curated · " + licenseLink(t.License) + "\n> " + md(t.Attribution) + "." + sourceLink("Source", t.SourceURL) + "\n\n")
547 case catalog.OriginAudius, catalog.OriginJamendo:
548 b.WriteString("> [!NOTE] Read live from " + platformName(t.Origin) + ": its title, artist and cover are not stored on chain (the platform's terms), the app shows them. © the artist, " +
549 licenseLink(t.License) + ".")
550 if l := inApp("track/"+id, "Open in the app"); l != "" {
551 b.WriteString(" " + l + ".")
552 }
553 b.WriteString("\n\n")
554 }
555
556 if len(t.Splits) > 0 {
557 b.WriteString("## Where your tip goes\n\n| Recipient | Share |\n|---|---|\n")
558 rest := int64(100)
559 for _, s := range t.Splits {
560 b.WriteString("| " + who(s.To) + " | " + text.Itoa64(s.Pct) + "% |\n")
561 rest -= s.Pct
562 }
563 b.WriteString("| " + artistLink(a) + " (artist) | " + text.Itoa64(rest) + "% |\n\n")
564 }
565 b.WriteString("## Early listeners\n\n")
566 for i, addr := range t.Early {
567 b.WriteString(text.Itoa(i+1) + ". " + who(addr) + "\n")
568 }
569 if len(t.Early) < 10 {
570 b.WriteString("\n**" + text.Itoa(10-len(t.Early)) + "** early-listener badges left: [like it now](" + catalogTx.NewLink("Like").AddArgs("trackID", id).URL() + ").\n")
571 }
572 g := radio.StationName(t.Genre)
573 b.WriteString("\n> [!NOTE]- More actions and proof\n> [Unlike](" + catalogTx.NewLink("Unlike").AddArgs("trackID", id).URL() + ") · " +
574 "Queue on [Main](" + queueURL(0, t.ID) + ") or [" + md(g) + "](" + queueURL(t.Genre, t.ID) + ") · " +
575 "Book a time on [Main](" + bookURL(0, t.ID) + ") or [" + md(g) + "](" + bookURL(t.Genre, t.ID) + ") (unix time, 15 min to 24 h ahead) · " +
576 "[Report](" + catalogTx.NewLink("Report").AddArgs("kind", "track", "target", id, "reason", "").URL() + ")\n>\n")
577 b.WriteString("> **Fingerprints.** Audio " + sanitize.InlineCode(t.Audio))
578 if t.AudioSHA != "" {
579 b.WriteString(" · sha256 " + sanitize.InlineCode(t.AudioSHA))
580 }
581 if t.Cover != "" {
582 b.WriteString(" \n> Cover " + sanitize.InlineCode(t.Cover))
583 if t.CoverSHA != "" {
584 b.WriteString(" · sha256 " + sanitize.InlineCode(t.CoverSHA))
585 }
586 }
587 b.WriteString(" \n> Published " + date(t.Created) + "\n")
588 return b.String()
589}
590
591// support explains how this track can be supported, by origin and claim.
592func support(t catalog.Track, a catalog.Artist) string {
593 if tippable(a.Owner, a.Verified) {
594 to := "100% of the artist share goes to the artist"
595 if n := len(t.Splits); n == 1 {
596 to = "The artist share is split with 1 collaborator"
597 } else if n > 1 {
598 to = "The artist share is split with " + text.Itoa(n) + " collaborators"
599 }
600 out := "**◈ Tip:** [1 GNOT](" + tipURL(t.ID, 1) + ") · [5 GNOT](" + tipURL(t.ID, 5) + ") · [20 GNOT](" + tipURL(t.ID, 20) + ") \n"
601 if hasTreasury() {
602 out += "Or [5 GNOT + 10% to keep GnoRadio on air](" + tipSupportURL(t.ID, 5, 10) + "). \n"
603 }
604 return out + to + ", same transaction.\n"
605 }
606 return verifyNote(a) + "\n"
607}
608
609// platformName names the platform a pointer is read from.
610func platformName(origin string) string {
611 if origin == catalog.OriginJamendo {
612 return "Jamendo"
613 }
614 return "Audius"
615}
616
617// verifyNote says where an artist without tips stands: verified without a
618// wallet, verification in progress, or not verified yet.
619func verifyNote(a catalog.Artist) string {
620 if a.Verified && a.Owner == "" {
621 return "Verified source · no wallet linked yet, so tips are off."
622 }
623 if pending, to, ready := catalog.ClaimState(a.ID); pending {
624 fin := catalogTx.NewLink("FinalizeClaim").AddArgs("artistID", text.Itoa(a.ID)).URL()
625 if time.Now().Unix() >= ready {
626 return "Verification for " + who(to) + " is ready: [finish it](" + fin + ") (anyone can)."
627 }
628 return "Verification in progress for " + who(to) + ": ready " + date(ready) + ", then [finish it](" + fin + ")."
629 }
630 return "Tips open once the artist verifies this profile. Is this you? " + verifyLink(a.ID)
631}
632
633// promoLines shows what the artist shares with listeners who play them: the
634// promo share of each tip and the sponsored-pick refund, when on.
635func promoLines(a catalog.Artist, trackID, genre int) string {
636 if !tippable(a.Owner, a.Verified) {
637 return ""
638 }
639 s := ""
640 if p := catalog.PromoShare(a.ID); p > 0 {
641 s += "Promo share: **" + text.Itoa(p) + "%** of each tip goes to the listener who picked it on air and whoever shared the link. \n"
642 }
643 if pay := catalog.PromoOffer(a.ID); pay > 0 {
644 s += "Sponsored picks: the artist refunds **" + text.GNOT(pay) + "** once your pick airs"
645 if trackID > 0 {
646 s += ": [pick it on Main](" + sponsoredURL(0, trackID) + ") or [on " + md(radio.StationName(genre)) + "](" + sponsoredURL(genre, trackID) + ")"
647 }
648 s += ".\n"
649 }
650 if s == "" {
651 return ""
652 }
653 return "\n" + s
654}
655
656func renderArtist(a catalog.Artist, pg int) string {
657 var b strings.Builder
658 title := md(a.Name)
659 if a.Verified {
660 title += " ✓"
661 }
662 b.WriteString(header(title, "-"))
663 b.WriteString("<gno-columns>\n" + avatarSVG(text.Itoa(a.ID)+a.Name, 160) + "\n<gno-columns-sep />\n")
664 if a.Verified {
665 // A proof shows control of a site, not who the artist is: say which.
666 if h := catalog.ProofHost(a.ID); h != "" {
667 b.WriteString("✓ Verified via **" + md(h) + "**: the artist proved they control this site.\n\n")
668 } else {
669 b.WriteString("✓ Verified by the GnoRadio moderator.\n\n")
670 }
671 }
672 if a.Bio != "" {
673 b.WriteString(md(a.Bio) + "\n\n")
674 }
675 followers := plural(a.Followers, "follower")
676 switch {
677 case a.Owner != "":
678 wallet := short(a.Owner)
679 if roles.Canonical(a.Owner) {
680 wallet = "[" + wallet + "](" + page("listener/"+a.Owner.String()) + ")"
681 }
682 b.WriteString("◈ **" + text.GNOT(a.Tips) + "** received · " + followers + " \nWallet: " + wallet)
683 if !tippable(a.Owner, a.Verified) {
684 b.WriteString(" \n" + verifyNote(a))
685 }
686 b.WriteString("\n")
687 case a.Kind == catalog.OriginAudius || a.Kind == catalog.OriginJamendo:
688 b.WriteString("Read live from " + platformName(a.Kind) + " · " + followers + " \n" + verifyNote(a) + "\n")
689 default:
690 b.WriteString("Curated CC artist" + sourceLink("source", a.Source) + " · " + followers + " \n" + verifyNote(a) + "\n")
691 }
692 b.WriteString(promoLines(a, 0, 0) + "\n")
693 b.WriteString("[Follow](" + catalogTx.NewLink("Follow").AddArgs("artistID", text.Itoa(a.ID)).URL() + ") · " +
694 "[Unfollow](" + catalogTx.NewLink("Unfollow").AddArgs("artistID", text.Itoa(a.ID)).URL() + ")")
695 if l := inApp("artist/"+text.Itoa(a.ID), "Open in the app"); l != "" {
696 b.WriteString(" · " + l)
697 }
698 b.WriteString("\n</gno-columns>\n\n")
699
700 // Tracks, newest first, pageSize per page; page 1 shows the first covers.
701 b.WriteString("## Tracks\n\n")
702 n := catalog.ArtistTrackCount(a.ID)
703 hi := n - (pg-1)*pageSize
704 lo := hi - pageSize
705 if lo < 0 {
706 lo = 0
707 }
708 var ids []int
709 if hi > 0 {
710 ids = catalog.ArtistTrackPage(a.ID, lo, hi-lo)
711 }
712 var covers []catalog.Track
713 var rows strings.Builder
714 for i := len(ids) - 1; i >= 0; i-- {
715 t, ok := catalog.GetTrack(ids[i])
716 if !ok || !playable(t) {
717 continue
718 }
719 if pg == 1 && len(covers) < maxCovers {
720 covers = append(covers, t)
721 } else {
722 rows.WriteString("- " + trackLink(t) + "\n")
723 }
724 }
725 if len(covers) == 0 && rows.Len() == 0 {
726 b.WriteString("No track yet.\n")
727 }
728 b.WriteString(coverGrid(covers) + "\n" + rows.String() + "\n" + pager("artist/"+text.Itoa(a.ID), pg, n) + "\n")
729 if len(a.Albums) > 0 {
730 b.WriteString("## Albums\n\n")
731 for _, id := range a.Albums {
732 if al, ok := catalog.GetAlbum(id); ok && !al.Hidden {
733 b.WriteString("- [" + md(al.Title) + "](" + page("album/"+text.Itoa(al.ID)) + ") · " + text.Itoa(al.Year) + "\n")
734 }
735 }
736 b.WriteString("\n")
737 }
738 var evs []tickets.Event
739 for _, id := range tickets.ArtistEvents(a.ID, 10) {
740 if e, ok := tickets.EventInfo(id); ok {
741 evs = append(evs, e)
742 }
743 }
744 if len(evs) > 0 {
745 // At most 10: an insertion sort by start time.
746 for i := 1; i < len(evs); i++ {
747 for j := i; j > 0 && evs[j].Start < evs[j-1].Start; j-- {
748 evs[j], evs[j-1] = evs[j-1], evs[j]
749 }
750 }
751 b.WriteString("## Concerts\n\n")
752 now := time.Now().Unix()
753 for _, e := range evs {
754 b.WriteString("- " + date(e.Start) + " · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " + md(e.Venue) + eventTag(e, now) + "\n")
755 }
756 }
757 b.WriteString("\n[Report this profile](" + catalogTx.NewLink("Report").AddArgs("kind", "artist", "target", text.Itoa(a.ID), "reason", "").URL() + ")\n")
758 return b.String()
759}
760
761// eventTag marks a cancelled or past concert in a list.
762func eventTag(e tickets.Event, now int64) string {
763 switch {
764 case e.Cancelled:
765 return " · **cancelled**"
766 case e.Start <= now:
767 return " · past"
768 }
769 return ""
770}
771
772func renderAlbum(al catalog.Album) string {
773 var b strings.Builder
774 a, _ := catalog.GetArtist(al.Artist)
775 b.WriteString(header(md(al.Title), "-"))
776 b.WriteString("<gno-columns>\n" + coverSVG(al.ID*7919, al.Artist, al.Title, 260) + "\n<gno-columns-sep />\n")
777 var total int64
778 var tips int64
779 tracks := make([]*catalog.Track, len(al.Tracks)) // read once, for the totals and the table
780 for i, id := range al.Tracks {
781 if t, ok := catalog.GetTrack(id); ok {
782 tracks[i] = &t
783 total += t.Duration
784 tips += t.Tips
785 }
786 }
787 b.WriteString("### " + artistLink(a) + "\n\n" + text.Itoa(al.Year) + " · " + plural(len(al.Tracks), "track") + " · " + clock(total) + " · ◈ " + text.GNOT(tips) + "\n</gno-columns>\n\n")
788 b.WriteString("| # | Track | Time | ♥ |\n|---|---|---|---|\n")
789 for i, t := range tracks {
790 if t != nil && playable(*t) {
791 b.WriteString("| " + text.Itoa(i+1) + " | [" + md(t.Title) + "](" + page("track/"+text.Itoa(t.ID)) + ") | " + clock(t.Duration) + " | " + text.Itoa(t.Likes) + " |\n")
792 }
793 }
794 b.WriteString("\n")
795 if l := inApp("album/"+text.Itoa(al.ID), "Play it in the app"); l != "" {
796 b.WriteString(l + " · ")
797 }
798 b.WriteString("[Report this album](" + catalogTx.NewLink("Report").AddArgs("kind", "album", "target", text.Itoa(al.ID), "reason", "").URL() + ")\n")
799 return b.String()
800}
801
802func renderPlaylist(pl catalog.Playlist, pg int) string {
803 var b strings.Builder
804 b.WriteString(header(md(pl.Title), "-"))
805 b.WriteString("Playlist by " + who(pl.Owner) + " · " + plural(len(pl.Tracks), "track") + " · updated " + date(pl.Updated))
806 if l := inApp("playlist/"+text.Itoa(pl.ID), "Play it in the app"); l != "" {
807 b.WriteString(" · " + l)
808 }
809 b.WriteString("\n\n| # | Track | Time |\n|---|---|---|\n")
810 for i := (pg - 1) * pageSize; i < len(pl.Tracks) && i < pg*pageSize; i++ {
811 if t, ok := catalog.GetTrack(pl.Tracks[i]); ok && playable(t) {
812 b.WriteString("| " + text.Itoa(i+1) + " | " + trackLink(t) + " | " + clock(t.Duration) + " |\n")
813 }
814 }
815 b.WriteString("\n" + pager("playlist/"+text.Itoa(pl.ID), pg, len(pl.Tracks)))
816 b.WriteString("\n[Report this playlist](" + catalogTx.NewLink("Report").AddArgs("kind", "playlist", "target", text.Itoa(pl.ID), "reason", "").URL() + ")\n")
817 return b.String()
818}
819
820func renderCatalog(pg, genre int) string {
821 var b strings.Builder
822 route, total := "catalog", catalog.TrackCount()
823 ids := catalog.TrackPage((pg-1)*pageSize, pageSize, true)
824 if genre > 0 {
825 route, total = "catalog?g="+text.Itoa(genre), catalog.GenreCount(genre)
826 ids = catalog.GenrePage(genre, (pg-1)*pageSize, pageSize)
827 b.WriteString(header("Catalog · "+md(catalog.GenreName(genre)), "catalog"))
828 } else {
829 b.WriteString(header("Catalog", "catalog"))
830 }
831 b.WriteString("Browse: [all](" + page("catalog") + ")")
832 for g := 1; g <= catalog.NumGenres; g++ {
833 b.WriteString(" · [" + md(catalog.GenreName(g)) + "](" + page("catalog?g="+text.Itoa(g)) + ")")
834 }
835 b.WriteString(" · [Artists](" + page("artists") + ") · [Albums](" + page("albums") + ") · [Playlists](" + page("playlists") + ")\n\n")
836 if len(ids) == 0 {
837 b.WriteString("> [!INFO] No track here yet\n> [Publish one](" + page("join") + ").\n")
838 return b.String()
839 }
840 b.WriteString("| Track | Genre | Time | ♥ |\n|---|---|---|---|\n")
841 for _, id := range ids {
842 if t, ok := catalog.GetTrack(id); ok && playable(t) {
843 b.WriteString("| " + trackLink(t) + " | " + md(catalog.GenreName(t.Genre)) + " | " + clock(t.Duration) + " | " + text.Itoa(t.Likes) + " |\n")
844 }
845 }
846 b.WriteString("\n" + pager(route, pg, total))
847 return b.String()
848}
849
850func renderArtists(pg int) string {
851 var b strings.Builder
852 b.WriteString(header("Artists", "catalog"))
853 b.WriteString("| Artist | Tracks | Followers | Tips |\n|---|---|---|---|\n")
854 for _, id := range catalog.ArtistPage((pg-1)*pageSize, pageSize) {
855 if a, ok := catalog.GetArtist(id); ok && !a.Hidden {
856 name := artistLink(a)
857 if a.Owner == "" {
858 name += " · " + md(a.Kind) + ", unclaimed"
859 }
860 b.WriteString("| " + name + " | " + text.Itoa(len(a.Tracks)) + " | " + text.Itoa(a.Followers) + " | " + text.GNOT(a.Tips) + " |\n")
861 }
862 }
863 b.WriteString("\n" + pager("artists", pg, catalog.ArtistCount()))
864 return b.String()
865}
866
867func renderAlbums(pg int) string {
868 var b strings.Builder
869 b.WriteString(header("Albums", "catalog"))
870 for _, id := range catalog.AlbumPage((pg-1)*pageSize, pageSize) {
871 if al, ok := catalog.GetAlbum(id); ok && !al.Hidden && catalog.ArtistVisible(al.Artist) {
872 b.WriteString("- [" + md(al.Title) + "](" + page("album/"+text.Itoa(al.ID)) + ") · " + artistRef(al.Artist) + " · " + text.Itoa(al.Year) + "\n")
873 }
874 }
875 b.WriteString("\n" + pager("albums", pg, catalog.AlbumCount()))
876 return b.String()
877}
878
879func renderPlaylists(pg int) string {
880 var b strings.Builder
881 b.WriteString(header("Playlists", "catalog"))
882 for _, id := range catalog.PlaylistPage((pg-1)*pageSize, pageSize) {
883 if owner, title, n, hidden, ok := catalog.PlaylistBrief(id); ok && !hidden {
884 b.WriteString("- [" + md(title) + "](" + page("playlist/"+text.Itoa(id)) + ") · " + plural(n, "track") + " · by " + who(owner) + "\n")
885 }
886 }
887 b.WriteString("\n" + pager("playlists", pg, catalog.PlaylistCount()))
888 return b.String()
889}
890
891// maxPastScan bounds how many concert ids the cancelled list reads.
892const maxPastScan = 300
893
894func renderConcerts(pg int) string {
895 var b strings.Builder
896 b.WriteString(header("Concerts", "concerts"))
897 if pg == 1 {
898 b.WriteString("## Upcoming\n\n")
899 up := tickets.Upcoming(20)
900 if len(up) == 0 {
901 b.WriteString("No concert announced. [Announce yours](" + page("join") + ").\n")
902 }
903 for _, e := range up {
904 b.WriteString("- **" + date(e.Start) + "** · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " +
905 artistRef(e.ArtistID) + " · " + md(e.Venue) + "\n")
906 }
907 if l := inApp("concerts", "Get tickets in the app"); l != "" {
908 b.WriteString("\n" + l + "\n")
909 }
910 }
911 now := time.Now().Unix()
912 // Future concerts that were cancelled, page 1 only: the newest ids.
913 // ponytail: an old id cancelled for a far date can be missed, and
914 // maxPastScan cancelled announcements hide older ones; an index of
915 // cancellations in tickets would list them all.
916 var cancelled strings.Builder
917 nCancelled := 0
918 for id, scanned := tickets.EventCount(), 0; pg == 1 && id >= 1 && scanned < maxPastScan && nCancelled < 10; id, scanned = id-1, scanned+1 {
919 if e, ok := tickets.EventInfo(id); ok && e.Cancelled && e.Start > now && catalog.ArtistVisible(e.ArtistID) {
920 nCancelled++
921 cancelled.WriteString("- ~~" + date(e.Start) + "~~ · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " + artistRef(e.ArtistID) + "\n")
922 }
923 }
924 // Past concerts, latest first, from the tickets' start-time index: what
925 // was cancelled or hidden takes no room there.
926 var past strings.Builder
927 ids, total := tickets.PastEvents((pg-1)*pageSize, pageSize)
928 shown, more := 0, pg*pageSize < total
929 for _, id := range ids {
930 if e, ok := tickets.EventInfo(id); ok && catalog.ArtistVisible(e.ArtistID) {
931 past.WriteString("- " + date(e.Start) + " · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " + artistRef(e.ArtistID) + eventTag(e, now) + "\n")
932 shown++
933 }
934 }
935 if nCancelled > 0 {
936 b.WriteString("\n## Cancelled\n\n" + cancelled.String())
937 }
938 b.WriteString("\n## Past concerts\n\n")
939 if shown == 0 {
940 if total == 0 {
941 b.WriteString("No past concert yet.\n")
942 } else {
943 b.WriteString("Nothing to show on this page.\n")
944 }
945 }
946 b.WriteString(past.String() + "\n" + pagerMore("concerts", pg, more))
947 return b.String()
948}
949
950func renderEvent(e tickets.Event) string {
951 var b strings.Builder
952 b.WriteString(header(md(e.Title), "-"))
953 b.WriteString(tickets.EventSVG(e.ID) + "\n\n")
954 a, _ := catalog.GetArtist(e.ArtistID)
955 b.WriteString("**" + artistLink(a) + "** · " + md(e.Venue) + " · **" + date(e.Start) + "** · seats **" + text.Itoa(e.Sold) + " / " + text.Itoa(e.Capacity) + "**\n\n")
956 if u := sanitize.URL(e.Link); u != "" && a.Verified { // an unverified profile's link may be phishing
957 b.WriteString("Link: [" + md(e.Link) + "](" + u + ") (public)\n\n")
958 }
959 switch {
960 case e.Cancelled && e.Price > 0:
961 b.WriteString("> [!CAUTION] Cancelled\n> Payments went straight to the artist: contact them for a refund.\n")
962 case e.Cancelled:
963 b.WriteString("> [!CAUTION] Cancelled\n> This free concert will not take place.\n")
964 case time.Now().Unix() >= e.Start:
965 b.WriteString("> [!NOTE] Past concert\n> Tickets stay in their holders' collections.\n")
966 case e.Sold >= e.Capacity:
967 b.WriteString("> [!WARNING] Sold out\n")
968 case e.Price > 0 && !tippable(a.Owner, a.Verified):
969 b.WriteString("> [!NOTE] Tickets open once the artist verifies this profile\n> Is this you? " + verifyLink(a.ID) + "\n")
970 default:
971 link := ticketsTx.NewLink("BuyTicket").AddArgs("eventID", text.Itoa(e.ID))
972 label, limit := "Get a free ticket", "one free ticket per wallet"
973 if e.Price > 0 {
974 fee := tickets.ServiceFee()
975 link = link.SetSend(strconv.FormatInt(e.Price+fee, 10) + "ugnot")
976 label, limit = "Buy for "+text.GNOT(e.Price+fee), "up to 4 per wallet"
977 if fee > 0 {
978 limit = text.GNOT(e.Price) + " to the artist + " + text.GNOT(fee) + " GnoRadio service fee · " + limit
979 }
980 }
981 b.WriteString("**[" + label + "](" + link.URL() + ")** · " + limit + "\n\n")
982 b.WriteString("Your ticket is an NFT drawn on-chain, in your name. 100% of the ticket price goes to the artist. " +
983 "You can gift it; there is no resale market. At the door the artist checks it in and it becomes an \"I was there\" keepsake.\n")
984 }
985 b.WriteString("\n[← All concerts](" + page("concerts") + ")\n")
986 return b.String()
987}
988
989func renderTicket(tk tickets.Ticket) string {
990 var b strings.Builder
991 e, ok := tickets.EventInfo(tk.Event)
992 if !ok {
993 b.WriteString(header("Ticket #"+text.Itoa(tk.Serial), "-"))
994 b.WriteString(tickets.TicketSVG(tk.ID) + "\n\n")
995 note := tickets.HiddenNote(tk.Event)
996 if note == "" {
997 note = "This concert is no longer listed"
998 }
999 b.WriteString("> [!NOTE] Concert removed\n> " + md(strings.TrimSuffix(removedNote(note), ".")) + ". The ticket stays in its holder's collection.\n\n")
1000 b.WriteString("Holder: " + who(tickets.TicketOwner(tk.ID)) + "\n")
1001 return b.String()
1002 }
1003 b.WriteString(header("Ticket #"+text.Itoa(tk.Serial)+" · "+md(e.Title), "-"))
1004 b.WriteString(tickets.TicketSVG(tk.ID) + "\n\n")
1005 b.WriteString("Holder: " + who(tickets.TicketOwner(tk.ID)) + " · [concert](" + page("event/"+text.Itoa(e.ID)) + ")" + ticketTag(tk, e) + "\n\n")
1006 if !tk.Attended && !e.Cancelled {
1007 b.WriteString("Holder: [gift this ticket](" + ticketsTx.NewLink("TransferTicket").AddArgs("to", "", "ticketID", text.Itoa(tk.ID)).URL() + ") · " +
1008 "[show at the door](" + ticketsTx.NewLink("Present").AddArgs("ticketID", text.Itoa(tk.ID), "code", "").URL() + ") with the door's code · " +
1009 "Artist: [check in at the door](" + ticketsTx.NewLink("CheckIn").AddArgs("ticketID", text.Itoa(tk.ID), "code", "").URL() + ")\n")
1010 }
1011 return b.String()
1012}
1013
1014// ticketTag marks an attended ticket or a cancelled concert.
1015func ticketTag(tk tickets.Ticket, e tickets.Event) string {
1016 switch {
1017 case tk.Attended:
1018 return " · ✓ I was there"
1019 case e.Cancelled:
1020 return " · **cancelled**"
1021 }
1022 return ""
1023}
1024
1025// renderListeners is the bare "listener" path: who picks for the stations.
1026func renderListeners() string {
1027 var b strings.Builder
1028 b.WriteString(header("Listeners", "-"))
1029 b.WriteString("Every listener has a public page: open one from the activity feed, a playlist or a station.\n\n## Top curators this week\n\n")
1030 top := radio.TopCurators(-1) // every station
1031 if len(top) == 0 {
1032 b.WriteString("No pick this week yet. [Pick a track](" + page("catalog") + ").\n")
1033 }
1034 for i, c := range top {
1035 b.WriteString(text.Itoa(i+1) + ". " + who(c.Address) + " · " + plural(c.Picks, "pick") + " · " + plural(c.Tips, "tip") + " on air · earned " + text.GNOT(c.Earned) + "\n")
1036 }
1037 b.WriteString("\n[Charts →](" + page("charts") + ")\n")
1038 return b.String()
1039}
1040
1041func renderListener(addr address, pg int) string {
1042 var b strings.Builder
1043 b.WriteString(header(md(displayName(addr)), "-"))
1044 b.WriteString("`" + addr.String() + "`")
1045 if userName(addr) == "" {
1046 b.WriteString(" · a nickname until this wallet registers a [gno.land name](/r/sys/users)")
1047 }
1048 b.WriteString("\n\n")
1049 if id := catalog.ArtistOf(addr); id != 0 {
1050 if s := artistRef(id); s != "" {
1051 b.WriteString("Also the artist " + s + ".\n\n")
1052 }
1053 }
1054 likes, follows, tipped, lists := catalog.UserStats(addr)
1055 b.WriteString("♥ " + plural(likes, "like") + " · " + plural(follows, "follow") + " · ◈ " + text.GNOT(tipped) + " given · " +
1056 plural(tickets.Attended(addr), "concert") + " attended\n\n")
1057 if c := radio.CuratorOf(addr); c.Picks > 0 {
1058 b.WriteString("**Curator:** " + plural(c.Picks, "pick") + " · " + plural(c.Tips, "tip") + " received on air · earned " + text.GNOT(c.Earned))
1059 if c.Promo > 0 {
1060 b.WriteString(" · " + text.GNOT(c.Promo) + " refunded for sponsored picks")
1061 }
1062 b.WriteString("\n\n")
1063 }
1064 if len(lists) > 0 {
1065 b.WriteString("## Playlists\n\n")
1066 for _, id := range lists {
1067 if _, title, n, hidden, ok := catalog.PlaylistBrief(id); ok && !hidden {
1068 b.WriteString("- [" + md(title) + "](" + page("playlist/"+text.Itoa(id)) + ") · " + plural(n, "track") + "\n")
1069 }
1070 }
1071 b.WriteString("\n")
1072 }
1073 route := "listener/" + addr.String()
1074 b.WriteString("## Tickets\n\n")
1075 ids := tickets.TicketsOf(addr, (pg-1)*pageSize, pageSize)
1076 if len(ids) == 0 {
1077 b.WriteString("No ticket. [See concerts](" + page("concerts") + ").\n")
1078 }
1079 for _, id := range ids {
1080 if tk, ok := tickets.TicketInfo(id); ok {
1081 e, ok := tickets.EventInfo(tk.Event)
1082 if !ok {
1083 b.WriteString("- [Removed by moderation #" + text.Itoa(tk.Serial) + "](" + page("ticket/"+text.Itoa(tk.ID)) + ")\n")
1084 continue
1085 }
1086 b.WriteString("- [" + md(e.Title) + " #" + text.Itoa(tk.Serial) + "](" + page("ticket/"+text.Itoa(tk.ID)) + ") · " + date(e.Start) + ticketTag(tk, e) + "\n")
1087 }
1088 }
1089 liked := catalog.LikedPage(addr, (pg-1)*pageSize, pageSize)
1090 b.WriteString("\n## Likes\n\n")
1091 var badges strings.Builder
1092 shown := 0
1093 for _, id := range liked {
1094 if t, ok := catalog.GetTrack(id); ok && playable(t) {
1095 b.WriteString("- " + trackLink(t) + "\n")
1096 for i, e := range t.Early {
1097 if e == addr && shown < maxCovers {
1098 badges.WriteString("[" + badgeSVG(t.Title, i+1) + "](" + page("track/"+text.Itoa(t.ID)) + ")")
1099 shown++
1100 }
1101 }
1102 }
1103 }
1104 if len(liked) == 0 {
1105 b.WriteString("No like yet.\n")
1106 }
1107 b.WriteString("\n## Follows\n\n")
1108 followed := catalog.FollowsPage(addr, (pg-1)*pageSize, pageSize)
1109 for _, id := range followed {
1110 if s := artistRef(id); s != "" {
1111 b.WriteString("- " + s + "\n")
1112 }
1113 }
1114 if len(followed) == 0 {
1115 b.WriteString("Follows no artist yet.\n")
1116 }
1117 b.WriteString("\n## Early-listener badges\n\n")
1118 if shown == 0 {
1119 b.WriteString("No early-listener badge on this page: be among the first 10 to like a new track.")
1120 }
1121 b.WriteString(badges.String() + "\n\n")
1122 more := len(ids) == pageSize || len(liked) == pageSize || len(followed) == pageSize
1123 b.WriteString(pagerMore(route, pg, more))
1124 return b.String()
1125}
1126
1127func renderCharts() string {
1128 var b strings.Builder
1129 b.WriteString(header("Charts", "charts"))
1130 b.WriteString("## Most supported artists\n\n")
1131 var rows []bar
1132 for _, r := range catalog.TopArtists() {
1133 if name, _, hidden, ok := catalog.ArtistBrief(atoi(r.Key)); ok && !hidden {
1134 rows = append(rows, bar{name, r.Value})
1135 }
1136 }
1137 if len(rows) == 0 {
1138 b.WriteString("> [!INFO] No tips yet\n> Open a track by a claimed artist and support them.\n\n")
1139 } else {
1140 b.WriteString(barsSVG(rows, text.GNOT) + "\n\n")
1141 }
1142 b.WriteString("## Most liked tracks\n\n| # | Track | ♥ | Supporters |\n|---|---|---|---|\n")
1143 n := 0
1144 for _, r := range catalog.TopLiked() {
1145 if t, ok := catalog.GetTrack(atoi(r.Key)); ok && playable(t) {
1146 n++
1147 b.WriteString("| " + text.Itoa(n) + " | " + trackLink(t) + " | " + text.Itoa(t.Likes) + " | " + text.Itoa(t.Supporters) + " |\n")
1148 }
1149 }
1150 if n == 0 {
1151 b.WriteString("| | No likes yet | | |\n")
1152 }
1153 b.WriteString("\n## Top supporters\n\n")
1154 fans := catalog.TopFans()
1155 if len(fans) == 0 {
1156 b.WriteString("> [!INFO] No supporter yet\n> Tips to artists put you on this board.\n")
1157 }
1158 for i, r := range fans {
1159 b.WriteString(text.Itoa(i+1) + ". " + who(address(r.Key)) + " · " + text.GNOT(r.Value) + "\n")
1160 }
1161 return b.String()
1162}
1163
1164func renderJoin() string {
1165 return header("Join GnoRadio", "join") +
1166 "**0. Your wallet is your identity.** Install [Adena](https://adena.app). Each step below opens a transaction you sign in your wallet.\n\n" +
1167 "## Artists\n\n" +
1168 "1. [Create your artist profile](" + catalogTx.NewLink("RegisterArtist").AddArgs("name", "", "bio", "").URL() + ") (Latin letters, 2-40 characters).\n" +
1169 "2. Export **MP3 320 kbps** or **Opus 160 kbps** around **-14 LUFS**, upload it to IPFS (or an allowed host) and note its sha256.\n" +
1170 "3. [Publish a track](" + radioTx.NewLink("PublishTrack").URL() + "): genre 1-" + text.Itoa(catalog.NumGenres) + ", duration as 3:45, license (CC0, CC BY, CC BY-SA, CC BY-NC… or ALL-RIGHTS-RESERVED), " +
1171 "collaborators as `g1…:30` in percent, and answer `yes` to: *\"" + md(catalog.RightsTerms()) + "\"* It joins its genre station right away, in the same transaction.\n" +
1172 "4. [Group tracks into an album](" + catalogTx.NewLink("CreateAlbum").URL() + ") and [announce a concert](" + ticketsTx.NewLink("CreateEvent").URL() + "): " +
1173 "start in UTC as `2026-11-20 21:00`, price in GNOT (`0` for free, `2.5`), and the number of seats.\n" +
1174 "5. [Set your promo share](" + catalogTx.NewLink("SetPromoShare").AddArgs("pct", "").URL() + "): 0 to 20% of each tip (none until you set it) goes to the listener who picked your track on air and whoever shared the link.\n" +
1175 "6. Once verified, [fund a sponsored-pick budget](" + catalogTx.NewLink("FundPromo").SetSend("1000000ugnot").URL() + ") (1 GNOT here, five refunds at the default; 0.1 GNOT at least) and " +
1176 "[set the refund per pick](" + catalogTx.NewLink("SetPromoPay").AddArgs("ugnotPerPick", "200000", "perDay", "0").URL() + "): " +
1177 "10000-500000 ugnot (0.01-0.5 GNOT) for each pick of your tracks that airs, 1 to 100 a day (0 = the default, 10); " +
1178 "the default 200000 (0.2 GNOT) covers what the pick and collecting its refund cost the listener. " +
1179 "[Withdraw the rest](" + dataTx.NewLink("VaultWithdraw").AddArgs("acct", "catalog/").URL() + ") at any time: the budget sits in GnoRadio's data realm, " +
1180 "account `catalog/` and your artist id on 8 digits (artist 12: `catalog/00000012`).\n\n" +
1181 "> [!CAUTION] SACEM members\n> Only works declared under a non-commercial CC license: use `sacem-nc` and a CC BY-NC license.\n\n" +
1182 "## Your music is already here?\n\n" +
1183 "Imported from Audius or an archive? Open your profile in the app and press **Verify**: put the code it gives you in your Audius bio " +
1184 "or at `https://yourdomain/.well-known/gnoradio.txt`. A robot checks it; tips open 72 hours later.\n\n" +
1185 "## Listeners\n\n[Publish a public playlist](" + catalogTx.NewLink("PublishPlaylist").URL() + ") with track ids like `12,40,7`. Private playlists and saves live in the app, for free.\n\n" +
1186 "Genres: " + genreList() + "\n"
1187}
1188
1189// aboutFees names the operator's income, only when there is any: the ticket
1190// service fee is 0 while no treasury is set.
1191func aboutFees() string {
1192 switch {
1193 case hasTreasury() && tickets.ServiceFee() > 0:
1194 return " An optional extra and a ticket service fee fund GnoRadio."
1195 case hasTreasury():
1196 return " An optional extra funds GnoRadio."
1197 }
1198 return ""
1199}
1200
1201func renderAbout() string {
1202 return header("How it works", "about") +
1203 "<gno-columns>\n" +
1204 "### Library and Live\n\nPlay any album or playlist in any order, or tune into a station where everyone hears the same second. The schedule lives on-chain; there is no streaming server to switch off.\n" +
1205 "<gno-columns-sep />\n" +
1206 "### 0% taken from artists, provably\n\nTips and ticket prices reach the artist in the same transaction." + aboutFees() + " Don't trust us, read the code.\n" +
1207 "<gno-columns-sep />\n" +
1208 "### Tickets that remember\n\nEach ticket is an NFT drawn by the contract, in your name. Gift it, get it checked in at the door, keep it after the show.\n" +
1209 "</gno-columns>\n\n" +
1210 "## Who does what\n\n" +
1211 "- **Here (gnoweb):** catalog, profiles, support, tickets and the on-chain proof. Every page is rendered by a contract.\n" +
1212 "- **The GnoRadio app:** the synced player, uploads, your library. Listening never needs a transaction." + appLine() + "\n" +
1213 "- **Audio:** IPFS, Arweave, allowed hosts (archive.org) and Audius. Metadata, links and fingerprints live on gno.land.\n\n" +
1214 "## Rules\n\n- Publish only music you own or control the rights to. Curated tracks are CC0, CC BY or CC BY-SA with attribution; Audius tracks follow the Audius Open Music License.\n" +
1215 "- Published files stay public: moderation can hide, not erase. Reports are public on [the moderation page](" + page("moderation") + ").\n\n" +
1216 noticeBlock() +
1217 "## Verify it yourself\n\n" +
1218 "- Tips and splits: [social.gno](" + sourceURL(catalogPath, "social.gno") + ") · GnoRadio funding: [support.gno](" + sourceURL(catalogPath, "support.gno") + ")\n" +
1219 "- Stations and queue: [radio.gno](" + sourceURL(radioPath, "radio.gno") + ") · Tickets and fees: [tickets.gno](" + sourceURL(ticketsPath, "tickets.gno") + ")\n" +
1220 "- Moderation powers: [moderation.gno](" + sourceURL(catalogPath, "moderation.gno") + "). Current moderator: " + role(catalog.Admin(), "GnoRadio (moderator)") + ".\n"
1221}
1222
1223func renderModeration(pg int) string {
1224 var b strings.Builder
1225 b.WriteString(header("Moderation", "-"))
1226 b.WriteString("Moderator: " + role(catalog.Admin(), "GnoRadio (moderator)") + ". Reports are public; decisions are visible transactions.\n\n" + noticeBlock())
1227 if catalog.ReportCount() == 0 {
1228 b.WriteString("> [!SUCCESS] No open report\n> Use the Report link on any track, artist, album or playlist.\n")
1229 return b.String()
1230 }
1231 b.WriteString("| # | Date | Target | Reason | By |\n|---|---|---|---|---|\n")
1232 for _, id := range catalog.ReportPage((pg-1)*pageSize, pageSize) {
1233 r, ok := catalog.GetReport(id)
1234 if !ok {
1235 continue
1236 }
1237 reason := sanitize.TableCell(r.Reason)
1238 if safe.Blocked(r.Reason) { // it may quote what it reports
1239 reason = "*(hidden: quotes a blocked word)*"
1240 }
1241 kind, target := md(r.Kind), text.Itoa(atoi(r.Target))
1242 b.WriteString("| " + text.Itoa(r.ID) + " | " + date(r.At) + " | [" + kind + " " + target + "](" + page(kind+"/"+target) +
1243 ") | " + reason + " | " + who(r.By) + " |\n")
1244 }
1245 b.WriteString("\n" + plural(catalog.ReportCount(), "open report") + ". " + pager("moderation", pg, catalog.ReportCount()))
1246 return b.String() + pendingClaims()
1247}
1248
1249// pendingClaims lists the verifications on their way: each stays public 72
1250// hours, so anyone can spot an impersonation and report it first.
1251func pendingClaims() string {
1252 cs, _ := catalog.PendingClaims(0, 100)
1253 if len(cs) == 0 {
1254 return ""
1255 }
1256 var b strings.Builder
1257 b.WriteString("\n\n## Pending verifications\n\nEach profile is verified 72 hours after its claim. Not the real artist? Report the profile.\n\n" +
1258 "| Artist | Wallet | Proof found on | Ready |\n|---|---|---|---|\n")
1259 for _, c := range cs {
1260 if s := artistRef(c.Artist); s != "" {
1261 b.WriteString("| " + s + " | " + who(c.To) + " | " + md(proofHostOf(c.Proof)) + " | " + date(c.ReadyAt) + " |\n")
1262 }
1263 }
1264 return b.String()
1265}
1266
1267// proofHostOf is the host of a proof link, as catalog.ProofHost shows it:
1268// up to the first / ? or #, and only a plain host (Claim refuses others; a
1269// later release's record shows "?" rather than a wrong host).
1270func proofHostOf(url string) string {
1271 h := strings.TrimPrefix(url, "https://")
1272 if i := strings.IndexAny(h, "/?#"); i >= 0 {
1273 h = h[:i]
1274 }
1275 h = strings.ToLower(h)
1276 for _, c := range h {
1277 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') {
1278 return "?"
1279 }
1280 }
1281 return h
1282}
1283
1284// renderRemoved is the page of hidden content: why it is gone, never what it was.
1285func renderRemoved(note string) string {
1286 return header("Removed", "-") + "> [!WARNING] Removed\n> " + md(removedNote(note)) + "\n\n[← Back to the radio](" + page("") + ")\n"
1287}
1288
1289// ---- helpers ----
1290
1291// header is the page title and the nav; cur is the nav entry to mark ("-": none).
1292func header(title, cur string) string { return "# " + title + "\n\n" + nav(cur) }
1293
1294// nav links the main pages; the current one is bold and unlinked.
1295func nav(cur string) string {
1296 items := [7][2]string{{"", "Radio"}, {"stations", "Stations"}, {"catalog", "Catalog"}, {"concerts", "Concerts"},
1297 {"charts", "Charts"}, {"join", "For artists"}, {"about", "About"}}
1298 var b strings.Builder
1299 for i, it := range items {
1300 if i > 0 {
1301 b.WriteString(" · ")
1302 }
1303 if it[0] == cur {
1304 b.WriteString("**" + it[1] + "**")
1305 } else {
1306 b.WriteString("[" + it[1] + "](" + page(it[0]) + ")")
1307 }
1308 }
1309 if l := inApp("legal", "Legal"); l != "" {
1310 b.WriteString(" · " + l)
1311 }
1312 return b.String() + "\n\n"
1313}
1314
1315// noticeBlock is how to send a copyright or illegal-content notice: the
1316// on-chain report, plus the admin's contact line when one is set.
1317func noticeBlock() string {
1318 s := "## Copyright or illegal content\n\nUse the **Report** link on the track, artist, album or playlist " +
1319 "(the catalog's [Report](" + catalogTx.NewLink("Report").AddArgs("kind", "", "target", "", "reason", "").URL() + ") transaction): " +
1320 "say which right it infringes or why it is illegal. Reports are public, with the reporter's address, on [the moderation page](" + page("moderation") + "). " +
1321 "The moderator hides reported content while checking and shows the reason on its page.\n"
1322 if c := contact(); c != "" {
1323 s += "\nContact: " + md(c) + "\n"
1324 }
1325 if l := inApp("legal", "Legal notice, privacy and terms"); l != "" {
1326 s += "\n" + l + "\n"
1327 }
1328 return s + "\n"
1329}
1330
1331func stats() string {
1332 return bold(plural(catalog.ArtistCount(), "artist")) + " · " + bold(plural(catalog.TrackCount(), "track")) + " · " +
1333 bold(plural(catalog.AlbumCount(), "album")) + " · " + bold(plural(tickets.TicketCount(), "ticket")) + " · v" + catalog.Version() + "\n"
1334}
1335
1336// bold makes the number of "12 tracks" bold.
1337func bold(s string) string {
1338 n, rest, _ := strings.Cut(s, " ")
1339 return "**" + n + "** " + rest
1340}
1341
1342func page(p string) string {
1343 if p == "" {
1344 return base
1345 }
1346 return base + ":" + p
1347}
1348
1349func pager(route string, pg, total int) string { return pagerMore(route, pg, pg*pageSize < total) }
1350
1351// pagerMore links the previous page and, when more is true, the next one.
1352func pagerMore(route string, pg int, more bool) string {
1353 sep := "?p="
1354 if strings.Contains(route, "?") {
1355 sep = "&p="
1356 }
1357 s := ""
1358 if pg > 1 {
1359 s += "[← Previous](" + page(route+sep+text.Itoa(pg-1)) + ") "
1360 }
1361 if more {
1362 s += "[Next →](" + page(route+sep+text.Itoa(pg+1)) + ")"
1363 }
1364 return s + "\n"
1365}
1366
1367func playable(t catalog.Track) bool { return !t.Hidden && catalog.ArtistVisible(t.Artist) }
1368
1369func trackLink(t catalog.Track) string {
1370 return "[" + md(t.Title) + "](" + page("track/"+text.Itoa(t.ID)) + ") · " + artistRef(t.Artist)
1371}
1372
1373// artistRef links an artist by id (✓ when verified), or prints nothing for
1374// a hidden or unknown one. It reads the artist's head only.
1375func artistRef(id int) string {
1376 name, verified, hidden, ok := catalog.ArtistBrief(id)
1377 if !ok || hidden {
1378 return ""
1379 }
1380 s := "[" + md(name) + "](" + page("artist/"+text.Itoa(id)) + ")"
1381 if verified {
1382 s += check(id)
1383 }
1384 return s
1385}
1386
1387// check is the ✓ of a verified artist, with the host its proof was found
1388// on: " ✓ via example.org", or " ✓" when the moderator verified it.
1389func check(id int) string {
1390 if h := catalog.ProofHost(id); h != "" {
1391 return " ✓ via " + md(h)
1392 }
1393 return " ✓"
1394}
1395
1396// trackRef links a track by id ("a track" when hidden or unknown).
1397func trackRef(id int) string {
1398 if t, ok := catalog.GetTrack(id); ok && playable(t) {
1399 return "[" + md(t.Title) + "](" + page("track/"+text.Itoa(t.ID)) + ")"
1400 }
1401 return "a track"
1402}
1403
1404func stationLink(st int) string {
1405 return "[" + md(radio.StationName(st)) + "](" + page("station/"+text.Itoa(st)) + ")"
1406}
1407
1408// tippable mirrors catalog: tips and paid tickets need a claimed, verified
1409// artist.
1410func tippable(owner address, verified bool) bool { return owner != "" && verified }
1411
1412// verifyLink sends an artist to the app's verification steps (catalog/verify.gno).
1413func verifyLink(artistID int) string {
1414 if l := inApp("artist/"+text.Itoa(artistID), "Verify it in the app"); l != "" {
1415 return l
1416 }
1417 return "verify it in the GnoRadio app"
1418}
1419
1420// genreBrowse points an empty or idle station to tracks it could play.
1421func genreBrowse(st int) string {
1422 switch st {
1423 case radio.MainStation:
1424 return "[Browse the catalog](" + page("catalog") + "), open a track and choose **Pick it on Main**."
1425 case radio.NewStation:
1426 return "The newest tracks join it as they sync. [Browse the catalog](" + page("catalog") + ")."
1427 case radio.ChoiceStation:
1428 return "Every track a listener queues joins it. [Browse the catalog](" + page("catalog") + ") and pick one."
1429 }
1430 return "[Browse " + md(catalog.GenreName(st)) + " tracks](" + page("catalog?g="+text.Itoa(st)) + "), open one and choose **Queue**."
1431}
1432
1433// stationOrder lists Main, then the community stations, then the genres.
1434func stationOrder() []int {
1435 out := []int{radio.MainStation, radio.NewStation, radio.ChoiceStation}
1436 for g := 1; g <= catalog.NumGenres; g++ {
1437 out = append(out, g)
1438 }
1439 return out
1440}
1441
1442// sourceURL links a contract file on gnoweb.
1443func sourceURL(pkgPath, file string) string {
1444 return strings.TrimPrefix(pkgPath, runtime.ChainDomain()) + "$source&file=" + file
1445}
1446
1447// sourceLink is " [label](url)." or "" when there is no URL.
1448func sourceLink(label, url string) string {
1449 if url = sanitize.URL(url); url == "" {
1450 return ""
1451 }
1452 return " [" + label + "](" + url + ")."
1453}
1454
1455func appLine() string {
1456 if l := inApp("", "Open it"); l != "" {
1457 return " " + l + "."
1458 }
1459 return ""
1460}
1461
1462func artistLink(a catalog.Artist) string {
1463 s := "[" + md(a.Name) + "](" + page("artist/"+text.Itoa(a.ID)) + ")"
1464 if a.Verified {
1465 s += check(a.ID)
1466 }
1467 return s
1468}
1469
1470// who links an address: its artist profile, else its listener page under
1471// its gno.land name, or its nickname and short address (a nickname alone is
1472// easy to grind).
1473func who(addr address) string {
1474 if !roles.Canonical(addr) { // a later release may store anything
1475 return md(displayName(addr))
1476 }
1477 if id := catalog.ArtistOf(addr); id != 0 {
1478 if s := artistRef(id); s != "" {
1479 return s
1480 }
1481 }
1482 label := "@" + userName(addr)
1483 if label == "@" {
1484 label = nickname(addr) + " (" + short(addr) + ")"
1485 }
1486 return "[" + md(label) + "](" + page("listener/"+addr.String()) + ")"
1487}
1488
1489// listenURL is where a track's audio plays, from offset seconds when the
1490// host takes a #t= fragment; an Audius track without its page stays here.
1491// Values from the catalog are checked again here (sanitize.URL): this page
1492// keeps rendering records later releases write.
1493func listenURL(id int, audio string, offset int64) string {
1494 if u := sanitize.URL(rawListenURL(id, audio, offset)); u != "" {
1495 return u
1496 }
1497 return page("track/" + text.Itoa(id))
1498}
1499
1500func rawListenURL(id int, audio string, offset int64) string {
1501 frag := ""
1502 if offset > 0 && !strings.Contains(audio, "#") {
1503 frag = "#t=" + text.Itoa64(offset)
1504 }
1505 switch {
1506 case strings.HasPrefix(audio, "ipfs://"):
1507 return "https://ipfs.io/ipfs/" + strings.TrimPrefix(audio, "ipfs://") + frag
1508 case strings.HasPrefix(audio, "ar://"):
1509 return "https://arweave.net/" + strings.TrimPrefix(audio, "ar://") + frag
1510 case strings.HasPrefix(audio, "audius:"), strings.HasPrefix(audio, "jamendo:"):
1511 return page("track/" + text.Itoa(id)) // a pointer plays in the app
1512 }
1513 return audio + frag
1514}
1515
1516// licenseLink links a license id to its deed. Creative Commons ids read
1517// CC-<terms>-<version>[-<port>], e.g. CC-BY-SA-3.0-DE →
1518// creativecommons.org/licenses/by-sa/3.0/de/.
1519func licenseLink(l string) string {
1520 switch {
1521 case l == "CC0-1.0":
1522 return "[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)"
1523 case l == catalog.AudiusLicense:
1524 return "[Audius Open Music License](https://openaudiofoundation.org/open-music-license.pdf)"
1525 case strings.HasPrefix(l, "CC-"):
1526 var terms []string
1527 ver, port := "", ""
1528 for _, part := range strings.Split(strings.TrimPrefix(l, "CC-"), "-") {
1529 switch {
1530 case ver == "" && strings.Contains(part, "."):
1531 ver = part
1532 case ver == "":
1533 terms = append(terms, part)
1534 case port == "":
1535 port = part
1536 default:
1537 return md(l)
1538 }
1539 }
1540 // only a-z, 0-9 and "." reach the URL: a later release may store other ids
1541 if ver == "" || len(terms) == 0 || !plain(strings.Join(terms, "")+ver+port) {
1542 return md(l)
1543 }
1544 url := "https://creativecommons.org/licenses/" + strings.ToLower(strings.Join(terms, "-")) + "/" + ver + "/"
1545 label := "CC " + strings.Join(terms, "-") + " " + ver
1546 if port != "" {
1547 url += strings.ToLower(port) + "/"
1548 label += " " + port
1549 }
1550 return "[" + md(label) + "](" + url + ")"
1551 }
1552 return "All rights reserved"
1553}
1554
1555// plain reports whether s holds only ASCII letters, digits and dots.
1556func plain(s string) bool {
1557 for _, c := range s {
1558 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.') {
1559 return false
1560 }
1561 }
1562 return true
1563}
1564
1565func queueURL(st, id int) string {
1566 return radioTx.NewLink("Queue").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id)).URL()
1567}
1568
1569// bookURL opens QueueAt with the time left for the listener to fill in.
1570func bookURL(st, id int) string {
1571 return radioTx.NewLink("QueueAt").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id), "at", "").URL()
1572}
1573
1574func sponsoredURL(st, id int) string {
1575 return radioTx.NewLink("QueueSponsored").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id)).URL()
1576}
1577
1578func tipOnAirURL(st, id int, gnots int64) string {
1579 return radioTx.NewLink("TipOnAir").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id), "supportPct", "0", "ref", "").
1580 SetSend(strconv.FormatInt(gnots*ugnot, 10) + "ugnot").URL()
1581}
1582
1583func supportURL(gnots int64) string {
1584 return catalogTx.NewLink("SupportGnoRadio").SetSend(strconv.FormatInt(gnots*ugnot, 10) + "ugnot").URL()
1585}
1586
1587// tipSupportURL tips gnots to the artist side plus pct% on top for GnoRadio.
1588func tipSupportURL(id int, gnots, pct int64) string {
1589 total := gnots * ugnot * (100 + pct) / 100
1590 return catalogTx.NewLink("TipWithSupport").AddArgs("trackID", text.Itoa(id), "supportPct", text.Itoa64(pct)).SetSend(strconv.FormatInt(total, 10) + "ugnot").URL()
1591}
1592
1593func tipURL(id int, gnots int64) string {
1594 return catalogTx.NewLink("Tip").AddArgs("trackID", text.Itoa(id)).SetSend(strconv.FormatInt(gnots*ugnot, 10) + "ugnot").URL()
1595}
1596
1597func genreList() string {
1598 var parts []string
1599 for g := 1; g <= catalog.NumGenres; g++ {
1600 parts = append(parts, text.Itoa(g)+" "+md(catalog.GenreName(g)))
1601 }
1602 return strings.Join(parts, " · ")
1603}
1604
1605func md(s string) string { return sanitize.InlineText(s) }
1606
1607func atoi(s string) int {
1608 n, err := strconv.Atoi(s)
1609 if err != nil || n < 0 {
1610 return 0
1611 }
1612 return n
1613}
1614
1615func short(a address) string {
1616 s := []rune(a.String())
1617 if len(s) < 12 {
1618 return md(string(s))
1619 }
1620 return md(string(s[:7]) + "…" + string(s[len(s)-4:]))
1621}
1622
1623func pad2(n int) string {
1624 if n < 10 {
1625 return "0" + text.Itoa(n)
1626 }
1627 return text.Itoa(n)
1628}
1629
1630func clock(sec int64) string { return text.Itoa64(sec/60) + ":" + pad2(int(sec%60)) }
1631
1632func date(unix int64) string { return time.Unix(unix, 0).UTC().Format("Jan 2, 2006 15:04") + " UTC" }
1633func hour(unix int64) string { return time.Unix(unix, 0).UTC().Format("15:04") }
1634
1635// day is "tomorrow " when unix falls on the next UTC day (a booked pick), else "".
1636func day(unix int64) string {
1637 if unix/86400 > time.Now().Unix()/86400 {
1638 return "tomorrow "
1639 }
1640 return ""
1641}