home.gno
62.11 Kb · 1644 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/v2"
21 "gno.land/r/nym-alexiscolin000/gnoradio/data"
22 "gno.land/r/nym-alexiscolin000/gnoradio/radio/v2"
23 "gno.land/r/nym-alexiscolin000/gnoradio/tickets/v2"
24)
25
26const (
27 catalogPath = "gno.land/r/nym-alexiscolin000/gnoradio/catalog/v2"
28 radioPath = "gno.land/r/nym-alexiscolin000/gnoradio/radio/v2"
29 ticketsPath = "gno.land/r/nym-alexiscolin000/gnoradio/tickets/v2"
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 every " + minutes(cooldown) + ", 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
452// minutes says a whole number of minutes in words: "30 minutes".
453func minutes(sec int64) string { return plural(int(sec/60), "minute") }
454
455func renderStation(st int) string {
456 var b strings.Builder
457 b.WriteString(header(md(radio.StationName(st)), "-"))
458 tracks, _, _ := radio.StationInfo(st)
459 if tracks == 0 {
460 b.WriteString("> [!INFO] This station is empty for now\n> " + genreBrowse(st) + "\n\n")
461 return b.String()
462 }
463 b.WriteString(onAir(st))
464 b.WriteString("## Up next\n\n")
465 shown := 0
466 for _, s := range radio.UpNext(st) {
467 if shown == 10 {
468 break
469 }
470 t, ok := catalog.GetTrack(s.Track)
471 if !ok {
472 continue
473 }
474 shown++
475 line := "- " + hour(s.Start) + " UTC · " + trackLink(t) + " · queued by " + who(s.By)
476 if s.At > 0 {
477 line = "- " + day(s.Start) + hour(s.Start) + " UTC · " + trackLink(t) + " · booked by " + who(s.By)
478 }
479 if s.Pay > 0 {
480 line += " · sponsored pick, paid by " + md(catalog.ArtistName(t.Artist))
481 }
482 if s.Note != "" {
483 line += " · ♥ *" + md(s.Note) + "*"
484 }
485 b.WriteString(line + "\n")
486 }
487 if shown == 0 {
488 b.WriteString("Nothing queued: the rotation plays. " + genreBrowse(st) + "\n")
489 }
490 b.WriteString("\n" + topCurators(st) + flowLine(st, tracks))
491 return b.String()
492}
493
494// flowLine says how a station chooses what plays when nobody picked.
495func flowLine(st, tracks int) string {
496 if st != radio.MainStation {
497 return plural(tracks, "track") + " in rotation, looping; listeners' picks play first.\n"
498 }
499 hour := ""
500 if g := radio.HourGenre(time.Now().Unix()); g != 0 {
501 hour = "This hour: **" + md(catalog.GenreName(g)) + "**. "
502 }
503 return "Main follows the time of day: one genre an hour, calm at night and peaking in the evening. " + hour +
504 "Listeners' picks play first. " + plural(tracks, "track") + " in rotation.\n"
505}
506
507func renderTrack(t catalog.Track) string {
508 if !playable(t) {
509 note := catalog.HiddenNote("track", t.ID)
510 if note == "" {
511 note = catalog.HiddenNote("artist", t.Artist)
512 }
513 if note == "" {
514 note = "This track is no longer available."
515 }
516 return renderRemoved(note)
517 }
518 a, _ := catalog.GetArtist(t.Artist)
519 id := text.Itoa(t.ID)
520 var b strings.Builder
521 b.WriteString(header(md(t.Title), "-"))
522 b.WriteString("<gno-columns>\n" + coverSVG(t.ID, t.Artist, t.Title, 300) + "\n<gno-columns-sep />\n")
523 b.WriteString("### by " + artistLink(a) + "\n\n")
524 b.WriteString(md(catalog.GenreName(t.Genre)) + " · " + clock(t.Duration) + " · " + licenseLink(t.License))
525 if t.CMO == "sacem-nc" {
526 b.WriteString(" · declared to SACEM")
527 }
528 b.WriteString(" \n")
529 if t.Credits != "" {
530 b.WriteString("Credits: " + md(t.Credits) + " \n")
531 }
532 b.WriteString("♥ " + text.Itoa(t.Likes) + " · ◈ " + text.GNOT(t.Tips) + " from " + plural(t.Supporters, "supporter"))
533 if t.Album != 0 {
534 if al, ok := catalog.GetAlbum(t.Album); ok && !al.Hidden {
535 b.WriteString(" · from [" + md(al.Title) + "](" + page("album/"+text.Itoa(al.ID)) + ")")
536 }
537 }
538 // One primary action line; the rest waits in the folded block below.
539 b.WriteString("\n\n**[▶ Listen](" + listenURL(t.ID, t.Audio, 0) + ")** · **[♥ Like](" + catalogTx.NewLink("Like").AddArgs("trackID", id).URL() + ")** · " +
540 "**[Pick it on Main](" + queueURL(0, t.ID) + ")**")
541 if l := inApp("track/"+id, "Open in the app"); l != "" {
542 b.WriteString(" · " + l)
543 }
544 b.WriteString("\n\n" + support(t, a) + promoLines(a, t.ID, t.Genre))
545 b.WriteString("</gno-columns>\n\n")
546
547 switch t.Origin {
548 case catalog.OriginCurated:
549 b.WriteString("> [!NOTE] Curated · " + licenseLink(t.License) + "\n> " + md(t.Attribution) + "." + sourceLink("Source", t.SourceURL) + "\n\n")
550 case catalog.OriginAudius, catalog.OriginJamendo:
551 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, " +
552 licenseLink(t.License) + ".")
553 if l := inApp("track/"+id, "Open in the app"); l != "" {
554 b.WriteString(" " + l + ".")
555 }
556 b.WriteString("\n\n")
557 }
558
559 if len(t.Splits) > 0 {
560 b.WriteString("## Where your tip goes\n\n| Recipient | Share |\n|---|---|\n")
561 rest := int64(100)
562 for _, s := range t.Splits {
563 b.WriteString("| " + who(s.To) + " | " + text.Itoa64(s.Pct) + "% |\n")
564 rest -= s.Pct
565 }
566 b.WriteString("| " + artistLink(a) + " (artist) | " + text.Itoa64(rest) + "% |\n\n")
567 }
568 b.WriteString("## Early listeners\n\n")
569 for i, addr := range t.Early {
570 b.WriteString(text.Itoa(i+1) + ". " + who(addr) + "\n")
571 }
572 if len(t.Early) < 10 {
573 b.WriteString("\n**" + text.Itoa(10-len(t.Early)) + "** early-listener badges left: [like it now](" + catalogTx.NewLink("Like").AddArgs("trackID", id).URL() + ").\n")
574 }
575 g := radio.StationName(t.Genre)
576 b.WriteString("\n> [!NOTE]- More actions and proof\n> [Unlike](" + catalogTx.NewLink("Unlike").AddArgs("trackID", id).URL() + ") · " +
577 "Queue on [Main](" + queueURL(0, t.ID) + ") or [" + md(g) + "](" + queueURL(t.Genre, t.ID) + ") · " +
578 "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) · " +
579 "[Report](" + catalogTx.NewLink("Report").AddArgs("kind", "track", "target", id, "reason", "").URL() + ")\n>\n")
580 b.WriteString("> **Fingerprints.** Audio " + sanitize.InlineCode(t.Audio))
581 if t.AudioSHA != "" {
582 b.WriteString(" · sha256 " + sanitize.InlineCode(t.AudioSHA))
583 }
584 if t.Cover != "" {
585 b.WriteString(" \n> Cover " + sanitize.InlineCode(t.Cover))
586 if t.CoverSHA != "" {
587 b.WriteString(" · sha256 " + sanitize.InlineCode(t.CoverSHA))
588 }
589 }
590 b.WriteString(" \n> Published " + date(t.Created) + "\n")
591 return b.String()
592}
593
594// support explains how this track can be supported, by origin and claim.
595func support(t catalog.Track, a catalog.Artist) string {
596 if tippable(a.Owner, a.Verified) {
597 to := "100% of the artist share goes to the artist"
598 if n := len(t.Splits); n == 1 {
599 to = "The artist share is split with 1 collaborator"
600 } else if n > 1 {
601 to = "The artist share is split with " + text.Itoa(n) + " collaborators"
602 }
603 out := "**◈ Tip:** [1 GNOT](" + tipURL(t.ID, 1) + ") · [5 GNOT](" + tipURL(t.ID, 5) + ") · [20 GNOT](" + tipURL(t.ID, 20) + ") \n"
604 if hasTreasury() {
605 out += "Or [5 GNOT + 10% to keep GnoRadio on air](" + tipSupportURL(t.ID, 5, 10) + "). \n"
606 }
607 return out + to + ", same transaction.\n"
608 }
609 return verifyNote(a) + "\n"
610}
611
612// platformName names the platform a pointer is read from.
613func platformName(origin string) string {
614 if origin == catalog.OriginJamendo {
615 return "Jamendo"
616 }
617 return "Audius"
618}
619
620// verifyNote says where an artist without tips stands: verified without a
621// wallet, verification in progress, or not verified yet.
622func verifyNote(a catalog.Artist) string {
623 if a.Verified && a.Owner == "" {
624 return "Verified source · no wallet linked yet, so tips are off."
625 }
626 if pending, to, ready := catalog.ClaimState(a.ID); pending {
627 fin := catalogTx.NewLink("FinalizeClaim").AddArgs("artistID", text.Itoa(a.ID)).URL()
628 if time.Now().Unix() >= ready {
629 return "Verification for " + who(to) + " is ready: [finish it](" + fin + ") (anyone can)."
630 }
631 return "Verification in progress for " + who(to) + ": ready " + date(ready) + ", then [finish it](" + fin + ")."
632 }
633 return "Tips open once the artist verifies this profile. Is this you? " + verifyLink(a.ID)
634}
635
636// promoLines shows what the artist shares with listeners who play them: the
637// promo share of each tip and the sponsored-pick refund, when on.
638func promoLines(a catalog.Artist, trackID, genre int) string {
639 if !tippable(a.Owner, a.Verified) {
640 return ""
641 }
642 s := ""
643 if p := catalog.PromoShare(a.ID); p > 0 {
644 s += "Promo share: **" + text.Itoa(p) + "%** of each tip goes to the listener who picked it on air and whoever shared the link. \n"
645 }
646 if pay := catalog.PromoOffer(a.ID); pay > 0 {
647 s += "Sponsored picks: the artist refunds **" + text.GNOT(pay) + "** once your pick airs"
648 if trackID > 0 {
649 s += ": [pick it on Main](" + sponsoredURL(0, trackID) + ") or [on " + md(radio.StationName(genre)) + "](" + sponsoredURL(genre, trackID) + ")"
650 }
651 s += ".\n"
652 }
653 if s == "" {
654 return ""
655 }
656 return "\n" + s
657}
658
659func renderArtist(a catalog.Artist, pg int) string {
660 var b strings.Builder
661 title := md(a.Name)
662 if a.Verified {
663 title += " ✓"
664 }
665 b.WriteString(header(title, "-"))
666 b.WriteString("<gno-columns>\n" + avatarSVG(text.Itoa(a.ID)+a.Name, 160) + "\n<gno-columns-sep />\n")
667 if a.Verified {
668 // A proof shows control of a site, not who the artist is: say which.
669 if h := catalog.ProofHost(a.ID); h != "" {
670 b.WriteString("✓ Verified via **" + md(h) + "**: the artist proved they control this site.\n\n")
671 } else {
672 b.WriteString("✓ Verified by the GnoRadio moderator.\n\n")
673 }
674 }
675 if a.Bio != "" {
676 b.WriteString(md(a.Bio) + "\n\n")
677 }
678 followers := plural(a.Followers, "follower")
679 switch {
680 case a.Owner != "":
681 wallet := short(a.Owner)
682 if roles.Canonical(a.Owner) {
683 wallet = "[" + wallet + "](" + page("listener/"+a.Owner.String()) + ")"
684 }
685 b.WriteString("◈ **" + text.GNOT(a.Tips) + "** received · " + followers + " \nWallet: " + wallet)
686 if !tippable(a.Owner, a.Verified) {
687 b.WriteString(" \n" + verifyNote(a))
688 }
689 b.WriteString("\n")
690 case a.Kind == catalog.OriginAudius || a.Kind == catalog.OriginJamendo:
691 b.WriteString("Read live from " + platformName(a.Kind) + " · " + followers + " \n" + verifyNote(a) + "\n")
692 default:
693 b.WriteString("Curated CC artist" + sourceLink("source", a.Source) + " · " + followers + " \n" + verifyNote(a) + "\n")
694 }
695 b.WriteString(promoLines(a, 0, 0) + "\n")
696 b.WriteString("[Follow](" + catalogTx.NewLink("Follow").AddArgs("artistID", text.Itoa(a.ID)).URL() + ") · " +
697 "[Unfollow](" + catalogTx.NewLink("Unfollow").AddArgs("artistID", text.Itoa(a.ID)).URL() + ")")
698 if l := inApp("artist/"+text.Itoa(a.ID), "Open in the app"); l != "" {
699 b.WriteString(" · " + l)
700 }
701 b.WriteString("\n</gno-columns>\n\n")
702
703 // Tracks, newest first, pageSize per page; page 1 shows the first covers.
704 b.WriteString("## Tracks\n\n")
705 n := catalog.ArtistTrackCount(a.ID)
706 hi := n - (pg-1)*pageSize
707 lo := hi - pageSize
708 if lo < 0 {
709 lo = 0
710 }
711 var ids []int
712 if hi > 0 {
713 ids = catalog.ArtistTrackPage(a.ID, lo, hi-lo)
714 }
715 var covers []catalog.Track
716 var rows strings.Builder
717 for i := len(ids) - 1; i >= 0; i-- {
718 t, ok := catalog.GetTrack(ids[i])
719 if !ok || !playable(t) {
720 continue
721 }
722 if pg == 1 && len(covers) < maxCovers {
723 covers = append(covers, t)
724 } else {
725 rows.WriteString("- " + trackLink(t) + "\n")
726 }
727 }
728 if len(covers) == 0 && rows.Len() == 0 {
729 b.WriteString("No track yet.\n")
730 }
731 b.WriteString(coverGrid(covers) + "\n" + rows.String() + "\n" + pager("artist/"+text.Itoa(a.ID), pg, n) + "\n")
732 if len(a.Albums) > 0 {
733 b.WriteString("## Albums\n\n")
734 for _, id := range a.Albums {
735 if al, ok := catalog.GetAlbum(id); ok && !al.Hidden {
736 b.WriteString("- [" + md(al.Title) + "](" + page("album/"+text.Itoa(al.ID)) + ") · " + text.Itoa(al.Year) + "\n")
737 }
738 }
739 b.WriteString("\n")
740 }
741 var evs []tickets.Event
742 for _, id := range tickets.ArtistEvents(a.ID, 10) {
743 if e, ok := tickets.EventInfo(id); ok {
744 evs = append(evs, e)
745 }
746 }
747 if len(evs) > 0 {
748 // At most 10: an insertion sort by start time.
749 for i := 1; i < len(evs); i++ {
750 for j := i; j > 0 && evs[j].Start < evs[j-1].Start; j-- {
751 evs[j], evs[j-1] = evs[j-1], evs[j]
752 }
753 }
754 b.WriteString("## Concerts\n\n")
755 now := time.Now().Unix()
756 for _, e := range evs {
757 b.WriteString("- " + date(e.Start) + " · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " + md(e.Venue) + eventTag(e, now) + "\n")
758 }
759 }
760 b.WriteString("\n[Report this profile](" + catalogTx.NewLink("Report").AddArgs("kind", "artist", "target", text.Itoa(a.ID), "reason", "").URL() + ")\n")
761 return b.String()
762}
763
764// eventTag marks a cancelled or past concert in a list.
765func eventTag(e tickets.Event, now int64) string {
766 switch {
767 case e.Cancelled:
768 return " · **cancelled**"
769 case e.Start <= now:
770 return " · past"
771 }
772 return ""
773}
774
775func renderAlbum(al catalog.Album) string {
776 var b strings.Builder
777 a, _ := catalog.GetArtist(al.Artist)
778 b.WriteString(header(md(al.Title), "-"))
779 b.WriteString("<gno-columns>\n" + coverSVG(al.ID*7919, al.Artist, al.Title, 260) + "\n<gno-columns-sep />\n")
780 var total int64
781 var tips int64
782 tracks := make([]*catalog.Track, len(al.Tracks)) // read once, for the totals and the table
783 for i, id := range al.Tracks {
784 if t, ok := catalog.GetTrack(id); ok {
785 tracks[i] = &t
786 total += t.Duration
787 tips += t.Tips
788 }
789 }
790 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")
791 b.WriteString("| # | Track | Time | ♥ |\n|---|---|---|---|\n")
792 for i, t := range tracks {
793 if t != nil && playable(*t) {
794 b.WriteString("| " + text.Itoa(i+1) + " | [" + md(t.Title) + "](" + page("track/"+text.Itoa(t.ID)) + ") | " + clock(t.Duration) + " | " + text.Itoa(t.Likes) + " |\n")
795 }
796 }
797 b.WriteString("\n")
798 if l := inApp("album/"+text.Itoa(al.ID), "Play it in the app"); l != "" {
799 b.WriteString(l + " · ")
800 }
801 b.WriteString("[Report this album](" + catalogTx.NewLink("Report").AddArgs("kind", "album", "target", text.Itoa(al.ID), "reason", "").URL() + ")\n")
802 return b.String()
803}
804
805func renderPlaylist(pl catalog.Playlist, pg int) string {
806 var b strings.Builder
807 b.WriteString(header(md(pl.Title), "-"))
808 b.WriteString("Playlist by " + who(pl.Owner) + " · " + plural(len(pl.Tracks), "track") + " · updated " + date(pl.Updated))
809 if l := inApp("playlist/"+text.Itoa(pl.ID), "Play it in the app"); l != "" {
810 b.WriteString(" · " + l)
811 }
812 b.WriteString("\n\n| # | Track | Time |\n|---|---|---|\n")
813 for i := (pg - 1) * pageSize; i < len(pl.Tracks) && i < pg*pageSize; i++ {
814 if t, ok := catalog.GetTrack(pl.Tracks[i]); ok && playable(t) {
815 b.WriteString("| " + text.Itoa(i+1) + " | " + trackLink(t) + " | " + clock(t.Duration) + " |\n")
816 }
817 }
818 b.WriteString("\n" + pager("playlist/"+text.Itoa(pl.ID), pg, len(pl.Tracks)))
819 b.WriteString("\n[Report this playlist](" + catalogTx.NewLink("Report").AddArgs("kind", "playlist", "target", text.Itoa(pl.ID), "reason", "").URL() + ")\n")
820 return b.String()
821}
822
823func renderCatalog(pg, genre int) string {
824 var b strings.Builder
825 route, total := "catalog", catalog.TrackCount()
826 ids := catalog.TrackPage((pg-1)*pageSize, pageSize, true)
827 if genre > 0 {
828 route, total = "catalog?g="+text.Itoa(genre), catalog.GenreCount(genre)
829 ids = catalog.GenrePage(genre, (pg-1)*pageSize, pageSize)
830 b.WriteString(header("Catalog · "+md(catalog.GenreName(genre)), "catalog"))
831 } else {
832 b.WriteString(header("Catalog", "catalog"))
833 }
834 b.WriteString("Browse: [all](" + page("catalog") + ")")
835 for g := 1; g <= catalog.NumGenres; g++ {
836 b.WriteString(" · [" + md(catalog.GenreName(g)) + "](" + page("catalog?g="+text.Itoa(g)) + ")")
837 }
838 b.WriteString(" · [Artists](" + page("artists") + ") · [Albums](" + page("albums") + ") · [Playlists](" + page("playlists") + ")\n\n")
839 if len(ids) == 0 {
840 b.WriteString("> [!INFO] No track here yet\n> [Publish one](" + page("join") + ").\n")
841 return b.String()
842 }
843 b.WriteString("| Track | Genre | Time | ♥ |\n|---|---|---|---|\n")
844 for _, id := range ids {
845 if t, ok := catalog.GetTrack(id); ok && playable(t) {
846 b.WriteString("| " + trackLink(t) + " | " + md(catalog.GenreName(t.Genre)) + " | " + clock(t.Duration) + " | " + text.Itoa(t.Likes) + " |\n")
847 }
848 }
849 b.WriteString("\n" + pager(route, pg, total))
850 return b.String()
851}
852
853func renderArtists(pg int) string {
854 var b strings.Builder
855 b.WriteString(header("Artists", "catalog"))
856 b.WriteString("| Artist | Tracks | Followers | Tips |\n|---|---|---|---|\n")
857 for _, id := range catalog.ArtistPage((pg-1)*pageSize, pageSize) {
858 if a, ok := catalog.GetArtist(id); ok && !a.Hidden {
859 name := artistLink(a)
860 if a.Owner == "" {
861 name += " · " + md(a.Kind) + ", unclaimed"
862 }
863 b.WriteString("| " + name + " | " + text.Itoa(len(a.Tracks)) + " | " + text.Itoa(a.Followers) + " | " + text.GNOT(a.Tips) + " |\n")
864 }
865 }
866 b.WriteString("\n" + pager("artists", pg, catalog.ArtistCount()))
867 return b.String()
868}
869
870func renderAlbums(pg int) string {
871 var b strings.Builder
872 b.WriteString(header("Albums", "catalog"))
873 for _, id := range catalog.AlbumPage((pg-1)*pageSize, pageSize) {
874 if al, ok := catalog.GetAlbum(id); ok && !al.Hidden && catalog.ArtistVisible(al.Artist) {
875 b.WriteString("- [" + md(al.Title) + "](" + page("album/"+text.Itoa(al.ID)) + ") · " + artistRef(al.Artist) + " · " + text.Itoa(al.Year) + "\n")
876 }
877 }
878 b.WriteString("\n" + pager("albums", pg, catalog.AlbumCount()))
879 return b.String()
880}
881
882func renderPlaylists(pg int) string {
883 var b strings.Builder
884 b.WriteString(header("Playlists", "catalog"))
885 for _, id := range catalog.PlaylistPage((pg-1)*pageSize, pageSize) {
886 if owner, title, n, hidden, ok := catalog.PlaylistBrief(id); ok && !hidden {
887 b.WriteString("- [" + md(title) + "](" + page("playlist/"+text.Itoa(id)) + ") · " + plural(n, "track") + " · by " + who(owner) + "\n")
888 }
889 }
890 b.WriteString("\n" + pager("playlists", pg, catalog.PlaylistCount()))
891 return b.String()
892}
893
894// maxPastScan bounds how many concert ids the cancelled list reads.
895const maxPastScan = 300
896
897func renderConcerts(pg int) string {
898 var b strings.Builder
899 b.WriteString(header("Concerts", "concerts"))
900 if pg == 1 {
901 b.WriteString("## Upcoming\n\n")
902 up := tickets.Upcoming(20)
903 if len(up) == 0 {
904 b.WriteString("No concert announced. [Announce yours](" + page("join") + ").\n")
905 }
906 for _, e := range up {
907 b.WriteString("- **" + date(e.Start) + "** · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " +
908 artistRef(e.ArtistID) + " · " + md(e.Venue) + "\n")
909 }
910 if l := inApp("concerts", "Get tickets in the app"); l != "" {
911 b.WriteString("\n" + l + "\n")
912 }
913 }
914 now := time.Now().Unix()
915 // Future concerts that were cancelled, page 1 only: the newest ids.
916 // ponytail: an old id cancelled for a far date can be missed, and
917 // maxPastScan cancelled announcements hide older ones; an index of
918 // cancellations in tickets would list them all.
919 var cancelled strings.Builder
920 nCancelled := 0
921 for id, scanned := tickets.EventCount(), 0; pg == 1 && id >= 1 && scanned < maxPastScan && nCancelled < 10; id, scanned = id-1, scanned+1 {
922 if e, ok := tickets.EventInfo(id); ok && e.Cancelled && e.Start > now && catalog.ArtistVisible(e.ArtistID) {
923 nCancelled++
924 cancelled.WriteString("- ~~" + date(e.Start) + "~~ · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " + artistRef(e.ArtistID) + "\n")
925 }
926 }
927 // Past concerts, latest first, from the tickets' start-time index: what
928 // was cancelled or hidden takes no room there.
929 var past strings.Builder
930 ids, total := tickets.PastEvents((pg-1)*pageSize, pageSize)
931 shown, more := 0, pg*pageSize < total
932 for _, id := range ids {
933 if e, ok := tickets.EventInfo(id); ok && catalog.ArtistVisible(e.ArtistID) {
934 past.WriteString("- " + date(e.Start) + " · [" + md(e.Title) + "](" + page("event/"+text.Itoa(e.ID)) + ") · " + artistRef(e.ArtistID) + eventTag(e, now) + "\n")
935 shown++
936 }
937 }
938 if nCancelled > 0 {
939 b.WriteString("\n## Cancelled\n\n" + cancelled.String())
940 }
941 b.WriteString("\n## Past concerts\n\n")
942 if shown == 0 {
943 if total == 0 {
944 b.WriteString("No past concert yet.\n")
945 } else {
946 b.WriteString("Nothing to show on this page.\n")
947 }
948 }
949 b.WriteString(past.String() + "\n" + pagerMore("concerts", pg, more))
950 return b.String()
951}
952
953func renderEvent(e tickets.Event) string {
954 var b strings.Builder
955 b.WriteString(header(md(e.Title), "-"))
956 b.WriteString(tickets.EventSVG(e.ID) + "\n\n")
957 a, _ := catalog.GetArtist(e.ArtistID)
958 b.WriteString("**" + artistLink(a) + "** · " + md(e.Venue) + " · **" + date(e.Start) + "** · seats **" + text.Itoa(e.Sold) + " / " + text.Itoa(e.Capacity) + "**\n\n")
959 if u := sanitize.URL(e.Link); u != "" && a.Verified { // an unverified profile's link may be phishing
960 b.WriteString("Link: [" + md(e.Link) + "](" + u + ") (public)\n\n")
961 }
962 switch {
963 case e.Cancelled && e.Price > 0:
964 b.WriteString("> [!CAUTION] Cancelled\n> Payments went straight to the artist: contact them for a refund.\n")
965 case e.Cancelled:
966 b.WriteString("> [!CAUTION] Cancelled\n> This free concert will not take place.\n")
967 case time.Now().Unix() >= e.Start:
968 b.WriteString("> [!NOTE] Past concert\n> Tickets stay in their holders' collections.\n")
969 case e.Sold >= e.Capacity:
970 b.WriteString("> [!WARNING] Sold out\n")
971 case e.Price > 0 && !tippable(a.Owner, a.Verified):
972 b.WriteString("> [!NOTE] Tickets open once the artist verifies this profile\n> Is this you? " + verifyLink(a.ID) + "\n")
973 default:
974 link := ticketsTx.NewLink("BuyTicket").AddArgs("eventID", text.Itoa(e.ID))
975 label, limit := "Get a free ticket", "one free ticket per wallet"
976 if e.Price > 0 {
977 fee := tickets.ServiceFee()
978 link = link.SetSend(strconv.FormatInt(e.Price+fee, 10) + "ugnot")
979 label, limit = "Buy for "+text.GNOT(e.Price+fee), "up to 4 per wallet"
980 if fee > 0 {
981 limit = text.GNOT(e.Price) + " to the artist + " + text.GNOT(fee) + " GnoRadio service fee · " + limit
982 }
983 }
984 b.WriteString("**[" + label + "](" + link.URL() + ")** · " + limit + "\n\n")
985 b.WriteString("Your ticket is an NFT drawn on-chain, in your name. 100% of the ticket price goes to the artist. " +
986 "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")
987 }
988 b.WriteString("\n[← All concerts](" + page("concerts") + ")\n")
989 return b.String()
990}
991
992func renderTicket(tk tickets.Ticket) string {
993 var b strings.Builder
994 e, ok := tickets.EventInfo(tk.Event)
995 if !ok {
996 b.WriteString(header("Ticket #"+text.Itoa(tk.Serial), "-"))
997 b.WriteString(tickets.TicketSVG(tk.ID) + "\n\n")
998 note := tickets.HiddenNote(tk.Event)
999 if note == "" {
1000 note = "This concert is no longer listed"
1001 }
1002 b.WriteString("> [!NOTE] Concert removed\n> " + md(strings.TrimSuffix(removedNote(note), ".")) + ". The ticket stays in its holder's collection.\n\n")
1003 b.WriteString("Holder: " + who(tickets.TicketOwner(tk.ID)) + "\n")
1004 return b.String()
1005 }
1006 b.WriteString(header("Ticket #"+text.Itoa(tk.Serial)+" · "+md(e.Title), "-"))
1007 b.WriteString(tickets.TicketSVG(tk.ID) + "\n\n")
1008 b.WriteString("Holder: " + who(tickets.TicketOwner(tk.ID)) + " · [concert](" + page("event/"+text.Itoa(e.ID)) + ")" + ticketTag(tk, e) + "\n\n")
1009 if !tk.Attended && !e.Cancelled {
1010 b.WriteString("Holder: [gift this ticket](" + ticketsTx.NewLink("TransferTicket").AddArgs("to", "", "ticketID", text.Itoa(tk.ID)).URL() + ") · " +
1011 "[show at the door](" + ticketsTx.NewLink("Present").AddArgs("ticketID", text.Itoa(tk.ID), "code", "").URL() + ") with the door's code · " +
1012 "Artist: [check in at the door](" + ticketsTx.NewLink("CheckIn").AddArgs("ticketID", text.Itoa(tk.ID), "code", "").URL() + ")\n")
1013 }
1014 return b.String()
1015}
1016
1017// ticketTag marks an attended ticket or a cancelled concert.
1018func ticketTag(tk tickets.Ticket, e tickets.Event) string {
1019 switch {
1020 case tk.Attended:
1021 return " · ✓ I was there"
1022 case e.Cancelled:
1023 return " · **cancelled**"
1024 }
1025 return ""
1026}
1027
1028// renderListeners is the bare "listener" path: who picks for the stations.
1029func renderListeners() string {
1030 var b strings.Builder
1031 b.WriteString(header("Listeners", "-"))
1032 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")
1033 top := radio.TopCurators(-1) // every station
1034 if len(top) == 0 {
1035 b.WriteString("No pick this week yet. [Pick a track](" + page("catalog") + ").\n")
1036 }
1037 for i, c := range top {
1038 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")
1039 }
1040 b.WriteString("\n[Charts →](" + page("charts") + ")\n")
1041 return b.String()
1042}
1043
1044func renderListener(addr address, pg int) string {
1045 var b strings.Builder
1046 b.WriteString(header(md(displayName(addr)), "-"))
1047 b.WriteString("`" + addr.String() + "`")
1048 if userName(addr) == "" {
1049 b.WriteString(" · a nickname until this wallet registers a [gno.land name](/r/sys/users)")
1050 }
1051 b.WriteString("\n\n")
1052 if id := catalog.ArtistOf(addr); id != 0 {
1053 if s := artistRef(id); s != "" {
1054 b.WriteString("Also the artist " + s + ".\n\n")
1055 }
1056 }
1057 likes, follows, tipped, lists := catalog.UserStats(addr)
1058 b.WriteString("♥ " + plural(likes, "like") + " · " + plural(follows, "follow") + " · ◈ " + text.GNOT(tipped) + " given · " +
1059 plural(tickets.Attended(addr), "concert") + " attended\n\n")
1060 if c := radio.CuratorOf(addr); c.Picks > 0 {
1061 b.WriteString("**Curator:** " + plural(c.Picks, "pick") + " · " + plural(c.Tips, "tip") + " received on air · earned " + text.GNOT(c.Earned))
1062 if c.Promo > 0 {
1063 b.WriteString(" · " + text.GNOT(c.Promo) + " refunded for sponsored picks")
1064 }
1065 b.WriteString("\n\n")
1066 }
1067 if len(lists) > 0 {
1068 b.WriteString("## Playlists\n\n")
1069 for _, id := range lists {
1070 if _, title, n, hidden, ok := catalog.PlaylistBrief(id); ok && !hidden {
1071 b.WriteString("- [" + md(title) + "](" + page("playlist/"+text.Itoa(id)) + ") · " + plural(n, "track") + "\n")
1072 }
1073 }
1074 b.WriteString("\n")
1075 }
1076 route := "listener/" + addr.String()
1077 b.WriteString("## Tickets\n\n")
1078 ids := tickets.TicketsOf(addr, (pg-1)*pageSize, pageSize)
1079 if len(ids) == 0 {
1080 b.WriteString("No ticket. [See concerts](" + page("concerts") + ").\n")
1081 }
1082 for _, id := range ids {
1083 if tk, ok := tickets.TicketInfo(id); ok {
1084 e, ok := tickets.EventInfo(tk.Event)
1085 if !ok {
1086 b.WriteString("- [Removed by moderation #" + text.Itoa(tk.Serial) + "](" + page("ticket/"+text.Itoa(tk.ID)) + ")\n")
1087 continue
1088 }
1089 b.WriteString("- [" + md(e.Title) + " #" + text.Itoa(tk.Serial) + "](" + page("ticket/"+text.Itoa(tk.ID)) + ") · " + date(e.Start) + ticketTag(tk, e) + "\n")
1090 }
1091 }
1092 liked := catalog.LikedPage(addr, (pg-1)*pageSize, pageSize)
1093 b.WriteString("\n## Likes\n\n")
1094 var badges strings.Builder
1095 shown := 0
1096 for _, id := range liked {
1097 if t, ok := catalog.GetTrack(id); ok && playable(t) {
1098 b.WriteString("- " + trackLink(t) + "\n")
1099 for i, e := range t.Early {
1100 if e == addr && shown < maxCovers {
1101 badges.WriteString("[" + badgeSVG(t.Title, i+1) + "](" + page("track/"+text.Itoa(t.ID)) + ")")
1102 shown++
1103 }
1104 }
1105 }
1106 }
1107 if len(liked) == 0 {
1108 b.WriteString("No like yet.\n")
1109 }
1110 b.WriteString("\n## Follows\n\n")
1111 followed := catalog.FollowsPage(addr, (pg-1)*pageSize, pageSize)
1112 for _, id := range followed {
1113 if s := artistRef(id); s != "" {
1114 b.WriteString("- " + s + "\n")
1115 }
1116 }
1117 if len(followed) == 0 {
1118 b.WriteString("Follows no artist yet.\n")
1119 }
1120 b.WriteString("\n## Early-listener badges\n\n")
1121 if shown == 0 {
1122 b.WriteString("No early-listener badge on this page: be among the first 10 to like a new track.")
1123 }
1124 b.WriteString(badges.String() + "\n\n")
1125 more := len(ids) == pageSize || len(liked) == pageSize || len(followed) == pageSize
1126 b.WriteString(pagerMore(route, pg, more))
1127 return b.String()
1128}
1129
1130func renderCharts() string {
1131 var b strings.Builder
1132 b.WriteString(header("Charts", "charts"))
1133 b.WriteString("## Most supported artists\n\n")
1134 var rows []bar
1135 for _, r := range catalog.TopArtists() {
1136 if name, _, hidden, ok := catalog.ArtistBrief(atoi(r.Key)); ok && !hidden {
1137 rows = append(rows, bar{name, r.Value})
1138 }
1139 }
1140 if len(rows) == 0 {
1141 b.WriteString("> [!INFO] No tips yet\n> Open a track by a claimed artist and support them.\n\n")
1142 } else {
1143 b.WriteString(barsSVG(rows, text.GNOT) + "\n\n")
1144 }
1145 b.WriteString("## Most liked tracks\n\n| # | Track | ♥ | Supporters |\n|---|---|---|---|\n")
1146 n := 0
1147 for _, r := range catalog.TopLiked() {
1148 if t, ok := catalog.GetTrack(atoi(r.Key)); ok && playable(t) {
1149 n++
1150 b.WriteString("| " + text.Itoa(n) + " | " + trackLink(t) + " | " + text.Itoa(t.Likes) + " | " + text.Itoa(t.Supporters) + " |\n")
1151 }
1152 }
1153 if n == 0 {
1154 b.WriteString("| | No likes yet | | |\n")
1155 }
1156 b.WriteString("\n## Top supporters\n\n")
1157 fans := catalog.TopFans()
1158 if len(fans) == 0 {
1159 b.WriteString("> [!INFO] No supporter yet\n> Tips to artists put you on this board.\n")
1160 }
1161 for i, r := range fans {
1162 b.WriteString(text.Itoa(i+1) + ". " + who(address(r.Key)) + " · " + text.GNOT(r.Value) + "\n")
1163 }
1164 return b.String()
1165}
1166
1167func renderJoin() string {
1168 return header("Join GnoRadio", "join") +
1169 "**0. Your wallet is your identity.** Install [Adena](https://adena.app). Each step below opens a transaction you sign in your wallet.\n\n" +
1170 "## Artists\n\n" +
1171 "1. [Create your artist profile](" + catalogTx.NewLink("RegisterArtist").AddArgs("name", "", "bio", "").URL() + ") (Latin letters, 2-40 characters).\n" +
1172 "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" +
1173 "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), " +
1174 "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" +
1175 "4. [Group tracks into an album](" + catalogTx.NewLink("CreateAlbum").URL() + ") and [announce a concert](" + ticketsTx.NewLink("CreateEvent").URL() + "): " +
1176 "start in UTC as `2026-11-20 21:00`, price in GNOT (`0` for free, `2.5`), and the number of seats.\n" +
1177 "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" +
1178 "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 " +
1179 "[set the refund per pick](" + catalogTx.NewLink("SetPromoPay").AddArgs("ugnotPerPick", "200000", "perDay", "0").URL() + "): " +
1180 "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); " +
1181 "the default 200000 (0.2 GNOT) covers what the pick and collecting its refund cost the listener. " +
1182 "[Withdraw the rest](" + dataTx.NewLink("VaultWithdraw").AddArgs("acct", "catalog/").URL() + ") at any time: the budget sits in GnoRadio's data realm, " +
1183 "account `catalog/` and your artist id on 8 digits (artist 12: `catalog/00000012`).\n\n" +
1184 "> [!CAUTION] SACEM members\n> Only works declared under a non-commercial CC license: use `sacem-nc` and a CC BY-NC license.\n\n" +
1185 "## Your music is already here?\n\n" +
1186 "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 " +
1187 "or at `https://yourdomain/.well-known/gnoradio.txt`. A robot checks it; tips open 72 hours later.\n\n" +
1188 "## 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" +
1189 "Genres: " + genreList() + "\n"
1190}
1191
1192// aboutFees names the operator's income, only when there is any: the ticket
1193// service fee is 0 while no treasury is set.
1194func aboutFees() string {
1195 switch {
1196 case hasTreasury() && tickets.ServiceFee() > 0:
1197 return " An optional extra and a ticket service fee fund GnoRadio."
1198 case hasTreasury():
1199 return " An optional extra funds GnoRadio."
1200 }
1201 return ""
1202}
1203
1204func renderAbout() string {
1205 return header("How it works", "about") +
1206 "<gno-columns>\n" +
1207 "### 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" +
1208 "<gno-columns-sep />\n" +
1209 "### 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" +
1210 "<gno-columns-sep />\n" +
1211 "### 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" +
1212 "</gno-columns>\n\n" +
1213 "## Who does what\n\n" +
1214 "- **Here (gnoweb):** catalog, profiles, support, tickets and the on-chain proof. Every page is rendered by a contract.\n" +
1215 "- **The GnoRadio app:** the synced player, uploads, your library. Listening never needs a transaction." + appLine() + "\n" +
1216 "- **Audio:** IPFS, Arweave, allowed hosts (archive.org) and Audius. Metadata, links and fingerprints live on gno.land.\n\n" +
1217 "## 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" +
1218 "- Published files stay public: moderation can hide, not erase. Reports are public on [the moderation page](" + page("moderation") + ").\n\n" +
1219 noticeBlock() +
1220 "## Verify it yourself\n\n" +
1221 "- Tips and splits: [social.gno](" + sourceURL(catalogPath, "social.gno") + ") · GnoRadio funding: [support.gno](" + sourceURL(catalogPath, "support.gno") + ")\n" +
1222 "- Stations and queue: [radio.gno](" + sourceURL(radioPath, "radio.gno") + ") · Tickets and fees: [tickets.gno](" + sourceURL(ticketsPath, "tickets.gno") + ")\n" +
1223 "- Moderation powers: [moderation.gno](" + sourceURL(catalogPath, "moderation.gno") + "). Current moderator: " + role(catalog.Admin(), "GnoRadio (moderator)") + ".\n"
1224}
1225
1226func renderModeration(pg int) string {
1227 var b strings.Builder
1228 b.WriteString(header("Moderation", "-"))
1229 b.WriteString("Moderator: " + role(catalog.Admin(), "GnoRadio (moderator)") + ". Reports are public; decisions are visible transactions.\n\n" + noticeBlock())
1230 if catalog.ReportCount() == 0 {
1231 b.WriteString("> [!SUCCESS] No open report\n> Use the Report link on any track, artist, album or playlist.\n")
1232 return b.String()
1233 }
1234 b.WriteString("| # | Date | Target | Reason | By |\n|---|---|---|---|---|\n")
1235 for _, id := range catalog.ReportPage((pg-1)*pageSize, pageSize) {
1236 r, ok := catalog.GetReport(id)
1237 if !ok {
1238 continue
1239 }
1240 reason := sanitize.TableCell(r.Reason)
1241 if safe.Blocked(r.Reason) { // it may quote what it reports
1242 reason = "*(hidden: quotes a blocked word)*"
1243 }
1244 kind, target := md(r.Kind), text.Itoa(atoi(r.Target))
1245 b.WriteString("| " + text.Itoa(r.ID) + " | " + date(r.At) + " | [" + kind + " " + target + "](" + page(kind+"/"+target) +
1246 ") | " + reason + " | " + who(r.By) + " |\n")
1247 }
1248 b.WriteString("\n" + plural(catalog.ReportCount(), "open report") + ". " + pager("moderation", pg, catalog.ReportCount()))
1249 return b.String() + pendingClaims()
1250}
1251
1252// pendingClaims lists the verifications on their way: each stays public 72
1253// hours, so anyone can spot an impersonation and report it first.
1254func pendingClaims() string {
1255 cs, _ := catalog.PendingClaims(0, 100)
1256 if len(cs) == 0 {
1257 return ""
1258 }
1259 var b strings.Builder
1260 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" +
1261 "| Artist | Wallet | Proof found on | Ready |\n|---|---|---|---|\n")
1262 for _, c := range cs {
1263 if s := artistRef(c.Artist); s != "" {
1264 b.WriteString("| " + s + " | " + who(c.To) + " | " + md(proofHostOf(c.Proof)) + " | " + date(c.ReadyAt) + " |\n")
1265 }
1266 }
1267 return b.String()
1268}
1269
1270// proofHostOf is the host of a proof link, as catalog.ProofHost shows it:
1271// up to the first / ? or #, and only a plain host (Claim refuses others; a
1272// later release's record shows "?" rather than a wrong host).
1273func proofHostOf(url string) string {
1274 h := strings.TrimPrefix(url, "https://")
1275 if i := strings.IndexAny(h, "/?#"); i >= 0 {
1276 h = h[:i]
1277 }
1278 h = strings.ToLower(h)
1279 for _, c := range h {
1280 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '-') {
1281 return "?"
1282 }
1283 }
1284 return h
1285}
1286
1287// renderRemoved is the page of hidden content: why it is gone, never what it was.
1288func renderRemoved(note string) string {
1289 return header("Removed", "-") + "> [!WARNING] Removed\n> " + md(removedNote(note)) + "\n\n[← Back to the radio](" + page("") + ")\n"
1290}
1291
1292// ---- helpers ----
1293
1294// header is the page title and the nav; cur is the nav entry to mark ("-": none).
1295func header(title, cur string) string { return "# " + title + "\n\n" + nav(cur) }
1296
1297// nav links the main pages; the current one is bold and unlinked.
1298func nav(cur string) string {
1299 items := [7][2]string{{"", "Radio"}, {"stations", "Stations"}, {"catalog", "Catalog"}, {"concerts", "Concerts"},
1300 {"charts", "Charts"}, {"join", "For artists"}, {"about", "About"}}
1301 var b strings.Builder
1302 for i, it := range items {
1303 if i > 0 {
1304 b.WriteString(" · ")
1305 }
1306 if it[0] == cur {
1307 b.WriteString("**" + it[1] + "**")
1308 } else {
1309 b.WriteString("[" + it[1] + "](" + page(it[0]) + ")")
1310 }
1311 }
1312 if l := inApp("legal", "Legal"); l != "" {
1313 b.WriteString(" · " + l)
1314 }
1315 return b.String() + "\n\n"
1316}
1317
1318// noticeBlock is how to send a copyright or illegal-content notice: the
1319// on-chain report, plus the admin's contact line when one is set.
1320func noticeBlock() string {
1321 s := "## Copyright or illegal content\n\nUse the **Report** link on the track, artist, album or playlist " +
1322 "(the catalog's [Report](" + catalogTx.NewLink("Report").AddArgs("kind", "", "target", "", "reason", "").URL() + ") transaction): " +
1323 "say which right it infringes or why it is illegal. Reports are public, with the reporter's address, on [the moderation page](" + page("moderation") + "). " +
1324 "The moderator hides reported content while checking and shows the reason on its page.\n"
1325 if c := contact(); c != "" {
1326 s += "\nContact: " + md(c) + "\n"
1327 }
1328 if l := inApp("legal", "Legal notice, privacy and terms"); l != "" {
1329 s += "\n" + l + "\n"
1330 }
1331 return s + "\n"
1332}
1333
1334func stats() string {
1335 return bold(plural(catalog.ArtistCount(), "artist")) + " · " + bold(plural(catalog.TrackCount(), "track")) + " · " +
1336 bold(plural(catalog.AlbumCount(), "album")) + " · " + bold(plural(tickets.TicketCount(), "ticket")) + " · v" + catalog.Version() + "\n"
1337}
1338
1339// bold makes the number of "12 tracks" bold.
1340func bold(s string) string {
1341 n, rest, _ := strings.Cut(s, " ")
1342 return "**" + n + "** " + rest
1343}
1344
1345func page(p string) string {
1346 if p == "" {
1347 return base
1348 }
1349 return base + ":" + p
1350}
1351
1352func pager(route string, pg, total int) string { return pagerMore(route, pg, pg*pageSize < total) }
1353
1354// pagerMore links the previous page and, when more is true, the next one.
1355func pagerMore(route string, pg int, more bool) string {
1356 sep := "?p="
1357 if strings.Contains(route, "?") {
1358 sep = "&p="
1359 }
1360 s := ""
1361 if pg > 1 {
1362 s += "[← Previous](" + page(route+sep+text.Itoa(pg-1)) + ") "
1363 }
1364 if more {
1365 s += "[Next →](" + page(route+sep+text.Itoa(pg+1)) + ")"
1366 }
1367 return s + "\n"
1368}
1369
1370func playable(t catalog.Track) bool { return !t.Hidden && catalog.ArtistVisible(t.Artist) }
1371
1372func trackLink(t catalog.Track) string {
1373 return "[" + md(t.Title) + "](" + page("track/"+text.Itoa(t.ID)) + ") · " + artistRef(t.Artist)
1374}
1375
1376// artistRef links an artist by id (✓ when verified), or prints nothing for
1377// a hidden or unknown one. It reads the artist's head only.
1378func artistRef(id int) string {
1379 name, verified, hidden, ok := catalog.ArtistBrief(id)
1380 if !ok || hidden {
1381 return ""
1382 }
1383 s := "[" + md(name) + "](" + page("artist/"+text.Itoa(id)) + ")"
1384 if verified {
1385 s += check(id)
1386 }
1387 return s
1388}
1389
1390// check is the ✓ of a verified artist, with the host its proof was found
1391// on: " ✓ via example.org", or " ✓" when the moderator verified it.
1392func check(id int) string {
1393 if h := catalog.ProofHost(id); h != "" {
1394 return " ✓ via " + md(h)
1395 }
1396 return " ✓"
1397}
1398
1399// trackRef links a track by id ("a track" when hidden or unknown).
1400func trackRef(id int) string {
1401 if t, ok := catalog.GetTrack(id); ok && playable(t) {
1402 return "[" + md(t.Title) + "](" + page("track/"+text.Itoa(t.ID)) + ")"
1403 }
1404 return "a track"
1405}
1406
1407func stationLink(st int) string {
1408 return "[" + md(radio.StationName(st)) + "](" + page("station/"+text.Itoa(st)) + ")"
1409}
1410
1411// tippable mirrors catalog: tips and paid tickets need a claimed, verified
1412// artist.
1413func tippable(owner address, verified bool) bool { return owner != "" && verified }
1414
1415// verifyLink sends an artist to the app's verification steps (catalog/verify.gno).
1416func verifyLink(artistID int) string {
1417 if l := inApp("artist/"+text.Itoa(artistID), "Verify it in the app"); l != "" {
1418 return l
1419 }
1420 return "verify it in the GnoRadio app"
1421}
1422
1423// genreBrowse points an empty or idle station to tracks it could play.
1424func genreBrowse(st int) string {
1425 switch st {
1426 case radio.MainStation:
1427 return "[Browse the catalog](" + page("catalog") + "), open a track and choose **Pick it on Main**."
1428 case radio.NewStation:
1429 return "The newest tracks join it as they sync. [Browse the catalog](" + page("catalog") + ")."
1430 case radio.ChoiceStation:
1431 return "Every track a listener queues joins it. [Browse the catalog](" + page("catalog") + ") and pick one."
1432 }
1433 return "[Browse " + md(catalog.GenreName(st)) + " tracks](" + page("catalog?g="+text.Itoa(st)) + "), open one and choose **Queue**."
1434}
1435
1436// stationOrder lists Main, then the community stations, then the genres.
1437func stationOrder() []int {
1438 out := []int{radio.MainStation, radio.NewStation, radio.ChoiceStation}
1439 for g := 1; g <= catalog.NumGenres; g++ {
1440 out = append(out, g)
1441 }
1442 return out
1443}
1444
1445// sourceURL links a contract file on gnoweb.
1446func sourceURL(pkgPath, file string) string {
1447 return strings.TrimPrefix(pkgPath, runtime.ChainDomain()) + "$source&file=" + file
1448}
1449
1450// sourceLink is " [label](url)." or "" when there is no URL.
1451func sourceLink(label, url string) string {
1452 if url = sanitize.URL(url); url == "" {
1453 return ""
1454 }
1455 return " [" + label + "](" + url + ")."
1456}
1457
1458func appLine() string {
1459 if l := inApp("", "Open it"); l != "" {
1460 return " " + l + "."
1461 }
1462 return ""
1463}
1464
1465func artistLink(a catalog.Artist) string {
1466 s := "[" + md(a.Name) + "](" + page("artist/"+text.Itoa(a.ID)) + ")"
1467 if a.Verified {
1468 s += check(a.ID)
1469 }
1470 return s
1471}
1472
1473// who links an address: its artist profile, else its listener page under
1474// its gno.land name, or its nickname and short address (a nickname alone is
1475// easy to grind).
1476func who(addr address) string {
1477 if !roles.Canonical(addr) { // a later release may store anything
1478 return md(displayName(addr))
1479 }
1480 if id := catalog.ArtistOf(addr); id != 0 {
1481 if s := artistRef(id); s != "" {
1482 return s
1483 }
1484 }
1485 label := "@" + userName(addr)
1486 if label == "@" {
1487 label = nickname(addr) + " (" + short(addr) + ")"
1488 }
1489 return "[" + md(label) + "](" + page("listener/"+addr.String()) + ")"
1490}
1491
1492// listenURL is where a track's audio plays, from offset seconds when the
1493// host takes a #t= fragment; an Audius track without its page stays here.
1494// Values from the catalog are checked again here (sanitize.URL): this page
1495// keeps rendering records later releases write.
1496func listenURL(id int, audio string, offset int64) string {
1497 if u := sanitize.URL(rawListenURL(id, audio, offset)); u != "" {
1498 return u
1499 }
1500 return page("track/" + text.Itoa(id))
1501}
1502
1503func rawListenURL(id int, audio string, offset int64) string {
1504 frag := ""
1505 if offset > 0 && !strings.Contains(audio, "#") {
1506 frag = "#t=" + text.Itoa64(offset)
1507 }
1508 switch {
1509 case strings.HasPrefix(audio, "ipfs://"):
1510 return "https://ipfs.io/ipfs/" + strings.TrimPrefix(audio, "ipfs://") + frag
1511 case strings.HasPrefix(audio, "ar://"):
1512 return "https://arweave.net/" + strings.TrimPrefix(audio, "ar://") + frag
1513 case strings.HasPrefix(audio, "audius:"), strings.HasPrefix(audio, "jamendo:"):
1514 return page("track/" + text.Itoa(id)) // a pointer plays in the app
1515 }
1516 return audio + frag
1517}
1518
1519// licenseLink links a license id to its deed. Creative Commons ids read
1520// CC-<terms>-<version>[-<port>], e.g. CC-BY-SA-3.0-DE →
1521// creativecommons.org/licenses/by-sa/3.0/de/.
1522func licenseLink(l string) string {
1523 switch {
1524 case l == "CC0-1.0":
1525 return "[CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/)"
1526 case l == catalog.AudiusLicense:
1527 return "[Audius Open Music License](https://openaudiofoundation.org/open-music-license.pdf)"
1528 case strings.HasPrefix(l, "CC-"):
1529 var terms []string
1530 ver, port := "", ""
1531 for _, part := range strings.Split(strings.TrimPrefix(l, "CC-"), "-") {
1532 switch {
1533 case ver == "" && strings.Contains(part, "."):
1534 ver = part
1535 case ver == "":
1536 terms = append(terms, part)
1537 case port == "":
1538 port = part
1539 default:
1540 return md(l)
1541 }
1542 }
1543 // only a-z, 0-9 and "." reach the URL: a later release may store other ids
1544 if ver == "" || len(terms) == 0 || !plain(strings.Join(terms, "")+ver+port) {
1545 return md(l)
1546 }
1547 url := "https://creativecommons.org/licenses/" + strings.ToLower(strings.Join(terms, "-")) + "/" + ver + "/"
1548 label := "CC " + strings.Join(terms, "-") + " " + ver
1549 if port != "" {
1550 url += strings.ToLower(port) + "/"
1551 label += " " + port
1552 }
1553 return "[" + md(label) + "](" + url + ")"
1554 }
1555 return "All rights reserved"
1556}
1557
1558// plain reports whether s holds only ASCII letters, digits and dots.
1559func plain(s string) bool {
1560 for _, c := range s {
1561 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.') {
1562 return false
1563 }
1564 }
1565 return true
1566}
1567
1568func queueURL(st, id int) string {
1569 return radioTx.NewLink("Queue").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id)).URL()
1570}
1571
1572// bookURL opens QueueAt with the time left for the listener to fill in.
1573func bookURL(st, id int) string {
1574 return radioTx.NewLink("QueueAt").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id), "at", "").URL()
1575}
1576
1577func sponsoredURL(st, id int) string {
1578 return radioTx.NewLink("QueueSponsored").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id)).URL()
1579}
1580
1581func tipOnAirURL(st, id int, gnots int64) string {
1582 return radioTx.NewLink("TipOnAir").AddArgs("stationID", text.Itoa(st), "trackID", text.Itoa(id), "supportPct", "0", "ref", "").
1583 SetSend(strconv.FormatInt(gnots*ugnot, 10) + "ugnot").URL()
1584}
1585
1586func supportURL(gnots int64) string {
1587 return catalogTx.NewLink("SupportGnoRadio").SetSend(strconv.FormatInt(gnots*ugnot, 10) + "ugnot").URL()
1588}
1589
1590// tipSupportURL tips gnots to the artist side plus pct% on top for GnoRadio.
1591func tipSupportURL(id int, gnots, pct int64) string {
1592 total := gnots * ugnot * (100 + pct) / 100
1593 return catalogTx.NewLink("TipWithSupport").AddArgs("trackID", text.Itoa(id), "supportPct", text.Itoa64(pct)).SetSend(strconv.FormatInt(total, 10) + "ugnot").URL()
1594}
1595
1596func tipURL(id int, gnots int64) string {
1597 return catalogTx.NewLink("Tip").AddArgs("trackID", text.Itoa(id)).SetSend(strconv.FormatInt(gnots*ugnot, 10) + "ugnot").URL()
1598}
1599
1600func genreList() string {
1601 var parts []string
1602 for g := 1; g <= catalog.NumGenres; g++ {
1603 parts = append(parts, text.Itoa(g)+" "+md(catalog.GenreName(g)))
1604 }
1605 return strings.Join(parts, " · ")
1606}
1607
1608func md(s string) string { return sanitize.InlineText(s) }
1609
1610func atoi(s string) int {
1611 n, err := strconv.Atoi(s)
1612 if err != nil || n < 0 {
1613 return 0
1614 }
1615 return n
1616}
1617
1618func short(a address) string {
1619 s := []rune(a.String())
1620 if len(s) < 12 {
1621 return md(string(s))
1622 }
1623 return md(string(s[:7]) + "…" + string(s[len(s)-4:]))
1624}
1625
1626func pad2(n int) string {
1627 if n < 10 {
1628 return "0" + text.Itoa(n)
1629 }
1630 return text.Itoa(n)
1631}
1632
1633func clock(sec int64) string { return text.Itoa64(sec/60) + ":" + pad2(int(sec%60)) }
1634
1635func date(unix int64) string { return time.Unix(unix, 0).UTC().Format("Jan 2, 2006 15:04") + " UTC" }
1636func hour(unix int64) string { return time.Unix(unix, 0).UTC().Format("15:04") }
1637
1638// day is "tomorrow " when unix falls on the next UTC day (a booked pick), else "".
1639func day(unix int64) string {
1640 if unix/86400 > time.Now().Unix()/86400 {
1641 return "tomorrow "
1642 }
1643 return ""
1644}