commit 22b49eca4c9534729bff24ffeedf53328e1b7db6
parent 30e531e1e7495fbef59fb9d07576034e6a5da92f
Author: Pablo Murad <pblmrd@gmail.com>
Date: Fri, 1 May 2026 12:30:44 -0300
metadata
Diffstat:
8 files changed, 402 insertions(+), 214 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -13,7 +13,8 @@
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"react": "^19.2.5",
- "react-dom": "^19.2.5"
+ "react-dom": "^19.2.5",
+ "react-router-dom": "^7.14.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -3796,6 +3797,44 @@
"react": "^19.2.5"
}
},
+ "node_modules/react-router": {
+ "version": "7.14.2",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
+ "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.14.2",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
+ "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.14.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
diff --git a/package.json b/package.json
@@ -20,7 +20,8 @@
"dotenv": "^17.4.2",
"fastify": "^5.8.5",
"react": "^19.2.5",
- "react-dom": "^19.2.5"
+ "react-dom": "^19.2.5",
+ "react-router-dom": "^7.14.2"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
diff --git a/src/App.css b/src/App.css
@@ -9,6 +9,26 @@
margin-bottom: 2rem;
}
+.logo-link {
+ display: inline-block;
+}
+
+.site-nav {
+ margin-top: 1rem;
+}
+
+.nav-link {
+ font-weight: 600;
+ color: var(--sky);
+ text-decoration: none;
+ border-bottom: 1px solid color-mix(in srgb, var(--sky) 45%, transparent);
+}
+
+.nav-link:hover {
+ color: var(--yellow);
+ border-bottom-color: color-mix(in srgb, var(--yellow) 50%, transparent);
+}
+
.logo {
max-width: min(360px, 100%);
height: auto;
@@ -208,3 +228,40 @@
font-size: 0.85em;
color: var(--sky);
}
+
+.about-main {
+ grid-column: 1 / -1;
+ max-width: 42rem;
+ margin: 0 auto;
+ width: 100%;
+}
+
+.about-card {
+ padding: 1.5rem 1.35rem 1.75rem;
+}
+
+.about-title {
+ margin: 0 0 1.25rem;
+ font-size: 1.35rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--yellow);
+ text-shadow: 0 1px 0 rgba(0, 0, 0, 0.5);
+}
+
+.about-prose p {
+ margin: 0 0 1rem;
+ font-size: 0.98rem;
+ line-height: 1.6;
+ color: color-mix(in srgb, #fff 88%, var(--muted));
+}
+
+.about-prose p:last-child {
+ margin-bottom: 0;
+}
+
+.about-signoff {
+ margin-top: 1.5rem !important;
+ font-style: italic;
+ color: var(--muted) !important;
+}
diff --git a/src/App.tsx b/src/App.tsx
@@ -1,213 +1,12 @@
-import { useCallback, useEffect, useRef, useState } from "react";
-import "./App.css";
-
-type TrackInfo = {
- id: string;
- title: string;
- artist: string;
-};
-
-type RandomResponse = {
- track: TrackInfo;
- streamUrl: string;
-};
-
-type ErrBody = { error?: string };
-
-type HealthBody = {
- tracksReady?: boolean;
- hint?: string;
- metadataTsv?: string;
- metadataExists?: boolean;
- trackCount?: number;
-};
-
-const MAX_ARCHIVE_STREAM_ERRORS = 3;
+import { Route, Routes } from "react-router-dom";
+import About from "./pages/About";
+import Home from "./pages/Home";
export default function App() {
- const audioRef = useRef<HTMLAudioElement>(null);
- const archiveStreamErrorsRef = useRef(0);
- const [track, setTrack] = useState<TrackInfo | null>(null);
- const [status, setStatus] = useState<string>("");
- const [history, setHistory] = useState<TrackInfo[]>([]);
- const [autoPlay, setAutoPlay] = useState(true);
- const [healthWarn, setHealthWarn] = useState<string | null>(null);
-
- const playUrl = useCallback((url: string) => {
- const a = audioRef.current;
- if (!a) return;
- a.src = url;
- a.load();
- void a.play().catch(() => {});
- }, []);
-
- const loadNext = useCallback(async () => {
- setStatus("Loading…");
- try {
- const res = await fetch("/api/track/random");
- const body = (await res.json()) as RandomResponse | ErrBody;
- if (!res.ok) {
- setTrack(null);
- setStatus(
- "error" in body && body.error
- ? body.error
- : "Service unavailable. Try again later.",
- );
- return;
- }
- const data = body as RandomResponse;
- const info: TrackInfo = {
- id: data.track.id,
- title: data.track.title,
- artist: data.track.artist,
- };
- setTrack(info);
- setHistory((h) => [info, ...h].slice(0, 15));
- playUrl(data.streamUrl);
- setStatus("");
- } catch {
- setStatus("Network error while requesting a track.");
- }
- }, [playUrl]);
-
- const handleAudioPlaying = useCallback(() => {
- archiveStreamErrorsRef.current = 0;
- setStatus("");
- }, []);
-
- const handleAudioError = useCallback(() => {
- archiveStreamErrorsRef.current += 1;
- const n = archiveStreamErrorsRef.current;
- if (n >= MAX_ARCHIVE_STREAM_ERRORS) {
- setStatus(
- "Internet Archive could not stream several tracks in a row (e.g. 503). Try Next or wait.",
- );
- return;
- }
- setStatus("This track is not available from the Archive right now; trying another…");
- if (autoPlay) void loadNext();
- }, [autoPlay, loadNext]);
-
- const requestNextTrack = useCallback(() => {
- archiveStreamErrorsRef.current = 0;
- void loadNext();
- }, [loadNext]);
-
- useEffect(() => {
- void (async () => {
- try {
- const res = await fetch("/api/health");
- const h = (await res.json()) as HealthBody;
- if (!h.tracksReady) {
- const parts = [
- h.hint,
- h.metadataTsv && `Path: ${h.metadataTsv}`,
- h.metadataExists === false && "File not found at configured path.",
- typeof h.trackCount === "number" && `Tracks loaded: ${h.trackCount}.`,
- ].filter(Boolean);
- setHealthWarn(
- parts.length > 0
- ? parts.join(" ")
- : "No tracks loaded. Check server metadata and /api/health.",
- );
- } else {
- setHealthWarn(null);
- }
- } catch {
- setHealthWarn(null);
- }
- })();
- }, []);
-
- useEffect(() => {
- // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount bootstrap
- void loadNext();
- }, [loadNext]);
-
- const onEnded = () => {
- if (autoPlay) void loadNext();
- };
-
return (
- <div className="page">
- {healthWarn ? (
- <div className="health-banner" role="alert">
- <strong>Server metadata</strong>
- <p>{healthWarn}</p>
- <p className="health-banner-hint">
- On the host, run <code>curl -sS http://127.0.0.1:38471/api/health</code> (adjust
- port) and fix <code>METADATA_TSV</code> or remove it to use the default{" "}
- <code>data/metadata.tsv</code>.
- </p>
- </div>
- ) : null}
- <header className="header">
- <img
- className="logo"
- src="/mymusics.png"
- alt="MyMusics"
- width={200}
- height={80}
- decoding="async"
- />
- </header>
-
- <main className="main">
- <section className="card now-playing">
- <h2>Now playing</h2>
- {track ? (
- <div className="track-block">
- <p className="artist">{track.artist}</p>
- <p className="title">{track.title}</p>
- </div>
- ) : (
- <p className="muted">{status || "No track loaded."}</p>
- )}
-
- <audio
- ref={audioRef}
- className="player"
- controls
- controlsList="nodownload noplaybackrate"
- preload="metadata"
- onEnded={onEnded}
- onPlaying={handleAudioPlaying}
- onError={handleAudioError}
- />
-
- <div className="actions">
- <button type="button" className="btn primary" onClick={() => void requestNextTrack()}>
- Next
- </button>
- <label className="check">
- <input
- type="checkbox"
- checked={autoPlay}
- onChange={(e) => setAutoPlay(e.target.checked)}
- />
- Auto-advance when track ends
- </label>
- </div>
- {status && track ? <p className="hint">{status}</p> : null}
- </section>
-
- <section className="card history">
- <h2>History</h2>
- <ol className="history-list">
- {history.map((t, idx) => (
- <li key={`${t.id}-${idx}-${t.title}`}>
- <span className="h-artist">{t.artist}</span>
- <span className="sep">—</span>
- <span className="h-title">{t.title}</span>
- </li>
- ))}
- </ol>
- </section>
- </main>
-
- <footer className="footer">
- <small className="muted">Developed by Pablo Murad — 2026</small>
- </footer>
- </div>
+ <Routes>
+ <Route path="/" element={<Home />} />
+ <Route path="/about" element={<About />} />
+ </Routes>
);
}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
@@ -0,0 +1,31 @@
+import { Link } from "react-router-dom";
+
+type NavKind = "home" | "about";
+
+export function SiteHeader({ nav }: { nav: NavKind }) {
+ return (
+ <header className="header">
+ <Link to="/" className="logo-link" aria-label="MyMusics home">
+ <img
+ className="logo"
+ src="/mymusics.png"
+ alt="MyMusics"
+ width={200}
+ height={80}
+ decoding="async"
+ />
+ </Link>
+ <nav className="site-nav" aria-label="Site">
+ {nav === "home" ? (
+ <Link to="/about" className="nav-link">
+ About
+ </Link>
+ ) : (
+ <Link to="/" className="nav-link">
+ Back to player
+ </Link>
+ )}
+ </nav>
+ </header>
+ );
+}
diff --git a/src/main.tsx b/src/main.tsx
@@ -1,5 +1,10 @@
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
+import "./index.css";
+import App from "./App.tsx";
-createRoot(document.getElementById('root')!).render(<App />)
+createRoot(document.getElementById("root")!).render(
+ <BrowserRouter>
+ <App />
+ </BrowserRouter>,
+);
diff --git a/src/pages/About.tsx b/src/pages/About.tsx
@@ -0,0 +1,51 @@
+import { SiteHeader } from "../components/SiteHeader";
+import "../App.css";
+
+export default function About() {
+ return (
+ <div className="page">
+ <SiteHeader nav="about" />
+
+ <main className="main about-main">
+ <article className="card about-card">
+ <h1 className="about-title">About MyMusics</h1>
+
+ <div className="about-prose">
+ <p>
+ MyMusics is a small love letter to a corner of the early web: millions of songs
+ lived on MySpace profiles—messy, heartfelt, often buried under glitter and
+ autoplay. Many of those tracks survived thanks to archivists and the{" "}
+ <strong>Internet Archive</strong>, bundled in collections such as{" "}
+ <em>The Myspace Dragon Hoard</em>. This project aggregates metadata for those
+ recordings and lets you hit “random” and listen again, streamed from the Archive’s
+ mirrors of history rather than from MySpace itself (that ship sailed long ago).
+ </p>
+
+ <p>
+ Years ago I stumbled on another player or demo built around the same idea. I wish I
+ could name the author and link to their work; memory failed me, so proper credit
+ goes missing here with my apologies. What you see today is not a fork: the stack,
+ server, UX, and plenty of behaviour were reworked from scratch. I changed a lot,
+ learned a lot, and I’m genuinely happy with how it turned out.
+ </p>
+
+ <p>
+ The old internet was slower, louder, and less polished—but it felt owned by people.
+ Bands shouted into the void with orange backgrounds; friends traded playlists in
+ comments; nothing asked you for a subscription before it would play a song. If
+ this player reminds you of that era for even a minute, it did its job.
+ </p>
+
+ <p className="about-signoff">
+ — Pablo Murad, 2026
+ </p>
+ </div>
+ </article>
+ </main>
+
+ <footer className="footer">
+ <small className="muted">Developed by Pablo Murad — 2026</small>
+ </footer>
+ </div>
+ );
+}
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
@@ -0,0 +1,205 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { SiteHeader } from "../components/SiteHeader";
+import "../App.css";
+
+type TrackInfo = {
+ id: string;
+ title: string;
+ artist: string;
+};
+
+type RandomResponse = {
+ track: TrackInfo;
+ streamUrl: string;
+};
+
+type ErrBody = { error?: string };
+
+type HealthBody = {
+ tracksReady?: boolean;
+ hint?: string;
+ metadataTsv?: string;
+ metadataExists?: boolean;
+ trackCount?: number;
+};
+
+const MAX_ARCHIVE_STREAM_ERRORS = 3;
+
+export default function Home() {
+ const audioRef = useRef<HTMLAudioElement>(null);
+ const archiveStreamErrorsRef = useRef(0);
+ const [track, setTrack] = useState<TrackInfo | null>(null);
+ const [status, setStatus] = useState<string>("");
+ const [history, setHistory] = useState<TrackInfo[]>([]);
+ const [autoPlay, setAutoPlay] = useState(true);
+ const [healthWarn, setHealthWarn] = useState<string | null>(null);
+
+ const playUrl = useCallback((url: string) => {
+ const a = audioRef.current;
+ if (!a) return;
+ a.src = url;
+ a.load();
+ void a.play().catch(() => {});
+ }, []);
+
+ const loadNext = useCallback(async () => {
+ setStatus("Loading…");
+ try {
+ const res = await fetch("/api/track/random");
+ const body = (await res.json()) as RandomResponse | ErrBody;
+ if (!res.ok) {
+ setTrack(null);
+ setStatus(
+ "error" in body && body.error
+ ? body.error
+ : "Service unavailable. Try again later.",
+ );
+ return;
+ }
+ const data = body as RandomResponse;
+ const info: TrackInfo = {
+ id: data.track.id,
+ title: data.track.title,
+ artist: data.track.artist,
+ };
+ setTrack(info);
+ setHistory((h) => [info, ...h].slice(0, 15));
+ playUrl(data.streamUrl);
+ setStatus("");
+ } catch {
+ setStatus("Network error while requesting a track.");
+ }
+ }, [playUrl]);
+
+ const handleAudioPlaying = useCallback(() => {
+ archiveStreamErrorsRef.current = 0;
+ setStatus("");
+ }, []);
+
+ const handleAudioError = useCallback(() => {
+ archiveStreamErrorsRef.current += 1;
+ const n = archiveStreamErrorsRef.current;
+ if (n >= MAX_ARCHIVE_STREAM_ERRORS) {
+ setStatus(
+ "Internet Archive could not stream several tracks in a row (e.g. 503). Try Next or wait.",
+ );
+ return;
+ }
+ setStatus("This track is not available from the Archive right now; trying another…");
+ if (autoPlay) void loadNext();
+ }, [autoPlay, loadNext]);
+
+ const requestNextTrack = useCallback(() => {
+ archiveStreamErrorsRef.current = 0;
+ void loadNext();
+ }, [loadNext]);
+
+ useEffect(() => {
+ void (async () => {
+ try {
+ const res = await fetch("/api/health");
+ const h = (await res.json()) as HealthBody;
+ if (!h.tracksReady) {
+ const parts = [
+ h.hint,
+ h.metadataTsv && `Path: ${h.metadataTsv}`,
+ h.metadataExists === false && "File not found at configured path.",
+ typeof h.trackCount === "number" && `Tracks loaded: ${h.trackCount}.`,
+ ].filter(Boolean);
+ setHealthWarn(
+ parts.length > 0
+ ? parts.join(" ")
+ : "No tracks loaded. Check server metadata and /api/health.",
+ );
+ } else {
+ setHealthWarn(null);
+ }
+ } catch {
+ setHealthWarn(null);
+ }
+ })();
+ }, []);
+
+ useEffect(() => {
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount bootstrap
+ void loadNext();
+ }, [loadNext]);
+
+ const onEnded = () => {
+ if (autoPlay) void loadNext();
+ };
+
+ return (
+ <div className="page">
+ {healthWarn ? (
+ <div className="health-banner" role="alert">
+ <strong>Server metadata</strong>
+ <p>{healthWarn}</p>
+ <p className="health-banner-hint">
+ On the host, run <code>curl -sS http://127.0.0.1:38471/api/health</code> (adjust
+ port) and fix <code>METADATA_TSV</code> or remove it to use the default{" "}
+ <code>data/metadata.tsv</code>.
+ </p>
+ </div>
+ ) : null}
+ <SiteHeader nav="home" />
+
+ <main className="main">
+ <section className="card now-playing">
+ <h2>Now playing</h2>
+ {track ? (
+ <div className="track-block">
+ <p className="artist">{track.artist}</p>
+ <p className="title">{track.title}</p>
+ </div>
+ ) : (
+ <p className="muted">{status || "No track loaded."}</p>
+ )}
+
+ <audio
+ ref={audioRef}
+ className="player"
+ controls
+ controlsList="nodownload noplaybackrate"
+ preload="metadata"
+ onEnded={onEnded}
+ onPlaying={handleAudioPlaying}
+ onError={handleAudioError}
+ />
+
+ <div className="actions">
+ <button type="button" className="btn primary" onClick={() => void requestNextTrack()}>
+ Next
+ </button>
+ <label className="check">
+ <input
+ type="checkbox"
+ checked={autoPlay}
+ onChange={(e) => setAutoPlay(e.target.checked)}
+ />
+ Auto-advance when track ends
+ </label>
+ </div>
+ {status && track ? <p className="hint">{status}</p> : null}
+ </section>
+
+ <section className="card history">
+ <h2>History</h2>
+ <ol className="history-list">
+ {history.map((t, idx) => (
+ <li key={`${t.id}-${idx}-${t.title}`}>
+ <span className="h-artist">{t.artist}</span>
+ <span className="sep">—</span>
+ <span className="h-title">{t.title}</span>
+ </li>
+ ))}
+ </ol>
+ </section>
+ </main>
+
+ <footer className="footer">
+ <small className="muted">Developed by Pablo Murad — 2026</small>
+ </footer>
+ </div>
+ );
+}