PageSourceSearch

https://magnatune.com/app/magnatune.js?v=1790764949

js magnatune.com collected 2026-10-02 02:45:16 UTC 237,313 bytes, 4,071 lines download raw bytes

1/* =====================================================================
2 * Magnatune web app — client (P1: browse)
3 * =====================================================================
4 * Catalog is read entirely client-side via sql.js. No audio yet (P2),
5 * no favorites/playlists yet (P3). Covers/photos load through the Phase-A
6 * HTTPS image proxy (cfg.image_base = "img.adp").
7 * ===================================================================== */
8"use strict";
9
10const MAG = { cfg: null, db: null, lists: {}, nowPlayingOpen: false };
11const el = (id) => document.getElementById(id);
12const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) =>
13  ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
14
15/* ---- pretty hash URLs ----
16 * Routes use human-readable name slugs instead of numeric ids:
17 *   #/artist/<artist>  #/album/<artist>/<album>  #/genre/<g>  #/tag/<t>  #/featured/<f>
18 * slug() lowercases and hyphenates; all magnatune names slugify uniquely (verified against
19 * the catalog: artists/genres/tags/featured fully unique, albums unique as artist/album).
20 * Slug holds the reverse slug->id maps, built once after the catalog loads (buildSlugMaps).
21 * URL builders are the single source of truth for every internal link. */
22const slug = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
23const Slug = { artist: new Map(), album: new Map(), genre: new Map(), tag: new Map(), featured: new Map() };
24const artistUrl   = (name)         => `#/artist/${slug(name)}`;
25const albumUrl    = (artist, name) => `#/album/${slug(artist)}/${slug(name)}`;
26const genreUrl    = (name)         => `#/genre/${slug(name)}`;
27const tagUrl      = (name)         => `#/tag/${slug(name)}`;
28const featuredUrl = (name)         => `#/featured/${slug(name)}`;
29const songUrl     = (artist, album, title) => `${albumUrl(artist, album)}/${slug(title)}`;
30
31/* ===================== PATH routing mode (flag-gated) ==========================
32 * In navim4 the whole site is served by NaviServer at REAL paths (no "#", no /app/):
33 * the prerendered SEO page boots this app and the app takes over the SAME real URL via
34 * the History API. Gated on window.MAG_ROUTING==='path' — DEFAULT (unset) is 100%
35 * hash-routed, so this deployed app is unaffected; navim4 sets the flag to serve the
36 * whole site at real URLs. The app builds "#/…" links everywhere and translates only at
37 * the address-bar boundary (clicks/boot/popstate) via the token<->path maps below. */
38const PATH_MODE = (typeof window !== "undefined" && window.MAG_ROUTING === "path");
39// real-path slug variants matching navim4's Tcl (strip_spaces / replace_spaces_with_underscore)
40const srvGenreSlug = (s) => String(s ?? "").toLowerCase().replace(/ /g, "");
41const srvCollSlug  = (s) => String(s ?? "").toLowerCase().replace(/ /g, "_");
42// token<->real-path lookup maps, populated in buildSlugMaps (PATH mode only).
43const P = {
44  pathByArtist: new Map(),   // artistSlug    -> "/artists/<page>/"
45  tokenByPage:  new Map(),   // <page>        -> "artist/<artistSlug>"
46  pathByAlbum:  new Map(),   // "aSlug/bSlug" -> "/artists/albums/<sku>/"
47  tokenBySku:   new Map(),   // <sku>         -> "album/<aSlug>/<bSlug>"
48  pathByGenre:  new Map(),   // genreSlug     -> "/genres/<srv>/"
49  tokenByGenre: new Map(),   // <srv>         -> "genre/<genreSlug>"
50  pathByTag:    new Map(),   // tagSlug       -> "/collections/<srv>"
51  tokenByColl:  new Map(),   // <srv>         -> "tag/<tagSlug>"
52};
53// Settings→About pages keep magnatune.com's REAL /info/ URLs (not /player/...).
54const INFO_URL = {
55  whynotevil: "/info/whynotevil", faq: "/info/faq_buyers", licensing: "/info/licensing",
56  give: "/info/give", why: "/info/why", podcast: "/info/podcast", api: "/info/api",
57  contact: "/info/contact", giftcards: "/buy/gift_cards", terms: "/terms_of_use",
58};
59const INFO_PATH = {};                       // reverse: real path -> info key
60for (const k in INFO_URL) INFO_PATH[INFO_URL[k]] = k;
61// An app "token" is a route minus the leading "#/" (e.g. "album/beth-quist/aegean-dreams").
62// Detail tokens map through P; overviews use magnatune.com's real bare-dir URLs; everything
63// else (favorites, playlists, search, settings, featured) lives under /player/.
64function tokenToPath(tok) {
65  const p = (tok || "").replace(/^[#/]+/, "").split("/").filter(
65Boolean);
66  const sec = p[0] || "popular";
67  switch (sec) {
68    case "popular": return "/";
69    case "artist":  return p[1] ? (P.pathByArtist.get(p[1]) || "/artists/") : "/artists/";
70    case "album": {
71      if (!p[1]) return "/artists/albums/";
72      const base = P.pathByAlbum.get(p[1] + "/" + (p[2] || ""));
73      if (!base) return "/artists/albums/";
74      return p[3] ? base + p[3] : base;            // song slug appends to "…/<sku>/"
75    }
76    case "genre":   return p[1] ? (P.pathByGenre.get(p[1]) || "/genres/") : "/genres/";
77    case "tag":     return p[1] ? (P.pathByTag.get(p[1])  || "/collections/") : "/collections/";
78    case "info":    return (p[1] && INFO_URL[p[1]]) || ("/player/info/" + (p[1] || ""));  // real /info/ URLs
79    default:        return "/player/" + p.join("/");   // featured/favorites/playlists/playlist/search/settings
80  }
81}
82function pathToToken(pathname) {
83  const p = decodeURIComponent(pathname || "/").replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
84  if (p.length === 0) return "popular";
85  const realInfo = INFO_PATH["/" + p.join("/")];        // the About pages' real /info/ URLs
86  if (realInfo) return "info/" + realInfo;
87  if (p[0] === "player") return p.slice(1).join("/") || "popular";
88  if (p[0] === "artists" && p[1] === "albums") {
89    const tok = P.tokenBySku.get(p[2]);
90    if (!tok) return "album";
91    return p[3] ? tok + "/" + p[3] : tok;          // trailing song slug
92  }
93  if (p[0] === "artists")     return P.tokenByPage.get(p[1]) || "artist";
94  if (p[0] === "genres")      return P.tokenByGenre.get(p[1]) || "genre";
95  if (p[0] === "collections") return P.tokenByColl.get(p[1]) || "tag";
96  return "popular";
97}
98// current app route token from the address bar (path OR hash), normalized (no leading slash).
99function currentToken() {
100  const t = PATH_MODE ? pathToToken(location.pathname) : (location.hash || "#/").slice(1);
101  return t.replace(/^\/+/, "");
102}
103// navigate to a token. hash mode: set the hash (fires hashchange→route). path mode:
104// pushState/replaceState the real path, then route() manually.
105function go(tok, replace) {
106  tok = tok.replace(/^[#/]+/, "");
107  if (PATH_MODE) {
108    history[replace ? "replaceState" : "pushState"](null, "", tokenToPath(tok) + location.search);
109    route();
110  } else if (replace) {
111    setHash("#/" + tok);
112  } else {
113    location.hash = "#/" + tok;
114  }
115}
116MAG.go = (u) => go(u);   // onclick navigation helper (works in both hash and PATH routing modes)
117// replaceState a new "#/..." hash WITHOUT going through the document base URL (hash mode);
118// in PATH mode, replaceState the equivalent real path instead.
119const setHash = (h) => PATH_MODE
120  ? history.replaceState(null, "", tokenToPath(h) + location.search)
121  : history.replaceState(null, "", location.pathname + location.search + h);
122// Reflect the now-playing song in the address bar — but only while the user is actually
123// viewing that song's album page, so playing from a playlist/search/artist page doesn't
124// hijack their location. replaceState (not location.hash=) so it doesn't fire a re-render.
125function syncSongInUrl(t) {
126  if (!t || !t.artist || !t.album) return;
127  const base = albumUrl(t.artist, t.album).replace(/^[#/]+/, "");   // token "album/a/b"
128  const cur = currentToken();
129  if (cur === base || cur.startsWith(base + "/")) {
130    const want = songUrl(t.artist, t.album, t.title).replace(/^[#/]+/, "");
131    if (cur !== want) go(want, true);   // reflect the song without adding a history entry
132  }
133}
134function buildSlugMaps() {
135  const set = (m, name, id) => { const s = slug(name); if (s && !m.has(s)) m.set(s, id); };
136  for (const r of q("select artists_id,name,page from artists")) {
137    set(Slug.artist, r.name, r.artists_id);
138    if (PATH_MODE && r.page) { const a = slug(r.name); P.pathByArtist.set(a, `/artists/${r.page}/`); P.tokenByPage.set(r.page, `artist/${a}`); }
139  }
140  // album key is the composite "artistslug/albumslug" — insert directly so the "/" survives
141  // (set() would re-slugify and collapse the slash into a hyphen)
142  for (const r of q("select a.album_id,a.name,a.sku,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id")) {
143    const k = slug(r.artist) + "/" + slug(r.name);
144    if (!Slug.album.has(k)) Slug.album.set(k, r.album_id);
145    if (PATH_MODE && r.sku) { P.pathByAlbum.set(k, `/artists/albums/${r.sku}/`); P.tokenBySku.set(r.sku, `album/${k}`); }
146  }
147  for (const r of q("select genre_id,name from genres")) {
148    set(Slug.genre, r.name, r.genre_id);
149    if (PATH_MODE) { const g = slug(r.name), s = srvGenreSlug(r.name); P.pathByGenre.set(g, `/genres/${s}/`); P.tokenByGenre.set(s, `genre/${g}`); }
150  }
151  for (const r of q("select collections_id,name from collections")) {
152    set(Slug.tag, r.name, r.collections_id);
153    if (PATH_MODE) { const c = slug(r.name), s = srvCollSlug(r.name); P.pathByTag.set(c, `/collections/${s}`); P.tokenByColl.set(s, `tag/${c}`); }
154  }
155  for (const r of q("select playlist_id,name from playlists where owner_id=0")) set(Slug.featured, r.name, r.playlist_id);
156}
157
158/* ---- asset URL builders ----
159 * Covers AND audio are served SAME-ORIGIN by navim4's /music/* handler
160 * (m3_music_serve: local /music disk on production, reverse-proxy in dev) — no
161 * external he3 dependency, and no mixed content when the site is HTTPS. Absolute
162 * path (leading /) so <base href="/app/"> does not rewrite it.
163 * Files are magnatune's pre-generated thumbnails; pick the smallest per context. */
164const MEDIA_BASE = "/music/";
165// Membership credential check (server-side, because the browser can't replay
166// HTTP Basic auth itself). Same-origin: navim4 serves this natively (m3_check_member,
167// local MySQL) — no external dependency. It only flips the member UI; real downloads
168// stay member-gated by m3_verify_member on /music and /membership/download3.
169const LOGIN_URL = "/membership/check.php";
170// Catalog is fetched as BYTES, so it's CORS-gated — unlike covers/audio (which
171// load via <img>/<audio> and work cross-origin). he3 sends no CORS header and is
172// a different origin from the app anyway, so the catalog must be SAME-ORIGIN: a
173// Catalog is fetched as BYTES → CORS-gated (unlike covers/audio, which load via
174// <img>/<audio> and work cross-origin). On magnatune.com it's a SAME-ORIGIN fetch of
175// magnatune's own published files (/info/...): always current, nothing shipped in the
176// repo. On any other host (local dev) /info/ won't exist and a cross-origin fetch of
177// magnatune.com is CORS-blocked, so fall back to app-dir copies (untracked dev-only
178// files). The catalog is gzipped (~2.6MB) → inflated in-browser by gunzip().
179const ON_MAGNATUNE = /(^|\.)magnatune\.com$/i.test(location.hostname);
180// The catalog now ships as two compact gzipped TEXT files instead of a SQLite db:
181//   sqlite_core.txt.gz         wave 1 — everything needed to browse/search/play
182//                              (NO album descriptions / artist bios). ~0.6MB gz.
183//   sqlite_descriptions.txt.gz wave 2 — album notes + artist bios, loaded async.
184// A JS importer (importCatalog / importDescriptions) builds an in-memory sql.js db
185// with the SAME schema the app has always used, so every query is unchanged.
186const CATALOG_URL = ON_MAGNATUNE ? "/info/sqlite_core.txt.gz"         : "sqlite_core.txt.gz";
187const DESC_URL    = ON_MAGNATUNE ? "/info/sqlite_descriptions.txt.gz" : "sqlite_descriptions.txt.gz";
188const CRC_URL     = ON_MAGNATUNE ? "/info/changed.txt"               : "catalog.crc";
189function imgURL(artist, album, file) {
190  if (!artist || !album) return "";
191  // ?v=catalogVersion busts any stale/poisoned browser cache after a fix or catalog
192  // change; the server proxy ignores v (its disk cache keys on artist/album/file only).
193  const v = MAG.cfg ? MAG.cfg.catalog_version : 0;
194  return MEDIA_BASE + encodeURIComponent(artist) + "/" + encodeURIComponent(album) + "/" + file + "?v=" + v;
195}
196// smallest sensible magnatune thumbnail per UI situation
197const COVER = { cell: "cover_200.webp", detail: "cover_400.webp", zoom: "cover_1400.webp" };
198const ARTIST = { cell: "artist_200.webp", detail: "artist_420.webp" };
199
200/* ---- image fallback: tiers + negative cache ----
201 * Not every album directory on he3 has every sized thumbnail generated (and webp exists only
202 * at some sizes), so a given cover_/artist_ file 404s for some albums/artists — leav
202ing a
203 * broken image. We fall back through the other tiers (preferring jpg, the complete family),
204 * and remember misses in localStorage so a 404'd tier isn't re-requested: the available size
205 * (already browser-cached) then loads instantly on later views and across page reloads.
206 * The webp sets below are MEASURED off /music (2026-07-20), not guessed — an entry that is
207 * not really on disk costs every viewer a 404 before the fallback. cover has no webp at
208 * 50/75/600; artist has webp at every size it ships. jpg stays in the chain as the backstop
209 * for the handful of albums whose source image cwebp could not read (CMYK jpegs). */
210const TIERS = {
211  cover:  { sizes: [50, 75, 100, 150, 200, 300, 400, 600, 800, 1400], webp: new Set([100, 150, 200, 300, 400, 800, 1400]) },
212  artist: { sizes: [50, 200, 420, 840],                                webp: new Set([50, 200, 420, 840]) },
213};
214// Ordered candidate files for an ideal file: the exact file first, then same-size jpg, then
215// larger sizes, then smaller — jpg always included (webp only where it exists on the server).
216function fallbackFiles(file) {
217  const m = /^(cover|artist)_(\d+)\.(webp|jpg)$/.exec(file);
218  if (!m) return [file];
219  const kind = m[1], ideal = +m[2], t = TIERS[kind];
220  const order = [ideal, ...t.sizes.filter((s) => s > ideal), ...t.sizes.filter((s) => s < ideal).reverse()];
221  const files = [];
222  const push = (f) => { if (!files.includes(f)) files.push(f); };
223  push(file);
224  for (const s of order) {
225    if (t.webp.has(s)) push(`${kind}_${s}.webp`);
226    push(`${kind}_${s}.jpg`);
227  }
228  return files;
229}
230const ImgMiss = {
231  KEY: "magnatune.imgmiss", _set: null,
232  _load() {
233    if (!this._set) {
234      try { this._set = new Set(JSON.parse(localStorage.getItem(this.KEY) || "[]")); }
235      catch (e) { this._set = new Set(); }
236    }
237    return this._set;
238  },
239  _k(a, al, f) { return a + "|" + al + "|" + f; },
240  has(a, al, f) { return this._load().has(this._k(a, al, f)); },
241  add(a, al, f) {
242    const s = this._load(), k = this._k(a, al, f);
243    if (s.has(k)) return;
244    s.add(k);
245    try { localStorage.setItem(this.KEY, JSON.stringify([...s])); } catch (e) {}
246  },
247};
248// A near-empty SVG used as the initial src for throttled-lazy images, so the
249// cell shows its grey background (no broken-image icon) until the real cover
250// is swapped in by LazyImg. Immutable / cached by the browser after the first.
251const IMG_PLACEHOLDER =
252  "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'/%3E";
253
254// Build an <img> that walks the fallback chain on error. opts.eager loads the
255// real cover immediately; otherwise the URL is parked in data-src and LazyImg
256// only fetches it once the image has SETTLED in/near the viewport (see below),
257// so a fast flick down the grid never queues the covers it flies past.
258function imgTag(artist, album, file, opts = {}) {
259  if (!artist || !album) return "";
260  const all = fallbackFiles(file);
261  const live = all.filter((f) => !ImgMiss.has(artist, album, f));
262  const list = live.length ? live : all;
263  const cur = list[0], rest = list.slice(1);
264  const url = imgURL(artist, album, cur);
265  const srcAttr = opts.eager
266    ? `src="${url}"`
267    : `src="${IMG_PLACEHOLDER}" data-src="${url}"`;
268  return `<img alt="" data-art="${esc(artist)}" data-alb="${esc(album)}"` +
269         ` data-cur="${esc(cur)}" data-fb="${esc(JSON.stringify(rest))}"` +
270         ` ${srcAttr} onerror="MAG.imgFail(this)">`;
271}
272
273/* ---- throttled lazy image loader --------------------------------------------
274 * Native loading="lazy" starts a fetch for EVERY image that crosses the viewport
275 * while scrolling. A fast flick down the album grid therefore queues 100+ cover
276 * requests onto HTTP/1.1's ~6 connections, and the last ones land seconds later.
277 *
278 * LazyImg loads a cover only once it has *settled* in (or within 400px of) the
279 * viewport for ~120ms. An image that merely flashes past during a fast scroll is
280 * never requested — its pending load is cancelled the moment it leaves. When you
281 * stop, whatever is on screen loads promptly. Eager images (Now Playing,
282 * mini-player, zoom) bypass this entirely and load at once.
283 *
284 * A MutationObserver on #content auto-registers images added by later renders
285 * (search-as-you-type, live filters), so callers don't have to remember to scan. */
286const LazyImg = {
287  io: null, mo: null, SETTLE: 120, MARGIN: 400,
288  // #content is the scroll container (overflow:auto), so intersection is measured
289  // against IT, not the window — correct for this layout and independent of any
290  // window-height quirk. Re-checked at settle time to skip flicked-past images.
291  _visible(img) {
292    const root = el("content");
293    if (!root) return true;
294    const r = img.getBoundingClientRect(), rr = root.getBoundingClientRect();
295    return r.width > 0 && r.bottom > rr.top - this.MARGIN && r.top < rr.bottom + this.MARGIN;
296  },
297  _load(img) {
298    const u = img.dataset.src;
299    if (!u) return;
300    delete img.dataset.src;
301    if (this.io) this.io.unobserve(img);
302    if (img._lt) { clearTimeout(img._lt); img._lt = 0; }
303    img.src = u;   // a 404 here still triggers onerror → MAG.imgFail (fallback walk)
304  },
305  init() {
306    if (this.io || !("IntersectionObserver" in window)) return;
307    this.io = new IntersectionObserver((entries) => {
308      for (const e of entries) {
309        const img = e.target;
310        if (e.isIntersecting) {
311          if (img._lt) continue;
312          img._lt = setTimeout(() => {
313            img._lt = 0;
314            if (img.dataset.src && this._visible(img)) this._load(img);
315          }, this.SETTLE);
316        } else if (img._lt) {
317          clearTimeout(img._lt); img._lt = 0;   // flew past before settling → skip it
318        }
319      }
320    }, { root: el("content"), rootMargin: this.MARGIN + "px 0px", threshold: 0 });
321    const content = el("content");
322    if (content && "MutationObserver" in window) {
323      this.mo = new MutationObserver((muts) => {
324        for (const m of muts)
325          for (const n of m.addedNodes) {
326            if (n.nodeType !== 1) continue;
327            if (n.matches && n.matches("img[data-src]")) this.io.observe(n);
328            if (n.querySelectorAll) n.querySelectorAll("img[data-src]").forEach((i) => this.io.observe(i));
329          }
330      });
331      this.mo.observe(content, { childList: true, subtree: true });
332    }
333  },
334  scan(root) {
335    this.init();
336    const imgs = (root || document).querySelectorAll("img[data-src]");
337    if (!this.io) { imgs.forEach((i) => this._load(i)); return; }   // no IO → just load all
338    imgs.forEach((i) => this.io.observe(i));
339  },
340};
341
342/* ---- sql.js query helper ---- */
343function q(sql, params = []) {
344  const st = MAG.db.prepare(sql);
345  st.bind(params);
346  const rows = [];
347  while (st.step()) rows.push(st.getAsObject());
348  st.free();
349  return rows;
350}
351const q1 = (sql, params = []) => q(sql, params)[0];
352
353/* ---- named catalog queries ---- */
354const Q = {
355  genres: () => q("select genre_id,name from genres order by name collate nocase"),
356  popularInGenre: (gid, lim) => q(
357    "select a.album_id,a.name,a.popularity,ar.name artist from albums a " +
358    "join genres_albums ga on ga.album_id=a.album_id " +
359    "join artists ar on ar.artists_id=a.artist_id " +
360    "where ga.genre_id=? order by a.popularity desc limit ?", [gid, lim]),
361  // sort: "recent" | "popular" | anything else (default) = alphabetical.
362  // Artists have no date/popularity of their own, so Recent/Popular sort by the artist's
363  // newest / most-popular album (MAX across their albums).
364  allArtists: (sort) => q(
365    "select ar.artists_id,ar.name," +
366    " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" +
367    " from artists ar left join albums a on a.artist_id=ar.artists_id group by ar.artists_id order by " +
368    (sort === "recent" ? "max(a.release_date) desc, ar.name collate nocase"
369      : sort === "popular" ? "max(a.popularity) desc, ar.name collate nocase"
370      : "ar.name collate nocase")),
371  allAlbums: (sort) => q(
372    "select a.album_id,a.name,ar.name artist from albums a " +
373    "join artists ar on ar.artists_id=a.artist_id order by " +
374    (sort === "recent" ? "a.release_date desc, a.name collate nocase"
375      : sort === "popular" ? "a.popularity desc, a.name collate nocase"
376      : "a.name collate nocase")),
377  genreArtists: (gid) => q(
378    "select distinct ar.artists_id,ar.name," +
379    " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" +
380    " from artists ar join albums a on a.artist_id=ar.artists_id " +
381    "join genres_albums ga on ga.album_id=a.album_id " +
382    "where ga.genre_id=? order by ar.name collate nocase", [gid]),
383  collections: () => q(
384    "select c.collections_id,c.name,count(ca.album_id) cnt from collections c " +
385    "join collections_albums ca on ca.collection_id=c.collections_id " +
386    "group by c.collections_id order by c.name collate nocase"),
387  collectionAlbums: (cid) => q(
388    "select a.album_id,a.name,ar.name artist from albums a " +
389    "join collections_albums ca on ca.album_id=a.album_id " +
390    "join artists ar on ar.artists_id=a.artist_id " +
391    "where ca.collection_id=? order by a.name collate nocase", [cid]),
392  // Every song in a genre / tag, ranked by album popularity then track order — for the
393  // category "Play"/"Shuffle" buttons. Same column shape as playlistSongs (trk() reads it).
394  genreSongs: (gid) => q(
395    "select s.song_id,s.name,s.duration,s.mp3,al.album_id,al.name album,ar.name artist " +
396    "from genres_albums ga join albums al on al.album_id=ga.album_id " +
397    "join artists ar on ar.artists_id=al.artist_id " +
398    "join songs s on s.album_id=al.album_id " +
399    "where ga.genre_id=? order by al.popularity desc, al.album_id, s.track_no", [gid]),
400  collectionSongs: (cid) => q(
401    "select s.song_id,s.name,s.duration,s.mp3,al.album_id,al.name album,ar.name artist " +
402    "from collections_albums ca join albums al on al.album_id=ca.album_id " +
403    "join artists ar on ar.artists_id=al.artist_id " +
404    "join songs s on s.album_id=al.album_id " +
405    "where ca.collection_id=? order by al.popularity desc, al.album_id, s.track_no", [cid]),
406  featured: () => q("select playlist_id,name from playlists where owner_id=0 order by sort_order"),
407  playlistSongs: (pid) => q(
408    "select s.song_id,s.name,s.duration,s.mp3,al.album_id,al.name album,ar.name artist " +
409    "from playlist_songs ps join songs s on s.song_id=ps.song_id " +
410    "join albums al on al.album_id=s.album_id " +
411    "join artists ar on ar.artists_id=al.artist_id " +
412    "where ps.playlist_id=? order by ps.sort_order", [pid]),
413  artist: (id) => q1("select artists_id,name,description,bio,city,state,country from artists where artists_id=?", [id]),
414  artistAlbums: (id) => q("select album_id,name from albums where artist_id=? order by release_date desc", [id]),
415  album: (id) => q1(
416    "select a.album_id,a.name,a.description,a.sku,a.release_date,a.artist_id,ar.name artist " +
417    "from albums a join artists ar on ar.artists_id=a.artist_id where a.album_id=?", [id]),
418  albumSongs: (id) => q("select song_id,name,track_no,duration,mp3 from songs where album_id=? order by track_no", [id]),
419  albumGenres: (id) => q("select g.genre_id,g.name from genres g join genres_albums ga on ga.genre_id=g.genre_id where ga.album_id=? order by g.name", [id]),
420  // Artist-level genres/tags: distinct genres/collections aggregated across all of this artist's albums (chips on the artist page, same as the album page).
421  artistGenres: (id) => q("select distinct g.genre_id,g.name from genres g join genres_albums ga on ga.genre_id=g.genre_id join albums a on a.album_id=ga.album_id where a.artist_id=? order by g.name", [id]),
422  artistTags: (id) => q("select distinct c.collections_id,c.name from collections c join collections_albums ca on ca.collection_id=c.collections_id join albums a on a.album_id=ca.album_id where a.artist_id=? order by c.name", [id]),
423  // "You might also like" — the magnatune recommendation engine's ranked sibling albums for an album.
424  albumRecs: (id) => q(
425    "select a.album_id,a.name,ar.name artist from recommendations r " +
426    "join albums a on a.album_id=r.recommended_album_id " +
427    "join artists ar on ar.artists_id=a.artist_id " +
428    "where r.album_id=? order by r.rank", [id]),
429  // Artist-level recommendations: aggregate the per-album recs across all of this artist's albums,
430  // tally which OTHER artists are recommended most (each rec = one vote), drop the artist itself.
431  artistRecArtists: (id) => q(
432    "select ar.artists_id,ar.name,count(*) score," +
433    " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" +
434    " from recommendations r " +
435    "join albums ra on ra.album_id=r.recommended_album_id " +
436    "join artists ar on ar.artists_id=ra.artist_id " +
437    "where r.album_id in (select album_id from albums where artist_id=?) and ra.artist_id<>? " +
438    "group by ar.artists_id order by score desc, ar.name collate nocase limit 16", [id, id]),
439  albumTags: (id) => q("select c.collections_id,c.name from collections c join collections_albums ca on ca.collection_id=c.collections_id where ca.album_id=? order by c.name", [id]),
440  searchArtists: (t) => q(
441    "select artists_id,name," +
442    " (select name from albums where artist_id=artists.artists_id order by popularity desc, album_id limit 1) album" +
443    " from artists where name like ? or description like ? or bio like ? order by name collate nocase limit 40",
444    ["%" + t + "%", "%" + t + "%", "%" + t + "%"]),
445  searchAlbums: (t) => q(
446    "select a.album_id,a.name,ar.artists_id artist_id,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id " +
447    "where a.name like ? or a.description like ? order by a.name collate nocase limit 40",
448    ["%" + t + "%", "%" + t + "%"]),
449  searchSongs: (t) => q(
450    "select s.song_id,s.name,s.mp3,s.duration,al.album_id,al.name album,ar.artists_id artist_id,ar.name artist from songs s " +
451    "join albums al on al.album_id=s.album_id join artists ar on ar.artists_id=al.artist_id " +
452    "where s.name like ? order by s.name collate nocase limit 60", ["%" + t + "%"]),
453  // ---- by-id lookups for favorites / playlists (localStorage stores only ids) ----
454  songsByIds: (ids) => ids.length ? q(
455    "select s.song_id,s.name,s.mp3,s.duration,s.track_no,al.album_id,al.name album,ar.artists_id artist_id,ar.name artist from songs s " +
456    "join albums al on al.album_id=s.album_id join artists ar on ar.artists_id=al.artist_id " +
457    `where s.song_id in (${ids.map(() => "?").join(",")})`, ids) : [],
458  albumsByIds: (ids) => ids.length ? q(
459    "select a.album_id,a.name,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id " +
460    `where a.album_id in (${ids.map(() => "?").join(",")})`, ids) : [],
461  artistsByIds: (ids) => ids.length ? q(
462    "select ar.artists_id,ar.name," +
463    " (select name from albums where artist_id=ar.artists_id order by popularity desc, album_id limit 1) album" +
464    ` from artists ar where ar.artists_id in (${ids.map(() => "?").join(",")})`, ids) : [],
465  artistSongIds: (id) => q(
466    "select s.song_id from songs s join albums a on a.album_id=s.album_id " +
467    "where a.artist_id=? order by a.release_date desc, s.track_no", [id]).map((r) => r.song_id),
468};
469
470/* ---- formatting + components ---- */
471const fmtDur = (s) => {
472  s = Math.round(s || 0);
473  return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
474};
475// On image load failure: remember the miss, then try the next file in the fallback chain;
476// when the chain is exhausted, hide the image and reveal any sibling .no-photo placeholder.
477MAG.imgFail = function (img) {
478  const a = img.dataset.art, al = img.dataset.alb, cur = img.dataset.cur;
479  if (a && al && cur) ImgMiss.add(a, al, cur);
480  let rest = [];
481  try { rest = JSON.parse(img.dataset.fb || "[]"); } catch (e) {}
482  if (rest.length) {
483    const next = rest.shift();
484    img.dataset.cur = next;
485    img.dataset.fb = JSON.stringify(rest);
486    img.src = imgURL(a, al, next);
487  } else {
488    img.onerror = null;
489    img.style.display = "none";
490    const ph = img.parentElement && img.parentElement.querySelector(".no-photo");
491    if (ph) ph.style.display = "";
492  }
493};
494
495function albumCell(a, opts) {
496  if (Suppress.album(a.album_id)) return "";   // hidden by "Hide things I dislike"
497  // opts.icon → small disc icon before the name (used in search results). opts is a number
498  // when called via .map (the index), which has no .icon, so other grids are unaffected.
499  const ic = opts && opts.icon ? `<i class="fa-solid fa-compact-disc cell-ic"></i> ` : "";
500  return `<a class="cell album-cell" href="${albumUrl(a.artist, a.name)}">
501    <div class="cover">${imgTag(a.artist, a.name, COVER.cell)}</div>
502    <div class="cell-name">${ic}${esc(a.name)}</div>
503    <div class="cell-sub">${esc(a.artist)}</div></a>`;
504}
505function artistCell(ar) {
506  if (Suppress.artist(ar.artists_id)) return "";   // hidden by "Hide things I dislike"
507  const img = ar.album
508    ? imgTag(ar.name, ar.album, ARTIST.cell) + `<div class="no-photo" style="display:none"></div>`
509    : `<div class="no-photo"></div>`;
510  return `<a class="cell artist-cell" href="${artistUrl(ar.name)}">
511    <div class="photo">${img}</div>
512    <div class="cell-name">${esc(ar.name)}</div></a>`;
513}
514// Artist LIST row (matches the iOS app): photo + name + inline favorite + add-to-playlist.
515// The name/photo are an <a> (the click-interceptor / hash routing navigates it); the action
516// buttons are siblings OUTSIDE that <a> so clicking them doesn't trigger navigation.
517function artistRow(ar) {
518  if (Suppress.artist(ar.artists_id)) return "";   // hidden by "Hide things I dislike"
519  const img = ar.album
520    ? imgTag(ar.name, ar.album, ARTIST.cell) + `<div class="no-photo" style="display:none"></div>`
521    : `<div class="no-photo"></div>`;
522  return `<div class="artist-row">
523    <a class="ar-main" href="${artistUrl(ar.name)}"><div class="ar-photo">${img}</div><div class="ar-name">${esc(ar.name)}</div></a>
524    <div class="ar-actions">${rowPlay("artist", ar.artists_id)}${favBtn("artist", ar.artists_id)}<button class="addbtn" title="Add all songs to playlist" onclick="MAG.addArtist(event,${ar.artists_id})">${addIcon(false)}</button></div>
525  </div>`;
526}
527const artistList = (artists) => `<div class="artist-list">${artists.map(artistRow).join("")}</div>`;
528// Artist bio / album description: clamped to a few lines with an explicit "Show more / Show
529// less" toggle (matches the iOS app). wireDescToggles() hides the toggle if the text doesn't
530// actually overflow the clamp.
531function descBlock(text) {
532  // The catalog stores real newlines (unesc turns \n back into "\n"); a single <p> would
533  // collapse them into a run-on paragraph, so preserve them as <br> (a blank line = <br><br>).
534  if (!text) return "";
535  const html = esc(text).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>");
536  return `<div class="desc-wrap"><p class="desc clamped">${html}</p><button class="desc-toggle" onclick="MAG.toggleDesc(this)" hidden>Show more</button></div>`;
537}
538MAG.toggleDesc = (btn) => {
539  const clamped = btn.parentNode.querySelector(".desc").classList.toggle("clamped");
540  btn.textContent = clamped ? "Show more" : "Show less";
541};
542function wireDescToggles() {
543  document.querySelectorAll(".desc-wrap").forEach((w) => {
544    const p = w.querySelector(".desc"), b = w.querySelector(".desc-toggle");
545    if (p && b && p.scrollHeight > p.clientHeight + 2) b.hidden = false;
546  });
547}
548/* normalize a catalog song row into a playable track */
549function trk(r, artist, album, albumId) {
550  return {
551    songId: r.song_id, title: r.name, mp3: r.mp3, duration: r.duration,
552    trackNo: r.track_no != null ? r.track_no : null,
553    artist: r.artist || artist, album: r.album || album, albumId: r.album_id || albumId,
554    artistId: r.artist_id != null ? r.artist_id : null,
555  };
556}
557/* register a track list so its rows can drive the player; returns an id */
558let _listSeq = 0;
559function registerList(tracks) {
560  const vis = tracks.filter((t) => !Suppress.song(t.songId));   // drop disliked-suppressed songs
561  const id = "L" + (++_listSeq); MAG.lists[id] = vis; return id;
562}
563function songsHTML(listId, withSub, navToAlbum) {
564  return `<div class="songs">` +
565    MAG.lists[listId].map((t, i) => songRow(t, i, withSub, listId, navToAlbum)).join("") + `</div>`;
566}
567// Small rounded-rectangle buttons for a song's artist + album (used in the sub line of
568// search / playlist rows). Each links to that artist/album; stopPropagation so it doesn't
569// also trigger the row's play action. In navToAlbum mode (search) the album chip deep-links
570// to the song on its album page (album + highlighted song) instead of just the album.
571function subChips(t, navToAlbum) {
572  let h = "";
573  if (t.artist) {
574    h += `<a class="subchip" href="${artistUrl(t.artist)}" onclick="event.stopPropagation()"><i class="fa-solid fa-user"></i>${esc(t.artist)}</a>`;
575  }
576  if (t.album) {
577    const albumHref = navToAlbum && t.artist ? songUrl(t.artist, t.album, t.title) : albumUrl(t.artist, t.album);
578    h += t.artist // the album slug needs the artist name; without it, show a plain chip
579      ? `<a class="subchip" href="${albumHref}" onclick="event.stopPropagation()"><i class="fa-solid fa-compact-disc"></i>${esc(t.album)}</a>`
580      : `<span class="subchip"><i class="fa-solid fa-compact-disc"></i>${esc(t.album)}</span>`;
581  }
582  return h;
583}
584function songRow(t, i, withSub, listId, navToAlbum) {
585  const sub = withSub ? `<div class="song-sub">${subChips(t, navToAlbum)}</div>` : "";
586  const cur = Player.current();
587  const playing = cur && cur.songId === t.songId ? " playing" : "";
588  const onPl = UserData.playlistedSongs().has(t.songId);
589  // In artwork mode (search / featured) show the album cover instead of a track number.
590  const lead = withSub
591    ? `<div class="song-art">${imgTag(t.artist, t.album, COVER.cell)}</div>`
592    : `<div class="track">${t.trackNo != null ? t.trackNo : i + 1}</div>`;
593  // navToAlbum (search): tapping the row (cover art or song name) opens the song's album page
594  // with that song highlighted, rather than playing it inline. The artist/album sub-chips and
595  // the action buttons stopPropagation so they keep their own behavior.
596  const rowClick = navToAlbum && t.artist && t.album
597    ? `MAG.go('${songUrl(t.artist, t.album, t.title)}')`
598    : `MAG.playFrom('${listId}',${i})`;
599  return `<div class="song-row${withSub ? " with-sub art" : ""}${navToAlbum ? " nav" : ""}${playing}" data-sid="${t.songId}" onclick="${rowClick}">
600    ${lead}
601    <div class="song-name">${esc(t.title)}</div>${sub}
602    <div class="dur">${fmtDur(t.duration)}</div>
603    <div class="song-actions">${favBtn("song", t.songId)}${dislikeBtn("song", t.songId)}${dlSongBtn(t)}<button class="addbtn" title="${onPl ? "Remove from playlist" : "Add to playlist"}" onclick="MAG.addSong(event,${t.songId})">${addIcon(onPl)}</button></div></div>`;
604}
605const grid = (cells, cls = "") => `<div class="grid ${cls}">${cells.join("")}</div>`;
606
607/* ===================== routes ===================== */
608function renderPopular(c) {
609  const rows = Q.genres().map((g) => {
610    // Drop disliked-genre albums; a row left with none (a disliked genre) disappears.
611    const albums = Q.popularInGenre(g.genre_id, 16).filter((a) => !Suppress.album(a.album_id));
612    if (!albums.length) return "";
613    return `<div class="row-head">${esc(g.name)}</div>
614      <div class="hrow">${albums.map(albumCell).join("")}</div>`;
615  });
616  c.innerHTML = `<div class="page-title">Popular</div>${rows.join("")}`;
617}
618
619// Sort choices for the Albums/Artists browse pages, shown in a select beside the filter box.
620const SORT_OPTIONS = [
621  { val: "popular", label: "Popular" },
622  { val: "alphabetical", label: "Alphabetical" },
623  { val: "recent", label: "Date" },   // "Date" = was "Recent"
624];
625
626// A browse page with a live client-side filter box above a grid/list, mirroring the iOS
627// Filter field. o = {title, count, items, cell, place, name, wrap, sortable?}.
628// sortable = {current, reload(sortVal)->items} adds a "Sort:" select to the right of the filter.
629function filterable(c, o) {
630  MAG._flt = o;
631  const sub = o.count != null ? ` <span class="section-sub">${o.count}</span>` : "";
632  // Two presentations of the same choice: the all-visible segmented control (wide) and a
633  // compact <select> (narrow/portrait, so it doesn't squeeze the filter box). CSS shows one.
634  const sortSel = o.sortable
635    ? `<div class="sortwrap">` +
636        `<div class="sortseg" role="group" aria-label="Sort">` +
637        SORT_OPTIONS.map((s) => `<button type="button" class="seg${s.val === o.sortable.current ? " on" : ""}" data-val="${s.val}" onclick="MAG.applySort('${s.val}')">${s.label}</button>`).join("") +
638        `</div>` +
639        `<select class="sortsel" aria-label="Sort" onchange="MAG.applySort(this.value)">` +
640        SORT_OPTIONS.map((s) => `<option value="${s.val}"${s.val === o.sortable.current ? " selected" : ""}>${s.label}</option>`).join("") +
641        `</select>` +
642      `</div>`
643    : "";
644  c.innerHTML =
645    `<div class="page-title">${o.title}${sub}</div>` +
646    `<div class="filterbar">` +
647      `<input class="searchbox" placeholder="${o.place}" oninput="MAG.applyFilter(this.value)" autocomplete="off">` +
648      sortSel +
649      (o.extra || "") +
650    `</div>` +
651    `<div class="${o.wrap}" id="flt-host"></div>`;
652  MAG.applyFilter("");
653}
654MAG.applyFilter = (s) => {
655  const o = MAG._flt; if (!o) return;
656  const ql = s.trim().toLowerCase();
657  const items = ql ? o.items.filter((it) => o.name(it).toLowerCase().includes(ql)) : o.items;
658  const host = el("flt-host"); if (host) host.innerHTML = items.map(o.cell).join("");
659  if (o.genre) refreshGenreOptions(ql);   // pare the genre dropdown to genres present in the text matches
660};
661// Rebuild the genre <select> to only the genres represented among items whose NAME matches
662// the current search text (over the whole catalog, not the genre-narrowed view). Empty text =
663// all genres. The active selection is always kept so it stays consistent with what's applied.
664function refreshGenreOptions(ql) {
665  const o = MAG._flt, gd = o && o.genre; if (!gd) return;
666  const sel = document.querySelector(".genresel"); if (!sel) return;
667  const cur = MAG[gd.stateKey] || "";
668  let allowed = null;
669  if (ql) {
670    allowed = new Set();
671    for (const it of gd.all) {
672      if (!o.name(it).toLowerCase().includes(ql)) continue;
673      const gs = gd.gByItem.get(gd.idOf(it)); if (gs) for (const g of gs) allowed.add(g);
674    }
675    if (cur) allowed.add(+cur);   // keep the active genre selectable even if its name doesn't match
676  }
677  const genres = Q.genres().filter((g) => !Suppress.genre(g.genre_id)).filter((g) => !allowed || allowed.has(g.genre_id));
678  sel.innerHTML = `<option value="">All Genres</option>` +
679    genres.map((g) => `<option value="${g.genre_id}"${String(g.genre_id) === String(cur) ? " selected" : ""}>${esc(g.name)}</option>`).join("");
680}
681// item_id -> Set(genre_id) maps for the genre dropdown's text-aware narrowing.
682function artistGenresMap() {
683  const m = new Map();
684  for (const r of q("select distinct al.artist_id aid, ga.genre_id gid from genres_albums ga join albums al on al.album_id=ga.album_id")) {
685    let s = m.get(r.aid); if (!s) m.set(r.aid, s = new Set()); s.add(r.gid);
686  }
687  return m;
688}
689function albumGenresMap() {
690  const m = new Map();
691  for (const r of q("select album_id aid, genre_id gid from genres_albums")) {
692    let s = m.get(r.aid); if (!s) m.set(r.aid, s = new Set()); s.add(r.gid);
693  }
694  return m;
695}
696MAG.applySort = (val) => {
697  const o = MAG._flt; if (!o || !o.sortable) return;
698  o.sortable.current = val;
699  document.querySelectorAll(".sortseg .seg").forEach((b) => b.classList.toggle("on", b.dataset.val === val));
700  const sel = document.querySelector(".filterbar .sortsel"); if (sel) sel.value = val;   // keep the dropdown in sync
701  o.items = o.sortable.reload(val);                 // re-query in the new order
702  const box = document.querySelector(".filterbar .searchbox");
703  MAG.applyFilter(box ? box.value : "");            // keep the current filter text applied
704};
705
706// Genre filter <select> for the Artists/Albums pages (right of the sort control);
707// "Genres" (value "") = all genres. `handler` is the MAG.* fn called onchange.
708function genreSelectHTML(gid, handler) {
709  const genres = Q.genres().filter((g) => !Suppress.genre(g.genre_id));
710  return `<select class="genresel" aria-label="Filter by genre" onchange="${handler}(this.value)">` +
711    `<option value="">All Genres</option>` +
712    genres.map((g) => `<option value="${g.genre_id}"${String(g.genre_id) === String(gid) ? " selected" : ""}>${esc(g.name)}</option>`).join("") +
713    `</select>`;
714}
715// All artists in the given sort order, optionally narrowed to one genre (gid; "" = all genres).
716function artistsInGenre(sort, gid) {
717  const all = Q.allArtists(sort);
718  if (!gid) return all;
719  const ids = new Set(Q.genreArtists(+gid).map((a) => a.artists_id));
720  return all.filter((a) => ids.has(a.artists_id));
721}
722function renderArtists(c) {
723  const sort = MAG._sortArtist || "popular";
724  const gid = MAG._artistGenre || "";
725  const items = artistsInGenre(sort, gid);
726  filterable(c, { title: "Artists", count: items.length, items, cell: artistRow, place: "Filter artists", name: (x) => x.name, wrap: "artist-list",
727    sortable: { current: sort, reload: (s) => { MAG._sortArtist = s; return artistsInGenre(s, MAG._artistGenre || ""); } },
728    extra: genreSelectHTML(gid, "MAG.applyArtistGenre"),
729    genre: { all: Q.allArtists("alphabetical"), gByItem: artistGenresMap(), idOf: (a) => a.artists_id, stateKey: "_artistGenre" } });
730}
731MAG.applyArtistGenre = (gid) => {
732  MAG._artistGenre = gid;
733  const o = MAG._flt; if (!o) return;
734  o.items = artistsInGenre(MAG._sortArtist || "popular", gid);
735  const sub = document.querySelector(".page-title .section-sub"); if (sub) sub.textContent = o.items.length;
736  const box = document.querySelector(".filterbar .searchbox");
737  MAG.applyFilter(box ? box.value : "");   // keep any filter text applied to the new genre subset
738};
739
740// All albums in the given sort order, optionally narrowed to one genre (gid; "" = all genres).
741function albumsInGenre(sort, gid) {
742  const all = Q.allAlbums(sort);
743  if (!gid) return all;
744  const ids = new Set(q("select album_id from genres_albums where genre_id=?", [+gid]).map((r) => r.album_id));
745  return all.filter((a) => ids.has(a.album_id));
746}
747function renderAlbums(c) {
748  const sort = MAG._sortAlbum || "popular";
749  const gid = MAG._albumGenre || "";
750  const items = albumsInGenre(sort, gid);
751  filterable(c, { title: "Albums", count: items.length, items, cell: albumCell, place: "Filter albums", name: (x) => x.name, wrap: "grid",
752    sortable: { current: sort, reload: (s) => { MAG._sortAlbum = s; return albumsInGenre(s, MAG._albumGenre || ""); } },
753    extra: genreSelectHTML(gid, "MAG.applyAlbumGenre"),
754    genre: { all: Q.allAlbums("alphabetical"), gByItem: albumGenresMap(), idOf: (a) => a.album_id, stateKey: "_albumGenre" } });
755}
756MAG.applyAlbumGenre = (gid) => {
757  MAG._albumGenre = gid;
758  const o = MAG._flt; if (!o) return;
759  o.items = albumsInGenre(MAG._sortAlbum || "popular", gid);
760  const sub = document.querySelector(".page-title .section-sub"); if (sub) sub.textContent = o.items.length;
761  const box = document.querySelector(".filterbar .searchbox");
762  MAG.applyFilter(box ? box.value : "");
763};
764
765// FontAwesome 7 equivalents of the iOS app's per-genre SF Symbols (genreIcon in BrowseViews.swift)
766const GENRE_ICON = {
767  "Classical": "fa-piano",        // ios: pianokeys
768  "New Age": "fa-moon",           // ios: moon.stars
769  "Electronica": "fa-waveform",   // ios: waveform
770  "World": "fa-globe",            // ios: globe
771  "Ambient": "fa-wind",           // ios: wind
772  "Jazz": "fa-music",             // ios: music.quarternote.3
773  "Hip Hop": "fa-microphone",     // ios: music.mic
774  "Alt Rock": "fa-guitar",        // ios: guitars
775  "Electro Rock": "fa-bolt",      // ios: bolt
776  "Hard Rock": "fa-fire",         // ios: flame
777};
778const genreIcon = (name) => GENRE_ICON[name] || "fa-music";
779// "Play" + "Shuffle" buttons for a whole category (genre or tag), given a registered
780// track list id. Renders nothing if the list is empty (all disliked / no songs).
781function catPlay(lid) {
782  const n = (MAG.lists[lid] || []).length;
783  if (!n) return "";
784  return `<div class="cat-play">` +
785    `<button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button>` +
786    `<button class="playall ghost" onclick="MAG.shuffleList('${lid}')"><i class="fa-solid fa-shuffle"></i> Shuffle</button>` +
787    `<span class="cat-count">${n} song${n === 1 ? "" : "s"}</span></div>`;
788}
789// Quick-play button embedded in a category ROW (the genre/tag list). Plays the whole
790// category ranked; MAG.playCat stops the surrounding <a> from navigating.
791function rowPlay(kind, id) {
792  return `<button class="rowplay" title="Play" onclick="MAG.playCat(event,'${kind}',${id})"><i class="fa-solid fa-circle-play"></i></button>`;
793}
794function renderGenres(c) {
795  const g = Q.genres().filter((x) => !Suppress.genre(x.genre_id));
796  const counts = liveCounts("select genre_id, album_id from genres_albums", "genre_id", "album");
797  c.innerHTML = `<div class="page-title">Genres</div><div class="list">` +
798    g.map((x) => `<a href="${genreUrl(x.name)}"><span class="g-label"><i class="fa-solid ${genreIcon(x.name)}"></i>${esc(x.name)}</span><span class="cnt">${counts.get(x.genre_id) || 0}</span>${dislikeBtn("genre", x.genre_id)}${rowPlay("genre", x.genre_id)}</a>`).join("") +
799    `</div>`;
800}
801function renderGenre(c, id) {
802  const g = q1("select name from genres where genre_id=?", [id]);
803  const artists = Q.genreArtists(id);
804  const lid = registerList(Q.genreSongs(id).map((r) => trk(r)));
805  c.innerHTML = `<div class="page-title">${esc(g ? g.name : "Genre")}</div>` +
806    catPlay(lid) +
807    `<div class="section-sub" style="margin-top:-.8rem;margin-bottom:1rem">${artists.length} artists</div>` +
808    artistList(artists);
809}
810
811function renderTags(c) {
812  const t = Q.collections().filter((x) => !Suppress.tag(x.collections_id));
813  const counts = liveCounts("select collection_id, album_id from collections_albums", "collection_id", "album");
814  const cell = (x) => `<a href="${tagUrl(x.name)}"><span class="rlbl">${esc(x.name)}</span><span class="cnt">${counts.get(x.collections_id) || 0}</span>${rowPlay("tag", x.collections_id)}</a>`;
815  filterable(c, { title: "Tags", count: t.length, items: t, cell, place: "Filter tags", name: (x) => x.name, wrap: "list" });
816}
817function renderTag(c, id) {
818  const t = q1("select name from collections where collections_id=?", [id]);
819  const albums = Q.collectionAlbums(id);
820  const lid = registerList(Q.collectionSongs(id).map((r) => trk(r)));
821  c.innerHTML = `<div class="page-title">${esc(t ? t.name : "Tag")}</div>` + catPlay(lid) + grid(albums.map(albumCell));
822}
823
824function renderFeatured(c) {
825  const p = Q.featured().filter((x) => !Suppress.playlist(x.playlist_id));
826  const counts = liveCounts("select ps.playlist_id, ps.song_id from playlist_songs ps join playlists p on p.playlist_id=ps.playlist_id where p.owner_id=0", "playlist_id", "song");
827  c.innerHTML = `<div class="page-title">Featured</div>` +
828    `<div class="section-sub" style="margin-top:-.8rem;margin-bottom:1rem">Curated playlists</div>` +
829    `<div class="list">` +
830    p.map((x) => `<a href="${featuredUrl(x.name)}"><span class="rlbl">${esc(x.name)}</span><span class="cnt">${counts.get(x.playlist_id) || 0}</span>${rowPlay("featured", x.playlist_id)}</a>`).join("") +
831    `</div>`;
832}
833function renderFeaturedPlaylist(c, id) {
834  const p = q1("select name from playlists where playlist_id=?", [id]);
835  const songs = Q.playlistSongs(id);
836  const lid = registerList(songs.map((s) => trk(s)));
837  c.innerHTML = `<div class="page-title">${esc(p ? p.name : "Playlist")}</div>` +
838    `<div class="pl-actions"><button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button></div>` +
839    songsHTML(lid, true);
840}
841
842// US state / territory postal codes → full names, so the artist "based in" line reads
843// "Berkeley, California, USA" rather than "Berkeley, CA, USA" (matches the old site's
844// state_name_short_to_long). Unknown/non-US values pass through unchanged.
845const US_STATES = {
846  AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", CA: "California",
847  CO: "Colorado", CT: "Connecticut", DE: "Delaware", DC: "District of Columbia",
848  FL: "Florida", GA: "Georgia", HI: "Hawaii", ID: "Idaho", IL: "Illinois",
849  IN: "Indiana", IA: "Iowa", KS: "Kansas", KY: "Kentucky", LA: "Louisiana",
850  ME: "Maine", MD: "Maryland", MA: "Massachusetts", MI: "Michigan", MN: "Minnesota",
851  MS: "Mississippi", MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada",
852  NH: "New Hampshire", NJ: "New Jersey", NM: "New Mexico", NY: "New York",
853  NC: "North Carolina", ND: "North Dakota", OH: "Ohio", OK: "Oklahoma", OR: "Oregon",
854  PA: "Pennsylvania", RI: "Rhode Island", SC: "South Carolina", SD: "South Dakota",
855  TN: "Tennessee", TX: "Texas", UT: "Utah", VT: "Vermont", VA: "Virginia",
856  WA: "Washington", WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
857  PR: "Puerto Rico", GU: "Guam", VI: "U.S. Virgin Islands", AS: "American Samoa",
858  MP: "Northern Mariana Islands",
859};
860const stateLong = (s) => { const t = (s || "").trim(); return t ? (US_STATES[t.toUpperCase()] || t) : ""; };
861
862function renderArtist(c, id) {
863  const ar = Q.artist(id);
864  if (!ar) { c.innerHTML = `<div class="pad">Artist not found.</div>`; return; }
865  const albums = Q.artistAlbums(id).map((al) => ({ ...al, artist: ar.name }));
866  const aIds = Q.artistSongIds(id);
867  const alid = registerList(orderByIds(Q.songsByIds(aIds), "song_id", aIds).map((r) => trk(r)));
868  const photo = albums.length ? imgTag(ar.name, albums[0].name, ARTIST.detail, { eager: true }) : "";
869  // Show the full artist bio (wave-2 data); fall back to the short genre tagline until it loads
870  // or when an artist has none. (Was description-only, so the rich bios never appeared.)
871  const blurb = ar.bio || ar.description || "";
872  // Where the artist is based — restored from the catalog (city/state/country); the old site
873  // showed this. Empty parts are dropped, so it shows "Paris, France" or "Berkeley, CA, USA"
874  // and nothing at all when the catalog has no location (older dumps carry none).
875  const locParts = [ar.city, stateLong(ar.state), ar.country].map((s) =>
875 (s || "").trim()).filter(Boolean);
876  const locHtml = locParts.length
877    ? `<div class="artist-loc"><i class="fa-solid fa-location-dot"></i> ${esc(locParts.join(", "))}</div>` : "";
878  const chips =
879    Q.artistGenres(id).map((g) => `<a class="chip" href="${genreUrl(g.name)}">${esc(g.name)}</a>`).join("") +
880    Q.artistTags(id).map((t) => `<a class="chip" href="${tagUrl(t.name)}">${esc(t.name)}</a>`).join("");
881  c.innerHTML =
882    `<div class="detail-head">
883       <div class="art round"${albums.length ? ` data-zoom="artist" data-artist="${esc(ar.name)}" data-album="${esc(albums[0].name)}"` : ""}>${photo}</div>
884       <div class="meta">
885         <div class="kind">Artist</div><h1>${esc(ar.name)}</h1>
886         ${locHtml}
887         <div class="detail-actions">
888           <button class="playall" data-list="${alid}" onclick="MAG.playFrom('${alid}',0)">${playLabel(alid)}</button>
889           ${favBtn("artist", id)}
890           ${dislikeBtn("artist", id)}
891           <button class="addbtn big" title="${aIds.some((sid) => UserData.playlistedSongs().has(sid)) ? "Remove songs from playlists" : "Add all songs to playlist"}" onclick="MAG.addArtist(event,${id})">${addIcon(aIds.some((sid) => UserData.playlistedSongs().has(sid)))}</button>
892         </div>
893       </div>
894     </div>` +
895    descBlock(blurb) +
896    (chips ? `<div class="chips">${chips}</div>` : "") +
897    `<div class="row-head">Albums</div>` + grid(albums.map(albumCell)) +
898    recArtistsHTML(id);
899}
900
901// "You might also like" for an artist page — other artists most often recommended across this
902// artist's albums, rendered as a horizontal scroll row (matching the album page; empty when none).
903function recArtistsHTML(id) {
904  const recs = Q.artistRecArtists(id);
905  return recs.length
906    ? `<br><div class="row-head">You might also like</div><div class="hrow rec-artists">${recs.map(artistCell).join("")}</div>`
907    : "";
908}
909
910// "You might also like" for an album page — the recommendation engine's ranked sibling albums,
911// rendered as a horizontal scroll row (empty string when none).
912function recAlbumsHTML(id) {
913  const recs = Q.albumRecs(id);
914  return recs.length
915    ? `<br><div class="row-head">You might also like</div><div class="hrow">${recs.map(albumCell).join("")}</div>`
916    : "";
917}
918
919function renderAlbum(c, id, songSlug) {
920  const al = Q.album(id);
921  if (!al) { c.innerHTML = `<div class="pad">Album not found.</div>`; return; }
922  const songs = Q.albumSongs(id);
923  const lid = registerList(songs.map((s) => trk(s, al.artist, al.name, al.album_id)));
924  const genres = Q.albumGenres(id);
925  const tags = Q.albumTags(id);
926  const year = al.release_date ? new Date(al.release_date * 1000).getFullYear() : "";
927  const chips =
928    genres.map((g) => `<a class="chip" href="${genreUrl(g.name)}">${esc(g.name)}</a>`).join("") +
929    tags.map((t) => `<a class="chip" href="${tagUrl(t.name)}">${esc(t.name)}</a>`).join("");
930  c.innerHTML =
931    `<div class="detail-head">
932       <div class="art" data-zoom="cover" data-artist="${esc(al.artist)}" data-album="${esc(al.name)}">
933         ${imgTag(al.artist, al.name, COVER.detail, { eager: true })}</div>
934       <div class="meta">
935         <div class="kind">Album</div><h1>${esc(al.name)}</h1>
936         <div class="by">by <a href="${artistUrl(al.artist)}">${esc(al.artist)}</a></div>
937         ${year ? `<div class="yr">${year} · ${songs.length} tracks</div>` : ""}
938         <div class="chips">${chips}</div>
939         <div class="detail-actions">
940           <button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button>
941           ${favBtn("album", al.album_id)}
942           ${dislikeBtn("album", al.album_id)}
943           ${dlAlbumBtn(al.sku, true)}
944           <button class="addbtn big" title="${songs.some((s) => UserData.playlistedSongs().has(s.song_id)) ? "Remove album from playlists" : "Add album to playlist"}" onclick="MAG.addAlbum(event,${al.album_id})">${addIcon(songs.some((s) => UserData.playlistedSongs().has(s.song_id)))}</button>
945         </div>
946       </div>
947     </div>` +
948    descBlock(al.description) +
949    songsHTML(lid, false) +
950    recAlbumsHTML(id);
951  // Deep link to a specific song (#/album/<artist>/<album>/<song>): scroll that song into
952  // view and flash-highlight it (e.g. arriving from a search result). Does NOT auto-play —
953  // the user lands on the album with the song clearly marked and can press play.
954  if (songSlug) {
955    const hit = (MAG.lists[lid] || []).find((t) => slug(t.title) === songSlug);
956    if (hit) {
957      setTimeout(() => {
958        const row = c.querySelector(`.song-row[data-sid="${hit.songId}"]`);
959        if (row) {
960          row.scrollIntoView({ block: "center", behavior: "smooth" });
961          row.classList.add("hl");
962          setTimeout(() => row.classList.remove("hl"), 2200);
963        }
964      }, 0);
965    }
966  }
967}
968
969/* ---- search ---- */
970let searchTimer = null;
971function renderSearch(c, query) {
972  query = (query || "").trim();
973  c.innerHTML =
974    `<div class="page-title">Search</div>
975     <input class="searchbox" id="q" type="search" placeholder="Search artists, albums, songs…" autocomplete="off">
976     <div id="results"><div class="pad">Type to search the catalog.</div></div>`;
977  const input = el("q");
978  input.value = query;
979  input.focus();
980  // Reflect the query into the URL (#/search/<words>) so searches are linkable, but via
981  // replaceState — assigning location.hash would fire hashchange → re-render → lose focus.
982  const run = () => {
983    const v = input.value.trim();
984    setHash(v ? "#/search/" + encodeURIComponent(v) : "#/search");
985    doSearch(v);
986  };
987  input.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(run, 160); });
988  if (query) doSearch(query);
989}
990// Compact search-result rows (small artist photo / album cover thumbnail + name), mirroring
991// the iOS search list.
992function searchArtistRow(ar) {
993  if (Suppress.artist(ar.artists_id)) return "";
994  return `<a class="sresult" href="${artistUrl(ar.name)}"><span class="sr-thumb round">${imgTag(ar.name, ar.album, ARTIST.cell)}</span><span class="sr-name">${esc(ar.name)}</span></a>`;
995}
996function searchAlbumRow(al) {
997  if (Suppress.album(al.album_id)) return "";
998  // Album name + thumbnail link to the album; the artist name is a chip linking to the
999  // artist (same style as the artist chip in the song rows). Separate links → the row is a
1000  // div, not a nested anchor.
1001  return `<div class="sresult">` +
1002    `<a class="sr-main" href="${albumUrl(al.artist, al.name)}"><span class="sr-thumb">${imgTag(al.artist, al.name, COVER.cell)}</span><span class="sr-name">${esc(al.name)}</span></a>` +
1003    `<a class="subchip" href="${artistUrl(al.artist)}"><i class="fa-solid fa-user"></i>${esc(al.artist)}</a>` +
1004    `</div>`;
1005}
1006function doSearch(t) {
1007  const r = el("results");
1008  if (!r) return;
1009  if (t.length < 2) { r.innerHTML = `<div class="pad">Type to search the catalog.</div>`; return; }
1010  const artists = Q.searchArtists(t), albums = Q.searchAlbums(t), songs = Q.searchSongs(t);
1011  let html = "";
1012  if (artists.length) html += `<div class="row-head">Artists</div><div class="sresults">` + artists.map(searchArtistRow).join("") + `</div>`;
1013  if (albums.length) html += `<div class="row-head">Albums</div><div class="sresults">` + albums.map(searchAlbumRow).join("") + `</div>`;
1014  if (songs.length) html += `<div class="row-head">Songs</div>` + songsHTML(registerList(songs.map((s) => trk(s))), true, true);
1015  r.innerHTML = html || `<div class="pad">No matches for “${esc(t)}”.</div>`;
1016}
1017
1018// Dedicated Songs page: a song-title search over the whole catalog (~24,000 tracks),
1019// mirroring the native iOS "Songs" browse section. Separate from the combined Search.
1020function renderSongs(c, query) {
1021  query = (query || "").trim();
1022  c.innerHTML =
1023    `<div class="page-title">Songs</div>
1024     <input class="searchbox" id="sq" type="search" placeholder="Search songs by title…" autocomplete="off">
1025     <div id="songresults"><div class="pad">There are ~24,000 tracks. Type to search by title.</div></div>`;
1026  const input = el("sq");
1027  input.value = query;
1028  input.focus();
1029  const run = () => {
1030    const v = input.value.trim();
1031    setHash(v ? "#/songs/" + encodeURIComponent(v) : "#/songs");
1032    doSongSearch(v);
1033  };
1034  input.addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(run, 160); });
1035  if (query) doSongSearch(query);
1036}
1037function doSongSearch(t) {
1038  const r = el("songresults");
1039  if (!r) return;
1040  if (t.length < 2) { r.innerHTML = `<div class="pad">There are ~24,000 tracks. Type to search by title.</div>`; return; }
1041  const songs = Q.searchSongs(t);
1042  r.innerHTML = songs.length
1043    ? songsHTML(registerList(songs.map((s) => trk(s))), true, true)
1044    : `<div class="pad">No songs match “${esc(t)}”.</div>`;
1045}
1046
1047/* ---- full-screen cover zoom ---- */
1048function openZoomImage(artist, album, file) {
1049  const d = document.createElement("div");
1050  d.className = "zoom";
1051  d.innerHTML = imgTag(artist, album, file, { eager: true });
1052  d.addEventListener("click", () => d.remove());
1053  document.body.appendChild(d);
1054}
1055// Delegated: click any [data-zoom] artwork → full-size overlay (works for all names,
1056// incl. apostrophes/ampersands, since the values come from safe data attributes).
1057document.addEventListener("click", (e) => {
1058  const z = e.target.closest("[data-zoom]");
1059  if (!z) return;
1060  const artist = z.dataset.artist, album = z.dataset.album;
1061  if (!artist || !album) return;
1062  const file = z.dataset.zoom === "artist" ? "artist_840.jpg" : COVER.zoom;
1063  openZoomImage(artist, album, file);
1064});
1065
1066/* ===================== audio / player ===================== */
1067// Ordered stem suffixes to try for the current track, best first. Members on "High" get
1068// the 192k Opus (<stem>_hi.opus); if it isn't available — album not yet encoded, or a
1069// browser that can't play Opus — it falls straight through to the Normal member stream
1070// (96k Opus <stem>.opus / 185k AAC <stem>.m4a), then the free announcement file
1071// (<stem>_spoken.*). Non-members only get the free file. The player walks this list on
1072// <audio> error, so a missing tier steps down safely (see the error handler).
1073// Opus-first for everyone; fall back to AAC only if playback actually fails. We deliberately
1074// do NOT gate on canPlayType('audio/ogg; codecs=opus') — Safari reports "" for Ogg Opus even
1075// on versions that can play it (the same false-negative we hit with AVFoundation on the native
1076// apps), so a capability sniff wrongly demotes Safari to AAC. Instead every browser tries Opus;
1077// if a track can't decode, the <audio> error handler walks down to AAC AND sets this flag, so
1078// the rest of the session skips Opus (no wasted per-track attempts on Opus-less browsers).
1079let OPUS_DISABLED = false;
1080// Ordered fallback chain of {s:suffix, e:extension}. The <audio> error handler walks it
1081// on failure, so a missing tier (e.g. an album not yet Opus-encoded) steps down safely:
1082// opus-normal -> aac-normal -> free advert. Non-members only ever get the free advert AAC;
1083// the free _spoken file is already a small 51k AAC, so it stays AAC (Opus would be bigger).
1084function streamChain() {
1085  const member = UserData.member && UserData.member.loggedIn;
1086  const opus = !OPUS_DISABLED;
1087  if (!member) {
1088    // Non-members: 40k Opus advert (equal quality to the 51k AAC, smaller) first; fall back to
1089    // the AAC advert if Opus won't play or the album isn't encoded yet.
1090    return opus
1091      ? [{ s: "_spoken", e: ".opus" }, { s: "_spoken", e: ".m4a" }]
1092      : [{ s: "_spoken", e: ".m4a" }];
1093  }
1094  const chain = [];
1095  // "High" tier (stored value still "lossless") = 192k Opus (near-transparent). If that file
1096  // isn't there for this album it falls to Normal Opus (96k), then AAC, then the advert. If the
1097  // browser can't decode Opus at all, every Opus entry fails and the error handler disables
1098  // Opus for the session (see makeEl), so later tracks skip straight to AAC.
1099  if (Player.quality === "lossless" && opus) chain.push({ s: "_hi", e: ".opus" });  // 192k Opus — High
1100  if (opus) chain.push({ s: "", e: ".opus" });           // 96k Opus member stream (Normal)
1101  chain.push({ s: "", e: ".m4a" });                      // 185k AAC (Opus-less browsers / missing opus)
1102  if (opus) chain.push({ s: "_spoken", e: ".opus" });    // 40k Opus advert (last resort)
1103  chain.push({ s: "_spoken", e: ".m4a" });               // AAC advert (final fallback)
1104  return chain;
1105}
1106// Opaque per-session token the server hands to real clients at startup. It is
1107// appended to stream URLs (as ?k=) purely so play logging can tell genuine
1108// playback from a crawler fetching a /music URL directly — a bot never asks
1109// for one. Best-effort: if it's missing, audio streams exactly as before, the
1110// play just isn't counted. Never gate playback on it.
1111let PLAY_TOKEN = "";
1112async function fetchPlayToken() {
1113  try {
1114    const r = await fetch("/api/playtoken", { cache: "no-store" });
1115    if (!r.ok) return;
1116    const j = await r.json();
1117    if (j && j.k) PLAY_TOKEN = j.k;
1118  } catch (e) { /* ignore — logging must never break the player */ }
1119}
1120
1121// Build a media URL for a specific chain entry {s,e}.
1122function mediaURL(t, entry) {
1123  const stem = (t.mp3 || "").replace(/\.mp3$/i, "");
1124  const file = stem + entry.s + entry.e;
1125  return MEDIA_BASE + encodeURIComponent(t.artist) + "/" + encodeURIComponent(t.album) + "/" + encodeURIComponent(file)
1126       + (PLAY_TOKEN ? "?k=" + PLAY_TOKEN : "");
1127}
1128function streamURL(t, idx) {
1129  const chain = streamChain();
1130  return mediaURL(t, chain[Math.min(idx || 0, chain.length - 1)]);
1131}
1132// Human label of the audio the player is CURRENTLY streaming, read from the live chain entry
1133// so it updates as the fallback steps down: "Opus · 192 kbps" / "Opus · 96 kbps" /
1134// "AAC · 185 kbps", or "… · preview" for the free advert. Shown under the title in Now Playing.
1135function streamFormatLabel() {
1136  const c = Player._chain && Player._chain[Player._fallback];
1137  if (!c) return "";
1138  const codec = c.e === ".opus" ? "Opus" : "AAC";
1139  if (c.s === "_spoken") return codec + " · preview";
1140  const kbps = c.s === "_hi" ? 192 : (c.e === ".opus" ? 96 : 185);
1141  return codec + " · " + kbps + " kbps";
1142}
1143
1144const REPEAT_CACHE_MAX = 30;   // max tracks held in the in-memory <audio> cache while repeat is on
1145const Player = {
1146  audio: null,        // the currently-ACTIVE <audio> element (drives the UI / transport)
1147  primary: null,      // single reusable element used when repeat is OFF
1148  pool: null,         // streamURL -> a retained, fully-buffered <audio> (the repeat cache)
1149  poolOrder: null,    // LRU order of pool keys
1150  queue: [], index: -1,
1151  _chain: [],         // fallback-chain snapshot for the CURRENT track — stable across its
1152                      // per-track error walk even if OPUS_DISABLED flips mid-track
1153  shuffle: localStorage.getItem("shuffle.enabled") === "1",
1154  repeat: localStorage.getItem("magnatune.repeat") === "1",
1155  volume: localStorage.getItem("magnatune.volume") == null ? 1 : +localStorage.getItem("magnatune.volume"),
1156  quality: localStorage.getItem("magnatune.quality") === "lossless" ? "lossless" : "normal",
1157  _fallback: 0,
1158  // Build a fully-wired <audio>. Primary + every pool element share the same listeners,
1159  // but each only drives the UI / transport while it is the ACTIVE element — so the
1160  // background-preloading pool elements never fire spurious ended/error transitions.
1161  makeEl() {
1162    const a = new Audio();
1163    a.preload = "auto";   // buffer the whole (small) file so repeat loops replay from memory
1164    a.volume = this.volume;
1165    a.setAttribute("x-webkit-airplay", "allow");
1166    a.addEventListener("timeupdate", () => { if (a === this.audio) playerTick(); });
1167    a.addEventListener("durationchange", () => { if (a === this.audio) playerTick(); });
1168    a.addEventListener("play", () => { if (a === this.audio) { updatePlayPauseUI(); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing"; } });
1169    a.addEventListener("pause", () => { if (a === this.audio) { updatePlayPauseUI(); if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused"; } });
1170    a.addEventListener("ended", () => { if (a === this.audio) this.next(); });
1171    a.addEventListener("error", () => {
1172      if (a !== this.audio) return;   // ignore errors from background-preloading pool elements
1173      // Step down this track's (snapshotted) fallback chain: High Opus -> Normal Opus -> AAC ->
1174      // advert. Crossing from the last Opus entry to a non-Opus one means every Opus stream
1175      // failed → this browser can't decode Opus, so disable it for the rest of the session
1176      // (later tracks then skip straight to AAC — no wasted attempts).
1177      const t = this.current();
1178      const chain = (this._chain && this._chain.length) ? this._chain : streamChain();
1179      const failed = this._fallback, next = failed + 1;
1180      if (t && next < chain.length) {
1181        if (chain[failed].e === ".opus" && chain[next].e !== ".opus") OPUS_DISABLED = true;
1182        this._fallback = next;
1183        a.src = mediaURL(t, chain[next]);
1184        a.play().catch(() => {});
1185        if (MAG.nowPlayingOpen) { const fe = el("np-format"); if (fe) fe.textContent = streamFormatLabel(); }
1186      } else console.warn("audio error", a.error && a.error.code);
1187    });
1188    return a;
1189  },
1190  init() {
1191    this.pool = new Map();
1192    this.poolOrder = [];
1193    this.primary = this.makeEl();
1194    this.audio = this.primary;
1195    setupMediaSession();
1196  },
1197  current() { return this.index >= 0 ? this.queue[this.index] : null; },
1198  eachEl(fn) { if (this.primary) fn(this.primary); if (this.pool) this.pool.forEach(fn); },
1199  play(tracks, startAt = 0) {
1200    if (!tracks || !tracks.length) return;
1201    let qq = tracks.slice(), start = startAt;
1202    if (this.shuffle) {
1203      const chosen = qq[startAt];
1204      qq = qq.filter((_, i) => i !== startAt);
1205      for (let i = qq.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [qq[i], qq[j]] = [qq[j], qq[i]]; }
1206      qq.unshift(chosen); start = 0;
1207    }
1208    this.queue = qq;
1209    this.resetCache();                 // a NEW thing was pressed Play on → drop the old repeat cache
1210    this.playAt(start);
1211    if (this.repeat) this.warmCache();  // pre-buffer the whole queue so the loop needs no network
1212  },
1213  // Pick the <audio> for track t: a retained, pre-buffered pool element when repeat is on
1214  // (so loops replay from memory with NO network), else the single primary element.
1215  elementFor(t) {
1216    if (!this.repeat) return this.primary;
1217    const url = streamURL(t, 0);
1218    let a = this.pool.get(url);
1219    if (!a) { a = this.makeEl(); a.src = url; this.pool.set(url, a); this.poolOrder.push(url); this.trimPool(); }
1220    else { const k = this.poolOrder.indexOf(url); if (k >= 0) { this.poolOrder.splice(k, 1); this.poolOrder.push(url); } }
1221    return a;
1222  },
1223  playAt(i) {
1224    if (i < 0 || i >= this.queue.length) return;
1225    this.index = i;
1226    this._fallback = 0;
1227    this._chain = streamChain();                             // snapshot for this track's error walk
1228    const t = this.queue[i];
1229    UserData.recordPlay(t.songId);   // add to "Recently Played" as it starts
1230    const a = this.elementFor(t);
1231    if (a === this.primary) a.src = mediaURL(t, this._chain[0]);   // primary: (re)point at this track
1232    if (this.audio && this.audio !== a) { try { this.audio.pause(); } catch (e) {} }
1233    this.audio = a;
1234    a.volume = this.volume;
1235    try { a.currentTime = 0; } catch (e) {}                   // pool element: restart its buffer (no refetch)
1236    a.play().catch((e) => console.warn("playback blocked:", e.message));
1237    renderMiniPlayer(); setMediaMetadata(t); highlightPlayingRow();
1238    syncSongInUrl(t);   // reflect the song in the URL when viewing its album page
1239    if (MAG.nowPlayingOpen) renderNowPlaying();
1240  },
1241  toggle() { this.audio.paused ? this.audio.play().catch(() => {}) : this.audio.pause(); },
1242  next() {
1243    if (this.index < this.queue.length - 1) this.playAt(this.index + 1);
1244    else if (this.repeat && this.queue.length) this.playAt(0);   // loop the whole queue forever
1245    else this.audio.pause();
1246  },
1247  prev() { if (this.audio.currentTime > 3 || this.index <= 0) this.audio.currentTime = 0; else this.playAt(this.index - 1); },
1248  seekFrac(f) { if (isFinite(this.audio.duration)) this.audio.currentTime = f * this.audio.duration; },
1249  setShuffle(on) { this.shuffle = on; localStorage.setItem("shuffle.enabled", on ? "1" : "0"); updateShuffleUI(); },
1250  setRepeat(on) {
1251    this.repeat = on; localStorage.setItem("magnatune.repeat", on ? "1" : "0"); updateRepeatUI();
1252    if (on) this.warmCache(); else this.resetCache();
1253  },
1254  // Pre-buffer every queue track (capped) into retained <audio> elements, so that once the
1255  // queue loops the audio comes entirely from memory — no further network fetches.
1256  warmCache() {
1257    if (!this.repeat || !this.queue.length) return;
1258    this.queue.slice(0, REPEAT_CACHE_MAX).forEach((t) => {
1259      const url = streamURL(t, 0);
1260      if (!this.pool.has(url)) { const a = this.makeEl(); a.src = url; this.pool.set(url, a); this.poolOrder.push(url); }
1261    });
1262    this.trimPool();
1263  },
1264  trimPool() {
1265    while (this.poolOrder.length > REPEAT_CACHE_MAX) {
1266      const url = this.poolOrder.shift();
1267      const a = this.pool.get(url);
1268      if (a && a !== this.audio) { try { a.pause(); a.removeAttribute("src"); a.load(); } catch (e) {} this.pool.delete(url); }
1269      else if (a) { this.poolOrder.push(url); break; }   // never evict the active element
1270    }
1271  },
1272  resetCache() {
1273    if (!this.pool) return;
1274    this.pool.forEach((a) => { if (a !== this.audio) { try { a.pause(); a.removeAttribute("src"); a.load(); } catch (e) {} } });
1275    this.pool = new Map(); this.poolOrder = [];
1276    if (this.audio && this.audio !== this.primary) {   // keep whatever is currently playing alive
1277      const cur = this.current(); if (cur) { const u = streamURL(cur, 0); this.pool.set(u, this.audio); this.poolOrder.push(u); }
1278    }
1279  },
1280  setVolume(v) { this.volume = Math.max(0, Math.min(1, v)); this.eachEl((a) => { a.volume = this.volume; }); localStorage.setItem("magnatune.volume", this.volume); },
1281  setQuality(q) {
1282    this.quality = q === "lossless" ? "lossless" : "normal";
1283    localStorage.setItem("magnatune.quality", this.quality);
1284    this.resetCache();                 // tier change → cached stream URLs are stale
1285    // Re-load the current track at the new tier, keeping playback position.
1286    const t = this.current();
1287    if (!t || !this.audio || !this.audio.src) return;
1288    const a = this.audio, pos = a.currentTime || 0, wasPlaying = !a.paused;
1289    this._fallback = 0;
1290    const onMeta = () => { try { a.currentTime = pos; } catch (e) {} if (wasPlaying) a.play().catch(() => {}); a.removeEventListener("loadedmetadata", onMeta); };
1291    a.addEventListener("loadedmetadata", onMeta);
1292    a.src = streamURL(t, 0);
1293    a.load();
1294    if (this.repeat) this.warmCache();
1295  },
1296};
1297MAG.playFrom = (listId, i) => { const ts = MAG.lists[listId]; if (ts) Player.play(ts, i); };
1298// Shuffle-play a whole registered list, regardless of the global shuffle toggle
1299// (pre-shuffles a copy so "Shuffle" always shuffles and the user's setting is untouched).
1300MAG.shuffleList = (listId) => {
1301  const ts = MAG.lists[listId]; if (!ts || !ts.length) return;
1302  const a = ts.slice();
1303  for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
1304  Player.play(a, 0);
1305};
1306// Quick-play a category (genre/tag) straight from its list row, ranked by popularity.
1307MAG.playCat = (e, kind, id) => {
1308  e.preventDefault(); e.stopPropagation();
1309  if (kind === "artist") { MAG.playArtist(id); return; }   // all of an artist's songs, ranked
1310  const rows = kind === "genre" ? Q.genreSongs(id) : kind === "featured" ? Q.playlistSongs(id) : Q.collectionSongs(id);
1311  const ts = MAG.lists[registerList(rows.map((r) => trk(r)))];
1312  if (ts && ts.length) Player.play(ts, 0);
1313};
1314
1315/* ---- mini-player ---- */
1316function renderMiniPlayer() {
1317  const t = Player.current(), mp = el("miniplayer");
1318  if (!mp) return;
1319  if (!t) { mp.hidden = true; return; }
1320  mp.hidden = false;
1321  el("mp-cover").innerHTML = imgTag(t.artist, t.album, COVER.cell, { eager: true });
1322  el("mp-title").textContent = t.title;
1323  el("mp-artist").textContent = t.artist;
1324  updatePlayPauseUI(); updateShuffleUI(); updateRepeatUI();
1325}
1326function playerTick() {
1327  const a = Player.audio, seek = el("mp-seek");
1328  if (!seek) return;
1329  const frac = (a.duration && isFinite(a.duration)) ? a.currentTime / a.duration : 0;
1330  seek.value = Math.round(frac * 1000);
1331  el("mp-cur").textContent = fmtDur(a.currentTime);
1332  el("mp-dur").textContent = isFinite(a.duration) ? fmtDur(a.duration) : fmtDur((Player.current() || {}).duration || 0);
1333  if (MAG.nowPlayingOpen) npTick();
1334}
1335function updatePlayPauseUI() {
1336  const icon = `<i class="fa-solid ${Player.audio.paused ? "fa-play" : "fa-pause"}"></i>`;
1337  const b = el("mp-playpause"); if (b) b.innerHTML = icon;
1338  const nb = el("np-playpause"); if (nb) nb.innerHTML = icon;
1339  syncPlayButtons();
1340}
1341// A detail "Play" button reads "Now Playing" while a track from its list (album / artist /
1342// playlist) is the current, actively-playing track.
1343function playLabel(lid) {
1344  const cur = Player.current();
1345  const on = cur && !Player.audio.paused && (MAG.lists[lid] || []).some((t) => t.songId === cur.songId);
1346  return on ? `<i class="fa-solid fa-volume-high"></i> Now Playing` : "▶ Play";
1347}
1348function syncPlayButtons() {
1349  document.querySelectorAll("button.playall[data-list]").forEach((b) => { b.innerHTML = playLabel(b.dataset.list); });
1350}
1351function updateShuffleUI() {
1352  ["mp-shuffle", "np-shuffle"].forEach((id) => { const b = el(id); if (b) b.classList.toggle("on", Player.shuffle); });
1353}
1354function updateRepeatUI() {
1355  ["mp-repeat", "np-repeat"].forEach((id) => { const b = el(id); if (b) b.classList.toggle("on", Player.repeat); });
1356}
1357function highlightPlayingRow() {
1358  const t = Player.current(); if (!t) return;
1359  document.querySelectorAll(".song-row.playing").forEach((r) => r.classList.remove("playing"));
1360  document.querySelectorAll(`.song-row[data-sid="${t.songId}"]`).forEach((r) => r.classList.add("playing"));
1361}
1362function wirePlayer() {
1363  el("mp-playpause").onclick = () => Player.toggle();
1364  el("mp-prev").onclick = () => Player.prev();
1365  el("mp-next").onclick = () => Player.next();
1366  el("mp-shuffle").onclick = () => Player.setShuffle(!Player.shuffle);
1367  el("mp-repeat").onclick = () => Player.setRepeat(!Player.repeat);
1368  el("mp-dislike").onclick = () => MAG.dislikeAndSkip();
1369  el("mp-seek").addEventListener("input", (e) => Player.seekFrac(e.target.value / 1000));
1370  el("mp-meta").onclick = () => openNowPlaying();
1371  // Right side: master volume + (Safari only) AirPlay. On wide screens the volume slider sits
1372  // inline; on narrow (phone) screens it collapses to a button that opens a popover slider —
1373  // matching the iOS app, which shows an inline slider on desktop and a popover on iPhone.
1374  const vol = Math.round(Player.volume * 100);
1375  const airplay = window.WebKitPlaybackTargetAvailabilityEvent
1376    ? `<button id="mp-airplay" title="AirPlay"><i class="fa-solid fa-tower-broadcast"></i><span class="mp-rlabel"> AirPlay</span></button>` : "";
1377  el("mp-right").innerHTML =
1378    `<button id="mp-vol-btn" class="mp-volbtn" title="Volume"><i class="fa-solid fa-volume-high"></i></button>` +
1379    `<div class="vol-ctl"><i class="fa-solid fa-volume-high"></i>` +
1380    `<input type="range" id="mp-vol" min="0" max="100" value="${vol}" title="Volume"></div>${airplay}` +
1381    `<div class="mp-vol-pop" id="mp-vol-pop" hidden><i class="fa-solid fa-volume-high"></i><input type="range" id="mp-vol2" min="0" max="100" value="${vol}" title="Volume"></div>`;
1382  const setVol = (v) => { Player.setVolume(v / 100); const a = el("mp-vol"), b = el("mp-vol2"); if (a) a.value = v; if (b) b.value = v; };
1383  el("mp-vol").addEventListener("input", (e) => setVol(+e.target.value));
1384  el("mp-vol2").addEventListener("input", (e) => setVol(+e.target.value));
1385  el("mp-vol-btn").onclick = (e) => { e.stopPropagation(); const p = el("mp-vol-pop"); p.hidden = !p.hidden; };
1386  document.addEventListener("click", (e) => {   // tap-away closes the popover
1387    const p = el("mp-vol-pop");
1388    if (p && !p.hidden && !e.target.closest("#mp-vol-pop") && !e.target.closest("#mp-vol-btn")) p.hidden = true;
1389  });
1390  if (window.WebKitPlaybackTargetAvailabilityEvent) {
1391    el("mp-airplay").onclick = () => { try { Player.audio.webkitShowPlaybackTargetPicker(); } catch (e) {} };
1392  }
1393}
1394
1395/* ---- Now Playing overlay ---- */
1396function openNowPlaying() { if (Player.current()) { MAG.nowPlayingOpen = true; renderNowPlaying(); } }
1397function closeNowPlaying() { MAG.nowPlayingOpen = false; const o = el("nowplaying"); if (o) o.remove(); }
1398window.closeNowPlaying = closeNowPlaying;
1399function renderNowPlaying() {
1400  const t = Player.current(); if (!t) { closeNowPlaying(); return; }
1401  let o = el("nowplaying");
1402  if (!o) { o = document.createElement("div"); o.id = "nowplaying"; o.className = "nowplaying"; document.body.appendChild(o); }
1403  o.innerHTML =
1404    `<button class="np-close" onclick="closeNowPlaying()" title="Close"><i class="fa-solid fa-chevron-down"></i></button>
1405     <div class="np-art" data-zoom="cover" data-artist="${esc(t.artist)}" data-album="${esc(t.album)}">${imgTag(t.artist, t.album, COVER.detail, { eager: true })}</div>
1406     <a class="np-title" href="${albumUrl(t.artist, t.album)}" onclick="closeNowPlaying()">${esc(t.title)}</a>
1407     <a class="np-artist" href="${artistUrl(t.artist)}" onclick="closeNowPlaying()">${esc(t.artist)}</a>
1408     <a class="np-album" href="${albumUrl(t.artist, t.album)}" onclick="closeNowPlaying()">${esc(t.album)}</a>
1409     <div class="np-format" id="np-format">${streamFormatLabel()}</div>
1410     <div class="np-seekrow"><span id="np-cur" class="mp-time">0:00</span>
1411       <input type="range" id="np-seek" min="0" max="1000" value="0">
1412       <span id="np-dur" class="mp-time">0:00</span></div>
1413     <div class="np-buttons">
1414       <button id="np-shuffle" title="Shuffle"><i class="fa-solid fa-shuffle"></i></button>
1415       <button id="np-prev" title="Previous"><i class="fa-solid fa-backward-step"></i></button>
1416       <button id="np-playpause" class="primary"><i class="fa-solid ${Player.audio.paused ? "fa-play" : "fa-pause"}"></i></button>
1417       <button id="np-next" title="Next"><i class="fa-solid fa-forward-step"></i></button>
1418       <button id="np-dislike" title="Dislike &amp; skip"><i class="fa-solid fa-heart-crack"></i></button>
1419       <button id="np-repeat" title="Repeat"><i class="fa-solid fa-repeat"></i></button>
1420       <button id="np-queue-btn" title="Current playlist"><i class="fa-solid fa-list-ol"></i></button>
1421     </div>` +
1422    npQueueHTML();
1423  el("np-prev").onclick = () => Player.prev();
1424  el("np-next").onclick = () => Player.next();
1425  el("np-playpause").onclick = () => Player.toggle();
1426  el("np-shuffle").onclick = () => Player.setShuffle(!Player.shuffle);
1427  el("np-repeat").onclick = () => Player.setRepeat(!Player.repeat);
1428  el("np-dislike").onclick = () => MAG.dislikeAndSkip();
1429  el("np-seek").addEventListener("input", (e) => Player.seekFrac(e.target.value / 1000));
1430  const qb = el("np-queue-btn");
1431  if (qb) qb.onclick = () => { MAG.queueOpen = !MAG.queueOpen; qb.classList.toggle("on", MAG.queueOpen); const p = el("np-queue"); if (p) { p.classList.toggle("open", MAG.queueOpen); if (MAG.queueOpen) { const cur = p.querySelector(".npq-row.cur"); if (cur) cur.scrollIntoView({ block: "nearest" }); } } };
1432  if (MAG.queueOpen) { const qb2 = el("np-queue-btn"); if (qb2) qb2.classList.add("on"); }
1433  updateShuffleUI(); updateRepeatUI(); npTick();
1434}
1435// "Current playlist" — the live play queue, shown inside Now Playing. Click a row to jump to it.
1436function npQueueHTML() {
1437  const qq = Player.queue || [];
1438  if (!qq.length) return "";
1439  const rows = qq.map((t, i) =>
1440    `<div class="npq-row${i === Player.index ? " cur" : ""}" onclick="MAG.queueJump(${i})">
1441       <span class="npq-idx">${i === Player.index ? '<i class="fa-solid fa-volume-high"></i>' : (i + 1)}</span>
1442       <span class="npq-meta"><span class="npq-title">${esc(t.title)}</span><span class="npq-art">${esc(t.artist)}</span></span>
1443       <span class="npq-dur">${fmtDur(t.duration || 0)}</span>
1444     </div>`).join("");
1445  return `<div class="np-queue${MAG.queueOpen ? " open" : ""}" id="np-queue">
1446     <div class="npq-head">Current playlist · ${qq.length} track${qq.length === 1 ? "" : "s"}</div>${rows}</div>`;
1447}
1448MAG.queueJump = (i) => { Player.playAt(i); renderNowPlaying(); };
1449function npTick() {
1450  const a = Player.audio, s = el("np-seek"); if (!s) return;
1451  const frac = (a.duration && isFinite(a.duration)) ? a.currentTime / a.duration : 0;
1452  s.value = Math.round(frac * 1000);
1453  el("np-cur").textContent = fmtDur(a.currentTime);
1454  el("np-dur").textContent = isFinite(a.duration) ? fmtDur(a.duration) : fmtDur((Player.current() || {}).duration || 0);
1455}
1456
1457/* ---- Media Session (OS now-playing / media keys / lock screen) ---- */
1458function setupMediaSession() {
1459  if (!("mediaSession" in navigator)) return;
1460  navigator.mediaSession.setActionHandler("play", () => Player.toggle());
1461  navigator.mediaSession.setActionHandler("pause", () => Player.toggle());
1462  navigator.mediaSession.setActionHandler("previoustrack", () => Player.prev());
1463  navigator.mediaSession.setActionHandler("nexttrack", () => Player.next());
1464}
1465
1466/* ---- Keyboard shortcuts ---- */
1467// Space toggles play/pause, the way every desktop music player does. Ignored while the
1468// user is typing (search/login boxes) or focused on an actionable control (so Space still
1469// activates buttons/links), and only when something is loaded — otherwise Space scrolls.
1470function setupKeyboard() {
1471  document.addEventListener("keydown", (e) => {
1472    if (e.key !== " " && e.code !== "Space") return;
1473    if (e.metaKey || e.ctrlKey || e.altKey) return;
1474    const t = e.target;
1475    if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON|A)$/.test(t.tagName))) return;
1476    if (!Player.current()) return;
1477    e.preventDefault();      // stop the page from scrolling
1478    Player.toggle();
1479  });
1480}
1481function setMediaMetadata(t) {
1482  if (!("mediaSession" in navigator)) return;
1483  const art = new URL(imgURL(t.artist, t.album, "cover_300.webp"), location.href).href;
1484  navigator.mediaSession.metadata = new MediaMetadata({
1485    title: t.title, artist: t.artist, album: t.album,
1486    artwork: [{ src: art, sizes: "300x300", type: "image/webp" }],
1487  });
1488}
1489
1490/* ===================== user data (localStorage) ===================== */
1491const RECENT_CAP = 100;   // "Recently Played" keeps at most this many songs
1492const UserData = {
1493  favs: null, dislikes: null, hideDislikes: true, playlists: null, recentlyPlayed: null, member: null, _seq: 1, _plSet: null,
1494  dlAlbum: "vbr", dlSong: "mp3", theme: "system",
1495  _read(k, d) { try { const v = JSON.parse(localStorage.getItem(k)); return v == null ? d : v; } catch (e) { return d; } },
1496  init() {
1497    this.favs = this._read("magnatune.favorites", { song: [], album: [], artist: [] });
1498    for (const k of ["song", "album", "artist"]) if (!Array.isArray(this.favs[k])) this.favs[k] = [];
1499    this.dislikes = this._read("magnatune.dislikes", { song: [], album: [], artist: [], genre: [] });
1500    for (const k of ["song", "album", "artist", "genre"]) if (!Array.isArray(this.dislikes[k])) this.dislikes[k] = [];
1501    this.hideDislikes = this._read("magnatune.hideDislikes", true);
1502    this.playlists = this._read("magnatune.playlists", []);
1503    this.recentlyPlayed = this._read("magnatune.recentlyPlayed", []);
1504    if (!Array.isArray(this.recentlyPlayed)) this.recentlyPlayed = [];
1505    this._seq = this._read("magnatune.plseq", 1);
1506    this.member = this._read("magnatune.member", { loggedIn: false, user: "" });
1507    this.dlAlbum = localStorage.getItem("magnatune.dl.album") || "vbr";
1508    this.dlSong = localStorage.getItem("magnatune.dl.song") || "mp3";
1509    this.theme = localStorage.getItem("magnatune.theme") || "system";
1510  },
1511  setTheme(v) { this.theme = v; localStorage.setItem("magnatune.theme", v); applyTheme(); try { Sync.markDirty(); } catch (e) {} },
1512  setDlAlbum(v) { this.dlAlbum = v; localStorage.setItem("magnatune.dl.album", v); try { Sync.markDirty(); } catch (e) {} },
1513  setDlSong(v) { this.dlSong = v; localStorage.setItem("magnatune.dl.song", v); try { Sync.markDirty(); } catch (e) {} },
1514  setMember(m) { this.member = m; localStorage.setItem("magnatune.member", JSON.stringify(m)); },
1515  _save() {
1516    localStorage.setItem("magnatune.favorites", JSON.stringify(this.favs));
1517    localStorage.setItem("magnatune.dislikes", JSON.stringify(this.dislikes));
1518    localStorage.setItem("magnatune.playlists", JSON.stringify(this.playlists));
1519    localStorage.setItem("magnatune.plseq", JSON.stringify(this._seq));
1520    this._plSet = null;   // invalidate the playlist-membership cache
1521    try { Sync.markDirty(); } catch (e) {}   // cloud sync (members only) — todo 10355013029
1522  },
1523  // Set of every song id that appears in any playlist (drives the add-to-playlist icon).
1524  playlistedSongs() {
1525    if (!this._plSet) this._plSet = new Set((this.playlists || []).flatMap((p) => p.songIds));
1526    return this._plSet;
1527  },
1528  isFav(kind, id) { return this.favs[kind].includes(id); },
1529  toggleFav(kind, id) {
1530    const a = this.favs[kind], i = a.indexOf(id);
1531    if (i >= 0) { a.splice(i, 1); }
1532    else { a.unshift(id); const d = this.dislikes[kind], di = d.indexOf(id); if (di >= 0) d.splice(di, 1); }
1533    this._save(); return i < 0;   // can't both love and dislike: favoriting clears a dislike
1534  },
1535  isDisliked(kind, id) { return this.dislikes[kind].includes(id); },
1536  toggleDislike(kind, id) {
1537    const a = this.dislikes[kind], i = a.indexOf(id);
1538    if (i >= 0) { a.splice(i, 1); }
1539    // Disliking clears any matching favorite — but genres have no favorite bucket, so guard it.
1540    else { a.unshift(id); const f = this.favs[kind]; if (f) { const fi = f.indexOf(id); if (fi >= 0) f.splice(fi, 1); } }
1541    this._save(); return i < 0;
1542  },
1543  setHideDislikes(v) { this.hideDislikes = !!v; localStorage.setItem("magnatune.hideDislikes", JSON.stringify(this.hideDislikes)); try { Sync.markDirty(); } catch (e) {} },
1544  playlist(id) { return this.playlists.find((p) => p.id === id); },
1545  createPlaylist(name) { const pl = { id: this._seq++, name, songIds: [] }; this.playlists.push(pl); this._save(); return pl; },
1546  addToPlaylist(id, songIds) { const p = this.playlist(id); if (!p) return; for (const s of songIds) if (!p.songIds.includes(s)) p.songIds.push(s); this._save(); },
1547  removeFromPlaylist(id, songId) { const p = this.playlist(id); if (p) { p.songIds = p.songIds.filter((s) => s !== songId); this._save(); } },
1548  removeSongsFromAllPlaylists(ids) { const set = new Set(ids); for (const p of this.playlists) p.songIds = p.songIds.filter((s) => !set.has(s)); this._save(); },
1549  renamePlaylist(id, name) { const p = this.playlist(id); if (p) { p.name = name; this._save(); } },
1550  deletePlaylist(id) { this.playlists = this.playlists.filter((p) => p.id !== id); this._save(); },
1551  // "Recently Played" — an auto-managed list, most-recent-first, de-duplicated (a replay
1552  // moves the song to the top), capped at RECENT_CAP. Written on every track start; kept in
1553  // its own localStorage key (hot path, independent of the _save() bundle above).
1554  recordPlay(songId) {
1555    if (songId == null) return;
1556    const a = this.recentlyPlayed, i = a.indexOf(songId);
1557    if (i >= 0) a.splice(i, 1);
1558    a.unshift(songId);
1559    if (a.length > RECENT_CAP) a.length = RECENT_CAP;
1560    localStorage.setItem("magnatune.recentlyPlayed", JSON.stringify(a));
1561    try { Sync.markDirty(); } catch (e) {}
1562  },
1563  clearRecentlyPlayed() { this.recentlyPlayed = []; localStorage.setItem("magnatune.recentlyPlayed", JSON.stringify([])); try { Sync.markDirty(); } catch (e) {} },
1564  // Snapshot the synced keys as a plain object (the canonical, client-neutral blob —
1565  // ids are catalog ids shared with the iOS app). schema=1. Cloud sync (todo 10355013029).
1566  localBlob() {
1567    return {
1568      schema: 1,
1569      favorites: this.favs, dislikes: this.dislikes, hideDislikes: this.hideDislikes,
1570      playlists: this.playlists, plseq: this._seq, recentlyPlayed: this.recentlyPlayed,
1571      dlAlbum: this.dlAlbum, dlSong: this.dlSong, theme: this.theme,
1572    };
1573  },
1574  // Overwrite local state from a merged blob and persist every key at once (does NOT
1575  // go through the per-field setters, so it won't re-trigger a sync push per field).
1576  adoptBlob(b) {
1577    if (b.favorites && typeof b.favorites === "object") {
1578      this.favs = b.favorites;
1579      for (const k of ["song", "album", "artist"]) if (!Array.isArray(this.favs[k])) this.favs[k] = [];
1580    }
1581    if (b.dislikes && typeof b.dislikes === "object") {
1582      this.dislikes = b.dislikes;
1583      for (const k of ["song", "album", "artist", "genre"]) if (!Array.isArray(this.dislikes[k])) this.dislikes[k] = [];
1584    }
1585    if (typeof b.hideDislikes === "boolean") this.hideDislikes = b.hideDislikes;
1586    if (Array.isArray(b.playlists)) this.playlists = b.playlists;
1587    if (typeof b.plseq === "number") this._seq = b.plseq;
1588    if (Array.isArray(b.recentlyPlayed)) this.recentlyPlayed = b.recentlyPlayed.slice(0, RECENT_CAP);
1589    if (typeof b.dlAlbum === "string") this.dlAlbum = b.dlAlbum;
1590    if (typeof b.dlSong === "string") this.dlSong = b.dlSong;
1591    if (typeof b.theme === "string") this.theme = b.theme;
1592    localStorage.setItem("magnatune.favorites", JSON.stringify(this.favs));
1593    localStorage.setItem("magnatune.dislikes", JSON.stringify(this.dislikes));
1594    localStorage.setItem("magnatune.hideDislikes", JSON.stringify(this.hideDislikes));
1595    localStorage.setItem("magnatune.playlists", JSON.stringify(this.playlists));
1596    localStorage.setItem("magnatune.plseq", JSON.stringify(this._seq));
1597    localStorage.setItem("magnatune.recentlyPlayed", JSON.stringify(this.recentlyPlayed));
1598    localStorage.setItem("magnatune.dl.album", this.dlAlbum);
1599    localStorage.setItem("magnatune.dl.song", this.dlSong);
1600    localStorage.setItem("magnatune.theme", this.theme);
1601    this._plSet = null;
1602  },
1603};
1604const orderByIds = (rows, key, ids) => { const m = new Map(rows.map((r) => [r[key], r])); return ids.map((id) => m.get(id)).filter(Boolean); };
1605
1606/* ===================== cloud sync of member settings (todo 10355013029) =====================
1607 * Members' favorites/dislikes/playlists/recently-played/prefs live in localStorage, so they
1608 * don't follow the member across devices. This mirrors that blob to the server
1609 * (/membership/settings) — pulled+merged on login, pushed on change (debounced, only-if-changed,
1610 * at most once/60s), with a sendBeacon flush on unload. Members only; a signed-out user is
1611 * unaffected. The server stores one opaque JSON blob per member and is client-agnostic, so the
1612 * iOS app can adopt the same endpoint later (shared catalog ids). */
1613const SYNC_MIN_INTERVAL = 60000;   // never POST more than once a minute
1614const SCALAR_DEFAULT = { hideDislikes: true, dlAlbum: "vbr", dlSong: "mp3", theme: "system" };
1615const Sync = {
1616  rev: 0, lastHash: "", base: {}, _timer: null, _lastPush: 0, _pulling: false,
1617  init() {
1618    this.rev = UserData._read("magnatune.sync.rev", 0) || 0;
1619    this.lastHash = localStorage.getItem("magnatune.sync.hash") || "";
1620    this.base = UserData._read("magnatune.sync.base", {}) || {};
1621  },
1622  _persistMeta() {
1623    try {
1624      localStorage.setItem("magnatune.sync.rev", JSON.stringify(this.rev));
1625      localStorage.setItem("magnatune.sync.hash", this.lastHash);
1626      localStorage.setItem("magnatune.sync.base", JSON.stringify(this.base));
1627    } catch (e) {}
1628  },
1629  // The blob we upload: last-known remote (preserves keys other clients set, e.g. iOS-only
1630  // prefs) with our own known keys overlaid.
1631  buildBlob() { return Object.assign({}, this.base, UserData.localBlob()); },
1632  _hash(obj) { const s = JSON.stringify(obj); let h = 0; for (let i = 0; i < s.length;
1632 i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h + ":" + s.length; },
1633  _uniq(arr) { const seen = new Set(), out = []; for (const x of arr || []) if (!seen.has(x)) { seen.add(x); out.push(x); } return out; },
1634  _unionKinds(a, b, kinds) {
1635    const out = {};
1636    for (const k of kinds) out[k] = this._uniq([...(a && a[k] || []), ...(b && b[k] || [])]);
1637    return out;
1638  },
1639  // Merge remote (another device) into local. Sets union; recently-played recency-merged
1640  // (local first — this device is "now"); scalars keep an explicit local choice but adopt
1641  // remote when local is still the default (a fresh device inherits the member's prefs).
1642  merge(local, remote) {
1643    const m = Object.assign({}, remote, local);   // start from union of keys; known keys fixed below
1644    m.schema = 1;
1645    m.favorites = this._unionKinds(local.favorites, remote.favorites, ["song", "album", "artist"]);
1646    m.dislikes  = this._unionKinds(local.dislikes,  remote.dislikes,  ["song", "album", "artist", "genre"]);
1647    m.recentlyPlayed = this._uniq([...(local.recentlyPlayed || []), ...(remote.recentlyPlayed || [])]).slice(0, RECENT_CAP);
1648    // playlists: union by id (merge songIds; keep local name on collision), then remote-only
1649    const byId = new Map();
1650    for (const p of remote.playlists || []) byId.set(p.id, { id: p.id, name: p.name, songIds: (p.songIds || []).slice() });
1651    for (const p of local.playlists || []) {
1652      const e = byId.get(p.id);
1653      if (e) { e.name = p.name; e.songIds = this._uniq([...(p.songIds || []), ...e.songIds]); }
1654      else byId.set(p.id, { id: p.id, name: p.name, songIds: (p.songIds || []).slice() });
1655    }
1656    m.playlists = [...byId.values()];
1657    m.plseq = Math.max(local.plseq || 1, remote.plseq || 1);
1658    for (const k of ["hideDislikes", "dlAlbum", "dlSong", "theme"]) {
1659      const lv = local[k], rv = remote[k];
1660      m[k] = (lv !== undefined && lv !== SCALAR_DEFAULT[k]) ? lv : (rv !== undefined ? rv : lv);
1661    }
1662    return m;
1663  },
1664  applyRemote(remoteObj) {
1665    this.base = Object.assign({}, remoteObj);           // retain unknown keys for round-trip
1666    const merged = this.merge(UserData.localBlob(), remoteObj);
1667    UserData.adoptBlob(merged);
1668    applyTheme();
1669    if (window.Suppress && Suppress.rebuild) Suppress.rebuild();
1670  },
1671  async pull() {
1672    if (!isMember() || this._pulling) return;
1673    this._pulling = true;
1674    try {
1675      const r = await fetch("/membership/settings", { credentials: "same-origin" }).then((r) => r.json());
1676      if (r && r.ok) {
1677        this.rev = r.rev || 0;
1678        if (r.data) this.applyRemote(r.data); else this.base = {};
1679        this.lastHash = "";               // force a reconcile push if local adds anything
1680        this._persistMeta();
1681        this.maybePush(true);             // push the merged union back up
1682        if (!MAG.loading) route();        // reflect merged data
1683      }
1684    } catch (e) {} finally { this._pulling = false; }
1685  },
1686  maybePush(immediate) {
1687    if (!isMember()) return;
1688    if (this._hash(this.buildBlob()) === this.lastHash) return;
1689    const wait = Math.max(0, SYNC_MIN_INTERVAL - (Date.now() - this._lastPush));
1690    clearTimeout(this._timer);
1691    this._timer = setTimeout(() => this._doPush(), (immediate && wait === 0) ? 0 : wait);
1692  },
1693  markDirty() { this.maybePush(false); },
1694  async _doPush() {
1695    if (!isMember()) return;
1696    const blob = this.buildBlob(), h = this._hash(blob);
1697    if (h === this.lastHash) return;
1698    this._lastPush = Date.now();
1699    try {
1700      const body = "rev=" + encodeURIComponent(this.rev) + "&data=" + encodeURIComponent(JSON.stringify(blob));
1701      const r = await fetch("/membership/settings", {
1702        method: "POST", credentials: "same-origin",
1703        headers: { "Content-Type": "application/x-www-form-urlencoded" }, body,
1704      }).then((r) => r.json());
1705      if (r.ok) { this.rev = r.rev; this.base = blob; this.lastHash = h; this._persistMeta(); }
1706      else if (r.conflict) {                 // another device won — merge its blob and retry
1707        this.rev = r.rev || 0;
1708        if (r.data) this.applyRemote(r.data);
1709        this.lastHash = "";
1710        this._persistMeta();
1711        this.maybePush(true);
1712        if (!MAG.loading) route();
1713      }
1714    } catch (e) {}
1715  },
1716  flushBeacon() {
1717    if (!isMember()) return;
1718    const blob = this.buildBlob();
1719    if (this._hash(blob) === this.lastHash) return;
1720    try {
1721      const body = "rev=" + encodeURIComponent(this.rev) + "&data=" + encodeURIComponent(JSON.stringify(blob));
1722      navigator.sendBeacon("/membership/settings", new Blob([body], { type: "application/x-www-form-urlencoded" }));
1723    } catch (e) {}
1724  },
1725};
1726
1727/* ---- dislike suppression ----
1728 * Disliked songs/albums/artists are hidden everywhere while UserData.hideDislikes is on.
1729 * Expanded through the catalog: a disliked artist also suppresses its albums + s
1729ongs; a
1730 * disliked album also suppresses its songs. Rebuilt whenever a dislike changes. */
1731const Suppress = {
1732  artists: new Set(), albums: new Set(), songs: new Set(),
1733  genres: new Set(), tags: new Set(), playlists: new Set(),
1734  rebuild() {
1735    const A = new Set(UserData.dislikes.artist);
1736    const AL = new Set(UserData.dislikes.album);
1737    const S = new Set(UserData.dislikes.song);
1738    const G = new Set(UserData.dislikes.genre);
1739    for (const aid of UserData.dislikes.artist)
1740      for (const r of q("select album_id from albums where artist_id=?", [aid])) AL.add(r.album_id);
1741    // A disliked genre hides every album in it; remember those albums + their artists.
1742    const genreAlbums = new Set(), candidates = new Set();
1743    for (const gid of UserData.dislikes.genre)
1744      for (const r of q("select ga.album_id, al.artist_id from genres_albums ga join albums al on al.album_id=ga.album_id where ga.genre_id=?", [gid])) {
1745        AL.add(r.album_id); genreAlbums.add(r.album_id); candidates.add(r.artist_id);
1746      }
1747    // Hide an artist whose every album is in a disliked genre.
1748    for (const aid of candidates) {
1749      if (A.has(aid)) continue;
1750      const albums = q("select album_id from albums where artist_id=?", [aid]);
1751      if (albums.length && albums.every((r) => genreAlbums.has(r.album_id))) A.add(aid);
1752    }
1753    // Every suppressed album drags its songs along.
1754    for (const alid of AL)
1755      for (const r of q("select song_id from songs where album_id=?", [alid])) S.add(r.song_id);
1756    // A tag with no surviving album, or a Featured playlist with no surviving song, is hidden.
1757    const T = new Set(), P = new Set();
1758    for (const r of q("select collections_id from collections")) {
1759      const al = q("select album_id from collections_albums where collection_id=?", [r.collections_id]);
1760      if (al.every((x) => AL.has(x.album_id))) T.add(r.collections_id);
1761    }
1762    for (const r of q("select playlist_id from playlists where owner_id=0")) {
1763      const ss = q("select song_id from playlist_songs where playlist_id=?", [r.playlist_id]);
1764      if (ss.every((x) => S.has(x.song_id))) P.add(r.playlist_id);
1765    }
1766    this.artists = A; this.albums = AL; this.songs = S;
1767    this.genres = G; this.tags = T; this.playlists = P;
1768  },
1769  album(id) { return UserData.hideDislikes && this.albums.has(id); },
1770  artist(id) { return UserData.hideDislikes && this.artists.has(id); },
1771  song(id) { return UserData.hideDislikes && this.songs.has(id); },
1772  genre(id) { return UserData.hideDislikes && this.genres.has(id); },
1773  tag(id) { return UserData.hideDislikes && this.tags.has(id); },
1774  playlist(id) { return UserData.hideDislikes && this.playlists.has(id); },
1775};
1776// Tally per-key counts (albums per genre/tag, songs per featured playlist) that EXCLUDE
1777// anything hidden by "Hide things I dislike", so the numbers match what's actually shown.
1778// `sql` returns (keyCol, <album_id|song_id>) pairs; kind is "album" or "song".
1779function liveCounts(sql, keyCol, kind) {
1780  const idCol = kind === "song" ? "song_id" : "album_id", m = new Map();
1781  for (const r of q(sql)) { if (Suppress[kind](r[idCol])) continue; m.set(r[keyCol], (m.get(r[keyCol]) || 0) + 1); }
1782  return m;
1783}
1784
1785/* ---- heart + dislike + add-to-playlist controls ---- */
1786// Broken-heart (Munch's Scream's cousin) icons: Font Awesome heart-crack — light outline
1787// when not disliked, solid when disliked (the solid only shows while "Hide things I dislike"
1788// is off, since disliked items are otherwise suppressed).
1789const HC_LIGHT = `<span class="hc m-hc-o" aria-hidden="true"></span>`;
1790const HC_SOLID = `<span class="hc m-hc" aria-hidden="true"></span>`;
1791
1792// Favorite heart icons (Font Awesome heart — solid when favorited, light outline when not),
1793// cropped to the path bounds so they render the same size as the dislike broken-heart.
1794const HEART_SOLID = `<span class="hc m-heart" aria-hidden="true"></span>`;
1795const HEART_LIGHT = `<span class="hc m-heart-o" aria-hidden="true"></span>`;
1796// Add-to-playlist icons (album-collection duotone): circle-plus = not yet on a playlist,
1797// circle-user = this song/album/artist already has song(s) on a playlist.
1798const PL_ADD = `<span class="ci m-pladd" aria-hidden="true"></span>`;
1799const PL_HAS = `<span class="ci m-plhas" aria-hidden="true"></span>`;
1800function addIcon(on) { return on ? PL_HAS : PL_ADD; }
1801
1802// Download icon (Font Awesome download-light, cropped to bounds).
1803const DL_ICON = `<span class="ci m-dl" aria-hidden="true"></span>`;
1804function isMember() { return !!(UserData.member && UserData.member.loggedIn); }
1805// Download buttons shown only to members. Album → same-origin zip endpoint; song → the
1806// same-origin per-track file (member-gated by m3_music_serve). Both open a new browser
1807// tab to do the actual download — no external he3 / download.magnatune.com dependency.
1808function dlAlbumBtn(sku, big) {
1809  if (!sku) return "";
1810  // Members download for free. Everyone else gets a buy-download affordance that
1811  // opens the server-rendered explainer/checkout page ($8, 100% to the musician).
1812  if (isMember())
1813    return `<button class="addbtn${big ? " big" : ""}" title="Download album" onclick="MAG.downloadAlbum(event,'${esc(sku)}')">${DL_ICON}</button>`;
1814  return `<a class="addbtn${big ? " big" : ""}" title="Buy &amp; download this album ($8)" href="/buy/album?sku=${encodeURIComponent(sku)}">${DL_ICON}</a>`;
1815}
1816function dlSongBtn(t) {
1817  if (!isMember()) return "";
1818  return `<button class="addbtn" title="Download song" onclick="MAG.downloadSong(event,${t.songId})">${DL_ICON}</button>`;
1819}
1820MAG.downloadAlbum = (e, sku) => {
1821  e.stopPropagation();
1822  window.open(`/membership/download3?sku=${encodeURIComponent(sku)}&format=${UserData.dlAlbum}`, "_blank");
1823};
1824MAG.downloadSong = (e, id) => {
1825  e.stopPropagation();
1826  const r = Q.songsByIds([id])[0];
1827  if (!r) return;
1828  const stem = (r.mp3 || "").replace(/\.mp3$/i, "");
1829  window.open(`/music/${encodeURIComponent(r.artist)}/${encodeURIComponent(r.album)}/${encodeURIComponent(stem)}.${UserData.dlSong}`, "_blank");
1830};
1831function favBtn(kind, id) {
1832  const on = UserData.isFav(kind, id);
1833  return `<button class="fav${on ? " on" : ""}" title="Favorite" onclick="MAG.toggleFav(event,'${kind}',${id})">${on ? HEART_SOLID : HEART_LIGHT}</button>`;
1834}
1835function dislikeBtn(kind, id) {
1836  const on = UserData.isDisliked(kind, id);
1837  const title = on ? "Disliked — tap to undo" : "I dislike this (hide it)";
1838  return `<button class="dislike${on ? " on" : ""}" title="${title}" onclick="MAG.toggleDislike(event,'${kind}',${id})">${on ? HC_SOLID : HC_LIGHT}</button>`;
1839}
1840// Keep favorites non-redundant (mirrors the iOS app): a favorited song whose album OR
1841// artist is also favorited is dropped; a favorited album whose artist is favorited is
1842// dropped — the broadest favorite is kept. Returns true if anything was removed.
1843function dedupeFavorites() {
1844  const f = UserData.favs;
1845  if (!f.song.length && !f.album.length) return false;
1846  const favAlbums = new Set(f.album), favArtists = new Set(f.artist);
1847  let changed = false;
1848  if (f.song.length) {
1849    const ph = f.song.map(() => "?").join(",");
1850    const meta = new Map(q(`select s.song_id, s.album_id, al.artist_id from songs s join albums al on al.album_id=s.album_id where s.song_id in (${ph})`, f.song).map((r) => [r.song_id, r]));
1851    const keep = f.song.filter((sid) => { const m = meta.get(sid); return !(m && (favAlbums.has(m.album_id) || favArtists.has(m.artist_id))); });
1852    if (keep.length !== f.song.length) { f.song = keep; changed = true; }
1853  }
1854  if (f.album.length && f.artist.length) {
1855    const ph = f.album.map(() => "?").join(",");
1856    const artistOf = new Map(q(`select album_id, artist_id from albums where album_id in (${ph})`, f.album).map((r) => [r.album_id, r.artist_id]));
1857    const keep = f.album.filter((aid) => !favArtists.has(artistOf.get(aid)));
1858    if (keep.length !== f.album.length) { f.album = keep; changed = true; }
1859  }
1860  if (changed) UserData._save();
1861  return changed;
1862}
1863MAG.toggleFav = (e, kind, id) => {
1864  e.stopPropagation();
1865  const wasDisliked = UserData.isDisliked(kind, id);
1866  const on = UserData.toggleFav(kind, id);
1867  const deduped = on ? dedupeFavorites() : false;
1868  const b = e.currentTarget; b.classList.toggle("on", on); b.innerHTML = on ? HEART_SOLID : HEART_LIGHT;
1869  // Favoriting may clear a dislike or drop a redundant favorite → full re-render then.
1870  if (deduped || wasDisliked || (location.hash || "").split("/")[1] === "favorites") route();
1871};
1872MAG.toggleDislike = (e, kind, id) => {
1873  e.stopPropagation();
1874  e.preventDefault();   // the genre/tag rows are <a> links — don't navigate when toggling
1875  UserData.toggleDislike(kind, id);
1876  Suppress.rebuild();
1877  route();   // re-render: suppression hides it (hide on) or flips the icon + heart (hide off)
1878};
1879// Now-playing "heart-broken" button: dislike the current song, then skip to the next.
1880// Idempotent on the dislike (never un-dislikes); suppression applies on the next render.
1881MAG.dislikeAndSkip = () => {
1882  const t = Player.current(); if (!t) return;
1883  if (!UserData.isDisliked("song", t.songId)) UserData.toggleDislike("song", t.songId);
1884  Suppress.rebuild();
1885  toast("Disliked — skipping");
1886  Player.next();
1887};
1888// The playlist button is an add/remove toggle: plus = add (opens the sheet), minus = the
1889// item already has song(s) on a playlist, so clicking removes them from all playlists.
1890function togglePlaylist(songIds) {
1891  if (songIds.some((id) => UserData.playlistedSongs().has(id))) {
1892    UserData.removeSongsFromAllPlaylists(songIds);
1893    toast(songIds.length > 1 ? "Removed from playlists" : "Removed from playlist");
1894    route();
1895  } else {
1896    openAddToPlaylist(songIds);
1897  }
1898}
1899MAG.addSong = (e, id) => { e.stopPropagation(); togglePlaylist([id]); };
1900MAG.addAlbum = (e, id) => { e.stopPropagation(); togglePlaylist(Q.albumSongs(id).map((s) => s.song_id)); };
1901MAG.addArtist = (e, id) => { e.stopPropagation(); togglePlaylist(Q.artistSongIds(id)); };
1902MAG.playArtist = (id) => { const ids = Q.artistSongIds(id); const s = orderByIds(Q.songsByIds(ids), "song_id", ids).map((r) => trk(r)); if (s.length) Player.play(s, 0); };
1903MAG.removeFromPlaylist = (e, plId, songId) => { e.stopPropagation(); UserData.removeFromPlaylist(plId, songId); route(); };
1904MAG.deletePlaylist = (id) => { if (confirm("Delete this playlist?")) { UserData.deletePlaylist(id); go("playlists"); } };
1905MAG.renamePlaylist = (id) => { const p = UserData.playlist(id); const n = prompt("Rename playlist", p ? p.name : ""); if (n && n.trim()) { UserData.renamePlaylist(id, n.trim()); route(); } };
1906MAG.clearRecentlyPlayed = () => { if (confirm("Clear your recently-played history?")) { UserData.clearRecentlyPlayed(); route(); } };
1907
1908function openAddToPlaylist(songIds) {
1909  if (!songIds || !songIds.length) return;
1910  const pls = UserData.playlists;
1911  const o = document.createElement("div");
1912  o.className = "sheet-overlay";
1913  o.innerHTML =
1914    `<div class="sheet">
1915       <div class="sheet-title">Add ${songIds.length} song${songIds.length > 1 ? "s" : ""} to…</div>
1916       <div class="sheet-new"><input id="pl-new" placeholder="New playlist name" autocomplete="off"><button id="pl-create" class="playall">Create</button></div>
1917       <div class="sheet-list">${pls.length
1918         ? pls.map((p) => `<button class="sheet-pl" data-pl="${p.id}"><span>${esc(p.name)}</span><span class="cnt">${p.songIds.length}</span></button>`).join("")
1919         : `<div class="pad" style="padding:1rem">No playlists yet — create one above.</div>`}</div>
1920       <button class="sheet-close">Cancel</button>
1921     </div>`;
1922  o.addEventListener("click", (ev) => { if (ev.target === o) o.remove(); });
1923  document.body.appendChild(o);
1924  o.querySelector(".sheet-close").onclick = () => o.remove();
1925  o.querySelector("#pl-create").onclick = () => {
1926    const n = o.querySelector("#pl-new").value.trim(); if (!n) return;
1927    const pl = UserData.createPlaylist(n); UserData.addToPlaylist(pl.id, songIds); o.remove(); toast(`Added to “${n}”`); route();
1928  };
1929  o.querySelectorAll(".sheet-pl").forEach((b) =>
1930    b.onclick = () => { UserData.addToPlaylist(+b.dataset.pl, songIds); o.remove(); toast("Added to playlist"); route(); });
1931  o.querySelector("#pl-new").focus();
1932}
1933function toast(msg) {
1934  let t = el("toast");
1935  if (!t) { t = document.createElement("div"); t.id = "toast"; t.className = "toast"; document.body.appendChild(t); }
1936  t.textContent = msg; t.classList.add("show");
1937  clearTimeout(MAG._toastT); MAG._toastT = setTimeout(() => t.classList.remove("show"), 1600);
1938}
1939
1940/* ---- Favorites + Playlists views ---- */
1941function renderFavorites(c) {
1942  const f = UserData.favs;
1943  const songs = f.song.length ? orderByIds(Q.songsByIds(f.song), "song_id", f.song).map((s) => trk(s)) : [];
1944  const albums = f.album.length ? orderByIds(Q.albumsByIds(f.album), "album_id", f.album) : [];
1945  const artists = f.artist.length ? orderByIds(Q.artistsByIds(f.artist), "artists_id", f.artist) : [];
1946  if (!songs.length && !albums.length && !artists.length) {
1947    c.innerHTML = `<div class="page-title">Favorites</div><div class="pad">No favorites yet. Tap ♡ on any song, album, or artist.</div>`;
1948    return;
1949  }
1950  let html = `<div class="page-title">Favorites</div>`;
1951  if (songs.length) { const lid = registerList(songs); html += `<div class="row-head">Songs <button class="playall sm" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button></div>` + songsHTML(lid, true); }
1952  if (albums.length) {
1953    const alid = registerList(albums.flatMap((al) => Q.albumSongs(al.album_id).map((s) => trk(s))));
1954    html += `<div class="row-head">Albums <button class="playall sm" data-list="${alid}" onclick="MAG.playFrom('${alid}',0)">${playLabel(alid)}</button></div>` + grid(albums.map(albumCell));
1955  }
1956  if (artists.length) {
1957    const ids = artists.flatMap((ar) => Q.artistSongIds(ar.artists_id));
1958    const arid = registerList(orderByIds(Q.songsByIds(ids), "song_id", ids).map((s) => trk(s)));
1959    html += `<div class="row-head">Artists <button class="playall sm" data-list="${arid}" onclick="MAG.playFrom('${arid}',0)">${playLabel(arid)}</button></div>` + artistList(artists);
1960  }
1961  c.innerHTML = html;
1962}
1963function renderPlaylists(c) {
1964  const pls = UserData.playlists;
1965  // Auto-managed "Recently Played" is always pinned at the top of the list (not user-created,
1966  // can't be renamed or deleted — it fills itself as you listen).
1967  const recentRow = `<a href="#/recent"><span><i class="fa-solid fa-clock-rotate-left" style="margin-right:.5rem;opacity:.65"></i>Recently Played</span><span class="cnt">${UserData.recentlyPlayed.length}</span></a>`;
1968  // "Recommended" — members only; server-side recs from your Recently Played (todo 10355065694).
1969  const recommendedRow = isMember()
1970    ? `<a href="#/recommended"><span><i class="fa-solid fa-wand-magic-sparkles" style="margin-right:.5rem;opacity:.65"></i>Recommended</span></a>`
1971    : "";
1972  c.innerHTML = `<div class="page-title">Playlists</div>` +
1973    `<div class="sheet-new" style="max-width:560px"><input id="np-name" placeholder="New playlist name" autocomplete="off"><button class="playall" id="np-create">Create</button></div>` +
1974    `<div class="list" style="margin-top:1rem">` + recentRow + recommendedRow +
1975      pls.map((p) => `<a href="#/playlist/${p.id}"><span>${esc(p.name)}</span><span class="cnt">${p.songIds.length}</span></a>`).join("") +
1976    `</div>`;
1977  el("np-create").onclick = () => { const n = el("np-name").value.trim(); if (n) { UserData.createPlaylist(n); route(); } };
1978}
1979function renderRecentlyPlayed(c) {
1980  // Ordered by the stored id list (most-recent-first); songs that fell out of the catalog are
1981  // dropped. Same layout as a Featured playlist (artwork rows via songsHTML), plus a Clear.
1982  const ids = UserData.recentlyPlayed;
1983  const songs = orderByIds(Q.songsByIds(ids), "song_id", ids).map((s) => trk(s));
1984  const lid = registerList(songs);
1985  c.innerHTML = `<div class="page-title">Recently Played</div>` +
1986    (songs.length
1987      ? `<div class="pl-actions">` +
1988          `<button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button>` +
1989          `<button class="playall ghost" onclick="MAG.clearRecentlyPlayed()"><i class="fa-solid fa-trash-can"></i> Clear history</button>` +
1990        `</div>` +
1991        songsHTML(lid, true)
1992      : `<div class="pad">Nothing yet — the songs you play will show up here automatically.</div>`);
1993}
1994/* ===================== "Recommended" playlist (todo 10355065694) =====================
1995 * Server-side recommendations built from the member's Recently Played, reusing the same
1996 * STREAM-based album-similarity as "You might also like" (server: /membership/recommended).
1997 * Members only, honors dislikes, refreshed at most once every 24h (cached in localStorage). */
1998const Recs = {
1999  CACHE_MS: 24 * 3600 * 1000,
2000  _uniq(a) { const s = new Set(), o = []; for (const x of a) if (x != null && !s.has(x)) { s.add(x); o.push(x); } return o; },
2001  // recently-played song ids -> album skus, most-recent-first, de-duped (the seed).
2002  seeds() {
2003    const ids = UserData.recentlyPlayed || [];
2004    if (!ids.length) return [];
2005    const ph = ids.map(() => "?").join(",");
2006    const map = new Map(q(`select s.song_id, al.sku from songs s join albums al on al.album_id=s.album_id where s.song_id in (${ph})`, ids).map((r) => [r.song_id, r.sku]));
2007    return this._uniq(ids.map((id) => map.get(id)));
2008  },
2009  // exclusion keys the server filters on. xalbums = albums the member has already
2010  // favorited OR disliked (don't recommend what they've already judged); xartists =
2011  // disliked artists (page-slugs); xgenres = disliked genres (names).
2012  dislikeParams() {
2013    const d = UserData.dislikes || { album: [], artist: [], genre: [] };
2014    const f = UserData.favs || { album: [] };
2015    const col = (ids, sql, key) => ids.length ? q(sql.replace("?#", ids.map(() => "?").join(",")), ids).map((r) => r[key]) : [];
2016    const exAlbumIds = [...new Set([...(d.album || []), ...(f.album || [])])];
2017    return {
2018      xalbums:  col(exAlbumIds, "select sku from albums where album_id in (?#)", "sku"),
2019      xartists: col(d.artist,   "select page from artists where artists_id in (?#)", "page"),
2020      xgenres:  col(d.genre,    "select name from genres where genre_id in (?#)", "name"),
2021    };
2022  },
2023  async fetch() {
2024    const seeds = this.seeds();
2025    const { xalbums, xartists, xgenres } = this.dislikeParams();
2026    const body = "seeds=" + encodeURIComponent(seeds.join("|")) +
2027      "&xalbums=" + encodeURIComponent(xalbums.join("|")) +
2028      "&xartists=" + encodeURIComponent(xartists.join("|")) +
2029      "&xgenres=" + encodeURIComponent(xgenres.join("|"));
2030    try {
2031      const r = await fetch("/membership/recommended", {
2032        method: "POST", credentials: "same-origin",
2033        headers: { "Content-Type": "application/x-www-form-urlencoded" }, body,
2034      }).then((r) => r.json());
2035      if (!r || !r.ok) return null;
2036      const skus = (r.albums || []).map((a) => a.sku);
2037      // cache only a real result — an empty list (no seeds yet) should be retried next visit
2038      if (skus.length) { try { localStorage.setItem("magnatune.recs", JSON.stringify({ at: Date.now(), skus })); } catch (e) {} }
2039      return skus;
2040    } catch (e) { return null; }
2041  },
2042  async get(force) {
2043    if (!force) {
2044      const c = UserData._read("magnatune.recs", null);
2045      if (c && Array.isArray(c.skus) && (Date.now() - c.at) < this.CACHE_MS) return c.skus;
2046    }
2047    const skus = await this.fetch();
2048    return skus || [];
2049  },
2050  // ordered album rows for the returned skus. Filtered at render (not just server-side)
2051  // so a just-favorited/disliked album disappears immediately, even though the recs
2052  // themselves are cached for 24h: drop hidden-by-dislike, plus any album the member
2053  // has favorited or disliked (you've already judged it — don't recommend it back).
2054  albumsForSkus(skus) {
2055    if (!skus.length) return [];
2056    const ph = skus.map(() => "?").join(",");
2057    const rows = q(`select a.album_id,a.name,a.sku,ar.name artist from albums a join artists ar on ar.artists_id=a.artist_id where a.sku in (${ph})`, skus);
2058    const bySku = new Map(rows.map((r) => [r.sku, r]));
2059    const fav = new Set(UserData.favs && UserData.favs.album || []);
2060    const dis = new Set(UserData.dislikes && UserData.dislikes.album || []);
2061    return skus.map((s) => bySku.get(s)).filter(Boolean)
2062      .filter((a) => !Suppress.album(a.album_id) && !fav.has(a.album_id) && !dis.has(a.album_id));
2063  },
2064};
2065function renderRecommended(c) {
2066  if (!isMember()) {
2067    c.innerHTML = `<div class="page-title">Recommended</div><div class="pad">Log in to your Magnatune membership to get personal recommendations — <a href="#/settings">go to Settings</a>.</div>`;
2068    return;
2069  }
2070  c.innerHTML = `<div class="page-title">Recommended</div><div class="pad" id="rec-status">Finding music you’ll like…</div>`;
2071  Recs.get().then((skus) => {
2072    const host = el("content");
2073    if (!host || currentToken().split("/").filter(Boolean)[0] !== "recommended") return;   // user navigated away
2074    const rows = Recs.albumsForSkus(skus);
2075    if (!rows.length) {
2076      host.innerHTML = `<div class="page-title">Recommended</div><div class="pad">Play a few albums first — recommendations are based on your Recently Played.</div>`;
2077      return;
2078    }
2079    const lid = registerList(rows.flatMap((a) => Q.albumSongs(a.album_id).map((s) => trk(s))));
2080    host.innerHTML = `<div class="page-title">Recommended</div>` +
2081      `<div class="section-sub" style="margin-top:-.8rem;margin-bottom:1rem">Based on your Recently Played · updates daily</div>` +
2082      catPlay(lid) +
2083      `<div class="grid">` + rows.map((a) =>
2083 albumCell(a)).join("") + `</div>`;
2084    LazyImg.scan(host);
2085    highlightPlayingRow();
2086  });
2087}
2088function renderUserPlaylist(c, id) {
2089  const p = UserData.playlist(id);
2090  if (!p) { c.innerHTML = `<div class="pad">Playlist not found.</div>`; return; }
2091  const songs = orderByIds(Q.songsByIds(p.songIds), "song_id", p.songIds).map((s) => trk(s));
2092  const lid = registerList(songs);
2093  let html = `<div class="page-title">${esc(p.name)}</div>
2094    <div class="pl-actions">
2095      ${songs.length ? `<button class="playall" data-list="${lid}" onclick="MAG.playFrom('${lid}',0)">${playLabel(lid)}</button>` : ""}
2096      <button class="ghostbtn" onclick="MAG.renamePlaylist(${id})">Rename</button>
2097      <button class="ghostbtn danger" onclick="MAG.deletePlaylist(${id})">Delete</button>
2098    </div>`;
2099  if (songs.length) {
2100    html += `<div class="songs">` + songs.map((t, i) =>
2101      `<div class="song-row with-sub" data-sid="${t.songId}" onclick="MAG.playFrom('${lid}',${i})">
2102         <div class="track">${i + 1}</div>
2103         <div class="song-name">${esc(t.title)}</div>
2104         <div class="song-sub">${esc(t.artist)} · ${esc(t.album)}</div>
2105         <div class="dur">${fmtDur(t.duration)}</div>
2106         <div class="song-actions"><button class="addbtn" title="Remove" onclick="MAG.removeFromPlaylist(event,${id},${t.songId})">✕</button></div>
2107       </div>`).join("") + `</div>`;
2108  } else html += `<div class="pad">Empty — add songs with the add-to-playlist button on any song, album, or artist.</div>`;
2109  c.innerHTML = html;
2110}
2111
2112/* ---- Previous downloads (member's own album download history) ----
2113   Reached from Settings -> Download formats -> "Previous downloads". Fetches the
2114   member's download history from /api/mydownloads (server reads MySQL
2115   membership_downloads keyed by the signed member cookie) and links each album to
2116   its page via the in-browser sku->slug map. Album-zip downloads only (per-song
2117   /music/* downloads aren't recorded server-side). */
2118async function renderDownloads(c) {
2119  const title = `<div class="page-title">Previous downloads</div>`;
2120  if (!isMember()) {
2121    c.innerHTML = title + `<div class="set-section"><div class="set-row"><div class="set-sub">Log in under <b>Settings &rarr; Account</b> to see the albums you've downloaded.</div></div></div>`;
2122    return;
2123  }
2124  c.innerHTML = title + `<div class="set-section"><div class="set-row"><div class="set-sub">Loading…</div></div></div>`;
2125  let items = [];
2126  try {
2127    const r = await fetch("/api/mydownloads").then((r) => r.json());
2128    if (r && r.ok) items = r.items || [];
2129  } catch (e) { /* fall through to empty */ }
2130  if (!items.length) {
2131    c.innerHTML = title + `<div class="set-section"><div class="set-row"><div class="set-sub">You haven't downloaded any albums yet. Open an album and choose <b>Download</b>.</div></div></div>`;
2132    return;
2133  }
2134  const rows = items.map((it) => {
2135    const label = `${esc(it.album)} <span class="set-sub" style="display:inline">— ${esc(it.artist)}</span>`;
2136    const meta = [it.format ? esc(it.format.toUpperCase()) : "", it.date ? esc(it.date) : ""].filter(Boolean).join(" · ");
2137    const inner = `<span class="toc-label">${label}${meta ? `<div class="set-sub">${meta}</div>` : ""}</span>`;
2138    const tok = P.tokenBySku.get(it.sku);   // "album/<aSlug>/<bSlug>" when the album is still in the catalog
2139    return tok
2140      ? `<a class="toc-item" href="${tokenToPath(tok)}" onclick="return MAG.doc(event,'${tok}')">${inner}<i class="fa-solid fa-chevron-right toc-chev"></i></a>`
2141      : `<div class="toc-item">${inner}</div>`;   // delisted album: show it, but nowhere to link
2142  }).join("");
2143  c.innerHTML = title + `<nav class="toc">${rows}</nav>`;
2144}
2145
2146/* ---- Settings (member login, catalog, about) ---- */
2147function renderSettings(c) {
2148  const m = UserData.member;
2149  const account = m.loggedIn
2150    ? `<div class="set-row"><div><div class="set-label">Magnatune membership</div>
2151         <div class="set-sub">Signed in as <b>${esc(m.user)}</b> — tracks stream without the spoken announcement.</div></div>
2152         <button class="ghostbtn" onclick="MAG.logout()">Log out</button></div>`
2153    : `<div class="set-row"><div style="flex:1">
2154         <div class="set-label">Magnatune membership</div>
2155         <div class="set-sub">Log in to stream member tracks without the end-of-track announcement.</div>
2156         <div class="login-form">
2157           <input id="mu" placeholder="Username" autocomplete="username">
2158           <input id="mp" type="password" placeholder="Password" autocomplete="current-password">
2159           <button class="playall" id="mlogin">Log in</button>
2160         </div>
2161         <div class="set-sub" style="margin-top:.5rem"><a href="/member/signup" target="_blank" rel="noopener">Getting a membership &rarr;</a></div>
2162         <div class="set-err" id="login-err"></div></div></div>`;
2163  // Members-only audio-quality picker.
2164  const audio = m.loggedIn
2165    ? `<div class="set-section"><div class="set-head">Audio</div>
2166         <div class="set-row"><div style="flex:1"><div class="set-label">Audio quality</div>
2167           <div class="set-sub">Normal streams 96 kbps Opus, High streams 192 kbps Opus. Playback tries Opus everywhere and falls back automatically — to a lower tier if a track isn't encoded at your choice, or to AAC if your browser can't play Opus.</div></div>
2168           <select class="qsel" onchange="MAG.setQuality(this.value)">
2169             <option value="normal"${Player.quality === "normal" ? " selected" : ""}>Normal</option>
2170             <option value="lossless"${Player.quality === "lossless" ? " selected" : ""}>High</option>
2171           </select></div></div>`
2172    : "";
2173  const albumFmts = [["vbr", "MP3 — high quality VBR (~88MB)"], ["mp3", "MP3 — 128k (smaller)"], ["aac", "AAC — for iTunes / iPod"], ["alac", "ALAC — Apple Lossless"], ["flac", "FLAC — lossless, open"], ["ogg", "OGG — high quality, open"], ["wav", "WAV — lossless, exact copy"]];
2174  const songFmts = [["mp3", "MP3 — high quality"], ["ogg", "OGG"], ["flac", "FLAC — lossless"], ["wav", "WAV — lossless"]];
2175  const opt = (list, cur) => list.map((f) => `<option value="${f[0]}"${cur === f[0] ? " selected" : ""}>${f[1]}</option>`).join("");
2176  const downloads = m.loggedIn
2177    ? `<div class="set-section"><div class="set-head">Download formats</div>
2178         <div class="set-row"><div style="flex:1"><div class="set-label">Album</div>
2179           <div class="set-sub">Format used when you download a whole album.</div></div>
2180           <select class="qsel" onchange="UserData.setDlAlbum(this.value)">${opt(albumFmts, UserData.dlAlbum)}</select></div>
2181         <div class="set-row"><div style="flex:1"><div class="set-label">Songs</div>
2182           <div class="set-sub">Format used when you download a single song.</div></div>
2183           <select class="qsel" onchange="UserData.setDlSong(this.value)">${opt(songFmts, UserData.dlSong)}</select></div>
2184         <div class="set-row"><div style="flex:1"><div class="set-label">Previous downloads</div>
2185           <div class="set-sub">The albums you've downloaded, linked to their pages.</div></div>
2186           <a class="ghostbtn" href="${tokenToPath("downloads")}" onclick="return MAG.doc(event,'downloads')">Previous downloads</a></div></div>`
2187    : "";
2188  // About = a table-of-contents list of real document links (not buttons): each has the
2189  // page's real /info/ URL as its href (openable / copyable / new-tab), and a plain click
2190  // soft-navigates in-app via MAG.doc so playback isn't interrupted by a full reload.
2191  const ABOUT_LINKS = [
2192    ["Why we are not evil", "info/whynotevil"], ["Founder's rant", "info/why"],
2193    ["FAQ", "info/faq"], ["Music licensing", "info/licensing"],
2194    ["Give away our music", "info/give"], ["Podcast our music", "info/podcast"],
2195    ["Gift cards", "info/giftcards"], ["Terms of use", "info/terms"],
2196    ["APIs, RSS & XML", "info/api"], ["Contact Magnatune", "info/contact"],
2197    ["App help", "help"],
2198  ];
2199  const about = `<div class="set-section"><div class="set-head">About</div>
2200       <nav class="toc">${ABOUT_LINKS.map(([label, tok]) =>
2201         `<a class="toc-item" href="${tokenToPath(tok)}" onclick="return MAG.doc(event,'${tok}')"><span class="toc-label">${esc(label)}</span><i class="fa-solid fa-chevron-right toc-chev"></i></a>`).join("")}</nav></div>`;
2202  const appearance = `<div class="set-section"><div class="set-head">Appearance</div>
2203       <div class="set-row"><div style="flex:1"><div class="set-label">Theme</div>
2204         <div class="set-sub">Dark mode for night listening. “System” follows your device's light/dark setting.</div></div>
2205         <select class="qsel" onchange="MAG.setTheme(this.value)">
2206           <option value="system"${UserData.theme === "system" ? " selected" : ""}>System</option>
2207           <option value="light"${UserData.theme === "light" ? " selected" : ""}>Light</option>
2208           <option value="dark"${UserData.theme === "dark" ? " selected" : ""}>Dark</option>
2209         </select></div></div>`;
2210  c.innerHTML =
2211    `<div class="page-title">Settings</div>
2212     <div class="set-section"><div class="set-head">Account</div>${account}</div>
2213     ${audio}
2214     ${downloads}
2215     ${appearance}
2216     <div class="set-section"><div class="set-head">
2216Catalog</div>
2217       <div class="set-row"><div><div class="set-label">Music catalog</div><div class="set-sub" id="cat-sub">…</div></div></div></div>
2218     <div class="set-section"><div class="set-head">Library</div>
2219       <div class="set-row"><div><div class="set-label">Saved in this browser</div>
2220         <div class="set-sub">${UserData.favs.song.length + UserData.favs.album.length + UserData.favs.artist.length} favorites · ${UserData.playlists.length} playlists · ${UserData.dislikes.song.length + UserData.dislikes.album.length + UserData.dislikes.artist.length} disliked</div></div></div></div>
2221     <div class="set-section"><div class="set-head">Dislikes</div>
2222       <div class="set-row"><div style="flex:1"><div class="set-label">Hide things I dislike</div>
2223         <div class="set-sub">Tap the broken-heart icon next to the heart on any song, album, or artist to dislike it. While this is on, disliked things are hidden everywhere. Turn it off to see them again and tap the broken heart to un-dislike.</div></div>
2224         <label class="switch"><input type="checkbox" id="hide-dislikes" ${UserData.hideDislikes ? "checked" : ""} onchange="MAG.setHideDislikes(this.checked)"><span class="track"></span></label></div></div>
2225     <div class="set-section"><div class="set-head">Apps</div>
2226       <div class="set-row"><div style="flex:1"><div class="set-label">Magnatune for Android</div>
2227         <div class="set-sub">Native player. Download the latest APK from GitHub releases and open it to install (you may need to allow installing from your browser / unknown sources).</div></div>
2228         <a class="ghostbtn" href="https://github.com/johnbuckman/magnatune-android/releases" onclick="return MAG.dlLatest(event,'johnbuckman/magnatune-android','apk')" target="_blank" rel="noopener">Download APK</a></div>
2229       <div class="set-row"><div style="flex:1"><div class="set-label">Magnatune for iOS (iPhone/iPad)</div>
2230         <div class="set-sub">Native player for iPhone &amp; iPad. There's no App Store version — install it for free with the Mac app <b>SideStep</b> (takes a couple of minutes). The app (.ipa) is published on <a href="https://github.com/johnbuckman/magnatune-app/releases" target="_blank" rel="noopener">GitHub releases</a>.</div></div>
2231         <a class="ghostbtn" href="${tokenToPath("ios")}" onclick="return MAG.doc(event,'ios')">How to install</a></div>
2232       <div class="set-row"><div style="flex:1"><div class="set-label">Magnatune for OSX</div>
2233         <div class="set-sub">Native player. Download the latest DMG from GitHub releases, open it, and drag Magnatune to Applications. Requires macOS 14 Sonoma or later. Signed &amp; notarized by Apple.</div></div>
2234         <a class="ghostbtn" href="https://github.com/johnbuckman/magnatune-app/releases" onclick="return MAG.dlLatest(event,'johnbuckman/magnatune-app','dmg')" target="_blank" rel="noopener">Download DMG</a></div></div>
2235     ${about}`;
2236  const na = q("select count(*) c from albums")[0].c, ns = q("select count(*) c from songs")[0].c;
2237  el("cat-sub").textContent = `${na.toLocaleString()} albums · ${ns.toLocaleString()} songs — kept up to date automatically`;
2238  if (!m.loggedIn) {
2239    el("mlogin").onclick = () => MAG.login();
2240    el("mp").addEventListener("keydown", (e) => { if (e.key === "Enter") MAG.login(); });
2241  }
2242}
2243MAG.setHideDislikes = (on) => { UserData.setHideDislikes(on); route(); };
2244MAG.setQuality = (q) => Player.setQuality(q);
2245MAG.login = async () => {
2246  const user = el("mu").value.trim(), pw = el("mp").value, err = el("login-err");
2247  if (!user || !pw) { err.textContent = "Enter username and password."; return; }
2248  err.textContent = "Checking…";
2249  try {
2250    const r = await fetch(LOGIN_URL, {
2251      method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },
2252      body: `user=${encodeURIComponent(user)}&pw=${encodeURIComponent(pw)}`,
2253    }).then((r) => r.json());
2254    if (r.ok) { UserData.setMember({ loggedIn: true, user: r.user }); toast("Logged in"); Sync.pull(); route(); }
2255    else err.textContent = r.error || "Login failed.";
2256  } catch (e) { err.textContent = "Network error."; }
2257};
2258MAG.logout = () => { fetch("/membership/logout", { method: "POST" }).catch(() => {}); UserData.setMember({ loggedIn: false, user: "" }); toast("Logged out"); route(); };
2259MAG.info = (page) => go("info/" + page);   // in-app About page (was external window.open)
2260// TOC doc link: a plain click soft-navigates in-app (keeps playback); a modified /
2261// middle click falls through to the browser so the real href opens in a new tab.
2262MAG.doc = (e, tok) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return true; e.preventDefault(); go(tok); return false; };
2263MAG.setTheme = (v) => UserData.setTheme(v);
2264// Download the newest GitHub release asset matching `ext` (e.g. "apk"/"dmg") for `repo`.
2265// Resolves the latest release that actually HAS such an asset (the newest release may be
2266// platform-specific, e.g. an iOS-only build with no .dmg), then navigates straight to the
2267// asset — release assets are served as attachments, so the browser downloads it. A modified
2268// or middle click, or any API failure/rate-limit, falls through to the releases page (the
2269// link's href). Kept dependency-free: api.github.com sends permissive CORS, so this runs
2270// straight from the static player.
2271MAG.dlLatest = (e, repo, ext) => {
2272  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return true;  // let it open the releases page
2273  e.preventDefault();
2274  const fallback = `https://github.com/${repo}/releases`;
2275  const toPage = () => window.open(fallback, "_blank", "noopener");
2276  fetch(`https://api.github.com/repos/${repo}/releases`, { headers: { Accept: "application/vnd.github+json" } })
2277    .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
2278    .then((rels) => {
2279      for (const rel of rels) {
2280        const a = (rel.assets || []).find((x) => x.name.toLowerCase().endsWith("." + ext));
2281        if (a) { window.location.href = a.browser_download_url; return; }
2282      }
2283      toPage();   // no matching asset anywhere → let the user pick from the releases page
2284    })
2285    .catch(toPage);
2286  return false;
2287};
2288
2289/* ===================== help page ===================== */
2290// Shown in the content area like any other section. Opened by clicking the mascot
2291// in the sidebar or Settings -> About -> "App help".
2292const HELP_HTML = `<div class="help">
2293<h2>Welcome to Magnatune</h2>
2294<p>Magnatune is an independent record label founded in 2003 on a simple idea: be fair to musicians and fair to listeners. Our motto is &ldquo;we are not evil.&rdquo;</p>
2295<p>Every album is hand-picked &mdash; we accept about 3% of submissions &mdash; and you can listen to everything in full before you decide to buy. When you do buy, half the money goes straight to the artist. All the music is Creative Commons licensed, with no copy protection (DRM).</p>
2296<p>This player streams Magnatune&rsquo;s entire catalog: hundreds of artists across classical, jazz, electronica, rock, ambient, world and more.</p>
2297
2298<h2>Finding your way around</h2>
2299<p>On a wide screen the sections live in the sidebar on the left. On a narrow screen or phone they&rsquo;re split between a bar at the top (for browsing) and a bar at the bottom.</p>
2300<ul>
2301<li><b>Popular</b> &mdash; the most-loved albums, grouped by genre.</li>
2302<li><b>Artists</b>, <b>Albums</b>, <b>Songs</b> &mdash; browse the whole catalog.</li>
2303<li><b>Genres</b> &mdash; pick a style, then drill into its artists and albums.</li>
2304<li><b>Tags</b> &mdash; albums grouped by mood and theme (&ldquo;Tagged as&hellip;&rdquo;).</li>
2305<li><b>Featured</b> &mdash; playlists hand-curated by Magnatune.</li>
2306<li><b>Search</b> &mdash; find any artist, album or song by name.</li>
2307<li><b>Favorites</b>, <b>Playlists</b>, <b>Settings</b> &mdash; your own music and options.</li>
2308</ul>
2309
2310<h2>Browsing music</h2>
2311<p>Click any artist, album or genre to open it. Album pages list every track; artist pages list their albums.</p>
2312<ul>
2313<li>Click album art or an artist photo to see it full-screen.</li>
2314<li>At the bottom of an album or artist page you&rsquo;ll find <b>&ldquo;You might also like&rdquo;</b> &mdash; related music chosen for you.</li>
2315<li>Genre and tag chips on a detail page are clickable, taking you to everything in that genre or tag.</li>
2316</ul>
2317
2318<h2>Playing music</h2>
2319<p>Click a song to play it. The <b>Play</b> button on an album or artist plays the whole thing; turn on <b>Shuffle</b> to mix up the order.</p>
2320<p>The mini-player sits at the bottom. Click it to open the full <b>Now Playing</b> view &mdash; there you can click the art, song or album to jump to that album, or the artist name to open the artist.</p>
2321<ul>
2322<li><b>Play / Pause</b>, <b>Previous</b>, <b>Next</b> &mdash; the usual transport controls.</li>
2323<li><b>Shuffle</b> plays the queue in random order. <b>Repeat</b> loops the queue forever once it reaches the end &mdash; and looped tracks play from memory, with no extra downloading.</li>
2324<li>Drag the progress bar to skip to any point in a track.</li>
2325<li><b>Volume</b> &mdash; the slider in the mini-player.</li>
2326<li><b>AirPlay</b> &mdash; in Safari, use the AirPlay button to send sound to AirPlay speakers, a HomePod or an Apple TV. (Other browsers don&rsquo;t offer AirPlay.)</li>
2327<li>Playback appears in your system media controls, so you can play, pause and skip from your keyboard or lock screen.</li>
2328</ul>
2329
2330<h2>Favorites &amp; dislikes</h2>
2331<p>Use the heart and broken-heart icons that appear next to every song, album and artist.</p>
2332<ul>
2333<li><b>Heart</b> &mdash; add to your Favorites. Everything you favorite lives under <b>Favorites</b>.</li>
2334<li><b>Broken heart</b> &mdash; mark something you&rsquo;d rather not see. With <b>Hide things I dislike</b> on (<b>Settings</b>), disliked music disappears everywhere; turn it off to bring it back.</li>
2335</ul>
2336
2337<h2>Your playlists</h2>
2338<p>Build your own playlists under <b>Playlists</b>.</p>
2339<ul>
2340<li>Click the <b>add-to-playlist</b> button next to any heart to add a song, a whole album, or an artist&rsquo;s entire catalog &mdash; or to create a new playlist on the spot.</li>
2341<li>Inside a playlist you can remove tracks or delete the playlist.</li>
2342</ul>
2343
2344<h2>Membership &amp; sound quality</h2>
2345<p>You can stream everything for free. Free streams include a short spoken announcement at the end of each track.</p>
2346<p>A Magnatune membership removes the announcement, unlocks higher-quality audio, and lets you download music. Log in under <b>Settings</b> &rarr; Account.</p>
2347<ul>
2348<li>Members can choose <b>Normal</b> or <b>Lossless</b> audio quality in <b>Settings</b>.</li>
2349</ul>
2350
2351<h2>Downloading music</h2>
2352<p>When you&rsquo;re a logged-in member, a download button appears on albums and on individual songs. It downloads the music through your browser, in the format you choose under <b>Settings</b> &rarr; Download formats.</p>
2353
2354<h2>Your data</h2>
2355<p>Your favorites, playlists and settings are saved in this browser. They stay on this device and aren&rsquo;t synced to your other devices or browsers, and clearing your browser data will remove them.</p>
2356
2357<h2>Settings</h2>
2358<p>Everything above is configurable under <b>Settings</b>: membership, audio quality, downloads, dislikes and your library. The catalog updates itself automatically in the background.</p>
2359<p>Tap <b>Why we are not evil</b> or <b>Founder&rsquo;s rant</b> in <b>Settings</b> to learn more about what Magnatune stands for.</p>
2360<p>Enjoy the music &mdash; and thanks for supporting independent artists.</p>
2361</div>`;
2362// Bolded words that name a navigable section -> its hash route. Turns <b>Section</b>
2363// into a link; other <b>bold</b> terms (Play, Shuffle, AirPlay, …) stay plain bold.
2364const HELP_LINKS = {
2365  Popular: "popular", Artists: "artist", Albums: "album", Songs: "songs", Genres: "genre", Tags: "tag",
2366  Featured: "featured", Search: "search", Favorites: "favorites", Playlists: "playlists", Settings: "settings",
2367};
2368function linkifyHelp(html) {
2369  return html.replace(/<b>([^<]+)<\/b>/g, (m, t) =>
2370    HELP_LINKS[t] ? `<a class="help-link" href="#/${HELP_LINKS[t]}">${t}</a>` : m);
2371}
2372/* ===================== About / info pages (scraped from magnatune.com /info/) ===================== */
2373// AUTO-GENERATED from magnatune.com /info/ pages — navim4 in-app About pages.
2374const INFO_PAGES = {
2375  whynotevil: { title: "Why we are not evil", html: `<b>Why Magnatune is not evil</b><p>
2376<li> <b>Fantastic music</b>: we work with artists directly, not with record labels, and all our music is hand-picked. On average, we accept 3% of submissions
2377<p>
2378<li> <b>Perfect audio quality</b>: our music is available in both lossless and smaller-size "lossy" formats
2379<p>
2380<li> <b>Many audio formats</b>: our albums can be download as WAV files, as well as super-high quality VBR MP3s, AAC, Apple Lossless (ALAC), and open source friendly FLAC, OGG and OPUS formats
2381<p>
2382<li> <b>
2382Creative Commons</b>: members get access to all our music under a friendly license that enables sharing and reuse [<a href="http://magnatune.com/info/cc_licensed" target="_blank" rel="noopener">more</a>]
2383<p>
2384<li> <b>Listen to everything</b>: all our albums can be listened to in their entirety before you buy.
2385<p>
2386<li> <b>Download everything</b>: once you've paid, you can download anything from our entire catalog--no limits.
2387<p>
2388<li> <b>Musicians get paid</b>: 50% of your purchase price goes directly to the musician, not to labels and their lawyers
2389<p>
2390<li> <b>Album art</b>: every album includes high quality album art (in both Adobe Acrobat and 300DPI JPG formats)
2391<p>
2392<li> <b>Give to your friends</b>: We encourage you to <a href="http://magnatune.com/give" target="_blank" rel="noopener">give 3 copies</a> of any music from your membership to your friends
2393<p>
2394
2395<li> <b>Artists direct</b>: we sign contracts directly with musicians, so you can rest assured that we can legally license music to you, and no middlemen get in the way of the artist's royalties
2396<p>
2397<li> <b>Podcast-legal</b>: non-commercial <a href="http://magnatune.com/podcast" target="_blank" rel="noopener">podcasters</a> can use our music for free
2398<p>
2399<li> <b>No major labels</b>: we have absolutely nothing to do with major labels, their lawyers or the RIAA
2400<p>
2401<li> <b>Financially support Open Source</b>: we financially support several open source projects such as <a href="http://blogs.magnatune.com/buckman/2006/11/amarok_adds_mag.html" target="_blank" rel="noopener">Amarok</a> and <a href="http://blogs.magnatune.com/buckman/2006/12/rhythmbox_adds_.html" target="_blank" rel="noopener">Rhythmbox</a>
2402<p>
2403<li> <b>No DRM</b>: No copy protection (DRM), so you can do what you like with your music` },
2404  faq: { title: "Frequently Asked Questions", html: `<b>Frequently Asked Questions</b>
2405 <p>
2406
2407 What does the a purchase of Magnatune's music get you?<br>
2408 How much does it cost? <br>
2409 Do I also get streaming?<br>
2410 What download formats do I get?<br>
2411 Are there any download limits?<br>
2412 Can I really download everything?<br>
2413 Are there members-only features?<br>
2414 How do I download music?<br>
2415 Can I burn CDs?<br>
2416 Do you use copy protection? DRM?<br>
2417 What rights do I have to the music?<br>
2418 Does it work on Linux? Mac? Windows?<br>
2419 Does it work with Firefox? IE? Others?<br>
2420 Can I use my own music player? (winamp? iTunes?)<br>
2421 How are your musicians paid?<br>
2422 Can I share the music I download?<br>
2423 Are there any desktop programs for using Magnatune download membership?<br>
2424 Can I share my login with others?<br>
2425 Do you donate money to open source programs?<br>
2426 Whatever happened to XXX artist? I know I heard them on your site before.<br>
2427 How do I get the music I downloaded into my iTunes library?<br>
2428 How do I add the album cover art to my iTunes library? When I imported the music, there was no artwork.<br>
2429 Can I pay extra to receive a physical copy of the CD?<br>
2430 What about my existing membership subscription?<br><p>
2431Answers:
2432<p><br>
2433
2434<br>
2435<b>What does the a purchase of Magnatune's music get you?</b>
2436<p>
2437
2438You get to listen to everything on the Magnatune web site, without any commercials, and anything you want to download.
2439<p>
2440
2441We offer MP3 and WAV files, and a variety of other formats as well (see below for a list of formats). All without copy protection (DRM). 
2442<p>
2443
2444You can also download a PDF of the album artwork.
2445<p>
2446<br>
2447
2448<b>How much does it cost? </b>
2449<p>
2450Our library costs $299 as a one-time payment. <p>
2451Because we aren't evil, fully 50% of your payment goes straight to the musicians (not to lawyers or labels) that you download from. You're doing a good deed by paying for our music!
2452<p>
2453<br>
2454
2455<b>Do I also get streaming?</b>
2456<p>
2457
2458Yes. You can listen to everything on the web site without needing to download it. Plus, the annoying commercials at the end of songs don't appear after you've paid.
2459<p>
2460<br>
2461
2462<b>What download formats do I get?</b>
2463<p>
2464
2465MP3 - high quality VBR as well as 128k MP3 (lower quality)<br>
2466WAV files - perfect quality<br>
2467ALAC - Apple lossless (without DRM)<br>
2468OPUS - high quality open source format.<br>
2469FLAC format - perfect quality open source format<br>
2470AAC files - high quality Apple iTunes only (without DRM)<br>
2471OGG Vorbis - high quality, open source format<br>
2472<p>
2473
2474<br>
2475
2476<b>Are there any download limits?</b>
2477<p>
2478
2479There are no download limits, you are free to download whatever albums you like at any time.
2480<p>
2481<br>
2482
2483<b>Can I really download everything?</b>
2484<p>
2485
2486Yes!<p>
2487<br>
2488
2489<b>Are there members-only features?</b>
2490<p>
2491
2492Yes, paying enables the "favorites" feature.<p> When you are listening to an album using the Magnatune web site, you
2493can mark an album as a favorite. You can then do a number of things with your favorite albums:
2494<p>
24951) one-click add albums to your favorites as you listen to them. Download them later, when it's convenient.
2496<p>
24972) listen to all your favorite albums (sequentially or as a shuffle mi
2497x) on the web with our flash player, or inside your own media player via the m3u/ogg/xspf links.
2498<p>
24993) personal podcast: if you subscribe to your favorites using podcast-download software (such as iTunes), then the albums you mark as favorites are automatically downloaded in the background, and added to your iPod. Favorites are automatically downloaded and added to your Windows Media Player Library, when you use the free "Juice" podcast download program.
2500<p>
25014) iTunes sync: instantly import their favorite Magnatune albums (as streams) into iTunes.
2502<p>
2503 More information about these features is available on these blog pages: 
2504<a href="http://blogs.magnatune.com/buckman/2008/06/new-feature-fav.html" target="_blank" rel="noopener">favorites</a> and
2505<a href="http://blogs.magnatune.com/buckman/2008/06/new-feature-exp.html" target="_blank" rel="noopener">instant iTunes import</a>.
2506
2507<p>
2508<br>
2509
2510<b>How do I download music?</b>
2511<p>
2512
2513When you are on an album page on Magnatune, simply click the "download" button. The next page will give you zip files of all the various download formats for that album, as well as a PDF of the artwork.
2514<p>
2515<br>
2516
2517<b>Can I burn CDs?</b>
2518<p>
2519
2520Yes, you can burn CDs from the downloaded files
2521<p>
2522
2523If you are doing that, we recommend you download the WAV format of the album, so that your CD is an exact duplicate of the master CD, and sounds perfect.
2524<p>
2525<br>
2526
2527<b>Do you use copy protection? DRM?</b>
2528<p>
2529
2530We never, ever use copy protection: that would be evil.
2531<p>
2532<br>
2533
2534<b>What rights do I have to the music?</b>
2535<p>
2536
2537You can play the music just as if you had purchased it individually from a store. 
2538<p>
2539
2540Put it on your computer, transfer it to your iPod, copy it to a USB key and take it to work, whatever you like.
2541<p>
2542 
2543We expect that you will want to share some of the great music you find with your friends, and that's great!
2544<p>
2545
2546However, please don't give a friend more than one album from your download membership per month, as we would like to strike a balance between helping you tell your friends about Magnatune, and having your friends sign up with their own membership. Use your own judgement.
2547<p>
2548
2549<br>
2550
2551<b>Does it work on Linux? Mac? Windows?</b>
2552<p>
2553
2554Yes, because we are simply a web site, and do not use any copy protection, everything works on all operating systems.
2555<p>
2556<br>
2557
2558<b>Does your web site work with Firefox? IE? Opera? Chrome? Others?</b>
2559<p>
2560
2561Yes, all modern web browsers work, and our web site no longer requires Flash.
2562
2563<p>
2564<br>
2565
2566<b>Can I use my own music player? (winamp? iTunes?)</b>
2567<p>
2568
2569The downloads from our web site are plain MP3s and should work in every music player.
2570
2571To stream music with a different (non web browser) player, click the ".m3u" link to play the music in your own music player.<p>
2572<br>
2573
2574<b>How are your musicians paid for my purchase?</b>
2575<p>
2576
257750% of your purchase is allocated to the musicians whose albums you download each month (divided equally based on which albums you downloaded). 
2578
2579<p>
2580<br>
2581
2582<b>Can I share the music I download?</b>
2583<p>
2584
2585We expect that you will want to share some of the great music you find with your friends, and that's great!
2586<p>
2587
2588However, please don't give a friend more than one album per month, as we would like to strike a balance between helping you tell your friends about Magnatune, and having your friends sign up on their own. Use your own judgement.
2589<p>
2590<br>
2591
2592<b>Are there any desktop programs for using Magnatune?</b>
2593<p>
2594
2595There are excellent iPhone and Android Phone applications that stream music over the mobile phone network, and both support membership for commercial-free listening.
2596<p>
2597The Linux programs Rhythmbox and Amarok support Magnatune very well and both support Magnatune memberships.
2598
2599<p>
2600<br>
2601
2602<b>Can I share my membership with others?</b>
2603<p>
2604
2605Sorry, but only one person may use your download membership. We may cancel your membership if we find many people using it at the same time.
2606<p>
2607<br>
2608
2609<b>Do you donate money to open source programs?</b><p>
2610Yes, if you pay via either <a href="http://www.gnome.org/projects/rhythmbox/" target="_blank" rel="noopener">Rhythmbox</a> or <a href="http://amarok.kde.org/" target="_blank" rel="noopener">Amarok</a>, 10% of your payment is donated to that project.
2611<p>
2612<br>
2613
2614<b>Whatever happened to XXX artist? I know I heard them on your site before.</b><p>
2615We try to be as fair as possible to our artists: our motto of 'We are
2616Not Evil' is as much for them as it is for the consumer. In some
2617cases, this has meant that artists decided to leave Magnatune to pursue
2618agreements with other record labels that don't allow the non-exclusive relationship we have.
2619We don't always agree with their decision, but we of course respect and abide by it. In those cases, we remove the artist's music from our web site.
2620<p>
2621<br>
2622
2623<b>How do I get the music I downloaded into my iTunes library?</b><p>
2624With Windows, right-click on the downloaded file and select 'Expand All' from the menu that appears.
2625<p>
2626
2627For Macs, just double clicking the download file will do it. Make sure to take note where you put the song files.
2628<p>
2629
2630*Launch iTunes, and select the 'File' option from the menu bar.
2631<p>
2632
2633*Select 'Add to Library'. A new window will come up, and you will need to find the album you downloaded. 
2634<p>
2635
2636*Hit 'Edit' and then 'Select All' to highlight all of the songs in the album.
2637<p>
2638
2639*Press the the 'Choose' button. This will add all of the tracks to your iTunes library, and you can then move them around as you would any other song from playlist to iPod etc.
2640<p>
2641<br>
2642
2643<b>How do I add the album cover art to my iTunes library? When I imported the music, there was no artwork.</b><p>
2644In iTunes, not every album from Magnatune will automatically display artwork. This is because iTunes gets its artwork from a 3rd party service which doesn't always have the latest Magnatune releases. You can certainly add artwork into iTunes, however, fairly easily. 
2645
2646<p>
2647You can use JPEG, GIF, PNG or Photoshop images to add artwork to any Magnatune albums that may be missing it. A high quality JPEG of the album cover is available on the album download page. 
2648<p>
2649
26501. In Browse view, select the album in your iTunes library.
2651<p>
2652Click the Browse button to switch to Browse view so that an Album column appears on the right side of the iTunes window. Click an album to select it.
2653<p>
2654
26552. Choose the File option from the top menu bar, Get Info or press Command-I (Mac) or Ctrl-I (PC).
2656<p>
2657
26583. Click 'Yes' to edit information for multiple items, in this case an entire album.
2659<p>
2660
2661The Multiple Song Information dialog appears.
2662<p>
2663
26644. Enable the Artwork field by selecting its check box.
2665<p>
2666
2667When you add a check mark to a field, iTunes assumes you want that field changed in all the selected songs. Make sure that no other check box is selected except Artwork.
2668<p>
2669
26705. Drag the graphics file to the Artwork panel.
2671<p>
2672
2673Drag the graphics file directly over the blank Artwork field in the Multiple Song Information dialog.
2674<p>
2675
26766. Click OK to make the change.
2677<p>
2678
26797. Click Yes to change the artwork when the warning message appears about changing multiple files.
2680<p>
2681<br>
2682
2683 <b>Can I pay extra to receive a physical copy of the CD?</b><p>
2684We are no longer offering physical CD's. CD burning technology has come
2685to the point where it is easy to create your own copies of any albums
2686you download from Magnatune, and we offer artwork that you can print
2687at home to give your discs a more polished look, if you would like. 
2688<p>
2689
2690<b>What about my existing membership subscription?</b>
2691<p>
2692
2693We will continue to honor existing membership subscriptions, but are not offering any new ones.` },
2694  licensing: { title: "Music Licensing", html: `<b>Music Licensing at Magnatune</b>
2695 
2696<p>Magnatune provides you with instant music licensing from our catalog of 20,000 songs, covering a wide variety of genres. With a few mouse clicks, you get CD quality audio files and the right to use the music in your project.
2697No waiting, no haggling, no lawyers. <p>
2698We sell more music licenses online than anyone else: over 7000 licenses in our 12 years of business. 4000 films have used our music. And there have never been any legal problems with any of them.
2699
2700<b>How Does It Work?</b><p>
2701Our music is licensed through two dedicated web sites that we also own and run.<ul>
2702
2703<li><b>
2703<a href="http://ilicensemusic.com" target="_blank" rel="noopener">iLicenseMusic</a></b> is our subscription music licensing service. For a simple monthly fee, you can use any of our music in any way you like.
2704If you're making a film, video, web site, presentation, or any other project which uses music, this is the web site for you.
2705<p>
2706<li><b><a href="http://moodmixes.com" target="_blank" rel="noopener">MoodMixes</a></b> is our background music for business service. We provide your restaurant, store, office or other public
2707space with complete shows, albums, downloads -- whatever you like.
2708
2709</ul>
2710
2711<b>What does this cost?</b><ul>
2712<li><a href="http://ilicensemusic.com" target="_blank" rel="noopener">iLicenseMusic</a> charges a simple $89/month for any use you can imagine. You can choose from any of the songs in our extensive catalog. No lawyers, no salespeople, no haggling.
2713<li><a href="http://moodmixes.com" target="_blank" rel="noopener">MoodMixes</a> starts at $15/month, depending on the size of the space where the music is played.
2714</ul>
2715
2716<b>Can I cancel at any time?</b><ul>
2717<li><a href="http://ilicensemusic.com" target="_blank" rel="noopener">iLicenseMusic</a>: yes, you can cancel at any time, but you will no longer have the right to use our music in new projects or new products.
2718<li><a href="http://moodmixes.com" target="_blank" rel="noopener">MoodMixes</a>: yes, you can cancel at any time, but you will need to stop playing our music at your place of business.
2719</ul>
2720
2721<b>How Can I Use The Music?</b><p>
2722Every day, Magnatune licenses music for use in films, television, web pages, CDs and DVDs, and much more. Other uses include telephone hold music, public space music for retail stores and galleries, music for runway fashion shows, and even themed CD compilations that act as extra revenue streams for shops, hotels, and spas. <a href="http://magnatune.com/info/licensing_examples" target="_blank" rel="noopener">See who has licensed our music</a>.
2723
2724<b>Can I Trust My License From You?</b><p>
2725Yes. Magnatune is a real company, incorporated since 1999 and we have <a href="http://magnatune.com/info/licensing_examples" target="_blank" rel="noopener">hundreds of music licensing clients</a>, including prominent filmmakers, large advertising firms, and video game producers. Our innovative online licensing tool was crafted in close collaboration with top flight entertainment lawyers, and are valid legal documents that have held up under scrutiny. 
2726
2727<p><b>What does "Royalty Free Music" Mean?</b><p>
2728Magnatune's music is "royalty free" in the sense that we don't build points into our agreements. In other words, if your project is wildly successful, you don't have to give us any of the profits. <i>However</i>, our music is not "production" or "stock" music. If you're using it for anything other than private enjoyment, <u>you must purchase a license</u> along with the music itself. Fortunately we've made doing this incredibly easy and affordable!
2729
2730<b>What Are the Terms of the Contract?</b><p>
2731Magnatune will not ask to see your work to approve the use. Our process and pricing structure is the same for all buyers, which removes legal fees as a built-in cost. The actual
2732legal license agreements for MoodMixes and iLicenseMusic are on those web sites.
2733
2734<b>Do You Offer "Buy Outs?"</b><p>
2735Absolutely. If you don't want to license our music by subscription, we are happy to negotiate a single "buy out" rate for the rights you need, so that you won't ever have to pay us again.
2736<a href="http://magnatune.com/info/email_us?to=John" target="_blank" rel="noopener">Email Us</a>.
2737
2738<b>Do You Offer Custom Licenses?</b><p>
2739Absolutely. If our standard approach doesn't meet your needs, we are happy to build a custom agreement for you. <a href="http://magnatune.com/info/email_us?to=John" target="_blank" rel="noopener">Email John</a>.
2740
2741<b>I don't want to subscribe!</b><p>
2742Our music licenses are available via subscription because we've found this to be a much better way to operate than having to negotiate lots of individual rights.
2743For example, most people making a film need to distribute DVDs, have their film in festivals, have advertisements, distribute through Netflix and show parts of their
2744film on Youtube. Our simple $89/month fee via <a href="http://ilice
2744nsemusic.com" target="_blank" rel="noopener">iLicenseMusic</a> grants you all these rights and more, so you can get back to your
2745project quick! Also, if you need more than one song, you're already a subscriber, and can use more music at no additional cost.<p>
2746
2747With iLicenseMusic, you can cancel at any time, though you will no longer have the right to use our music in new projects or new products after you have cancelled.<p>
2748With MoodMixes you can also cancel at any time, but you will need to stop playing our music in your place of business.
2749</ul>
2750
2751If this still fails to convince you, we do offer a buyout option. <a href="http://magnatune.com/info/email_us?to=John" target="_blank" rel="noopener">Email John</a>.
2752
2753<b>How Does The Artist Benefit?</b><p>
2754At Magnatune our artists retain all the rights to their own music. Each one signs a contract with Magnatune that allows us to license their music for commercial use. Unlike our competitors, we split the licensing fee directly with the artists, 50-50. Our artists love it when their work is licensed because it provides them with both income and exposure. 
2755
2756<b>What <i>is</i> Music Licensing Anyway?</b><p>
2757Music licensing is all around us. A popular song in a TV commercial or in a movie. The music on the radio. Music in restaurants and even elevators. Basically, whenever music is used in a commercial project or public space, that music has to be licensed for a specific type of use. The rights to use music are bought and sold every day. 
2758
2759<b>What About Non-Commercial Projects?</b><p>
2760
2761Magnatune maintains generous policies regarding non-commercial use of our music.
2762To use our music in a non-commercial project, you must first meet the <a href="http://creativecommons.org/licenses/by-nc-sa/1.0/legalcode" target="_blank" rel="noopener">legal requirement</a> for "non-commercial use"
2763as defined by the Creative Commons License which governs all Magnatune music.
2764If you meet these requirements, simply join as a regular paying member, use the music as you like, as long as you abide the Creative Commons <a href="http://creativecommons.org/licenses/by-nc-sa/1.0/legalcode" target="_blank" rel="noopener">by-nc-sa</a> license.` },
2765  give: { title: "Give away our music", html: `<b>Give 3 Free Copies to Your Friends</b>
2766<p>
2767While other record labels are busy suing their customers for introducing their friends to great music...
2768 <p>
2769At Magnatune, we want you to copy our music for your friends.<p>
2770
2771Yes, at Magnatune you can legally copy any album from your <a href="http://magnatune.com/downloads" target="_blank" rel="noopener">membership</a> downloads to up to 3 of your friends.
2772
2773 <p>
2774
2775Why do we do this? Because we know you've discovered great music through your friends. It's always been this way: from mix tapes, to CDRs to Peer to Peer. People fall in love with new music
2776by being exposed to it by others. It's such an obvious point, and everyone knows the truth of it, yet the music industry has always fought it (despite the fact that you're actually trying to help them!).<p>
2777What, specifically does our policy mean?
2778<ul>
2779<li> When you download an album you can pass the files to up to 3 of your friends. 
2780
2781<li> You're also welcome to email the mp3s themselves, or burn a CDR as a way of giving it to your 3 friends. 
2782<li> In short, use whatever mechanism you prefer to send them the music!
2783
2784</ul>
2785
2786Why are we doing this?
2787<ul>
2788<li> We know that if you're buying Magnatune music, that you loved what you heard, and chances are you'll want to play the music to others who might like it too.
2789<li> You're going to introduce people to Magnatune's music, why would we want to prevent that?
2790<li> Despite all the "We are not evil", we-want-to-change-the-world stuff that we rant about, we know that in the end it's all about the music, 
2791and that people will only visit and buy from us if they find out about it, and think what they hear is amazing.
2792<li> And finally, because we wanted to reward so many of our customers who emailed us asking if it was ok to make a copy for a friend. We always said yes! so we decided to make it official policy.
2793
2794</ul>
2795
2796Aren't you worried I'll abuse this?
2797<ul>
2798<li>No, because you've always had the technical capability to copy anything from Magnatune (since we don't use DRM): we don't believe in copy protection and we think you're honest, otherwise you wouldn't be bothering to read this!
2799<li>Dishonest people can always abuse the system. Rather, we want to reward all the honest people who truly want to do the right thing.
2800<li>If you abuse our generosity, we're not going to break down your door and throw you in jail. We just want you to feel a little guilty about it &lt;grin&gt;. 
2801</ul>
2802
2803We're trusting you to do the right thing, and introduce new people to the music you love. <p>
2804You'll feel good about it, your friends will thank you, and you'll help Magnatune prosper.` },
2805  why: { title: "Why I created Magnatune", html: `<b>Founder's Rant</b><br>
2806 by 
2807 John Buckman, founder/owner<p>
2808
2809 Magnatune 
2810 was born out of some observations I'd gathered about the music industry, 
2811 along with personal experiences from my wife releasing her CD on an
2812 Indie record label.
2813
2814 <p>
2815
2816 <i>Personal experience:</i>
2817
2818 <ul>
2819
2820<li>When my wife was signed to an Indie record label, we were really excited. In the end, she sold close to 1000 CDs, lost all rights to her music for 8 years (even though the CD had been out of print for several years), and earned a little over $100 in royalties (no one is really sure), some of which was paid to her as CD copies of her own C
2820D which she then gave away for promotion.
2821
2822<li>The record label that signed her wasn't evil: they were one of the good guys, and gave her a 70/30 split of the profits (of which there were few). The label got screwed at every turn: distributors refused to carry their CDs unless they spent thousands on useless print ads, or they didn't pay for the CD's sold, etc. In general, all forces colluded to prevent this small, progressive label from succeeding.
2823
2824<li>She was one of the lucky ones. We knew several musicians, signed to various labels, who were also frustrated, who received no money ever and who lost the rights to their music forever.
2825
2826 </ul>
2827
2828 <i>Industry Observations:</i>
2829
2830 <ul>
2831 <li>
2832 Radio is boring: everyone I know is into interesting music, yet good 
2833 music is rarely played on the air. I'm into everything from Ambient, 
2834 Industrial, Goth, Metal to Renaissance, Baroque, Tango, Indian 
2835 Classical and New Age (and many other genres!), and so are many of my 
2836 friends. Yet, these genres are barely visible in record stores, and 
2837 totally absent from the airwaves. Radio is mostly about Country, Pop, 
2838 and Rock, with a little bit of dull, safe classical thrown in.
2839
2840 <li>
2841 CDs cost too much, and artists only get 20 cents to a dollar for each 
2842 CD sold. If they're lucky. And, most CDs quickly go out of print: I 
2843 buy more CDs from EBay than Amazon.
2844
2845 <li>
2846 Online sales (such as over Amazon.com) often cost the artist 50% of 
2847 their already-pathetic royalty (due to a common record contract 
2848 provision). International sales and mark-downs often net the artist no 
2849 royalties.
2850
2851 <li>
2852 Record labels lock their artists into legal agreements that hold them 
2853 for a decade or more. If it's not working out, labels don't print the 
2854 band's recordings but nonetheless keep them locked into the contract, 
2855 forcing them to produce new albums each year. Even hugely successful 
2856 artists often end up owing their record label money.
2857
2858 <li>
2859 Peer to peer software has proven that there's a huge continuing demand for music, and they 
2860 want to share it. Lawsuits may shut Bittorrent down, just as Napster was shut down. Clearly 
2861 there's a huge public demand for <a href="http://magnatune.com/openmusic" target="_blank" rel="noopener">Open Music</a>.
2862
2863 <li>
2864 Using the Internet to listen to music is usually tedious: there are too 
2865 many ads, too many clicks, and the sound quality is usually bad. It's 
2866 too much work, not enough reward. A well run Internet radio station solves that, but the entrenched record 
2867 industry wants to kill that too, using extremely high fees as the weapon.
2868
2869 <li>
2870 I read <a href="https://www.salon.com/2000/06/14/love_7/" target="_blank" rel="noopener">this 
2871 article by Courtney Love</a> six months after starting Magnatune, and 
2872 was stunned by how much I have in common with her vision and 
2873 understanding of the music business. And, she's much more eloquent 
2874 than I am.
2875
2876 </ul>
2877
2878 <i>My solution:</i>
2879
2880 <ul>
2881
2882 <li>
2883 I thought: why not make a record label that has a clue? That helps 
2884 artists get exposure, make at least as much money they would make with 
2885 traditional labels, and help them get fans and concerts.
2886
2887 <li>
2888 Magnatune is my project. The goal is to find a way to run a record 
2889 label in the Internet Reality: file trading, Internet Radio, musicians' 
2890 rights, the whole nine-yards.
2891
2892 <li>
2893 If you think Magnatune is a worthy goal, please support it. There are 
2894 powerful forces who want it to fail, so I need your help if this is 
2895 going to work.
2896
2897 </ul>
2898
2899Magnatune was founded in April 2003, in the People's Republic of <a href="http://en.wikipedia.org/wiki/Berkeley,_California" target="_blank" rel="noopener">Berkeley</a>, California.` },
2900  podcast: { title: "Podcast", html: `<b>Podcast our music!</b>
2901
2902 <p>If you love music as much as we do, we want you to use our music in your podcasts!
2903
2904<p>
2905
2906Magnatune is one of the only record labels on the planet whose music you can
2907legally use in your non-commercial podcast without a collection society.<p>
2908
2909We specifically grant non-commercial podcasters a waiver to use our music
2910without paying any fee to a royalty collection agency, such as ASCAP, BMI or SoundExchange.
2911Because we work directly with artists, we can legally do this.
2912<p>
2913If your podcast is commercial you can obtain a low-cost <a href="http://ilice
2913nsemusic.com" target="_blank" rel="noopener">commercial license</a> from our other company iLicenseMusic in
2914order to use Magnatune music in your commercial podcast.
2915
2916<p>
2917
2918If your podcast is non-commercial, we ask
2919that you become a <a href="http://magnatune.com/downloads" target="_blank" rel="noopener">download member</a>, and you will then be able to download
2920everything at our site, and 
2921can play all our music on your podcast 100% legally without paying collecting society
2922fees.
2923<p>
2924 You can read more about our <a href='/info/licensing/'>music licensing</a>.
2925
2926</ul>` },
2927  giftcards: { title: "Gift cards", html: `<b>Gift Cards</b><p>
2928
2929A Gift Card is a pre-paid card that lets someone obtain a single album directly, instead of buying a membership.<p>
2930
2931Gift cards are available as a charitable giveaway or as corporate promotion gifts.
2932
2933 <p>
2934 
2935 <a href="http://magnatune.com/gift_cards_about?sitePrefix=" target="_blank" rel="noopener">Read more about Gift Cards</a><p>
2936Next steps:<p>
2937
2938 buy an album download with your gift card
2939 look up the current status of your gift card
2940 can't remember your card number?
2941
2942</ul>` },
2943  terms: { title: "Terms of use", html: `<b>Magnatune Terms of Use</b><p>
2944This is a legal agreement between you, the potential user of this website ("you") 
2945and Redmood, Inc. DBA Magnatune ("Magnatune"). Your use of this website ("Site") or any of the 
2946products and services offered on this Site, as well as mobile applications which provide
2947access to the products and services on this Site (collectively, the "Services") is 
2948conditioned on your acceptance without modification of these Terms of Service ("Terms").
2949<p>
2950Please read these Terms carefully. If you do not agree to these Terms, 
2951you should not use the Site or Services. Magnatune may at its sole discretion 
2952modify these Terms at any time and such modifications will be effective 
2953immediately upon being posted on this Site. The most current version of these 
2954Terms can be accessed through the "Terms" link in the "About" section of the 
2955Site. You agree to review these Terms periodically, and your continued use of 
2956the Site and Services after the posting of modifications constitutes your 
2957affirmative agreement to abide and be bound by the Terms, as modified.
2958
2959<p>
2960<b>Changes to the Site.</b>
2961<p>
2962Magnatune reserves the right to modify or 
2963discontinue any of the Services and material displayed on or offered through the 
2964Site, with or without notice to you, and is not obligated to support or update 
2965the Site or Services.
2966<p>
2967<b>Eligibility.</b>
2968<p>
2969Use of the Site and Services is void 
2970where prohibited. You shall comply with all applicable local, state, federal and 
2971national laws, statutes, ordinances and regulations regarding the use of the 
2972Site and Services. You represent and warrant that all registration information 
2973you submit is truthful and accurate and that you agree to maintain the accuracy 
2974of such information. You must be at least 18 years of age and have Internet 
2975access, a valid e-mail address, and a valid shipping address to register for the 
2976Site and Services. Full use of the Site and Services may require you to provide 
2977a valid credit card. While individuals between the ages of 13 and 18 may utilize 
2978the Site and Services, they may do so only with the involvement of a parent or 
2979legal guardian. If you are 13 or older but under the age of 18, you should 
2980review these Terms with your parent or guardian to make sure that you and your 
2981parent or guardian understand these Terms.
2982<p>
2983<b>
2983Registration and Account Rules.</b>
2984<p>
2985To register, you must provide your name, e-mail address and payment details. 
2986You shall permit Magnatune to store and use this personal data provided by you 
2987for purposes of maintaining your account. In addition, you agree to comply with the following terms:
2988
2989<li>
2990You shall maintain your account with up-to-date information;
2991<p>
2992<li>
2993You shall be responsible for maintaining the confidentiality and security of 
2994your account;
2995<p>
2996<li>
2997You shall immediately report any unauthorized use of your account to Magnatune, 
2998as well as any other breach of security;
2999<p>
3000<li>
3001You shall not share your password with any other person outside of your family household; and
3002<p>
3003<li>
3004You shall not assign, sell or transfer your account to any other person.
3005<p>
3006<b>Fees and Charges.</b>
3007<p>
3008Magnatune provides both a free version of the Service, as well as additional features and benefits to
3009paid members. The cost to join as a paid member depend on a number of factors, such as which services
3010are being purchased, your country, the currency you are paying in, and are also liable to change
3011from time to time. Our current fee schedule is published on the Site. 
3012<p>
3013<b>Cancellation.</b>
3014<p>
3015Magnatune reserves the right to terminate or suspend your access to the Services 
3016or Site, with or without cause, at any time and effective immediately. Reasons 
3017Magnatune may terminate or suspend your account shall include, but are not 
3018limited to, the violation of any of these Terms.
3019<p>
3020You may cancel your Magnatune account at anytime, and cancellation will be 
3021effective immediately. You acknowledge, however, that cancellation will result 
3022in your forfeiture of any access to the Services. 
3023To cancel your account, you can contact us by using the "Contact us" link in the "About" 
3024section of the Site.<p>
3025<b>Privacy Policy.</b>
3026<p>
3027Magnatune's personal information practices on the Site and Services are 
3028governed by Magnatune's Privacy Policy. 
3029
3030<p>
3031<b>Cookies.</b>
3032<p>
3033Magnatune may place a cookie or other small text file on any computer from which 
3034you access the Site or Services and you consent to that placement.
3035<p>
3036<b>Consent to Receive E-mail.</b>
3037<p>
3038By using the Site and Services, you consent to receiving e-mail from Magnatune. 
3039These e-mailings will include, but are not limited to, notices about your 
3040account (for example, shipping and receiving e-mails) and information concerning 
3041or related to the Site or Services, such as recommendations or notifications of 
3042posted blurbs. These e-mailings are part of your relationship with Magnatune.
3043<p>
3044<b>Use of Information Submitted.</b>
3045<p>
3046You acknowledge and agree that any ratings, reviews, comments, feedback, 
3047suggestions, pictures and other materials (collectively, the "Content") you 
3048contribute to the Site shall become Magnatune's property and you hereby assign 
3049all rights, title and interests in and to such contributions to Magnatune. 
3050Magnatune is hereby granted a non-exclusive, worldwide, royalty-free, 
3051transferable right to fully exploit such Content (including all related 
3052intellectual property rights). Magnatune does not regularly review the content 
3053posted on the Site; however, Magnatune reserves the right to remove or edit any 
3054Content from the Site at any time without notice.
3055
3056<p>
3057<b>Your Conduct.</b>
3058<p>
3059You warrant, represent and agree that you will not contribute any Content that 
3060infringes or violates the rights of any other party or violates any laws, 
3061contributes to or encourages infringing or otherwise unlawful conduct, or is 
3062obscene or in poor taste. You agree that use of the Site and Services is subject 
3063to all applicable national, federal, state, and local laws and regulations, and 
3064that you are solely responsible for the contents of your communications through 
3065the Site and Services.
3066<p>
3067<b>Objectionable Material.</b>
3068<p>
3069You understand that by using the Services you may encounter content that may be 
3070deemed objectionable, obscene or in poor taste, which content may or may not be 
3071identified as having explicit language. You agree to use the Services at your 
3072own risk and that Magnatune shall have no liability to you for content that you 
3073may find objectionable, obscene or in poor taste.
3074<p>
3075<b>Intellectual Property.</b>
3076<p>
3077You agree that the Site and Services, including but not limited to graphics, 
3078audio clips, and editorial content, contain proprietary information and material 
3079that is owned by Magnatune and/or its licensors, and is protected by applicable 
3080intellectual property and other laws, including but not limited to copyright, 
3081and that you will not use such proprietary information or materials in any way 
3082whatsoever except for use of the Site and Services in compliance with these 
3083Terms. No portion of the Site or Services may be reproduced in any form or by 
3084any means, unless previous written permission is obtained, except where said
3085content is explicitly marked with a license authorizing it, such as a Creative
3086Commons license. You agree not to 
3087modify, rent, lease, loan, sell, distribute or create derivative works based on 
3088the Site or Services in any unauthorized way whatsoever, except as explicitly
3089permitted by a license authorizing it, such as on the API section of the Service,
3090or as permitted by a Creative Commons license that has been explicitly assigned
3091to the specific contents.
3092<p>
3093The trademarks, logos and service marks (collectively, the "trademarks") 
3094displayed on the Site or Services, unless otherwise specified, are the 
3095registered and unregistered trademarks of Magnatune or their respective owners. 
3096Nothing contained on the Site or Services shall be construed as granting, by 
3097implication or otherwise, any license or right to use any trademark displayed on 
3098the Site or Services without the express written permission of the trademark 
3099owner.
3100<p>
3101<b>Claims of Copyright Infringement.</b>
3102<p>
3103Magnatune respects the intellectual property of others, and we ask you to do the 
3104same. If you believe that your work has been copied in a way that constitutes 
3105copyright infringement, please provide our Copyright Agent with the following 
3106information:
3107<p>
3108<li>
3109An electronic or physical signature of the person authorized to act on behalf of 
3110the owner of the copyright interest;
3111<p>
3112<li>
3113A description of the copyrighted work that you claim has been infringed;
3114<p>
3115<li>
3116A description of where the material that you claim is infringing is located on 
3117the Site;
3118<p>
3119<li>
3120Your address, telephone number, and e-mail address;
3121<p>
3122<li>
3123A statement by you that you have a good faith belief that the disputed use is 
3124not authorized by the copyright owner, its agent, or the law; and
3125<p>
3126<li>
3127A statement by you, made under penalty of perjury, that the above information in 
3128your notice is accurate and that you are the copyright owner or authorized to 
3129act on the copyright owner's behalf.
3130<p>
3131Magnatune's Copyright Agent for notice of claims of copyright infringement can 
3132be reached as follows: Copyright Agent, Magnatune, 454 Las Gallinas Ave #142, San Rafael, CA 94903.
3133<p>
3134<b>Software Available on the Site</b>
3135
3136<p>
3137All software that is made available on the Site is the copyrighted work of 
3138Magnatune and/or its suppliers and is licensed, not sold. Your use of any such 
3139software is governed by the terms of the license agreement, if any, which 
3140accompanies or is included with the software. You may not install or use any 
3141software that is accompanied by or includes a license agreement unless you first 
3142agree to the terms of the license agreement. For any software not accompanied by 
3143a license agreement, Magnatune hereby grants you a non-exclusive, revocable, 
3144personal, non-transferable license to use the software solely in connection with 
3145the Site and Services in accordance with these Terms.
3146<p>
3147You will not disassemble, decompile or reverse engineer the software. You 
3148acknowledge that the software and any accompanying documentation is subject to 
3149applicable export laws and regulations of the United States. You agree not to 
3150export or re-export the software, directly or indirectly, to any countries that 
3151are subject to United States export restrictions.
3152<p>
3153
3154Your license to use the software terminates upon the termination of your access 
3155to the Site or Services. Your license to the software may also terminate if 
3156Magnatune, in its sole discretion, modifies the Site or Services to no longer 
3157support the software.
3158<p>
3159<b>Disclaimers of Warranties.</b>
3160<p>
3161THIS SITE, ITS CONTENTS, AND ANY SOFTWARE, PRODUCTS, AND SERVICES OFFERED OR 
3162CONTAINED HEREIN ARE PROVIDED ON AN "AS IS" BASIS AND ON AN "AS AVAILABLE" BASIS. 
3163MAGNATUNE MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND WITH RESPECT TO 
3164THIS SITE, ITS CONTENTS, OR SUCH SOFTWARE PRODUCTS AND SERVICES, AND DISCLAIMS 
3165ALL SUCH REPRESENTATIONS AND WARRANTIES, INCLUDING, WITHOUT LIMITATION, 
3166WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT 
3167OF THIRD PARTY RIGHTS, ERROR-FREE OR UNINTERRUPTED SERVICE, ACCURACY, 
3168AVAILABILITY, RELIABILITY, SECURITY, CURRENCY AND COMPLETENESS ARISING FROM OR 
3169RELATING TO THIS SITE, ITS CONTENT OR ANY LINKS, ANY SOFTWARE, TOOLS, TIPS, 
3170PRODUCTS OR SERVICES PROVIDED THROUGH THIS SITE. IN ADDITION, MAGNATUNE DOES NOT 
3171REPRESENT OR WARRANT THAT THE INFORMATION ACCESSIBLE VIA THIS SITE IS ACCURATE, 
3172COMPLETE OR CURRENT. ALL INFORMATION IS SUBJECT TO CHANGE WITHOUT NOTICE.
3173
3174<p>
3175<b>Limitations on Liability.</b>
3176<p>
3177IN NO EVENT SHALL MAGNATUNE, ANY OFFICER, AFFILIATE, DIRECTOR, SHAREHOLDER, 
3178AGENT, CONTRACTOR OR EMPLOYEE OF MAGNATUNE BE LIABLE FOR ANY DIRECT, INDIRECT, 
3179SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR AGGRAVATED DAMAGES (
3179INCLUDING 
3180WITHOUT LIMITATION DAMAGES FOR LOSS OF DATA, INCOME OR PROFIT, LOSS OF OR DAMAGE 
3181TO PROPERTY AND THIRD PARTY CLAIMS) OR ANY OTHER DAMAGES OF ANY KIND, ARISING 
3182OUT OF OR IN CONNECTION WITH: THIS SITE; ANY MATERIALS, INFORMATION, 
3183QUALIFICATION AND RECOMMENDATIONS APPEARING ON THIS SITE; ANY SOFTWARE, TOOLS, 
3184TIPS, PRODUCTS, OR SERVICES OFFERED THROUGH, CONTAINED IN OR ADVERTISED ON THIS 
3185SITE; ANY LINK PROVIDED ON THIS SITE; AND YOUR ACCOUNT AND PASSWORD, WHETHER OR 
3186NOT MAGNATUNE HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. THIS 
3187EXCLUSION OF LIABILITY SHALL APPLY TO THE FULLEST EXTENT PERMITTED BY LAW. THIS 
3188PROVISION SHALL SURVIVE THE TERMINATION OF YOUR RIGHT TO USE THIS SITE. YOU 
3189ACKNOWLEDGE THAT YOU WILL BE FULLY LIABLE FOR ALL DAMAGES RESULTING DIRECTLY OR 
3190INDIRECTLY FROM YOUR USE OF THIS SITE.
3191<p>
3192<b>User Disputes.</b>
3193<p>
3194You are solely responsible for your interactions with other Magnatune users. 
3195Magnatune reserves the right, but has no obligation, to monitor disputes between 
3196you and other users.
3197<p>
3198<b>Electronic Signature and Contracts.</b>
3199<p>
3200YOU ACKNOWLEDGE THAT YOUR USE OF THE SITE OR SERVICES CONSTITUTE YOUR AGREEMENT AND 
3201INTENT TO BE BOUND BY SUCH AGREEMENTS. YOUR AGREEMENT AND INTENT TO BE BOUND BY 
3202YOUR USE OF THE SITE OR SERVICES APPLIES TO ALL RECORDS RELATING TO ALL TRANSACTIONS YOU 
3203ENTER INTO ON THE SITE, INCLUDING NOTICES OF CANCELLATION, POLICIES, CONTRACTS 
3204AND APPLICATIONS.
3205<p>
3206<b>Governing Law.</b> 
3207<p>
3208The laws of the State of California, excluding its conflicts of law rules, 
3209govern these Terms and your use of the Site and Services. Your use of the Site 
3210and Services may also be subject to other local, state or federal laws. You 
3211expressly agree that exclusive jurisdiction for any claim or dispute with 
3212Magnatune relating in any way to your use of the Site or Services resides in the 
3213courts of the State of California.
3214<p>
3215<b>Notices.</b>
3216<p>
3217Magnatune may send you notice with respect to the Site or Services by sending an 
3218e-mail message to the e-mail address listed in your account information, by 
3219sending a letter via postal mail to the shipping address listed in your account 
3220information, or by a posting on the Site. Notices shall become effective 
3221immediately when posted to the Site and upon delivery when sent by e-mail or 
3222postal mail.
3223
3224<p>
3225<b>Miscellaneous.</b>
3226<p>
3227These Terms constitute the entire agreement between you and Magnatune and govern 
3228your use of the Site and Services, superseding any prior agreements between you 
3229and Magnatune. You may also be subject to additional terms and conditions that 
3230may apply when you use third-party content or third party-software. If any part 
3231of these Terms is held invalid or unenforceable, that portion shall be construed 
3232in a manner consistent with applicable law to reflect, as nearly as possible, 
3233the original intentions of the parties, and the remaining portions shall remain 
3234in full force and effect. Magnatune's failure to enforce any right or provision 
3235in these Terms will not constitute a waiver of such provision, or any other 
3236provision of these Terms. If any provision of these Terms is found by a court of 
3237competent jurisdiction to be invalid, the other provisions will remain in full 
3238force and effect. Magnatune will not be responsible for failures to fulfill any 
3239obligations due to causes beyond its control. No agency, partnership, joint 
3240venture or employment is created as a result of these Terms. All provisions of 
3241these Terms which by their nature should survive termination will survive 
3242termination, including, without limitation, ownership provisions, warranty 
3243disclaimers, and limitations of liability.` },
3244  api: { title: "APIs, RSS & XML", html: `<a name="xml"> 
3245 <b>Data Feeds in XML, CSV and SQL</b>
3246
3247 <p>
3248
3249 If you want to integrate Magnatune's Creative Commons' by-nc-sa v1.0 licensed music into your 
3250 web site or application, these links will help you.
3251 <p>
3252
3253 <b>WE HIGHLY RECOMMEND YOU USE <a href="http://magnatune.com/info/sqlite-normalized" target="_blank" rel="noopener">THIS SQLITE DATABASE</a> for your data feed, as the others are all being fazed out and do not have all the metadata that this database file has.</b>
3254<p>
3255
3256 If you're wondering how we'd feel about a specific use of 
3257 Magnatune's music, feel free to <a href="http://magnatune.com/contact" target="_blank" rel="noopener">contact us</a>
3257 and tell us your thoughts.
3258 <hr>
3259 Below are the data feeds which can help you. These are continuously updated with the latest data. <a href="http://magnatune.com/contact" target="_blank" rel="noopener">Contact us</a> if you need something special; we're happy to 
3260oblige.
3261 <ul>
3262 <li>
3263 <a href="http://he3.magnatune.com/info/album_info.xml" target="_blank" rel="noopener">Albums and songs XML file</a> Every album and song 
3264 described, with each song grouped into the album it belongs to. This provides 
3265 more information, and is useful for uses that are more album oriented.<br>
3266 This file is also available in a number of compressed formats: 
3267 <a href="http://he3.magnatune.com/info/album_info_xml.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/album_info_xml.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/album_info_xml.bz2" target="_blank" rel="noopener">bzip2</a>.<br><br>
3268 <li>
3269 <a href="http://magnatune.com/info/sqlite" target="_blank" rel="noopener">SQLite Export</a>: a small database in SQLite format.<br><br>
3270 <li>
3271 <a href="http://magnatune.com/info/sqlite-normalized" target="_blank" rel="noopener">SQLite Normalized Export</a>: a fully normalized small database in SQLite format.<br><br>
3272 <li>
3273 <a href="http://he3.magnatune.com/info/song_info.sql" target="_blank" rel="noopener">Song-by-song MYSQL table dump</a>. Every 128k mp3 at Magnatune 
3274 described, with each mp3 file (ie, each song) fully described, loadable directly into
3275 your MYSQL database. Creates a "magnatune_song_info" table.<br><br>
3276 <li>
3277 <a href="http://he3.magnatune.com/info/song_info.xml" target="_blank" rel="noopener">Song-by-song XML file</a>. Every 128k mp3 at Magnatune 
3278 described, with each mp3 file (ie, each song) fully described. This file is for 
3279 uses that are more single-mp3 oriented. A <a href="http://he3.magnatune.com/info/song_info2.xml" target="_blank" rel="noopener">simplified 
3280 version</a> of this file is also available. <br>
3281 These files are also available in a number of compressed formats: <br>
3282 Song-by-song XML file: <a http://he3.magnatune.com/info/song_info_xml.zip">zip</a>, <a href="http://he3.magnatune.com/info/song_info_xml.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info_xml.bz2" target="_blank" rel="noopener">bzip2</a><br>
3283 simplified version: <a href="http://he3.magnatune.com/info/song_info2_xml.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/song_info2_xml.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info2_xml.bz2" target="_blank" rel="noopener">bzip2</a><br>
3284 Also slim version (without artistbio): <a href="http://he3.magnatune.com/info/song_info_slim.xml" target="_blank" rel="noopener">xml</a>, <a href="http://he3.magnatune.com/info/song_info_slim_xml.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/song_info_xml_slim.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info_xml_slim.bz2" target="_blank" rel="noopener">bzip2</a>.
3285
3286 <br>
3287 <li>
3288 <a href="http://he3.magnatune.com/info/song_info.csv" target="_blank" rel="noopener">Song-by-song CSV file</a>. Every 128k mp3 at Magnatune 
3289 described, with each mp3 file (ie, each song) fully described, the comma-separated-value 
3290 (CSV) format which imports directly into most database programs and Microsoft 
3291 Excel.<br>
3292 This file is also available in a number of compressed formats: 
3293 <a href="http://he3.magnatune.com/info/song_info_csv.zip" target="_blank" rel="noopener">zip</a>, <a href="http://he3.magnatune.com/info/song_info_csv.gz" target="_blank" rel="noopener">gzip</a>, <a href="http://he3.magnatune.com/info/song_info_csv.bz2" target="_blank" rel="noopener">bzip2</a><br><br>
3294 </ul>
3295 To keep these downloads small, we reccomend using the bzip2 files if possible.
3296
3297<a name="purchase"><p>
3298<b>Memberships</b>
3299
3300 <p>
3301 Magnatune.com offers memberships that lets users enjoy additional benefits. Users can stream the entire Magnatune.com catalog with no speaker interruption at the end at each track and listen to ogg streams in higher quality. The download membership lets users download an unlimited amount of albums at no additional charge. While memberships has to be created at the Magnatune.com site, it is possible to use the memberships from other applications. <p>
3302
3303To play a membership stream with no speaker interruption, the standard stream url should be modified to point at the correct membership sub-site. Authentication can either be handleded by imnbedding the membership username and password in the url, or by handeling sessions. Also, all speechless streams has _nospeech appended to the filename.
3304<p>
3305for instance, to transform the url <br>
3306http://he3.magnatune.com/all/03--Massassi%20Morning%20Star--Ishwish.mp3"<p>to a valid stream membership url, could look like this
3307<p>
3308http://username:[email protected]/all/03--Massassi%20Morning%20Star--Ishwish_nospeech.mp3
3309<p>
3310The steps neeed for this transformation are:
3311<p>
3312<ul>
3313<li> "http://he3" should be replaced with "http://username:[email protected]". If your programming language's HTTP API doesn't support inline username/passwords in the URL, consult your HTTP API document for how to do HTTP AUTH queries.
3314<li> ".mp3" or ".ogg" should be replaced with "_nospeech.mp3" or "_nospeech.ogg" respectively.
3315</ul>
3316To download albums without having to pay for each one uses an API very similar to the one for making credit card purchases. Both the request and the resulting xml reply are modelled on the purchase API.<p>
3317
3318To let a download member download an album for free, the following url should be used: <br>
3319<a href="http://download.magnatune.com/buy/membership_free_dl_xml" target="_blank" rel="noopener">http://download.magnatune.com/buy/membership_free_dl_xml</a><p>
3320
3321This is a password protected url, so either you will need to handle logging in or pass the username and password in the url.<p>
3322
3323The following fields are required:<p>
3324
3325<ul>
3326<li>sku= field, which is the album's sku code
3327<li>
3327id= field, which is your partner id. <a href="http://magnatune.com/info/email_us?to=john&subject=sales+partner+id" target="_blank" rel="noopener">Contact john</a> to obtain one (there is no cost).
3328</ul>
3329
3330This returns an xml reply that contains the download information for the different formats, simmilar to the reply from a purchase request. <p>
3331
3332As an example, this i how this url is constructed in Amarok 2: <br><br>
3333QString purchaseURL = "http://" + username + ":" + password + "@" + membershipType + ".magnatune.com/buy/membership_free_dl_xml?sku=" + m_currentAlbum->albumCode() + "&id=myId";<p>
3334<b>Detecting new releases</b>
3335 <p>
3336To detect whether we have any new releases, please download this file:<p>
3337<a href="http://magnatune.com/info/changed.txt" target="_blank" rel="noopener">http://magnatune.com/info/changed.txt</a><p>
3338
3339The changed.txt file contains a single number, which is a CRC of all our releases. When that number changes, this means that all the other data feeds also have new data. You can check this changed.txt file as often as you like, but we recommend no more frequently than every 24h.<p>
3340<b>Embedded Music Purchasing</b>
3341
3342 <p>
3343 Music Purchasing through the API is no longer supported. Apologies for this, but our VISA credit card processor really didn't like this, as it was thoroughly NOT "PCI Compliant" for us to allow this.` },
3344  contact: { title: "Contact Magnatune", html: `<b>How to contact Magnatune</b>
3345 <p>
3346We are based in San Rafael, California, which is 15 miles north of San Francisco.<p>
3347<b>email:</b><p><a href="http://a.magnatune.com/support/submit" target="_blank" rel="noopener">Click here</a> to send us email.<p>
3348<b>postal mail:</b><p>
3349Magnatune<br>
3350454 Las Gallinas Ave, #142<br>
3351San Rafael, CA 94903<br>
3352 USA
3353 <p>
3354 <b>We are not currently accepting submissions.</b>` },
3355};
3356// Mirror of the server's m3_selfhost_html: make in-app doc pages fully self-hosted
3357// (retired he3 asset host + magnatune's own catalog/nav hosts -> same-origin), so
3358// nothing here links out to an external magnatune box and "he3" is never shown.
3359// Genuinely external sites (blogs./my./a. magnatune subdomains, creativecommons,
3360// wikipedia, …) are left untouched. Steps run in order (longest hosts first) so a
3361// full he3 URL collapses to same-origin before the bare "http://he3" prose token.
3362function selfhostHTML(s) {
3363  return s
3364    .split("http://he3.magnatune.com").join("")
3365    .split("https://he3.magnatune.com").join("")
3366    .split("http://username:[email protected]/all/").join("/all/")
3367    .split("http://username:[email protected]").join("your member login on this server")
3368    .split("http://download.magnatune.com/all/").join("/all/")
3369    .split("http://download.magnatune.com/music/").join("/music/")
3370    .split("http://download.magnatune.com/buy/").join("/buy/")
3371    .split("http://magnatune.com/").join("/")
3372    .split("https://magnatune.com/").join("/")
3373    .split("http://www.magnatune.com/").join("/")
3374    .split("http://magnatune.com").join("/")
3375    .split("http://he3").join("this server");
3376}
3377function renderInfo(c, key) {
3378  const pg = INFO_PAGES[key];
3379  if (!pg) { c.innerHTML = `<div class="pad">Page not found.</div>`; return; }
3380  c.innerHTML = `<div class="page-title">${esc(pg.title)}</div><div class="help">${selfhostHTML(pg.html)}</div>`;
3381}
3382
3383function renderHelp(c) { c.innerHTML = `<div class="page-title">Help</div>` + linkifyHelp(selfhostHTML(HELP_HTML)); }
3384
3385/* ===================== Magnatune for iOS (SideStep sideload guide) =====================
3386 * Static how-to page. Route token "ios" (path /player/ios), linked from Settings -> Apps.
3387 * Explains installing the native iOS app via the Mac app SideStep -> Search AltStore -> "magnatune".
3388 * Screenshots live in /app/ios/. Styles are inlined so this stands alone in magnatune.js. */
3389function renderIosInstall(c) {
3390  c.innerHTML = `<div class="page-title">Magnatune for iOS (iPhone &amp; iPad)</div>
3391    <div class="help ios-install">
3392      <style>
3393        .ios-install{max-width:820px;}
3394        .ios-install h2{margin:30px 0 6px;font-size:17px;}
3395        .ios-install p{line-height:1.55;}
3396        .ios-install figure{margin:14px 0 4px;}
3397        .ios-install img{display:block;max-width:100%;height:auto;border:1px solid rgba(128,128,128,.28);border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.14);}
3398        .ios-install .win img{max-width:min(100%,480px);}
3399        .ios-install figcaption{margin-top:8px;font-size:13px;opacity:.72;}
3400        .ios-shots{display:flex;flex-wrap:wrap;gap:22px;align-items:flex-start;margin-top:10px;}
3401        .ios-shots figure{flex:0 1 auto;margin-top:0;}
3402        .ios-shots .phone img{max-width:min(100%,300px);}
3403        .ios-shots .pad img{max-width:min(100%,560px);}
3404        .ios-install .ios-dl{display:inline-flex;align-items:center;gap:9px;margin:10px 0 2px;padding:11px 20px;border-radius:10px;background:#1667c4;color:#fff;font-weight:600;text-decoration:none;}
3405        .ios-install .ios-dl:hover{background:#12559f;}
3406        .ios-install .dl-sub{font-size:12.5px;opacity:.62;margin:6px 0 0;}
3407        .ios-install .ios-note{margin:28px 0 0;padding:14px 16px;border:1px solid rgba(128,128,128,.28);border-radius:12px;font-size:14px;line-height:1.5;}
3408        .ios-install .ios-note b{display:block;margin-bottom:4px;}
3409        .ios-install .ios-oneclick-box{background:rgba(22,103,196,.06);border:1px solid rgba(22,103,196,.22);border-radius:12px;padding:2px 18px 14px;margin:6px 0;}
3410        .ios-install .manual-divider{margin:34px 0 0;padding-top:18px;border-top:1px solid rgba(128,128,128,.22);font-weight:600;}
3411      </style>
3412      <p>There's a native <b>Magnatune player for iPhone and iPad</b>. It isn't on the App Store — instead you install it for free using <b>SideStep</b>, a small Mac app that makes side-loading apps onto an iPhone or iPad about as easy as it is on Android. The first-time setup takes a few minutes; after that, updates are automatic.</p>
3413
3414      <div class="ios-oneclick-box">
3415        <h2>Fastest: one-click installer</h2>
3416        <p>On your <b>Mac</b>, click below and double-click the downloaded installer. It sets up SideStep, walks you through connecting your iPhone or iPad over USB, and installs Magnatune — then keeps it refreshed. You'll need a Mac and a USB cable.</p>
3417        <p><a id="ios-oneclick" class="ios-dl" href="https://github.com/johnbuckman/magnatune-app/releases/latest/download/magnatune-app-installer.zip" onclick="return MAG.iosInstall(event)"><i class="fa-solid fa-wand-magic-sparkles"></i> Install Magnatune on iPhone / iPad</a></p>
3418        <p id="ios-oneclick-note" class="dl-sub"></p>
3419        <div id="ios-fallback" hidden>
3420          <p class="dl-sub">Don't have SideStep yet? <a href="https://github.com/johnbuckman/magnatune-app/releases/latest/download/magnatune-app-installer.zip" download>Download the Magnatune installer</a> — double-click it and follow the prompts; it installs SideStep for you.</p>
3421        </div>
3422      </div>
3423
3424      <p class="manual-divider">Prefer to set it up by hand? The steps below do the same thing manually.</p>
3425
3426      <h2>1. Get SideStep for your Mac</h2>
3427      <p>Download the latest release of SideStep, open the <b>.dmg</b>, and drag SideStep into your Applications folder.</p>
3428      <p><a class="ios-dl" href="https://github.com/johnbuckman/SideStep/releases/download/v0.2.4/SideStep-0.2.4.dmg" download><i class="fa-solid fa-download"></i> Download SideStep 0.2.4 (.dmg)</a></p>
3429      <p class="dl-sub">macOS · 9.5&nbsp;MB · or browse <a href="https://github.com/johnbuckman/SideStep/releases" target="_blank" rel="noopener">all SideStep releases</a>.</p>
3430
3431      <h2>2. Run SideStep and sign in</h2>
3432      <p>Launch SideStep and add an Apple account. Any Apple ID works — you can even make a new, free one at <
3432a href="https://icloud.com" target="_blank" rel="noopener">iCloud.com</a>. (A free account can side-load up to 3 apps; you can add several free logins — see the note below.)</p>
3433      <figure class="win"><img src="ios/sidestep.jpg" alt="The SideStep app running on macOS" loading="lazy"><figcaption>SideStep on your Mac.</figcaption></figure>
3434
3435      <h2>3. Connect your iPhone or iPad</h2>
3436      <p>Plug the device into your Mac with a cable. If it asks, tap <b>Trust This Computer</b> on the device and enter your passcode. (After the first install you can add and refresh apps over Wi-Fi — see the note below.)</p>
3437
3438      <h2>4. Enable Developer Mode on your device</h2>
3439      <p>On the iPhone or iPad, go to <b>Settings → Privacy &amp; Security → Developer Mode</b> (near the bottom) and turn it on. The device asks you to <b>restart</b>; after it reboots, swipe up and tap <b>Turn On</b> to confirm. Apple's guide: <a href="https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device" target="_blank" rel="noopener">Enabling Developer Mode on a device</a>.</p>
3440
3441      <h2>5. Install Magnatune via AltStore</h2>
3442      <p>In SideStep, click <b>Search AltStore</b>, type <b>magnatune</b> into the search box, and click <b>Install</b> next to Magnatune. SideStep installs it onto your device and keeps it refreshed automatically.</p>
3443      <figure class="win"><img src="ios/altstore-search.jpg" alt="Searching AltStore for magnatune inside SideStep" loading="lazy"><figcaption>Search AltStore for &ldquo;magnatune&rdquo;, then click <b>Install</b>.</figcaption></figure>
3444
3445      <h2>6. Approve the app on your device</h2>
3446      <p>Apps installed with a free Apple account must be trusted once before they'll open. On the device, go to <b>Settings → General → VPN &amp; Device Management</b>, tap your Apple ID under <b>Developer App</b>, then tap <b>Trust</b> / <b>Approve</b>.</p>
3447
3448      <h2>7. Open Magnatune</h2>
3449      <p>Launch Magnatune from your Home Screen and sign in with your Magnatune membership (in the app's Settings) to stream and download the full catalog without the spoken announcement.</p>
3450      <div class="ios-shots">
3451        <figure class="phone"><img src="ios/iphone.jpg" alt="Magnatune running on an iPhone" loading="lazy"><figcaption>Magnatune on iPhone</figcaption></figure>
3452        <figure class="pad"><img src="ios/ipad.jpg" alt="Magnatune running on an iPad" loading="lazy"><figcaption>Magnatune on iPad</figcaption></figure>
3453      </div>
3454
3455      <div class="ios-note"><b>Good to know</b>
3456        Once set up, you don't need to keep the device plugged in — as long as it's on and awake, SideStep can install and refresh apps over Wi-Fi. Free Apple accounts are limited to 3 side-loaded apps each, but you can add as many free Apple logins to SideStep as you like.</div>
3457    </div>`;
3458}
3459
3460// One-click iOS install. On a Mac, try to hand off to an already-installed
3461// SideStep via sidestep://install?repo=johnbuckman/magnatune-app; if nothing
3462// takes over within ~1.5s (SideStep isn't installed), reveal the installer
3463// download — the installer sets SideStep up too. SideStep is a Mac app, so off a
3464// Mac (including on the iPhone/iPad itself) we don't hand off; we explain a Mac is
3465// needed and show the download. iPadOS Safari poses as a Mac (platform
3466// "MacIntel") but reports touch, so a real Mac = Mac-like AND non-touch. If JS is
3467// off, the link falls through to downloading the installer .zip directly.
3468MAG.iosInstall = (e) => {
3469  e.preventDefault();
3470  const note = el("ios-oneclick-note"), fb = el("ios-fallback");
3471  const macLike = /Mac/.test(navigator.platform || navigator.userAgent || "");
3472  const onMac = macLike && (navigator.maxTouchPoints || 0) <= 1;
3473  if (!onMac) {
3474    if (note) note.textContent = "Installing needs a Mac with a USB cable — open this page on your Mac, then click here.";
3475    if (fb) fb.hidden = false;
3476    return false;
3477  }
3478  let handled = false;
3479  const onhide = () => { handled = true; };
3480  document.addEventListener("visibilitychange", onhide, { once: true });
3481  window.addEventListener("blur", onhide, { once: true });
3482  window.location.href = "sidestep://install?repo=johnbuckman/magnatune-app";
3483  setTimeout(() => {
3484    document.removeEventListener("visibilitychange", onhide);
3485    window.removeEventListener("blur", onhide);
3486    if (!handled && fb) {
3487      fb.hidden = false;
3488      if (note) note.textContent = "SideStep didn't open — install it with the link below (it also installs the app).";
3489    }
3490  }, 1500);
3491  return false;
3492};
3493
3494/* ===================== catalog text-format importer =====================
3495 * The app keeps ONE in-memory sql.js database with the schema below — the same
3496 * tables/columns the queries have always used. It is rebuilt from compact text
3497 * downloads (not a .db), in two waves:
3498 *   wave 1 (MTCORE1) -> importCatalog(): every row except album descriptions /
3499 *      artist bios. songs.mp3 is rebuilt from the per-album stem (+ rare override).
3500 *   wave 2 (MTDESC1) -> importDescriptions(): UPDATEs description/bio into rows.
3501 * Columns no query reads (homepage/photo/society/upc/itunes_buy_url, subgenres)
3502 * are simply absent. A new catalog version reimports from scratch into a fresh
3503 * Database (clean slate — no stale rows), then hot-swaps.  See CATALOG_URL. */
3504const CORE_SCHEMA = `
3505CREATE TABLE artists(artists_id INTEGER PRIMARY KEY, name TEXT, description TEXT, bio TEXT, page TEXT, city TEXT, state TEXT, country TEXT);
3506CREATE TABLE albums(album_id INTEGER PRIMARY KEY, artist_id INTEGER, name TEXT, description TEXT, sku TEXT, release_date INTEGER, popularity INTEGER);
3507CREATE TABLE genres(genre_id INTEGER PRIMARY KEY, name TEXT);
3508CREATE TABLE collections(collections_id INTEGER PRIMARY KEY, name TEXT);
3509CREATE TABLE genres_albums(genre_id INTEGER, album_id INTEGER);
3510CREATE TABLE collections_albums(collection_id INTEGER, album_id INTEGER);
3511CREATE TABLE songs(song_id INTEGER PRIMARY KEY, album_id INTEGER, name TEXT, track_no INTEGER, duration INTEGER, mp3 TEXT);
3512CREATE TABLE playlists(playlist_id INTEGER PRIMARY KEY, sort_order INTEGER, owner_id INTEGER, name TEXT);
3513CREATE TABLE playlist_songs(playlist_id INTEGER, sort_order INTEGER, song_id INTEGER);
3514CREATE TABLE recommendations(album_id INTEGER, recommended_album_id INTEGER, rank INTEGER);`;
3515const CORE_INDEXES = `
3516CREATE INDEX albums_artist_id ON albums(artist_id);
3517CREATE INDEX songs_album_id ON songs(album_id);
3518CREATE INDEX genres_albums_album_id ON genres_albums(album_id);
3519CREATE INDEX collections_albums_album_id ON collections_albums(album_id);
3520CREATE INDEX songs_playlist_id ON playlist_songs(playlist_id);
3521CREATE INDEX recommendations_album_id ON recommendations(album_id);`;
3522
3523function freshCoreDb() { const db = new MAG.SQL.Database(); db.run(CORE_SCHEMA); return db; }
3524
3525// reverse of the generator's escaping: \\ \t \n \r
3526function unesc(s) {
3527  if (s.indexOf("\\") < 0) return s;
3528  let out = "";
3529  for (let k = 0; k < s.length; k++) {
3530    const c = s[k];
3531    if (c === "\\" && k + 1 < s.length) {
3532      const n = s[++k];
3533      out += n === "t" ? "\t" : n === "n" ? "\n" : n === "r" ? "\r" : n;
3534    } else out += c;
3535  }
3536  return out;
3537}
3538const splitU = (line) => line.split("\t").map(unesc);   // fields never contain a real TAB (escaped)
3539const numOrNull = (s) => (s === "" ? null : +s);
3540// rebuild songs.mp3 exactly as the catalog generator stored it
3541function reconMp3(trackNo, name, stem) {
3542  return String(trackNo === "" ? 0 : trackNo).padStart(2, "0") + "-" +
3543         String(name).replace(/:/g, "_") + "-" + stem + ".mp3";
3544}
3545
3546// Walk "@table\t<count>\n<rows…>" sections, calling fn(name, rows[]) for each.
3547function eachSection(text, magic, fn) {
3548  const lines = text.split("\n");
3549  if (lines[0] !== magic) throw new Error("bad catalog header (expected " + magic + ")");
3550  let i = 1;
3551  while (i < lines.length) {
3552    if (!lines[i]) { i++; continue; }
3553    if (lines[i][0] !== "@") throw new Error("expected @section at line " + (i + 1));
3554    const tab = lines[i].indexOf("\t");
3555    const name = lines[i].slice(1, tab);
3556    const count = +lines[i].slice(tab + 1);
3557    i++;
3558    fn(name, lines.slice(i, i + count));
3559    i += count;
3560  }
3561}
3562
3563// wave 1: populate an empty (CORE_SCHEMA) db, then build indexes.
3564function importCatalog(db, text) {
3565  const stemOf = Object.create(null);   // album_id -> filename stem (wire-only, for mp3)
3566  db.run("BEGIN");
3567  const insert = (sql, build) => (rows) => {
3568    const st = db.prepare(sql);
3569    for (const r of rows) st.run(build(splitU(r)));
3570    st.free();
3571  };
3572  const handlers = {
3573    artists: insert("INSERT INTO artists(artists_id,name,description,page,city,state,country) VALUES (?,?,?,?,?,?,?)",
3574                    f => [+f[0], f[1], f[2], f[3] ?? "", f[4] ?? "", f[5] ?? "", f[6] ?? ""]),   // f[3]=page, f[4..6]=city/state/country; all absent in older catalogs → ""
3575    albums: (rows) => {
3576      const st = db.prepare("INSERT INTO albums(album_id,artist_id,name,sku,release_date,popularity) VALUES (?,?,?,?,?,?)");
3577      for (const r of rows) {
3578        const f = splitU(r);                 // id, artist_id, name, sku, stem, release_date, popularity
3579        stemOf[f[0]] = f[4];
3580        st.run([+f[0], +f[1], f[2], f[3], numOrNull(f[5]), numOrNull(f[6])]);
3581      }
3582      st.free();
3583    },
3584    songs: (rows) => {
3585      const st = db.prepare("INSERT INTO songs(song_id,album_id,name,track_no,duration,mp3) VALUES (?,?,?,?,?,?)");
3586      for (const r of rows) {
3587        const f = splitU(r);                 // id, album_id, name, track_no, duration, mp3_override
3588        const mp3 = f[5] !== "" ? f[5] : reconMp3(f[3], f[2], stemOf[f[1]] || "");
3589        st.run([+f[0], +f[1], f[2], numOrNull(f[3]), numOrNull(f[4]), mp3]);
3590      }
3591      st.free();
3592    },
3593    genres: insert("INSERT INTO genres VALUES (?,?)", f => [+f[0], f[1]]),
3594    collections: insert("INSERT INTO collections VALUES (?,?)", f => [+f[0], f[1]]),
3595    genres_albums: insert("INSERT INTO genres_albums VALUES (?,?)", f => [+f[0], +f[1]]),
3596    collections_albums: insert("INSERT INTO collections_albums VALUES (?,?)", f => [+f[0], +f[1]]),
3597    playlists: insert("INSERT INTO playlists VALUES (?,?,?,?)", f => [+f[0], numOrNull(f[1]), numOrNull(f[2]), f[3]]),
3598    playlist_songs: insert("INSERT INTO playlist_songs VALUES (?,?,?)", f => [+f[0], +f[1], +f[2]]),
3599    recommendations: (rows) => {                // grouped: seed \t rec rec rec…  (rank = position)
3600      const st = db.prepare("INSERT INTO recommendations VALUES (?,?,?)");
3601      for (const r of rows) {
3602        const tab = r.indexOf("\t");
3603        const seed = +r.slice(0, tab);
3604        const ids = r.slice(tab + 1).split(" ");
3605        for (let k = 0; k < ids.length;
3605 k++) if (ids[k]) st.run([seed, +ids[k], k + 1]);
3606      }
3607      st.free();
3608    },
3609  };
3610  eachSection(text, "MTCORE1", (name, rows) => { if (handlers[name]) handlers[name](rows); });
3611  db.run("COMMIT");
3612  db.run(CORE_INDEXES);
3613}
3614
3615// wave 2: UPDATE descriptions/bios into the already-loaded rows.
3616function importDescriptions(db, text) {
3617  db.run("BEGIN");
3618  eachSection(text, "MTDESC1", (name, rows) => {
3619    const sql = name === "album_descriptions" ? "UPDATE albums SET description=? WHERE album_id=?"
3620              : name === "artist_bios"        ? "UPDATE artists SET bio=? WHERE artists_id=?"
3621              : null;
3622    if (!sql) return;
3623    const st = db.prepare(sql);
3624    for (const r of rows) { const f = splitU(r); st.run([f[1], +f[0]]); }
3625    st.free();
3626  });
3627  db.run("COMMIT");
3628}
3629
3630// fetch a gzipped text file -> decoded string. withProgress shows the loader bar.
3631// --- resilient startup fetch -------------------------------------------------
3632// The big startup transfers (the sql.js wasm blob ~653KB and the catalog .gz
3633// ~585KB) are large same-origin fetches. Over HTTP/3 a stream can occasionally
3634// reset mid-body, so fetch()/the stream reader rejects with "Load failed" and the
3635// app fails to start. Retry a few times with a short pause: after an h3 failure
3636// the browser demotes the endpoint to HTTP/2 for the retry, so the next attempt
3637// goes through — the same reason a manual reload fixes it, but automatic.
3638function pause(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
3639async function fetchRetry(fn, tries = 3, delayMs = 500) {
3640  for (let attempt = 1; ; attempt++) {
3641    try { return await fn(); }
3642    catch (e) {
3643      if (attempt >= tries) throw e;
3644      console.warn(`startup fetch failed (attempt ${attempt}/${tries}): ${e.message} — retrying in ${delayMs}ms`);
3645      await pause(delayMs);
3646    }
3647  }
3648}
3649
3650async function fetchCatalogText(url, withProgress) {
3651  const gz = withProgress ? await downloadCatalog(url, 0)
3652                          : await fetch(url).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.arrayBuffer(); });
3653  const buf = await gunzip(gz);
3654  return new TextDecoder("utf-8").decode(new Uint8Array(buf));
3655}
3656
3657// wave 2 — background; refresh the current view so notes/bios appear when ready.
3658async function loadDescriptions() {
3659  if (MAG.descLoaded) return;
3660  try {
3661    const text = await fetchRetry(() => fetchCatalogText(DESC_URL + "?v=" + MAG.cfg.catalog_version, false));
3662    importDescriptions(MAG.db, text);
3663    MAG.descLoaded = true;
3664    if (!MAG.loading) route();
3665  } catch (e) { /* offline / transient — retry on next load */ }
3666}
3667
3668/* Hardcoded config — there is no server. The catalog version is the same-origin
3669 * catalog.crc, used both as the cache-buster (?v=) and to detect updates. If it's
3670 * unreachable we still boot (version 0). */
3671async function loadConfig() {
3672  let version = 0;
3673  try {
3674    const t = (await fetch(CRC_URL).then((r) => r.text())).trim();
3675    if (t) version = t;
3676  } catch (e) { /* boot anyway */ }
3677  return { catalog_url: CATALOG_URL, catalog_version: version, catalog_bytes: 0,
3678           image_base: "", stream_base: "" };
3679}
3680
3681/* Background catalog refresh — at most once per 24h on load: re-reads the
3682 * same-origin catalog.crc; if the CRC moved (a fresh catalog was deployed),
3683 * REIMPORTS FROM SCRATCH into a brand-new sql.js Database (a fresh db is a clean
3684 * slate — no stale rows, nothing to DROP), loads BOTH waves into it, then
3685 * hot-swaps it in (no page reload). Building fully before swapping means the
3686 * live view never briefly loses its descriptions. */
3687async function autoUpdateCatalog() {
3688  const KEY = "magnatune.catalogCheck";
3689  if (Date.now() - (+localStorage.getItem(KEY) || 0) < 24 * 3600 * 1000) return;
3690  try {
3691    const remote = (await fetch(CRC_URL).then((r) => r.text())).trim();
3692    localStorage.setItem(KEY, Date.now());
3693    if (!remote || remote == MAG.cfg.catalog_version) return;
3694    const fresh = freshCoreDb();
3695    importCatalog(fresh, await fetchCatalogText(CATALOG_URL + "?v=" + remote, false));
3696    let gotDesc = false;
3697    try { importDescriptions(fresh, await fetchCatalogText(DESC_URL + "?v=" + remote, false)); gotDesc = true; }
3698    catch (e) { /* descriptions are optional; load lazily after swap */ }
3699    const old = MAG.db;
3700    MAG.db = fresh;
3701    MAG.cfg.catalog_version = remote;
3702    MAG.descLoaded = gotDesc;
3703    try { old.close(); } catch (e) {}
3704    buildSlugMaps();      // rebuild slug->id maps against the refreshed catalog
3705    dedupeFavorites();
3706    Suppress.rebuild();
3707    route();              // re-render the current view against the new catalog
3708    if (!gotDesc) loadDescriptions();
3709  } catch (e) { /* offline / transient — try again next load */ }
3710}
3711
3712/* ===================== shell + router ===================== */
3713// Two tab groups mirroring the native Catalyst app's iPhone-mode toolbars:
3714// primary → top tab bar, secondary → bottom tab bar (see the responsive CSS).
3715// In the wide layout both stack vertically at the top of the left sidebar
3716// (secondary directly under primary; the spacer+mascot fill the bottom).
3717const NAV_ITEMS = {
3718  popular:   { label: "Popular",   icon: "fa-fire" },
3719  artist:    { label: "Artists",   icon: "fa-users" },
3720  album:     { label: "Albums",    icon: "fa-record-vinyl" },
3721  songs:     { label: "Songs",     icon: "fa-music" },
3722  genre:     { label: "Genres",    icon: "fa-guitar" },
3723  tag:       { label: "Tags",      icon: "fa-tag" },
3724  featured:  { label: "Featured",  icon: "fa-star" },
3725  search:    { label: "Search",    icon: "fa-magnifying-glass" },
3726  favorites: { label: "Favorites", icon: "fa-heart" },
3727  playlists: { label: "Playlists", icon: "fa-list" },
3728  settings:  { label: "Settings",  icon: "fa-gear" },
3729};
3730// The native app groups the nav DIFFERENTLY per layout, and so do we:
3731//  • Wide sidebar (iOS desktop): Tags + Featured live in the top Browse group; Search +
3732//    library sit in the lower group; Settings is pinned at the very bottom, under the mascot.
3733//  • Narrow tab bars (iOS iPhone): Tags + Featured move down to the bottom (secondary) bar,
3734//    and Settings rides in that bottom bar too (no mascot on narrow).
3735// renderNav() picks the right grouping for the current breakpoint and rebuilds on resize.
3736const NAV_WIDE = {
3737  primary:   ["popular", "artist", "album", "genre", "tag", "featured", "songs"],  // Browse
3738  secondary: ["favorites", "playlists"],   // Library
3739  tertiary:  ["search"],                   // Search (own group, like iOS)
3740  bottom:    ["settings"],                 // pinned below the mascot
3741};
3742const NAV_NARROW = {
3743  primary:   ["popular", "artist", "album", "songs", "genre", "search"],
3744  secondary: ["favorites", "playlists", "tag", "featured", "settings"],
3745};
3746const NAV_SECS = Object.keys(NAV_ITEMS);
3747const navItem = (sec) => { const n = NAV_ITEMS[sec]; return `<a id="nav-${sec}" href="#/${sec}" class="nav-item"><i class="fa-solid ${n.icon}"></i><span>${n.label}</span></a>`; };
3748const _navMQ = window.matchMedia("(max-width:1000px)");
3749function renderNav() {
3750  const g = _navMQ.matches ? NAV_NARROW : NAV_WIDE;
3751  const fill = (cls, secs) => { const box = document.querySelector(cls); if (box) box.innerHTML = (secs || []).map(navItem).join(""); };
3752  fill(".nav-primary", g.primary);
3753  fill(".nav-secondary", g.secondary);
3754  fill(".nav-tertiary", g.tertiary);   // wide: Search in its own group; narrow: empty (Search is in the top bar)
3755  fill(".nav-bottom", g.bottom);   // wide: Settings under the mascot; narrow: empty (Settings is in the bottom bar)
3756  if (MAG.lastSec) setActive(MAG.lastSec);   // re-apply active highlight after a layout rebuild
3757}
3758_navMQ.addEventListener("change", renderNav);
3759
3760function buildShell() {
3761  el("app").innerHTML =
3762    `<div class="appframe">
3763       <div class="shell">
3764         <nav class="sidebar">
3765           <a href="#/popular" class="brand-link" title="Popular"><img class="brand-logo" src="magnatune_logo.png?v=1" alt="Magnatune"></a>
3766           <div class="nav nav-primary"></div>
3767           <div class="nav nav-secondary"></div>
3768           <div class="nav nav-tertiary"></div>
3769           <div class="nav-spacer"></div>
3770           <a class="mascot-link" href="#/help" title="App help"><img class="mascot" loading="lazy" src="magnatune_mascot.png?v=1" alt="Help"></a>
3771           <div class="nav nav-bottom"></div>
3772         </nav>
3773         <div class="maincol">
3774         <main class="content" id="content"></main>
3775       <div class="miniplayer" id="miniplayer" hidden>
3776         <div class="mp-meta" id="mp-meta" title="Now playing">
3777           <div class="mp-cover" id="mp-cover"></div>
3778           <div class="mp-text"><div class="mp-title" id="mp-title"></div><div class="mp-artist" id="mp-artist"></div></div>
3779         </div>
3780         <div class="mp-center">
3781           <div class="mp-buttons">
3782             <button id="mp-repeat" title="Repeat"><i class="fa-solid fa-repeat"></i></button>
3783             <button id="mp-shuffle" title="Shuffle"><i class="fa-solid fa-shuffle"></i></button>
3784             <button id="mp-prev" title="Previous"><i class="fa-solid fa-backward-step"></i></button>
3785             <button id="mp-playpause" class="primary" title="Play / Pause"><i class="fa-solid fa-play"></i></button>
3786             <button id="mp-next" title="Next"><i class="fa-solid fa-forward-step"></i></button>
3787             <button id="mp-dislike" title="Dislike &amp; skip"><i class="fa-solid fa-heart-crack"></i></button>
3788           </div>
3789           <div class="mp-seekrow">
3790             <span class="mp-time" id="mp-cur">0:00</span>
3791             <input type="range" id="mp-seek" min="0" max="1000" value="0">
3792             <span class="mp-time" id="mp-dur">0:00</span>
3793           </div>
3794         </div>
3795         <div class="mp-right" id="mp-right"></div>
3796       </div>
3797       </div>
3798       </div>
3799     </div>`;
3800  renderNav();
3801  Player.init();
3802  wirePlayer();
3803}
3804function setActive(sec) {
3805  MAG.lastSec = sec;   // remembered so renderNav() can re-apply the highlight after a layout rebuild
3806  // user playlists share the Playlists nav entry; everything else maps 1:1
3807  const nav = sec === "playlist" ? "playlists" : sec;
3808  NAV_SECS.forEach((s) => {
3809    const a = el("nav-" + s);
3810    if (a) a.classList.toggle("active", s === nav);
3811  });
3812}
3813
3814function route() {
3815  const parts = currentToken().split("/").filter(Boolean);
3816  const sec = parts[0] || "popular";
3817  const c = el("content");
3818  if (!c) return;
3819  if (MAG.loading) { setActive(sec); return; } // catalog not ready — keep the loader visible
3820  MAG.lists = {}; // drop previous page's registered lists (the playing queue is held by Player)
3821  c.scrollTop = 0;
3822  setActive(sec);
3823  // resolve a name-slug detail segment to a catalog id (undefined = no segment = overview;
3824  // null = a segment was given but matched nothing → show "not found")
3825  const lookup = (map, key) => key == null ? undefined : (map.has(key) ? map.get(key) : null);
3826  const aSlug = parts[1] != null ? slug(decodeURIComponent(parts[1])) : undefined;
3827  const bSlug = parts[2] != null ? slug(decodeURIComponent(parts[2])) : undefined;
3828  const notFound = (what) => { c.innerHTML = `<div class="pad">${what} not found.</div>`; };
3829  try {
3830    switch (sec) {
3831      case "popular": renderPopular(c); break;
3832      case "artist": {
3833        if (aSlug === undefined) { renderArtists(c); break; }
3834        const id = lookup(Slug.artist, aSlug);
3835        id ? renderArtist(c, id) : notFound("Artist"); break;
3836      }
3837      case "album": {
3838        if (aSlug === undefined) { renderAlbums(c); break; }
3839        const id = lookup(Slug.album, bSlug === undefined ? null : aSlug + "/" + bSlug);
3840        const songSlug = parts[3] != null ? slug(decodeURIComponent(parts[3])) : undefined;
3841        id ? renderAlbum(c, id, songSlug) : notFound("Album"); break;
3842      }
3843      case "genre": {
3844        if (aSlug === undefined) { renderGenres(c); break; }
3845        const id = lookup(Slug.genre, aSlug);
3846        id ? renderGenre(c, id) : notFound("Genre"); break;
3847      }
3848      case "tag": {
3849        if (aSlug === undefined) { renderTags(c); break; }
3850        const id = lookup(Slug.tag, aSlug);
3851        id ? renderTag(c, id) : notFound("Tag"); break;
3852      }
3853      case "featured": {
3854        if (aSlug === undefined) { renderFeatured(c); break; }
3855        const id = lookup(Slug.featured, aSlug);
3856        id ? renderFeaturedPlaylist(c, id) : notFound("Playlist"); break;
3857      }
3858      case "search": renderSearch(c, parts.slice(1).map(decodeURIComponent).join("/")); break;
3859      case "songs": renderSongs(c, parts.slice(1).map(decodeURIComponent).join("/")); break;
3860      case "favorites": renderFavorites(c); break;
3861      case "playlists": renderPlaylists(c); break;
3862      case "recent": renderRecentlyPlayed(c); break;  // auto "Recently Played" pseudo-playlist
3863      case "recommended": renderRecommended(c); break;  // per-member recommendations (todo 10355065694)
3864      case "playlist": renderUserPlaylist(c, +parts[1]); break;  // user playlists keep numeric ids (local, no stable name)
3865      case "settings": renderSettings(c); break;
3866      case "downloads": renderDownloads(c); break;
3867      case "help": renderHelp(c); break;
3868      case "ios": renderIosInstall(c); break;
3869      case "info": renderInfo(c, parts[1]); break;
3870      default: c.innerHTML = `<div class="pad">Not found.</div>`;
3871    }
3872    highlightPlayingRow();
3873    wireDescToggles();
3874    LazyImg.scan(c);   // throttled cover loading for the just-rendered grid
3875  } catch (e) {
3876    console.error(e);
3877    c.innerHTML = `<div class="error"><div>Render error</div><div>${esc(e.message)}</div></div>`;
3878  }
3879}
3880
3881/* ===================== boot ===================== */
3882/* ---- first-load progress: the catalog SQLite is a few MB, so show what's happening ---- */
3883// Render the loader into the content area once the shell exists (so the whole app —
3884// sidebar, logo, nav — is visible while the catalog downloads); before that, into #app.
3885function showLoader(msg, withBar) {
3886  const host = el("content") || el("app");
3887  if (!host) return;
3888  if (!host.querySelector("#loader-card")) {
3889    host.innerHTML =
3890      `<div class="loader" id="loader-card">
3891         <div class="loader-spinner"></div>
3892         <div class="loader-msg" id="loader-msg"></div>
3893         <div class="loader-bar" id="loader-bar" hidden><div class="loader-fill" id="loader-fill"></div></div>
3894         <div class="loader-sub" id="loader-sub"></div>
3895       </div>`;
3896  }
3897  if (msg != null) el("loader-msg").textContent = msg;
3898  if (withBar) el("loader-bar").hidden = false;
3899}
3900const fmtMB = (b) => (b / 1048576).toFixed(1) + " MB";
3901const fmtETA = (s) =>
3902  !isFinite(s) || s <= 0 ? "almost done" :
3903  s < 60 ? "~" + Math.ceil(s) + "s left" :
3904  "~" + Math.floor(s / 60) + "m " + Math.ceil(s % 60) + "s left";
3905function updateLoader(received, total, start) {
3906  const pct = Math.min(100, Math.round((received / total) * 100));
3907  const elapsed = (performance.now() - start) / 1000;
3908  const rate = elapsed > 0 ? received / elapsed : 0;
3909  const eta = rate > 0 ? Math.max(0, total - received) / rate : Infinity;
3910  const fill = el("loader-fill"); if (fill) fill.style.width = pct + "%";
3911  const msg = el("loader-msg"); if (msg) msg.textContent = `Downloading music catalog… ${pct}%`;
3912  const sub = el("loader-sub");
3913  if (sub) sub.textContent =
3914    `${fmtMB(received)} of ${fmtMB(total)} · ${(rate / 1048576).toFixed(1)} MB/s · ${received >= total ? "finishing…" : fmtETA(eta)}`;
3915}
3916// Stream the catalog download so we can show a real % + ETA (browser auto-decompresses
3917// gzip, so received bytes match catalog_bytes). Falls back to a plain fetch if streaming
3918// or the size is unavailable.
3919// Lazy-load the vendored fflate gunzip fallback. Only reached on browsers without
3920// DecompressionStream (Safari < 16.4 / iOS < 16.4), so modern browsers never fetch
3921// it. Memoized so concurrent catalog waves share one load.
3922let _fflateLoad = null;
3923function loadFflate() {
3924  if (window.fflate) return Promise.resolve(window.fflate);
3925  if (_fflateLoad) return _fflateLoad;
3926  _fflateLoad = new Promise((resolve, reject) => {
3927    const s = document.createElement("script");
3928    s.src = "fflate.min.js";
3929    s.onload = () => window.fflate ? resolve(window.fflate)
3930                                   : reject(new Error("fflate loaded but global missing"));
3931    s.onerror = () => reject(new Error("failed to load fflate.min.js"));
3932    document.head.appendChild(s);
3933  });
3934  return _fflateLoad;
3935}
3936
3937// Inflate a gzip ArrayBuffer in the browser. Prefer the native DecompressionStream;
3938// fall back to the vendored fflate on browsers that lack it (Safari < 16.4 / iOS
3939// < 16.4 threw "Can't find variable: DecompressionStream" and never started).
3940// Guard: if the bytes lack the gzip magic (e.g. a server applied Content-Encoding
3941// and already inflated the stream), return them untouched so we never double-decompress.
3942async function gunzip(buf) {
3943  const u8 = new Uint8Array(buf);
3944  if (u8.length < 2 || u8[0] !== 0x1f || u8[1] !== 0x8b) return buf;
3945  if (typeof DecompressionStream !== "undefined") {
3946    const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream("gzip"));
3947    return await new Response(stream).arrayBuffer();
3948  }
3949  const fflate = await loadFflate();
3950  return fflate.gunzipSync(u8);   // Uint8Array — the caller wraps it in new Uint8Array()
3951}
3952
3953async function downloadCatalog(url, total) {
3954  const resp = await fetch(url);
3955  if (!resp.ok) throw new Error("catalog fetch HTTP " + resp.status);
3956  if (!total) { const cl = +resp.headers.get("content-length"); if (cl > 0) total = cl; }
3957  if (!resp.body || !resp.body.getReader || !total) {
3958    showLoader("Downloading music catalog…");
3959    return await resp.arrayBuffer();
3960  }
3961  showLoader("Downloading music catalog…", true);
3962  const reader = resp.body.getReader();
3963  const chunks = []; let received = 0; const start = performance.now(); let lastUI = 0;
3964  for (;;) {
3965    const { done, value } = await reader.read();
3966    if (done) break;
3967    chunks.push(value); received += value.length;
3968    const now = performance.now();
3969    if (now - lastUI > 120) { lastUI = now; updateLoader(received, total, start); }
3970  }
3971  updateLoader(received, total, start);
3972  const out = new Uint8Array(received); let off = 0;
3973  for (const c of chunks) { out.set(c, off); off += c.length; }
3974  return out.buffer;
3975}
3976
3977// Reflect the saved theme onto <html data-theme> so the CSS variable overrides apply.
3978// "system" defers to the OS via @media (prefers-color-scheme). A tiny inline script in
3979// the shell <head> sets this first to avoid a light flash before the app boots.
3980function applyTheme() { try { document.documentElement.dataset.theme = UserData.theme || "system"; } catch (e) {} }
3981
3982// The signed, HttpOnly m3member cookie is the real session (JS can't read it);
3983// navim4 sets a readable "m3user" hint cookie beside it carrying the logged-in
3984// username. Reconcile our cached localStorage member state with that cookie on
3985// every boot, so the app reflects the ACTUAL session — a fresh device that only
3986// has the cookie, or an admin "log in as", both show logged-in — and a cleared/
3987// expired cookie drops a stale cached login. Cookie = source of truth for
3988// session presence; localStorage stays a cache (+ holds prefs like dl formats).
3989function memberHintCookie() {
3990  const m = document.cookie.match(/(?:^|;\s*)m3user=([^;]*)/);
3991  if (!m || !m[1]) return "";
3992  let v = m[1];
3993  try { v = decodeURIComponent(v); } catch (e) {}
3994  return v.replace(/^"|"$/g, "");
3995}
3996function syncMemberFromCookie() {
3997  const u = memberHintCookie();
3998  const cur = UserData.member || { loggedIn: false, user: "" };
3999  if (u) {
4000    if (!cur.loggedIn || cur.user !== u) UserData.setMember({ loggedIn: true, user: u });
4001  } else if (cur.loggedIn) {
4002    UserData.setMember({ loggedIn: false, user: "" });
4003  }
4004}
4005
4006async function boot() {
4007  // Tell the cold-load watchdog (inline in the page <head>) that the SPA script
4008  // actually loaded and is executing — reaching here means the HTTP/3 asset stall
4009  // that leaves visitors on the unstyled #seo-prerender did not happen, so the
4010  // watchdog must not fire its one-shot reload.
4011  window.__magBooted = true;
4012  try { sessionStorage.removeItem("magnatune.bootReloaded"); } catch (e) {}
4013  try {
4014    MAG.loading = true;
4015    showLoader("Connecting…");
4016    MAG.cfg = await loadConfig();
4017    UserData.init();                  // localStorage only — no catalog needed
4018    Sync.init();                      // cloud-sync metadata (rev/hash/base) from localStorage
4019    syncMemberFromCookie();           // reconcile cached login with the session cookie
4020    // Best-effort flush of pending setting changes when leaving/backgrounding the page.
4021    addEventListener("pagehide", () => Sync.flushBeacon());
4022    document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") Sync.flushBeacon(); });
4023    fetchPlayToken();                 // fire-and-forget: play logging only, never blocks the UI
4024    applyTheme();
4025    buildShell();                     // show the full UI immediately (sidebar, logo, nav, player)
4026    setupKeyboard();                  // Space = play/pause (global shortcut)
4027    if (PATH_MODE) {
4028      // Links are still built as "#/…". Intercept their clicks → pushState the real path.
4029      document.addEventListener("click", (e) => {
4030        if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
4031        // Controls inside a link (fav/dislike/add buttons, inputs) handle their own click —
4032        // don't let this capture-phase interceptor navigate the surrounding <a> first.
4033        if (e.target.closest("button, input, select, textarea, label")) return;
4034        const a = e.target.closest && e.target.closest('a[href^="#/"]');
4035        if (!a) return;
4036        e.preventDefault();
4037        go(a.getAttribute("href"));     // go() strips the leading "#/"
4038      }, true);
4039      window.addEventListener("popstate", route);
4040    } else {
4041      window.addEventListener("hashchange", route);
4042    }
4043    showLoader("Loading database engine…");   // progress now lives in the content area
4044    // Prefetch the wasm and hand it to sql.js as `wasmBinary` so it skips
4045    // WebAssembly.instantiateStreaming (which the browser refuses unless the .wasm
4046    // is served as application/wasm — most static hosts don't). Avoids the noisy
4047    // "wasm streaming compile failed … falling back" console error; no server config.
4048    const wasmBinary = await fetchRetry(() => fetch("sql-wasm.wasm").then((r) => r.arrayBuffer()));
4049    MAG.SQL = await initSqlJs({ wasmBinary, locateFile: (f) => "" + f });
4050    // 1) empty db with the full schema  2) download catalog text (progress bar)
4051    // 3) import it  4) app becomes usable.  Descriptions follow async (5-6).
4052    const db = freshCoreDb();
4053    const text = await fetchRetry(() => fetchCatalogText(CATALOG_URL + "?v=" + MAG.cfg.catalog_version, true));
4054    showLoader("Preparing catalog…");
4055    importCatalog(db, text);
4056    MAG.db = db;
4057    buildSlugMaps();      // reverse name-slug -> id maps for pretty URL routing
4058    dedupeFavorites();    // clean any redundant favorites saved previously (needs the catalog)
4059    Suppress.rebuild();   // expand disliked ids through the catalog for UI suppression
4060    MAG.loading = false;
4061    route();              // render the real content over the loader
4062    if (isMember()) Sync.pull();   // pull+merge this member's cloud settings (todo 10355013029)
4063    loadDescriptions();   // wave 2 — async album notes + artist bios
4064    autoUpdateCatalog();  // background, throttled to 24h
4065  } catch (e) {
4066    console.error(e);
4067    const host = el("content") || el("app");
4068    if (host) host.innerHTML = `<div class="error"><div>⚠️ Failed to start Magnatune</div><div>${esc(e.message)}</div></div>`;
4069  }
4070}
4071boot();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.