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

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