Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}