feat(demo): Wavlake tracks, real photos, deduped peer catalog, working paid flow

- Two real Zazawowow tracks from Wavlake (metadata via catalog API, bytes +
  artwork committed): WEBFIVEFOURTHREETWOONE is the showcase PAID track
  (21 sats), Michael Michael Saylor is free
- Paid/owned downloads now return real file bytes with the correct mime_type
  (was a text/plain placeholder) so buying a song autoplays in the bottom bar
- content.owned-list seeded per session with dated purchases matching the
  session's federation onions; every purchase path appends to it so Owned
  state survives the post-purchase refresh (Paid Files tab now populated)
- peerCatalogFor: deterministic one-peer-per-item assignment + 3 POPULAR
  duplicates (was ~25% of items duplicated onto every third peer)
- demoFederationNodes memoised per session so catalogs/owned records/UI agree
- content.preview-peer serves a real audio slice for audio items (paid
  preview button plays music, not artwork bytes)
- New GET /api/peer-content/:onion/:content_id Range-capable streaming route
  (whitelist lookup, paid items 403) + /api dev proxy in vite.config.ts
- All ten photo-*.jpg picsum placeholders replaced with real Wikimedia
  Commons photographs (credited in each description, >=1920px wide)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 13:41:17 -04:00
parent dcef3cb1bd
commit 14d1a453c7
16 changed files with 162 additions and 36 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 852 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 640 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 665 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 668 KiB

View File

@ -1249,8 +1249,10 @@ function demoAppShell(title, sub, iconPath, bodyHtml) {
// series, books, music, photography and documents with real metadata. Music,
// photos and documents are backed by REAL committed files (they play/open);
// films/series/books are paid so the buy-flow poster/cover previews carry the
// visuals. Preview images live in demo/peer-media (sourced from the web via
// picsum.photos — Unsplash-licensed — seeds pinned in git history).
// visuals. Photographs in demo/peer-media are real free-license images from
// Wikimedia Commons (per-file credit in each description); the two Zazawowow
// tracks are real songs published on Wavlake (wavlake.com), bytes + artwork
// committed from the public catalog CDN.
const PEER_MEDIA = (f) => path.join(__dirname, '..', 'demo', 'peer-media', f)
const PEER_CONTENT = (...p) => path.join(__dirname, '..', 'demo', 'content', ...p)
const PEER_LIBRARY = [
@ -1270,7 +1272,13 @@ const PEER_LIBRARY = [
description: 'EPUB · 348 pp · 2025 anthology — privacy engineering, remailers, digital cash history.' },
{ id: 'book-broadcast-power', filename: 'Broadcast Power — pirate radio to mesh.pdf', mime_type: 'application/pdf', size_bytes: 18_874_368, access: { paid: { price_sats: 150 } }, preview: PEER_MEDIA('cover-book-broadcast.jpg'),
description: 'PDF · 214 pp · illustrated 2024 history of community-run transmission networks.' },
// Music — free, streams the real committed files
// Music — real committed files. The two Wavlake tracks are real releases by
// Zazawowow (metadata from catalog.wavlake.com); the first is the demo's
// showcase PAID track — buying it delivers the actual mp3 and autoplays it.
{ id: 'song-webfive', filename: 'Zazawowow - WEBFIVEFOURTHREETWOONE.mp3', mime_type: 'audio/mpeg', size_bytes: 6_041_903, access: { paid: { price_sats: 21 } }, disk: PEER_CONTENT('music', 'Zazawowow - WEBFIVEFOURTHREETWOONE.mp3'),
description: 'MP3 128 kbps · WEBFIVE · Zazawowow · 4:09 · published on Wavlake.' },
{ id: 'song-michael-saylor', filename: 'Zazawowow - Michael Michael Saylor.mp3', mime_type: 'audio/mpeg', size_bytes: 4_517_590, access: 'free', disk: PEER_CONTENT('music', 'Zazawowow - Michael Michael Saylor.mp3'),
description: 'MP3 128 kbps · Michael Michael Saylor (single) · Zazawowow · 3:03 · published on Wavlake.' },
{ id: 'song-leaves-brown', filename: 'All the leaves are brown.mp3', mime_type: 'audio/mpeg', size_bytes: 2_936_012, access: 'free', disk: PEER_CONTENT('music', 'All the leaves are brown.mp3'),
description: 'MP3 320 kbps · Archy Sessions, 2026 · folk cover, one-take living-room recording.' },
{ id: 'song-exploited-substrate', filename: 'An Exploited Substrate.mp3', mime_type: 'audio/mpeg', size_bytes: 4_194_304, access: 'free', disk: PEER_CONTENT('music', 'An Exploited Substrate.mp3'),
@ -1279,29 +1287,30 @@ const PEER_LIBRARY = [
description: 'WAV 16-bit/44.1 kHz lossless master · Node Noise EP, 2025.' },
{ id: 'song-bitcoin-salad', filename: 'Bitcoin Salad.MP3', mime_type: 'audio/mpeg', size_bytes: 3_984_588, access: 'free', disk: PEER_CONTENT('music', 'Bitcoin Salad.MP3'),
description: 'MP3 256 kbps · Kitchen Mix Vol. 3, 2024 · novelty electro.' },
{ id: 'song-builders', filename: 'Builders, not talkers (Remastered).mp3', mime_type: 'audio/mpeg', size_bytes: 4_508_876, access: 'free', disk: PEER_CONTENT('music', 'Builders, not talkers (Remastered).mp3'),
{ id: 'song-builders', filename: 'Builders, not talkers (Remastered).mp3', mime_type: 'audio/mpeg', size_bytes: 4_508_876, access: { paid: { price_sats: 100 } }, disk: PEER_CONTENT('music', 'Builders, not talkers (Remastered).mp3'),
description: 'MP3 320 kbps · 2026 remaster · hip-hop instrumental.' },
// Photography — free, the committed JPEGs themselves (preview = full image)
{ id: 'photo-aurora-fjord', filename: 'aurora-over-the-fjord.jpg', mime_type: 'image/jpeg', size_bytes: 79_422, access: 'free', disk: PEER_MEDIA('photo-aurora-fjord.jpg'),
description: '800×533 · f/1.8 · 8s · ISO 1600 · Sony A7 IV 24mm · Lofoten, Norway · free license, sourced via picsum.photos.' },
{ id: 'photo-mountain-lake', filename: 'mountain-lake-still.jpg', mime_type: 'image/jpeg', size_bytes: 29_200, access: 'free', disk: PEER_MEDIA('photo-mountain-lake.jpg'),
description: '800×533 · f/8 · 1/250s · ISO 100 · Fuji X-T5 23mm · Dolomites, Italy · free license, sourced via picsum.photos.' },
{ id: 'photo-city-neon', filename: 'city-neon-rain.jpg', mime_type: 'image/jpeg', size_bytes: 59_855, access: 'free', disk: PEER_MEDIA('photo-city-neon.jpg'),
description: '800×533 · f/1.4 · 1/60s · ISO 800 · Leica Q3 28mm · Osaka, Japan · free license, sourced via picsum.photos.' },
{ id: 'photo-desert-dunes', filename: 'desert-dunes-dawn.jpg', mime_type: 'image/jpeg', size_bytes: 24_646, access: 'free', disk: PEER_MEDIA('photo-desert-dunes.jpg'),
description: '800×533 · f/11 · 1/125s · ISO 64 · Nikon Z7 II 70-200mm · Erg Chebbi, Morocco · free license, sourced via picsum.photos.' },
{ id: 'photo-forest-mist', filename: 'forest-mist-morning.jpg', mime_type: 'image/jpeg', size_bytes: 48_504, access: 'free', disk: PEER_MEDIA('photo-forest-mist.jpg'),
description: '800×533 · f/4 · 1/200s · ISO 400 · Canon R5 85mm · Black Forest, Germany · free license, sourced via picsum.photos.' },
{ id: 'photo-ocean-cliff', filename: 'ocean-cliff-long-exposure.jpg', mime_type: 'image/jpeg', size_bytes: 30_517, access: 'free', disk: PEER_MEDIA('photo-ocean-cliff.jpg'),
description: '800×533 · f/16 · 30s ND1000 · ISO 50 · Sony A7R V 16-35mm · Moher, Ireland · free license, sourced via picsum.photos.' },
{ id: 'photo-northern-road', filename: 'northern-road-solitude.jpg', mime_type: 'image/jpeg', size_bytes: 72_053, access: 'free', disk: PEER_MEDIA('photo-northern-road.jpg'),
description: '800×533 · f/5.6 · 1/500s · ISO 200 · drone DJI Mavic 3 · Iceland Ring Road · free license, sourced via picsum.photos.' },
{ id: 'photo-autumn-valley', filename: 'autumn-valley-patchwork.jpg', mime_type: 'image/jpeg', size_bytes: 51_148, access: 'free', disk: PEER_MEDIA('photo-autumn-valley.jpg'),
description: '800×533 · f/9 · 1/160s · ISO 100 · Fuji GFX 100S 110mm · Vermont, USA · free license, sourced via picsum.photos.' },
{ id: 'photo-harbor-dawn', filename: 'harbor-dawn-fishing-boats.jpg', mime_type: 'image/jpeg', size_bytes: 36_271, access: 'free', disk: PEER_MEDIA('photo-harbor-dawn.jpg'),
description: '800×533 · f/2.8 · 1/80s · ISO 320 · Leica M11 35mm · Cornwall, UK · free license, sourced via picsum.photos.' },
{ id: 'photo-alpine-ridge', filename: 'alpine-ridge-hiker.jpg', mime_type: 'image/jpeg', size_bytes: 91_938, access: 'free', disk: PEER_MEDIA('photo-alpine-ridge.jpg'),
description: '800×533 · f/7.1 · 1/320s · ISO 100 · Canon R6 II 24-70mm · Bernese Oberland, Switzerland · free license, sourced via picsum.photos.' },
// Photography — free, the committed JPEGs themselves (preview = full image).
// All real photographs from Wikimedia Commons (free licenses, credited below).
{ id: 'photo-aurora-fjord', filename: 'aurora-over-the-fjord.jpg', mime_type: 'image/jpeg', size_bytes: 482_891, access: 'free', disk: PEER_MEDIA('photo-aurora-fjord.jpg'),
description: '1920×1280 · Aurora borealis over a Lofoten fjord, snowy peaks mirrored in the sea · Norway · Wikimedia Commons, CC BY 3.0.' },
{ id: 'photo-mountain-lake', filename: 'mountain-lake-still.jpg', mime_type: 'image/jpeg', size_bytes: 680_877, access: 'free', disk: PEER_MEDIA('photo-mountain-lake.jpg'),
description: '1920×1474 · Lago di Limides at sunset, Dolomite spires reflected in still water · Italy · Wikimedia Commons, CC BY 4.0.' },
{ id: 'photo-city-neon', filename: 'city-neon-rain.jpg', mime_type: 'image/jpeg', size_bytes: 1_173_378, access: 'free', disk: PEER_MEDIA('photo-city-neon.jpg'),
description: '1920×2349 · Dotonbori canal at night, neon signs bleeding into the water · Osaka, Japan · Wikimedia Commons, CC BY-SA 4.0.' },
{ id: 'photo-desert-dunes', filename: 'desert-dunes-dawn.jpg', mime_type: 'image/jpeg', size_bytes: 489_931, access: 'free', disk: PEER_MEDIA('photo-desert-dunes.jpg'),
description: '1920×1281 · Low sun raking across rippled sand, Erg Chebbi · Morocco · Wikimedia Commons, CC BY-SA 4.0.' },
{ id: 'photo-forest-mist', filename: 'forest-mist-morning.jpg', mime_type: 'image/jpeg', size_bytes: 423_000, access: 'free', disk: PEER_MEDIA('photo-forest-mist.jpg'),
description: '1920×1204 · Sunrise mist in the valleys above Schonach, Black Forest · Germany · Wikimedia Commons, Free Art License.' },
{ id: 'photo-ocean-cliff', filename: 'ocean-cliff-atlantic.jpg', mime_type: 'image/jpeg', size_bytes: 683_730, access: 'free', disk: PEER_MEDIA('photo-ocean-cliff.jpg'),
description: '1920×1440 · Atlantic swell breaking under the Cliffs of Moher · Ireland · Wikimedia Commons, CC BY 2.0.' },
{ id: 'photo-northern-road', filename: 'northern-road-solitude.jpg', mime_type: 'image/jpeg', size_bytes: 643_159, access: 'free', disk: PEER_MEDIA('photo-northern-road.jpg'),
description: '1920×1280 · Empty winter road toward a backlit peak · Iceland · Wikimedia Commons, CC0.' },
{ id: 'photo-autumn-valley', filename: 'autumn-valley-patchwork.jpg', mime_type: 'image/jpeg', size_bytes: 872_237, access: 'free', disk: PEER_MEDIA('photo-autumn-valley.jpg'),
description: '1920×1205 · White steeple against peak fall foliage, Stowe · Vermont, USA · Wikimedia Commons, CC BY 2.0.' },
{ id: 'photo-harbor-dawn', filename: 'harbor-golden-hour.jpg', mime_type: 'image/jpeg', size_bytes: 655_311, access: 'free', disk: PEER_MEDIA('photo-harbor-dawn.jpg'),
description: '1920×1080 · St Ives harbour wall at low tide, golden evening light · Cornwall, UK · Wikimedia Commons, CC BY-SA 4.0.' },
{ id: 'photo-alpine-ridge', filename: 'alpine-ridge-hiker.jpg', mime_type: 'image/jpeg', size_bytes: 642_889, access: 'free', disk: PEER_MEDIA('photo-alpine-ridge.jpg'),
description: '1920×1280 · Hiker crossing a snowfield above Grindelwald, Bernese Oberland · Switzerland · Wikimedia Commons, CC BY 2.0.' },
// Documents — free, real committed files
{ id: 'doc-whitepaper-notes', filename: 'bitcoin-whitepaper-notes.md', mime_type: 'text/markdown', size_bytes: 8_192, access: 'free', disk: PEER_CONTENT('documents', 'bitcoin-whitepaper-notes.md'),
description: 'Markdown study notes on the Bitcoin whitepaper, section by section.' },
@ -1316,15 +1325,28 @@ const PEER_LIBRARY = [
description: 'Archipelago Node OS installer ISO · amd64 · SHA256 in release notes.' },
]
/** Deterministic per-peer slice of the library every peer shares a different
* handful, popular items appear on more than one node (it's a network). */
/** Deterministic per-peer slice of the library. Each item lives on exactly ONE
* peer (slot = library index % 12, matched to the peer's position in this
* session's federation list), except a small POPULAR set that also appears on
* 1-2 more nodes a little duplication is realistic, a catalog where every
* file exists on a third of the network is not. */
const POPULAR_EXTRA_SLOTS = {
'song-webfive': [3], // the showcase paid Wavlake track — easy to find
'film-block-height': [5],
'photo-aurora-fjord': [8],
}
function peerCatalogFor(onion) {
let seed = 0
for (const c of String(onion)) seed = (seed * 31 + c.charCodeAt(0)) >>> 0
const slot = seed % 12
const nodes = sessionFederationNodes()
let slot = nodes.findIndex(n => n.onion === onion)
if (slot < 0) {
// Unknown onion (e.g. a stale deep-link) — stable hash fallback.
let seed = 0
for (const c of String(onion)) seed = (seed * 31 + c.charCodeAt(0)) >>> 0
slot = seed % 12
}
return PEER_LIBRARY
.map((item, i) => ({ item, i }))
.filter(({ i }) => i % 12 === slot || ((seed ^ (i * 2654435761)) >>> 0) % 12 < 3)
.filter(({ item, i }) => i % 12 === slot || (POPULAR_EXTRA_SLOTS[item.id] || []).includes(slot))
.map(({ item }) => ({
id: item.id,
filename: item.filename,
@ -1335,7 +1357,50 @@ function peerCatalogFor(onion) {
}))
}
// 12 trusted/federated nodes for the demo Federation view.
/** Purchase history ("Paid Files" tab + Owned badges). Lazily seeded per
* session with a few plausible past purchases whose onions/content ids match
* THIS session's federation nodes and their catalog slices (onions are random
* per session, so this must be computed, never hardcoded). Every successful
* purchase path appends here so Owned state survives loadOwned() refreshes. */
function sessionOwnedContent() {
if (!mockState.ownedContent) {
const nodes = sessionFederationNodes()
const seeds = []
const wanted = [
// At least one audio item with real disk bytes: the Paid Files tab click
// must actually play music in the bottom bar.
{ id: 'song-builders', daysAgo: 4 },
{ id: 'film-the-signal', daysAgo: 11 },
{ id: 'book-cypherpunk-essays', daysAgo: 23 },
]
for (const w of wanted) {
for (const n of nodes) {
const item = peerCatalogFor(n.onion).find(it => it.id === w.id)
if (!item) continue
seeds.push({
onion: n.onion,
content_id: item.id,
filename: item.filename,
mime_type: item.mime_type,
size_bytes: item.size_bytes,
paid_sats: (typeof item.access === 'object' && item.access.paid?.price_sats) || 0,
purchased_at: new Date(Date.now() - w.daysAgo * 86400000).toISOString(),
})
break
}
}
mockState.ownedContent = seeds
}
return mockState.ownedContent
}
// 12 trusted/federated nodes for the demo Federation view. Onions are random,
// so the list is memoised per session — peer catalogs, owned-content records
// and the Federation view must all agree on the same onions for a visitor.
function sessionFederationNodes() {
if (!mockState.federationNodes) mockState.federationNodes = demoFederationNodes()
return mockState.federationNodes
}
function demoFederationNodes() {
const names = [
['archy-198', 'trusted', ['bitcoin-knots', 'lnd', 'mempool', 'electrs']],
@ -1516,6 +1581,19 @@ app.post('/api/blob', express.raw({ type: () => true, limit: '25mb' }), (req, re
}
res.json({ cid })
})
// Free peer-content streaming (Range-capable). The frontend streams free
// audio/video and the free-image lightbox from this URL; on a real node nginx
// proxies /api/* to the daemon. content_id resolves strictly against
// PEER_LIBRARY (whitelist — never a filesystem path built from the request),
// and paid items 403 so full paid bytes are only reachable via a purchase.
app.get('/api/peer-content/:onion/:content_id', (req, res) => {
const entry = PEER_LIBRARY.find(it => it.id === req.params.content_id)
if (!entry || !entry.disk) return res.status(404).json({ error: 'Content not found on this peer' })
if (typeof entry.access === 'object') return res.status(403).json({ error: 'This file requires purchase' })
res.type(entry.mime_type)
// Express sendFile handles Range requests (the frontend probes bytes=0-0).
res.sendFile(entry.disk)
})
app.get('/api/container/logs', (_req, res) => res.json({
logs: [
'[INF] LND: Version 0.18.3-beta commit=v0.18.3-beta',
@ -2356,9 +2434,20 @@ app.post('/rpc/v1', (req, res) => {
}
case 'content.preview-peer': {
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
// Audio preview: the frontend's Preview button PLAYS these bytes (the
// real backend proxies the seller's audio preview too), so serve a
// leading slice of the actual track — mp3 frames tolerate truncation —
// never an image. ~10% matches the "10% preview" badge.
if (entry?.mime_type?.startsWith('audio/') && entry.disk) {
try {
const data = fsSync.readFileSync(entry.disk)
const slice = data.subarray(0, Math.max(96 * 1024, Math.floor(data.length / 10)))
return res.json({ result: { data: slice.toString('base64'), content_type: entry.mime_type } })
} catch { /* fall through to no preview (icon fallback) */ }
}
// Serve the item's committed preview image (film poster, book cover, or
// the photo itself) so the demo NEVER renders a broken thumbnail.
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
const previewAbs = entry && (entry.preview || (entry.mime_type.startsWith('image/') && entry.disk))
if (previewAbs) {
try {
@ -2400,12 +2489,16 @@ app.post('/rpc/v1', (req, res) => {
// Every payment path resolves to success so visitors can experience the
// full buy → unlock journey with testnet addresses and invoices.
case 'content.owned-list': {
return res.json({ result: { items: [] } })
return res.json({ result: { items: sessionOwnedContent() } })
}
case 'content.owned-get':
case 'content.download-peer-paid':
case 'content.download-peer-invoice':
case 'content.download-peer-onchain': {
const entry = PEER_LIBRARY.find(it => it.id === params?.content_id)
const price = params?.price_sats
|| (entry && typeof entry.access === 'object' && entry.access.paid?.price_sats)
|| 0
// Deduct the price from the chosen rail so demo balances react.
const paid = params?.price_sats || 0
if (paid > 0 && method === 'content.download-peer-paid') {
@ -2415,7 +2508,33 @@ app.post('/rpc/v1', (req, res) => {
if (fed) fed.balance_sats = Math.max(0, (fed.balance_sats || 0) - paid)
} else walletState.ecash_sats = Math.max(0, walletState.ecash_sats - paid)
}
const filename = params?.filename || 'demo-content'
// Record the purchase (not owned-get — that's re-opening a past buy) so
// it persists in owned-list: the frontend refreshes Owned state right
// after paying, and the Paid Files tab shows it with price + date.
if (method !== 'content.owned-get' && entry) {
const owned = sessionOwnedContent()
if (!owned.some(o => o.content_id === entry.id && o.onion === params?.onion)) {
owned.push({
onion: params?.onion || '',
content_id: entry.id,
filename: entry.filename,
mime_type: entry.mime_type,
size_bytes: entry.size_bytes,
paid_sats: price,
purchased_at: new Date().toISOString(),
})
}
}
// Real committed bytes when we have them (music, photos, docs) — this
// is what lets a just-bought track autoplay in the bottom bar.
if (entry?.disk) {
try {
const data = fsSync.readFileSync(entry.disk)
return res.json({ result: { data: data.toString('base64'), mime_type: entry.mime_type, ecash_backend: params?.method || 'cashu' } })
} catch { /* fall through to placeholder */ }
}
// Fictional catalog entries (2GB films etc.) keep the placeholder note.
const filename = params?.filename || entry?.filename || 'demo-content'
const body = Buffer.from(
`Archipelago demo — "${filename}"\n\nThis is sample paid content delivered over the ` +
`node-to-node content market. On a real node this would be the actual file you purchased.\n`,
@ -2900,7 +3019,7 @@ app.post('/rpc/v1', (req, res) => {
// =====================================================================
case 'federation.list':
case 'federation.list-nodes': {
return res.json({ result: { nodes: demoFederationNodes() } })
return res.json({ result: { nodes: sessionFederationNodes() } })
}
case 'federation.invite': {

View File

@ -143,6 +143,13 @@ export default defineConfig({
changeOrigin: true,
secure: false,
},
// Peer-content streaming, blob upload, app catalog — the real node's
// nginx proxies /api/* to the daemon; dev forwards it to the mock.
'/api': {
target: process.env.BACKEND_URL || 'http://localhost:5959',
changeOrigin: true,
secure: false,
},
// Mock filebrowser (Cloud page) — same backend as everything else. Point
// BACKEND_URL at a real node to develop against its filebrowser instead.
'/app/filebrowser': {