diff --git a/.claude/skills/scene-design/SKILL.md b/.claude/skills/scene-design/SKILL.md index 37d7d500..977475d9 100644 --- a/.claude/skills/scene-design/SKILL.md +++ b/.claude/skills/scene-design/SKILL.md @@ -6,13 +6,10 @@ user-invocable: true # scene-design -The one design skill for the **scene** monorepo. Both apps — [tracker](../../../apps/tracker) -(a FastTracker 2-style module player) and [party](../../../apps/party) (a -demoparty archive player) — share a **demoscene** identity layered on the shared -halo-design family tokens. The product _is_ retro, so the surface leans into a -FT2 / Amiga / DOS-demoscene look rather than the family's neutral chrome. - -This skill covers the **shared scene identity** once, then the two apps' **deltas**. +Both scene apps share a **demoscene** identity layered on the halo-design family +tokens — the product _is_ retro, so the surface leans into a FT2 / Amiga / +DOS-demoscene look rather than the family's neutral chrome. Shared identity +first, then each app's deltas. ## Tokens @@ -60,10 +57,7 @@ a demoscene **title-screen** treatment instead: just an accent dot — it reads as a lit demoscene logo. - **No dot** → no trailing accent period. -Lowercase and the terse voice still hold. This is exactly the kind of deviation -the family sanctions when it's documented with a reason (see the `halo-design` -"Wordmark" section) — this section is that documentation. Per-app text differs -(below). +Lowercase and the terse voice still hold. Per-app text differs (below). ## Icons & voice @@ -111,14 +105,3 @@ the family sanctions when it's documented with a reason (see the `halo-design` - **Layout** — landing is a grid of party **cards** (the glyph is the artwork fallback when a party has no logo); the player stage is shared via `@scene/player` (`PlayerStage`, with the `SampleBrowser`). - -## Production sources of truth - -- `packages/design/src/halo.css` — tokens (adopted halo-design), the theme store, - fonts. Consumed as `@scene/design`. -- `packages/player` — `@scene/player`: the shared libopenmpt engine + transport / - `PlayerStage` UI both apps embed. -- tracker: `apps/tracker/frontend/src/routes/{+page,+layout}.svelte`, - `apps/tracker/frontend/static/favicon.svg`. -- party: `apps/party/frontend/src/routes/{+page,+layout}.svelte`, - `apps/party/frontend/static/favicon.svg`. diff --git a/CLAUDE.md b/CLAUDE.md index de00ab3e..98d8c139 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,8 @@ Siblings: deploy via `../raspi`; media sidecar pattern from `../scribe`. packages/ shared FRONTEND libs (yarn workspace members, source-only) player/ @scene/player — libopenmpt (chiptune3) engine + store + transport UI design/ @scene/design — halo tokens, fonts, theme store +crates/ shared BACKEND libs (cargo workspace members) + scene-backend/ error/auth/CSP/SPA/scan plumbing shared by the app backends apps/ tracker/ MOD/tracker-music player backend/ tracker-backend (cargo member) diff --git a/Cargo.lock b/Cargo.lock index 6676db37..e2ce2004 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1122,6 +1122,7 @@ dependencies = [ "rayon", "reqwest", "rusqlite", + "scene-backend", "serde", "serde_json", "sha2", @@ -1523,6 +1524,23 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scene-backend" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.23.1", + "mime_guess", + "reqwest", + "rusqlite", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "scene-transcoder" version = "0.1.0" @@ -2044,6 +2062,7 @@ dependencies = [ "rayon", "reqwest", "rusqlite", + "scene-backend", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 1483c14d..5dbde771 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "apps/tracker/backend", "apps/tracker/integration", "apps/party/backend", + "crates/scene-backend", "services/transcoder", ] @@ -14,6 +15,8 @@ edition = "2021" version = "0.1.0" [workspace.dependencies] +# Shared backend plumbing (error/auth/CSP/SPA/scan utilities) for the app crates. +scene-backend = { path = "crates/scene-backend" } axum = { version = "0.8", features = ["macros", "tokio", "tracing"] } rusqlite = { version = "0.40", features = ["bundled"] } walkdir = "2" diff --git a/README.md b/README.md index f72b5f10..5bc50a90 100644 --- a/README.md +++ b/README.md @@ -55,19 +55,17 @@ packages/ shared FRONTEND libs (yarn workspace, source-only) player/ @scene/player — libopenmpt engine + transport UI design/ @scene/design — halo design tokens, fonts, theme apps/ - tracker/{backend,frontend,e2e} + tracker/{backend,frontend,integration} party/{backend,frontend,parties} services/ transcoder/ ffmpeg sidecar (own image) -Cargo.toml one Rust workspace (all backends + e2e + transcoder) +Cargo.toml one Rust workspace (all backends + integration + transcoder) package.json one yarn workspace (packages/* + apps/*/frontend) justfile task runner ``` -**Two workspaces, one repo:** frontends use yarn (Berry, vendored — no global -yarn needed); backends are one cargo workspace sharing a single `Cargo.lock` and -`target/`. Shared packages export raw `.svelte`/`.ts`; the consuming app's Vite -transpiles them (no build step in `packages/*`). +Two workspaces, one repo — yarn for frontends (Berry, vendored), one cargo +workspace for backends; conventions in [CLAUDE.md](CLAUDE.md). ## Develop diff --git a/apps/party/AMIGA-ROMS.md b/apps/party/AMIGA-ROMS.md index 33ed0cec..c6d58f37 100644 --- a/apps/party/AMIGA-ROMS.md +++ b/apps/party/AMIGA-ROMS.md @@ -1,23 +1,18 @@ # Amiga Kickstart ROMs (party app) -Amiga demos need a **Kickstart ROM** (the Amiga's boot firmware). ROMs are -copyrighted, so they are **not** bundled — you supply them. There are two ways to -provide one: - -1. **Server-side** — drop the ROM in the party's support dir; it's served to all - visitors. Do this for ROMs you're licensed to redistribute in your deployment. -2. **Client-side upload** — a visitor supplies their own ROM in the browser; it's - injected into the emulator and never sent to the server (see *Client-side ROMs*). - -Without a ROM, PUAE falls back to the built-in **AROS** replacement (lower -compatibility — many AGA demos misbehave or drop to a CLI). +Amiga demos need a **Kickstart ROM** (boot firmware). ROMs are copyrighted and +**not** bundled — supply them either **server-side** (drop in the support dir, +served to all visitors — for ROMs you're licensed to redistribute) or +**client-side** (a visitor uploads their own in the browser, never sent to the +server — see *Client-side ROMs*). Without one, PUAE falls back to the built-in +**AROS** replacement (lower compatibility — many AGA demos misbehave or drop to +a CLI). ## Which ROMs, and where -Place ROMs in **`/.support/`** (the shared, unscanned support dir that -spans all parties — the same place as `results.txt`-adjacent assets). **Filenames -matter**: PUAE (libretro-uae) selects the ROM by the machine model's expected -filename, so the name must be exact. +Place ROMs in **`/.support/`** (the shared, unscanned support dir +spanning all parties). **Filenames matter**: PUAE (libretro-uae) selects the ROM +by the machine model's expected filename — exact names only. | filename | Kickstart | size | CRC32 | MD5 | used by (demo filename tag) | | --- | --- | --- | --- | --- | --- | @@ -29,7 +24,9 @@ filename, so the name must be exact. > different ROMs** (different CRC). You can't rename one to the other. ROMs must be **raw/decrypted** dumps (no `rom.key`, no `AMIROMTYPE1` header). The -CRC32/MD5 above are for the raw dumps — verify yours match. +CRC32/MD5 above are for the raw dumps — verify yours match. `.support/` (ROMs +included) is baked into the data image by `just package-party-data` — see +`parties/README.md`, Step 8. ## How the app picks the machine + ROM @@ -52,21 +49,21 @@ just amiga "Desert Dream" # ambiguous → it lists the matches and stops just amiga "/path/to/Demo (AGA).hdf" ``` -**Why bother when the app runs them in a browser:** the in-browser core -(libretro-uae under EmulatorJS) has **no JIT**. A 68020+ demo pushing a full-rate -AGA display is exactly the workload that needs one, so AGA entries can crawl in the -SPA while being perfectly fine on the hardware they targeted. fs-uae JITs. That -makes this the way to tell **"this demo is broken"** from **"the WASM core is too -slow"** — a distinction you can't make from the browser alone, and the one that -matters when deciding whether an `.hdf` needs rebuilding. +The in-browser core (libretro-uae under EmulatorJS) has **no JIT**; fs-uae JITs. +A 68020+ AGA demo can crawl in the SPA and still be fine on its target hardware — +if it runs at speed in fs-uae, the image is good and the browser is the limit +(don't rebuild the `.hdf`). The recipe derives the machine and Kickstart from the same filename tags as the -table above, so a local run reproduces what the app *aims* for. It mirrors the two -app defaults that bite if you hand-roll the command: - -- **8 MB fast RAM on A1200.** The model preset implies it, but a bare - `--amiga_model=A1200` leaves any sizable demo aborting with *"not enough memory - available"* — the single most common reason a freshly-imaged demo "doesn't start". +table above, and mirrors the two app defaults that bite if you hand-roll the +command: + +- **8 MB fast RAM on A1200.** The model preset implies it, but the individual + memory options default to fast = 0 and **override the preset** — any sizable + demo then aborts with *"not enough memory available"* / returncode 10 and drops + to the CLI. The single most common reason a freshly-imaged demo "doesn't + start". The app forces `puae_fastmem_size = "8"` + (`frontend/src/lib/EjsEmulator.svelte`); the recipe passes `--fast_memory=8192`. - **`(030)` → `A4000` + `--cpu=68030`**, since fs-uae has no `A4000/030` model. Equivalent by hand: @@ -76,9 +73,9 @@ fs-uae --amiga_model=A1200 --kickstart_file="$PARTY_ROOT/.support/kick40068.A120 ``` Floppy images (`.adf`/`.dms`/`.adz`/`.ipf`) go to DF0 instead of a hard drive; the -recipe picks by extension. Walking the tree over SMB takes minutes, so the image -list is cached at `~/.cache/scene-amiga-images.txt` and rebuilt only when a search -misses — which also picks up newly-added demos on its own. +recipe picks by extension. The image list is cached at +`~/.cache/scene-amiga-images.txt` (SMB walks are slow) and rebuilt when a search +misses, which also picks up newly-added demos. ## Verifying a ROM @@ -95,16 +92,9 @@ dump of your own hardware. Amiga Forever ROMs may be encrypted (`rom.key`); decr to raw before placing (or keep `rom.key` alongside — libretro-uae can read encrypted ROMs if the key is present, but raw is simplest). -## Deploy - -`.support/` is packaged with the party data. `just package-party-data` strips macOS -junk (`._*`, `.DS_Store`) — those sidecar files next to the ROMs are harmless. Just -ensure the real ROM files are present with the exact names above. - ## Client-side ROMs -When the server doesn't have a ROM a demo needs, the Amiga player shows an **upload** -control. The visitor picks their own ROM file; the SPA injects it into the emulator -(client-side only — the ROM is never uploaded to the server) and can remember it -(IndexedDB) so it isn't re-picked every launch. This lets a deployment ship *no* -copyrighted ROMs while still letting users who own them run the demos. +When the server lacks a ROM a demo needs, the Amiga player shows an **upload** +control: the visitor picks their own ROM, the SPA injects it into the emulator +(never uploaded to the server) and remembers it (IndexedDB). A deployment can ship +*no* copyrighted ROMs while users who own them still run the demos. diff --git a/apps/party/CREDITS.md b/apps/party/CREDITS.md deleted file mode 100644 index 92caedcd..00000000 --- a/apps/party/CREDITS.md +++ /dev/null @@ -1,14 +0,0 @@ -# Credits - -## Fonts (`frontend/static/fonts/`) - -- **TopazPlus a1200** — Amiga system font used for the tracker/player surfaces. - GPL with Font Exception. -- **WebPlus IBM VGA 8x16** — exact CP437 VGA text font used for DOS-era NFO/DIZ - art (box-drawing tiles seamlessly). From the *Ultimate Oldschool PC Font Pack* - by VileR, licensed **CC BY-SA 4.0**. - -## Audio - -- **chiptune3 / libopenmpt** — module playback engine, vendored from the sibling - `tracker` app. chiptune3 by DrSnuggles (MIT); libopenmpt (BSD). diff --git a/apps/party/backend/.env.example b/apps/party/backend/.env.example index 5f569149..331393ea 100644 --- a/apps/party/backend/.env.example +++ b/apps/party/backend/.env.example @@ -2,14 +2,10 @@ # loads `.env` from the working dir on startup. Copy to `.env` and adjust. # Relative paths below resolve against `backend/`. Only PARTY_ROOT is required. -# Root of the Parties/ tree — each immediate subdirectory is one party. -# Quote the value if the path contains spaces. +# Root of the Parties/ tree — each immediate subdirectory is one party +# (config read from each party's `.party.json`). Quote paths with spaces. PARTY_ROOT="/path/to/Scene/Parties" -# Per-party config now lives inside each party folder as `.party.json` (compos + -# scraped results), so the tree is self-contained and there's nothing to configure -# here — the backend reads //.party.json for each party. - # SQLite cache (path index + parsed metadata). Rebuilt on demand. PARTY_DB_PATH=party.db @@ -32,13 +28,9 @@ PARTY_BIND=127.0.0.1:3020 # Bypass forward-auth for local dev / LAN-only deploys (do NOT set in prod). PARTY_OPEN=1 -# Kiosk = read-only public mode: refuses operator mutations (POST /api/rescan -# → 403) and redacts the filesystem root from /status; the SPA hides the rescan -# button. Orthogonal to auth (PARTY_OPEN = who can reach it; KIOSK = what actions -# exist). Turn ON for a public instance — especially the immutable data-image -# deploy, where the tree never changes so rescan is meaningless. /api/meta -# enrichment stays on (idempotent, content-hash-keyed). Off (unset) for the -# NAS-mounted admin/dev instance that needs rescan. +# Kiosk = read-only public mode: rescan → 403, /status root redacted, SPA hides +# the rescan button. Orthogonal to PARTY_OPEN (auth vs allowed actions); /api/meta +# enrichment stays on. Set for public / data-image deploys. # PARTY_KIOSK=1 # Transcoder sidecar. Leave unset until the service exists; image/video assets diff --git a/apps/party/backend/Cargo.toml b/apps/party/backend/Cargo.toml index 9581c654..093d377f 100644 --- a/apps/party/backend/Cargo.toml +++ b/apps/party/backend/Cargo.toml @@ -12,6 +12,7 @@ name = "party_backend" path = "src/lib.rs" [dependencies] +scene-backend = { workspace = true } axum = { workspace = true } rusqlite = { workspace = true } walkdir = { workspace = true } diff --git a/apps/party/backend/src/auth.rs b/apps/party/backend/src/auth.rs index c78aa1a8..d98ba357 100644 --- a/apps/party/backend/src/auth.rs +++ b/apps/party/backend/src/auth.rs @@ -1,99 +1,10 @@ -//! Edge-trust auth. The party archive is a single shared, read-only collection — -//! there's no per-user state — so the binary doesn't run its own login. It sits -//! behind oauth2-proxy forward-auth and only asserts that the edge vouched for -//! the request via `X-Auth-Request-User`, returning 401 if absent. Bypassed by -//! `DEV_AUTH=1` (local work) or `PARTY_OPEN=1` (a LAN-only deploy). `/status` -//! stays unauthenticated. +//! Edge-trust auth (see `scene_backend::auth`). Bypassed by `DEV_AUTH=1` +//! (local work) or `PARTY_OPEN=1` (a LAN-only deploy). -use axum::extract::FromRequestParts; -use axum::http::request::Parts; +pub use scene_backend::auth::Auth; -use crate::error::AppError; -use crate::state::AppState; - -const HDR_USER: &str = "x-auth-request-user"; - -/// Zero-sized proof the request is authenticated. Required by every `/api/*` -/// handler; there's no identity to carry because data isn't per-user. -pub struct Auth; - -impl FromRequestParts for Auth { - type Rejection = AppError; - - async fn from_request_parts( - parts: &mut Parts, - state: &AppState, - ) -> Result { - if state.cfg.dev_auth { - return Ok(Auth); - } - let user = parts - .headers - .get(HDR_USER) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if !user.is_empty() { - return Ok(Auth); - } - Err(AppError::Unauthorized) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::db::Db; - use crate::party::PartyConfigs; - use axum::http::Request; - use std::path::PathBuf; - - fn state(dev_auth: bool) -> AppState { - let cfg = Config { - bind: String::new(), - dev_auth, - kiosk: false, - root: PathBuf::new(), - cache_dir: PathBuf::new(), - db_path: PathBuf::new(), - static_dir: PathBuf::new(), - support_dir: PathBuf::new(), - transcoder_url: None, - transcoder_token: None, - }; - AppState::new( - cfg, - Db::open_in_memory().unwrap(), - PartyConfigs::load(&PathBuf::from("/nonexistent")), - ) - } - - async fn extract(req: Request<()>, dev_auth: bool) -> Result { - let (mut parts, _) = req.into_parts(); - Auth::from_request_parts(&mut parts, &state(dev_auth)).await - } - - #[tokio::test] - async fn rejects_without_header_in_prod() { - let req = Request::builder().body(()).unwrap(); - assert!(matches!( - extract(req, false).await, - Err(AppError::Unauthorized) - )); - } - - #[tokio::test] - async fn accepts_forward_auth_header() { - let req = Request::builder() - .header(HDR_USER, "alice") - .body(()) - .unwrap(); - assert!(extract(req, false).await.is_ok()); - } - - #[tokio::test] - async fn dev_auth_bypasses() { - let req = Request::builder().body(()).unwrap(); - assert!(extract(req, true).await.is_ok()); +impl scene_backend::auth::AuthGate for crate::state::AppState { + fn auth_bypassed(&self) -> bool { + self.cfg.dev_auth } } diff --git a/apps/party/backend/src/error.rs b/apps/party/backend/src/error.rs index c7a9faac..1d3070c5 100644 --- a/apps/party/backend/src/error.rs +++ b/apps/party/backend/src/error.rs @@ -1,85 +1 @@ -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum::Json; -use serde_json::json; - -#[derive(Debug, thiserror::Error)] -pub enum AppError { - #[error("unauthorized")] - Unauthorized, - #[error("forbidden")] - Forbidden, - #[error("not found")] - NotFound, - #[error("bad request: {0}")] - BadRequest(String), - #[error("conflict: {0}")] - Conflict(String), - /// An upstream sidecar (the transcoder) failed or is unreachable. Maps to - /// 502 so the SPA can fall back to a download/placeholder. - #[error("upstream: {0}")] - Upstream(String), - /// The transcoder can never convert this source (ffmpeg failed / unsupported - /// ext / too large) — a *permanent* failure, distinct from a transient - /// `Upstream`. Maps to 422 so the SPA falls back without retrying, and lets - /// the asset handler negatively cache it (no re-encode on every view). - #[error("unprocessable: {0}")] - Unprocessable(String), - #[error(transparent)] - Internal(#[from] anyhow::Error), -} - -impl AppError { - fn status(&self) -> StatusCode { - match self { - AppError::Unauthorized => StatusCode::UNAUTHORIZED, - AppError::Forbidden => StatusCode::FORBIDDEN, - AppError::NotFound => StatusCode::NOT_FOUND, - AppError::BadRequest(_) => StatusCode::BAD_REQUEST, - AppError::Conflict(_) => StatusCode::CONFLICT, - AppError::Upstream(_) => StatusCode::BAD_GATEWAY, - AppError::Unprocessable(_) => StatusCode::UNPROCESSABLE_ENTITY, - AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, - } - } -} - -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let status = self.status(); - let body = Json(json!({ - "error": status.as_u16(), - "detail": self.to_string(), - })); - if status.is_server_error() { - tracing::error!(?self, "request failed"); - } - (status, body).into_response() - } -} - -pub type AppResult = Result; - -impl From for AppError { - fn from(e: std::io::Error) -> Self { - match e.kind() { - std::io::ErrorKind::NotFound => AppError::NotFound, - _ => AppError::Internal(e.into()), - } - } -} - -impl From for AppError { - fn from(e: rusqlite::Error) -> Self { - match e { - rusqlite::Error::QueryReturnedNoRows => AppError::NotFound, - _ => AppError::Internal(e.into()), - } - } -} - -impl From for AppError { - fn from(e: reqwest::Error) -> Self { - AppError::Upstream(e.to_string()) - } -} +pub use scene_backend::error::{AppError, AppResult}; diff --git a/apps/party/backend/src/lib.rs b/apps/party/backend/src/lib.rs index 620db91a..b68177ca 100644 --- a/apps/party/backend/src/lib.rs +++ b/apps/party/backend/src/lib.rs @@ -11,7 +11,6 @@ pub mod state; pub mod transcoder; use std::path::{Path, PathBuf}; -use std::sync::atomic::Ordering; use std::sync::Arc; use tower_http::set_header::SetResponseHeaderLayer; @@ -23,67 +22,6 @@ use party::PartyConfigs; use scan::ScanResult; use state::{AppState, ScanProgress}; -/// Content-Security-Policy. Same-origin plus the Google Fonts hosts halo-design -/// uses. The music player runs libopenmpt as WebAssembly inside an AudioWorklet -/// (`'wasm-unsafe-eval'`). The emulators go further: js-dos and EmulatorJS's -/// libretro cores `eval()` JavaScript at runtime (the cores ship as code the -/// loader evaluates), which only `'unsafe-eval'` permits — `'wasm-unsafe-eval'` -/// alone blocks it. Acceptable here: a LAN-only archive whose whole point is -/// running sandboxed WASM demos. EmulatorJS also decompresses its core to a -/// `blob:` URL and runs/fetches it from there, so `blob:` is allowed in -/// `script-src`, `worker-src`, and `connect-src` (the wasm is fetched from the -/// blob). HSTS / X-Frame-Options are the edge's job. -fn build_csp(script_hashes: &[String]) -> String { - let mut script_src = String::from("'self' 'wasm-unsafe-eval' 'unsafe-eval' blob:"); - for h in script_hashes { - script_src.push(' '); - script_src.push_str(h); - } - format!( - "default-src 'self'; \ - script-src {script_src}; \ - style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \ - font-src 'self' data: https://fonts.gstatic.com; \ - img-src 'self' data: blob:; \ - media-src 'self' blob:; \ - connect-src 'self' blob:; \ - worker-src 'self' blob:; \ - child-src 'self' blob:; \ - frame-ancestors 'none'; \ - base-uri 'self'; \ - object-src 'none'; \ - form-action 'self'" - ) -} - -/// CSP `'sha256-…'` source for every inline `") else { - break; - }; - let body = &html[body_start..body_start + close]; - if !open.contains("src=") { - let digest = Sha256::digest(body.as_bytes()); - out.push(format!("'sha256-{}'", STANDARD.encode(digest))); - } - idx = body_start + close + "".len(); - } - out -} - /// Run a full scan on a blocking thread and return the reconciliation counts. pub async fn run_scan( db: Db, @@ -98,23 +36,13 @@ pub async fn run_scan( // while the scan actually finished. A drop guard resets it on any exit // (return, error, or panic), and the blocking task can't be cancelled. tokio::task::spawn_blocking(move || { - progress.scanning.store(true, Ordering::Relaxed); - let _done = ScanFlagGuard(progress.clone()); + let _done = scene_backend::scan::ScanFlagGuard::set(progress.clone()); let mut conn = db.blocking_lock(); scan::scan_into(&mut conn, &root, &parties, &progress) }) .await? } -/// Resets the `scanning` flag to false when dropped, so a scan always clears it -/// regardless of how it ends. Lives inside the (non-cancellable) blocking task. -struct ScanFlagGuard(Arc); -impl Drop for ScanFlagGuard { - fn drop(&mut self) { - self.0.scanning.store(false, Ordering::Relaxed); - } -} - /// Whether real party data is present under `root`. The mountpoint always /// exists, so `exists()` proves nothing; require at least one non-hidden, /// non-empty subdirectory (a party folder like `Assembly95`). A mounted-but-empty @@ -213,7 +141,7 @@ pub async fn run_server() -> anyhow::Result<()> { let index_path = state.cfg.static_dir.join("index.html"); let hashes = std::fs::read_to_string(&index_path) - .map(|h| inline_script_hashes(&h)) + .map(|h| scene_backend::csp::inline_script_hashes(&h)) .unwrap_or_default(); if hashes.is_empty() { tracing::warn!( @@ -221,8 +149,15 @@ pub async fn run_server() -> anyhow::Result<()> { "no inline-script hashes (index.html missing or no inline scripts)" ); } - let csp_value = axum::http::HeaderValue::from_str(&build_csp(&hashes)) - .map_err(|e| anyhow::anyhow!("invalid CSP header: {e}"))?; + // `emulator_blobs`: js-dos and EmulatorJS's libretro cores `eval()` JS at + // runtime (only `'unsafe-eval'` permits that), and EmulatorJS decompresses + // its core to a `blob:` URL and runs/fetches it from there — so `blob:` + // joins `script-src`/`connect-src`/`media-src`. Acceptable here: a LAN-only + // archive whose whole point is running sandboxed WASM demos. See + // scene_backend::csp. + let csp_value = + axum::http::HeaderValue::from_str(&scene_backend::csp::build_csp(&hashes, true)) + .map_err(|e| anyhow::anyhow!("invalid CSP header: {e}"))?; // Cross-origin isolation (COOP + COEP) exposes SharedArrayBuffer, which lets // the WASM emulators (EmulatorJS) run their cores in a worker thread — far // smoother in fullscreen, and required for heavier cores. Safe here because @@ -269,21 +204,4 @@ mod tests { std::fs::write(root.join("Assembly95/results.txt"), b"x").unwrap(); assert!(data_present(root), "a non-empty party subdir → ready"); } - - #[test] - fn hashes_inline_scripts_skips_external() { - let html = r#""#; - assert_eq!( - inline_script_hashes(html), - vec!["'sha256-ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0='"] - ); - } - - #[test] - fn csp_allows_wasm_and_media() { - let csp = build_csp(&["'sha256-X'".into()]); - assert!(csp.contains("script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval' blob: 'sha256-X'")); - assert!(csp.contains("worker-src 'self' blob:")); - assert!(csp.contains("media-src 'self' blob:")); - } } diff --git a/apps/party/backend/src/routes.rs b/apps/party/backend/src/routes.rs index fa0a46ba..43da6e81 100644 --- a/apps/party/backend/src/routes.rs +++ b/apps/party/backend/src/routes.rs @@ -17,29 +17,17 @@ pub fn router(state: AppState) -> Router { Router::new() // Unauthenticated liveness probe. .route("/status", get(status)) - // Landing: the list of parties. .route("/api/parties", get(api_parties)) - // A party's catalog of productions (grouped/ordered by competition). .route("/api/parties/{slug}/productions", get(api_productions)) - // One production with its files + music metadata. .route("/api/production/{id}", get(api_production)) - // Raw file bytes by content hash (player + emulator + download). .route("/api/file/{hash}", get(api_file)) - // Same bytes, name-in-URL variant so emulators can read the extension. .route("/api/file/{hash}/{name}", get(api_file_named)) - // Shared, unscanned support data (e.g. emulator BIOS) served by filename. .route("/api/support/{file}", get(api_support)) - // Text/NFO/DIZ content, decoded CP437 → UTF-8. .route("/api/text/{hash}", get(api_text)) - // Derived (transcoded) asset: `.png` | `.mp4`. Cached on disk. .route("/api/asset/{file}", get(api_asset)) - // js-dos bundle (`.jsdos`) for running a PC demo/intro in-browser. .route("/api/bundle/{file}", get(api_bundle)) - // libopenmpt enrichment posted by the frontend after parsing a module. .route("/api/meta/{hash}", post(api_meta)) - // Re-walk the tree. .route("/api/rescan", post(api_rescan)) - // SPA fallback. .fallback(get(serve_spa)) .with_state(state) } @@ -48,33 +36,9 @@ async fn serve_spa( State(state): State, uri: axum::http::Uri, ) -> axum::response::Response { - use axum::response::Html; - - let base = &state.cfg.static_dir; - let rel = uri.path().trim_start_matches('/'); - - if !rel.is_empty() { - let candidate = base.join(rel); - if let Ok(canon) = candidate.canonicalize() { - if let Ok(canon_base) = base.canonicalize() { - if canon.starts_with(&canon_base) && canon.is_file() { - if let Ok(bytes) = tokio::fs::read(&canon).await { - let mime = mime_guess::from_path(&canon).first_or_octet_stream(); - return ([(header::CONTENT_TYPE, mime.as_ref())], bytes).into_response(); - } - } - } - } - } - - match tokio::fs::read_to_string(base.join("index.html")).await { - Ok(html) => Html(html).into_response(), - Err(_) => (StatusCode::NOT_FOUND, "not found").into_response(), - } + scene_backend::spa::spa_response(&state.cfg.static_dir, &uri).await } -// ---------- public probe ---------- - async fn status(State(state): State) -> Json { let scanning = state.scan.scanning.load(Ordering::Relaxed); let counts: Option<(i64, i64, i64)> = if scanning { @@ -113,8 +77,6 @@ async fn status(State(state): State) -> Json { })) } -// ---------- gated api ---------- - #[derive(Serialize)] struct PartyOut { slug: String, @@ -488,12 +450,18 @@ async fn api_production( /// goes through here, including ones that came from a party config rather than /// from the scan. fn resolve_in_root(state: &AppState, rel: &str) -> AppResult { - let full = state.cfg.root.join(rel); - let (canon, canon_root) = match (full.canonicalize(), state.cfg.root.canonicalize()) { + contained_file(&state.cfg.root, rel) +} + +/// Canonicalised `base/rel` iff it stays under `base` and is a file — a `..` +/// segment or a symlink out is a 404. +fn contained_file(base: &std::path::Path, rel: &str) -> AppResult { + let full = base.join(rel); + let (canon, canon_base) = match (full.canonicalize(), base.canonicalize()) { (Ok(a), Ok(b)) => (a, b), _ => return Err(AppError::NotFound), }; - if !canon.starts_with(&canon_root) || !canon.is_file() { + if !canon.starts_with(&canon_base) || !canon.is_file() { return Err(AppError::NotFound); } Ok(canon) @@ -540,13 +508,7 @@ async fn api_file_named( async fn serve_file(state: &AppState, hash: &str) -> AppResult { let canon = resolve_file(state, hash).await?; let bytes = tokio::fs::read(&canon).await?; - Ok(( - [ - (header::CONTENT_TYPE, "application/octet-stream".to_string()), - (header::CACHE_CONTROL, "private, max-age=3600".to_string()), - ], - bytes, - )) + Ok(cached("application/octet-stream", 3600, bytes)) } /// Serve a file from the shared support dir (e.g. an emulator BIOS) by filename. @@ -555,22 +517,9 @@ async fn api_support( State(state): State, Path(file): Path, ) -> AppResult { - let full = state.cfg.support_dir.join(&file); - let (canon, base) = match (full.canonicalize(), state.cfg.support_dir.canonicalize()) { - (Ok(a), Ok(b)) => (a, b), - _ => return Err(AppError::NotFound), - }; - if !canon.starts_with(&base) || !canon.is_file() { - return Err(AppError::NotFound); - } + let canon = contained_file(&state.cfg.support_dir, &file)?; let bytes = tokio::fs::read(&canon).await?; - Ok(( - [ - (header::CONTENT_TYPE, "application/octet-stream".to_string()), - (header::CACHE_CONTROL, "private, max-age=86400".to_string()), - ], - bytes, - )) + Ok(cached("application/octet-stream", 86400, bytes)) } async fn api_text( @@ -581,26 +530,23 @@ async fn api_text( let canon = resolve_file(&state, &hash).await?; let bytes = tokio::fs::read(&canon).await?; let text = crate::cp437::decode(&bytes); - Ok(( - [ - ( - header::CONTENT_TYPE, - "text/plain; charset=utf-8".to_string(), - ), - (header::CACHE_CONTROL, "private, max-age=3600".to_string()), - ], - text, - )) + Ok(cached("text/plain; charset=utf-8", 3600, text)) } -fn asset_response(content_type: &'static str, bytes: Vec) -> impl IntoResponse { +/// Body + content type + a private cache window, as one response shape. +fn cached( + content_type: &'static str, + max_age: u32, + body: impl IntoResponse, +) -> axum::response::Response { ( [ (header::CONTENT_TYPE, content_type.to_string()), - (header::CACHE_CONTROL, "private, max-age=86400".to_string()), + (header::CACHE_CONTROL, format!("private, max-age={max_age}")), ], - bytes, + body, ) + .into_response() } /// A sidecar soundtrack named by a party config: located and described, but not @@ -756,7 +702,7 @@ async fn api_asset( // Cached success + bytes on disk → serve straight from cache. Some("ok") if cache_path.is_file() => { let bytes = tokio::fs::read(&cache_path).await?; - return Ok(asset_response(content_type, bytes)); + return Ok(cached(content_type, 86400, bytes)); } // Negative cache: the sidecar already rejected this source. Don't re-run // ffmpeg on every view — fall back immediately (SPA offers the original). @@ -845,7 +791,7 @@ async fn api_asset( }) .await?; - Ok(asset_response(content_type, out)) + Ok(cached(content_type, 86400, out)) } #[derive(Deserialize)] @@ -1100,13 +1046,7 @@ async fn api_bundle( .map_err(|e| AppError::Internal(e.into()))? .map_err(|e| AppError::Internal(e.into()))?; - Ok(( - [ - (header::CONTENT_TYPE, "application/zip".to_string()), - (header::CACHE_CONTROL, "private, max-age=3600".to_string()), - ], - zip_bytes, - )) + Ok(cached("application/zip", 3600, zip_bytes)) } async fn api_rescan(_auth: Auth, State(state): State) -> AppResult> { diff --git a/apps/party/backend/src/scan.rs b/apps/party/backend/src/scan.rs index 44857146..8db3ec7a 100644 --- a/apps/party/backend/src/scan.rs +++ b/apps/party/backend/src/scan.rs @@ -22,13 +22,7 @@ use walkdir::{DirEntry, WalkDir}; use crate::party::{slugify, PartyConfigs}; use crate::state::ScanProgress; -/// Module extensions libopenmpt can open (from tracker). Lowercase. -pub const MODULE_EXTS: &[&str] = &[ - "mod", "xm", "s3m", "it", "mptm", "stm", "nst", "m15", "stk", "wow", "ult", "669", "mtm", - "med", "far", "amf", "ams", "dbm", "digi", "dmf", "dsm", "dtm", "fmt", "imf", "j2b", "mdl", - "mo3", "mt2", "okt", "okta", "plm", "psm", "pt36", "ptm", "sfx", "sfx2", "st26", "stp", "umx", - "gdm", "gmc", "ice", "itp", "mms", "oct", "tcb", "ftm", "rtm", "c67", "symmod", -]; +pub use scene_backend::scan::MODULE_EXTS; const IMAGE_EXTS: &[&str] = &[ "lbm", "iff", "ilbm", "ham", "pic", "pcx", "tif", "tiff", "gif", "jpg", "jpeg", "png", "tga", @@ -44,29 +38,23 @@ const TEXT_EXTS: &[&str] = &[ ]; const ARCHIVE_EXTS: &[&str] = &["zip", "lha", "lzh", "arj", "rar", "7z", "gz", "dms"]; +/// Extension list → `kind`, in match priority order (`.dms` is in both +/// diskimage and archive; diskimage wins). +const KINDS_BY_EXT: &[(&[&str], &str)] = &[ + (MODULE_EXTS, "music"), + (IMAGE_EXTS, "image"), + (VIDEO_EXTS, "video"), + (DISKIMAGE_EXTS, "diskimage"), + (EXE_EXTS, "exe"), + (TEXT_EXTS, "text"), + (ARCHIVE_EXTS, "archive"), +]; + /// Classify a file into a `kind` from its extension (and filename for /// extensionless README/NFO-style files). pub fn classify(filename: &str, ext: &str) -> &'static str { - if MODULE_EXTS.contains(&ext) { - return "music"; - } - if IMAGE_EXTS.contains(&ext) { - return "image"; - } - if VIDEO_EXTS.contains(&ext) { - return "video"; - } - if DISKIMAGE_EXTS.contains(&ext) { - return "diskimage"; - } - if EXE_EXTS.contains(&ext) { - return "exe"; - } - if TEXT_EXTS.contains(&ext) { - return "text"; - } - if ARCHIVE_EXTS.contains(&ext) { - return "archive"; + if let Some((_, kind)) = KINDS_BY_EXT.iter().find(|(exts, _)| exts.contains(&ext)) { + return kind; } if ext.is_empty() { let up = filename.to_ascii_uppercase(); @@ -209,14 +197,7 @@ fn is_win_pe(path: &Path) -> bool { fn is_junk(name: &str) -> bool { name == crate::party::CONFIG_FILE // .party.json — config, not a browsable file - || name == ".DS_Store" - || name.starts_with("._") - || name == ".Trashes" - || name == ".Spotlight-V100" - || name == ".AppleDouble" - || name == ".fseventsd" - || name == ".DocumentRevisions-V100" - || name == ".TemporaryItems" + || scene_backend::scan::is_macos_junk(name) } fn is_hidden_dir(e: &DirEntry) -> bool { @@ -281,18 +262,18 @@ struct Cached { /// Map a stored `kind` string back to the canonical `&'static str`. Only used to /// reuse a prior content-sniff decision (text vs data) for unchanged files. fn intern_kind(k: &str) -> &'static str { - match k { - "music" => "music", - "image" => "image", - "video" => "video", - "exe" => "exe", - "amiga_exe" => "amiga_exe", - "win_exe" => "win_exe", - "diskimage" => "diskimage", - "text" => "text", - "archive" => "archive", - _ => "data", - } + const KINDS: &[&str] = &[ + "music", + "image", + "video", + "exe", + "amiga_exe", + "win_exe", + "diskimage", + "text", + "archive", + ]; + KINDS.iter().find(|&&s| s == k).copied().unwrap_or("data") } /// A file as walked, with everything needed to index it and assign it to a @@ -353,13 +334,7 @@ fn humanize(category: &str) -> String { category .split(['/', '_', '-']) .filter(|s| !s.is_empty()) - .map(|w| { - let mut c = w.chars(); - match c.next() { - Some(f) => f.to_uppercase().collect::() + c.as_str(), - None => String::new(), - } - }) + .map(scene_backend::capitalize_first) .collect::>() .join(" ") } @@ -526,7 +501,6 @@ pub fn scan_into( } progress.total.store(cache.len(), Ordering::Relaxed); - // 1) Walk (sequential directory traversal) and collect candidate file paths. let mut entries: Vec<(PathBuf, String, String)> = Vec::new(); // (path, rel_path, name) let walker = WalkDir::new(root) .follow_links(false) @@ -560,14 +534,13 @@ pub fn scan_into( entries.push((path.to_path_buf(), rel_path, name)); } - // 2) Resolve each file's metadata + hash + kind in parallel. This is the - // network-heavy part (a stat each, plus hashing/sniffing new-or-changed - // files), so spreading it across rayon threads overlaps the per-file NAS - // round-trips. Only the shared read-only `cache` and the atomic progress - // counters are touched here (no DB), so it's safe. Unchanged files (size + - // mtime match) reuse the cached hash AND the cached content-sniff (text vs - // data) — no open/read at all; the cheap extension classify still runs every - // scan, so classification fixes take effect on rescan without a re-sniff. + // The network-heavy part (a stat each, plus hashing/sniffing new-or-changed + // files): rayon threads overlap the per-file NAS round-trips. Only the + // shared read-only `cache` and the atomic progress counters are touched + // here (no DB), so it's safe. Unchanged files (size + mtime match) reuse the + // cached hash AND the cached content-sniff (text vs data) — no open/read at + // all; the cheap extension classify still runs every scan, so + // classification fixes take effect on rescan without a re-sniff. struct Resolved { rel_path: String, name: String, @@ -648,8 +621,8 @@ pub fn scan_into( }) .collect(); - // 3) Assign each resolved file to a production (cheap, pure — kept sequential - // so the per-party config lookup stays simple). + // Cheap and pure — kept sequential so the per-party config lookup stays + // simple. let mut walked: Vec = Vec::with_capacity(resolved.len()); for r in resolved { let segs: Vec<&str> = r.rel_path.split('/').collect(); @@ -678,7 +651,6 @@ pub fn scan_into( }); } - // 2) Group files into productions. struct Accum { party_slug: String, party_dir: String, @@ -707,7 +679,6 @@ pub fn scan_into( let mut result = ScanResult::default(); let tx = conn.transaction()?; { - // --- parties --- let mut seen_parties: Vec = Vec::new(); { let mut party_dirs: HashMap = HashMap::new(); @@ -739,7 +710,6 @@ pub fn scan_into( } result.parties = seen_parties.len(); - // --- productions --- let mut seen_prods: Vec = Vec::new(); let mut prod_id_by_dir: HashMap = HashMap::new(); { @@ -838,7 +808,6 @@ pub fn scan_into( } result.productions = seen_prods.len(); - // --- files --- let mut seen_files: Vec = Vec::new(); { let mut up = tx.prepare( @@ -875,7 +844,6 @@ pub fn scan_into( } result.hashed = progress.hashed.load(Ordering::Relaxed); - // --- drop stale rows --- let seen_files_set: std::collections::HashSet<&String> = seen_files.iter().collect(); let stale_files: Vec = cache .keys() @@ -910,7 +878,6 @@ pub fn scan_into( } } - // --- party counts --- tx.execute( "UPDATE parties SET n_files = (SELECT COUNT(*) FROM files f WHERE f.party_slug = parties.slug), @@ -920,7 +887,7 @@ pub fn scan_into( } tx.commit()?; - // 3) Join scraped results from each party's config (best-effort). + // Join scraped results from each party's config (best-effort). let party_dirs: HashMap = walked .iter() .map(|w| (w.party_slug.clone(), w.party_dir.clone())) diff --git a/apps/party/backend/src/state.rs b/apps/party/backend/src/state.rs index 9eb5c5f4..44fdd526 100644 --- a/apps/party/backend/src/state.rs +++ b/apps/party/backend/src/state.rs @@ -16,6 +16,13 @@ pub struct ScanProgress { pub hashed: AtomicUsize, } +// Lets `scene_backend::scan::ScanFlagGuard` manage the `scanning` flag. +impl AsRef for ScanProgress { + fn as_ref(&self) -> &AtomicBool { + &self.scanning + } +} + #[derive(Clone)] pub struct AppState { pub cfg: Arc, diff --git a/apps/party/frontend/package.json b/apps/party/frontend/package.json index 9cb07f21..263ce9bd 100644 --- a/apps/party/frontend/package.json +++ b/apps/party/frontend/package.json @@ -25,8 +25,7 @@ "@fontsource-variable/inter": "^5.3.0", "@lucide/svelte": "^1.28.0", "@scene/design": "workspace:*", - "@scene/player": "workspace:*", - "@tanstack/svelte-virtual": "^3.13.35" + "@scene/player": "workspace:*" }, "devDependencies": { "@anarkisti/eslint-config": "^1.0.0", @@ -38,7 +37,6 @@ "@vitejs/plugin-basic-ssl": "^2.3.0", "@vitest/browser-playwright": "^4.1.10", "eslint": "^10.8.0", - "js-dos": "^8.4.1", "playwright": "^1.62.1", "prettier": "^3.9.6", "prettier-plugin-svelte": "^4.0.1", diff --git a/apps/party/frontend/src/lib/EjsEmulator.svelte b/apps/party/frontend/src/lib/EjsEmulator.svelte index c24705da..00f20e32 100644 --- a/apps/party/frontend/src/lib/EjsEmulator.svelte +++ b/apps/party/frontend/src/lib/EjsEmulator.svelte @@ -3,6 +3,8 @@ // and Amiga (puae). EmulatorJS shows its own themed "Start Game" button and // only downloads the core on that click, so it's already lazy + provides the // audio gesture — no separate launch button needed. We add Fullscreen + Stop. + import "./emu-bar.css"; + import { Cpu, Maximize, Power, Sparkles, Trash2, Upload } from "@lucide/svelte"; import { onDestroy, onMount, tick } from "svelte"; @@ -111,7 +113,7 @@ let controlsHidden = $state(false); let scriptEl: HTMLScriptElement | null = null; - // --- Boot watchdog ------------------------------------------------------ + // Boot watchdog. // EmulatorJS builds its (disabled) virtual-gamepad overlay in the SAME // constructor step that, on a degraded load, can throw before the Launch // button is ever created — stranding the user on a dead controller overlay @@ -241,26 +243,10 @@ g.EJS_volume = 1; g.EJS_color = cssVar("--accent", "#f78f08"); g.EJS_backgroundColor = cssVar("--bg", "#0f0f0f"); - // Core option defaults (the INITIAL value; once the user changes an option - // in the settings menu their choice persists in localStorage and wins, - // unless "Recommended" above forces these): - // - Amiga: A1200 (AGA), authentic 68020, CPU compatibility 'normal' (the faster, - // non-cycle-exact CPU emulation); immediate blits + no collision (demos don't - // use it) save CPU. This is the stock EmulatorJS PUAE core, a plain - // interpreter. 020 is the default and lightest to emulate; demos that REQUIRE an - // accelerator (an 030/040/FPU to boot) can be bumped to an A4000/030 or /040 per - // demo via the CPU control, but a heavier CPU emulates slower on the interpreter. - // Cycle-exact timing is used only for the A500/OCS class (68000 demos need it). - // - C64: drive-sound emulation off by default. VICE models the 1541's - // motor/stepper noise faithfully, and many demos keep the drive spinning, - // so the sound runs on under the demo (unlike Amiga, whose floppy noise - // stops with the motor). Re-enable it in the settings menu if wanted. - // autostart_warp: warp the machine while the (slow) 1541 loads, then drop - // back to 1× — keeps true drive emulation on (demos' fastloaders still - // work), only the load is sped up. - // - virtual-gamepad off: EmulatorJS defaults it to "enabled" on mobile, but - // these are keyboard/non-interactive demos, so the touch d-pad just covers - // the screen with nothing useful. Still re-enableable in the settings menu. + // Core option defaults — the INITIAL value only; a user's settings-menu + // choice persists in localStorage and wins, unless "Recommended" above + // forces these. + // // Force the modern WebGL2 core build. EmulatorJS picks the "-legacy" (WebGL1) // core whenever `webgl2Enabled` is falsy, and that value defaults to null unless // a saved setting or the core's report JSON (options.defaultWebGL2) supplies one @@ -270,6 +256,8 @@ // localhost's localStorage happened to have WebGL2 persisted. preGetSetting reads // this default when there's no saved value, so this pins WebGL2 on everywhere. const opts: Record = { + // Mobile default is "enabled", but these are keyboard/non-interactive + // demos — the touch d-pad just covers the screen. "virtual-gamepad": "disabled", webgl2Enabled: "enabled", }; @@ -317,7 +305,11 @@ opts.puae_sound_filter = "off"; } } else if (core === "c64") { + // VICE models the 1541's motor noise faithfully and many demos keep the + // drive spinning, so the sound would run on under the demo. opts.vice_drive_sound_emulation = "disabled"; + // Warp only while the (slow) 1541 loads, then drop to 1× — true drive + // emulation stays on, so demos' fastloaders still work. opts.vice_autostart_warp = "enabled"; } g.EJS_defaultOptions = opts; @@ -680,21 +672,7 @@ gap: 6px; flex-wrap: wrap; } - .bar button { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 5px 10px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--panel); - color: var(--text); - font-size: 12px; - cursor: pointer; - } - .bar button:hover { - border-color: var(--accent); - } + /* Button chrome comes from the shared emu-bar.css. */ /* Toggles read as switches: dotted when off, solid-filled when on. */ .bar button.tgl { border-style: dashed; @@ -736,10 +714,6 @@ position: relative; z-index: 1; } - .bar button.exit:hover { - border-color: #ff4136; - color: #ff4136; - } /* "Provide ROM" stands out (a required action), like the on-toggle accent. */ .bar button.rom-need { background: var(--accent); diff --git a/apps/party/frontend/src/lib/Emulator.svelte b/apps/party/frontend/src/lib/Emulator.svelte index ddb85213..6d3b2153 100644 --- a/apps/party/frontend/src/lib/Emulator.svelte +++ b/apps/party/frontend/src/lib/Emulator.svelte @@ -3,6 +3,8 @@ // runtime + WASM (~1.4 MB+, or ~7.9 MB for the DOSBox-X core) load only when the // user clicks Launch — never on page view. Everything is same-origin, so the // strict CSP is unchanged. + import "./emu-bar.css"; + import { Keyboard, Maximize, Play, Power, Volume2, X } from "@lucide/svelte"; import { onDestroy } from "svelte"; @@ -177,7 +179,7 @@ ci?.simulateKeyPress(KBD_ESC); } - // --- Mobile soft keyboard → DOSBox ------------------------------------- + // Mobile soft keyboard → DOSBox. // Focus the off-screen input (within the tap gesture) to raise the soft // keyboard. Only on coarse pointers — on desktop js-dos handles the physical // keyboard itself, and stealing focus here would break it. @@ -439,29 +441,7 @@ gap: 6px; transition: opacity 0.2s ease; } - .bar button { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 5px 10px; - border: 1px solid var(--border); - border-radius: 6px; - background: var(--panel); - color: var(--text); - font-size: 12px; - cursor: pointer; - } - .bar button:hover:not(:disabled) { - border-color: var(--accent); - } - .bar button:disabled { - opacity: 0.5; - cursor: default; - } - .bar button.exit:hover { - border-color: #ff4136; - color: #ff4136; - } + /* Button chrome comes from the shared emu-bar.css. */ .bar button.active { border-color: var(--accent); color: var(--accent); diff --git a/apps/party/frontend/src/lib/FileBrowser.svelte b/apps/party/frontend/src/lib/FileBrowser.svelte index 415e5c2d..5280793d 100644 --- a/apps/party/frontend/src/lib/FileBrowser.svelte +++ b/apps/party/frontend/src/lib/FileBrowser.svelte @@ -72,7 +72,7 @@ } const visible = $derived(files.filter((f) => !hidden(f))); - // --- Folder tree --------------------------------------------------------- + // Folder tree. // Files carry their full rel_path (from PARTY_ROOT). Strip the shared prod-root // prefix so the tree shows only the production's own directory structure // (e.g. GFX/ANIM/FRAK001.GIF), then group into a collapsible tree. diff --git a/apps/party/frontend/src/lib/Settings.svelte b/apps/party/frontend/src/lib/Settings.svelte index 67328a3b..5050fb82 100644 --- a/apps/party/frontend/src/lib/Settings.svelte +++ b/apps/party/frontend/src/lib/Settings.svelte @@ -5,6 +5,8 @@ // lives here (not just the landing header), it can be triggered from inside a // party view too. `onRescanned` lets the host refresh its list when a scan // finishes (the landing passes its loader; the party view can omit it). + import "@scene/design/modal.css"; + import { Monitor, Moon, RefreshCw, Settings as Gear, Sun } from "@lucide/svelte"; import { setTheme, theme, trapFocus } from "@scene/design"; @@ -132,51 +134,7 @@ .gear:hover { border-color: var(--accent); } - .modal-bg { - position: fixed; - inset: 0; - z-index: 20; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - } - .modal-scrim { - position: absolute; - inset: 0; - border: none; - background: rgba(0, 0, 0, 0.5); - cursor: pointer; - } - .modal { - position: relative; - z-index: 1; - width: 100%; - max-width: 420px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 10px; - } - /* Fill the viewport on phones instead of a cramped centred card (safe-area - aware, scrolls if tall). */ - @media (max-width: 640px) { - .modal-bg { - padding: 0; - } - .modal { - max-width: none; - height: 100%; - border: 0; - border-radius: 0; - overflow-y: auto; - padding: calc(16px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) - calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); - } - } + /* Shell (scrim + card + phone-fullscreen) comes from @scene/design/modal.css. */ .modal h3 { margin: 0; font-size: 14px; diff --git a/apps/party/frontend/src/lib/api.ts b/apps/party/frontend/src/lib/api.ts index 899de0cb..2c626cc5 100644 --- a/apps/party/frontend/src/lib/api.ts +++ b/apps/party/frontend/src/lib/api.ts @@ -121,7 +121,17 @@ async function request(path: string, init?: RequestInit): Promise { ...init, }); if (!res.ok) { - throw new ApiError(res.status, `${init?.method ?? "GET"} ${path} → ${res.status}`); + // Prefer the backend's own words — its 4xx bodies are written to be read. + // Falls back to the method/status form for an empty body or an HTML error + // page from a proxy, neither of which says anything a user can act on. + let detail = ""; + try { + const body = (await res.text()).trim(); + if (body && !body.startsWith("<") && body.length <= 300) detail = body; + } catch { + /* body already consumed or unreadable — the fallback still holds */ + } + throw new ApiError(res.status, detail || `${init?.method ?? "GET"} ${path} → ${res.status}`); } if (res.status === 204) { return undefined as T; diff --git a/apps/party/frontend/src/lib/emu-bar.css b/apps/party/frontend/src/lib/emu-bar.css new file mode 100644 index 00000000..c703a1d2 --- /dev/null +++ b/apps/party/frontend/src/lib/emu-bar.css @@ -0,0 +1,27 @@ +/* Emulator toolbar button chrome, shared by Emulator.svelte (DOSBox) and + EjsEmulator.svelte (EmulatorJS). The .bar container layout stays per + component; their own scoped rules (toggles, segments, mobile padding) + override this base by specificity. */ +.bar button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--text); + font-size: 12px; + cursor: pointer; +} +.bar button:hover:not(:disabled) { + border-color: var(--accent); +} +.bar button:disabled { + opacity: 0.5; + cursor: default; +} +.bar button.exit:hover { + border-color: #ff4136; + color: #ff4136; +} diff --git a/apps/party/frontend/src/routes/[slug]/+page.svelte b/apps/party/frontend/src/routes/[slug]/+page.svelte index 81aca025..17a27f23 100644 --- a/apps/party/frontend/src/routes/[slug]/+page.svelte +++ b/apps/party/frontend/src/routes/[slug]/+page.svelte @@ -3,6 +3,8 @@ // detail panel that dispatches to the right viewer by medium — music → the // libopenmpt Player, images/video native where possible, text → NfoView, // demos/intros → an emulator placeholder (Phase 3). Everything downloadable. + import "@scene/design/modal.css"; + import { ChevronRight, CircleHelp, @@ -503,51 +505,12 @@ font-size: 13px; margin-right: auto; } - /* Help overlay (mirrors the Settings modal's look). */ + /* Help overlay — shell from @scene/design/modal.css; sits above the viewer + chrome and runs a touch wider/roomier than the settings card. */ .modal-bg { - position: fixed; - inset: 0; - z-index: 30; - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - } - .modal-scrim { - position: absolute; - inset: 0; - border: none; - background: rgba(0, 0, 0, 0.5); - cursor: pointer; - } - .modal { - position: relative; - z-index: 1; - width: 100%; - max-width: 460px; - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 12px; - } - /* Fill the viewport on phones instead of a cramped centred card (safe-area - aware, scrolls if tall). */ - @media (max-width: 640px) { - .modal-bg { - padding: 0; - } - .modal { - max-width: none; - height: 100%; - border: 0; - border-radius: 0; - overflow-y: auto; - padding: calc(16px + env(safe-area-inset-top)) calc(16px + env(safe-area-inset-right)) - calc(16px + env(safe-area-inset-bottom)) calc(16px + env(safe-area-inset-left)); - } + --modal-z: 30; + --modal-max-w: 460px; + --modal-gap: 12px; } .help-head { display: flex; diff --git a/apps/party/parties/README.md b/apps/party/parties/README.md index ca04b147..8a7dbad2 100644 --- a/apps/party/parties/README.md +++ b/apps/party/parties/README.md @@ -4,14 +4,10 @@ This directory holds the checked-in per-party metadata configs (`.json`) for the **party** app. This README is the runbook for turning a demoparty's scene.org archive into a tree the backend can serve. -It was reconstructed by reverse-engineering the **Assembly '95** export (the -first party ingested), then hardened ingesting **Assembly '96**, **The -Gathering '96**, and **The Gathering '97** — each surfaced new edge cases now -folded in below (TG97, the sparsest tree yet, drove the demozoo-API recovery -recipe in Step 4½). Use `assembly95.json` / `assembly96.json` / -`gathering96.json` / `gathering97.json` + their live -`/Volumes/scene/parties/` trees as worked examples throughout; they -bracket most of the variation seen so far. +`assembly95.json` / `assembly96.json` / `gathering96.json` / +`gathering97.json` and their live `/Volumes/scene/parties/` trees are +worked examples throughout; when in doubt, diff them — they bracket most of the +variation seen so far. ## Mental model @@ -38,45 +34,6 @@ scene.org archive → a laid-out party tree → backend scans & indexes → - **Amiga (and C64) demos run in-browser** via EmulatorJS. Amiga prods boot from per-prod disk images under a `.support/` subdir; PC demos run via js-dos. -You only ever arrange files + write JSON. The backend does the rest. - -## How generic is this, really? - -The **spine is universal**; the details are per-party. Treat the steps below as the -reliable backbone and budget for a handful of party-specific surprises each time. - -- **Universal**: the pipeline (download → arrange → scan → serve), the folder - grammar (`NN - Group - Title`, `rest/`, one- vs two-level compos), the config - schema, the results-into-config join, the emulation/transcode-at-serve model. -- **Varies every party**: the compo set + folder names; the `results.txt` format - (each party differs — *scrape, never parse at runtime*); which prods were actually - archived vs. lost; the long unranked music/graphics tail; per-prod quirks (split - executables, Amiga launch lines, a demo that needs sound setup). -- **Rules of thumb we keep relearning**: scene.org is often **incomplete** (winners - missing entirely — Step 4½; TG97 was only **66/167** ranked entries on the mirror, - demozoo got it to 121); the same prod sometimes lands in **both** a ranked - folder and `rest/`, so it lists twice (Step 5, dedup); auto-naming the tail with - an LLM is great but it **hallucinates** occasionally, so cross-check (Step 5); and - emulation has **non-obvious runtime knobs** (Amiga fast RAM, PC GUS setup) that - make a "correctly arranged" prod still fail — so actually **boot a few** before - declaring victory (Step 7½). -- **Folder↔compo is not 1:1.** A single compo can be split across two scene.org - dirs (TG97 stored one Graphics compo across `grfx/` **and** `grtc/`, with one pic - duplicated in both) — merge them under one category key. And a dir's name lies: - TG97's `grtc` is *not* raytrace (the raytrace compo had its own single entry, - absent from the tree). Map dirs by their **contents vs the results**, not the name. -- **`results.txt` can omit whole compos.** TG97's file had no raytraced-graphics - section at all — only demozoo revealed the compo existed. Always reconcile the - compo *list* against demozoo, not just the placings (Step 4). -- **Ranks are the results' line order, ties included** — not points-deduped. Two - entries on the same points each consume a rank (TG97 amiga #8/#9 both 220 pts). - The `FILE_ID.DIZ` is the oracle: many state "Place 11th" / "#13 in the … compo", - which catches off-by-one rank slips (Step 5). - -When in doubt, diff the four worked examples (`assembly95.json`, `assembly96.json`, -`gathering96.json`, `gathering97.json`) — they bracket most of the variation seen -so far. - ## Prerequisites - A **downloader** that can mirror a subtree: `wget -r`, `lftp`, or `rsync` @@ -212,7 +169,9 @@ Worked example — the Assembly '95 category set (mirror in `assembly95.json`): scene.org tree actually has. Assembly '96, for instance, has `amiga/in64` (not `in40`), splits C64 into `c64/demo` + `c64/grfx_music`, and lists a *Wild* compo in the results that has **no folder on the server** (those prods were never archived). -`assembly96.json` is a second worked example — diff it against `assembly95.json`. +Folder↔compo is not 1:1 either: one compo can be split across two scene.org dirs +(merge them under one category key), and a dir's name can lie — map dirs by their +contents vs the results, not the name. ## Step 3 — Extract the archives @@ -329,13 +288,8 @@ Stage downloads outside the live tree, **verify each is the right prod** (read i `FILE_ID.DIZ`, or byte-compare if it claims to match an existing file — recovery links do mislink), then drop into the proper `NN - Group - Title` folder. -TG96: **46** missing, **11** recovered, rest `lost`/Cloudflare-walled. TG97 worked -example (API-driven): **102** ranked entries missing from scene.org; **55** -recovered (music +24, graphics +11, fastintro +9, demo +3, …) bringing it to -**121/167**; of the rest, 45 were demozoo-`lost`, 1 had no demozoo record, 1 a dead -link — and **39 of those 47 are the Wild compo** (wild is chronically unarchived). -Don't expect a clean sweep — log what stays missing so the disabled rows are -understood, not mistaken for a bug. +Don't expect a clean sweep — Wild compos especially are chronically unarchived. +Log what stays missing so the disabled rows are understood, not mistaken for a bug. ## Step 5 — Author the party config (`.party.json`) @@ -426,52 +380,34 @@ Key rules: fix the name (don't delete). A quick script comparing every `unranked` title to the compo's `results` titles is the cheapest way to flag both cases at once. - **Sidecar soundtracks + a lying frame rate** — the `files` map and `video_fps`. - The Assembly '95/'96 animation compos are **video-only MPEG-1 elementary streams** - with the soundtrack in a separate file next to the picture, played back by the - party's own DOS player (`ASMPEG.EXE`, in `Assembly95/anim/rest/` and - `Assembly96/anim/04 - Heroes/`). Without config they play silent *and* at double - speed. Twelve entries across the two parties need it. The recipe: - **unsigned 8-bit mono PCM at 12288 Hz, picture at 12.5 fps.** Not guessed — - `Assembly96/anim/06 …/VISITOR.TXT` states it (*"soundtrack: visitor.raw (12288kHz, - raw, unsigned, 8bit)"*, *"The playback rate of the animation is 12.5 frames"*), and - `GBIOS.WAV` is `GBIOS.SND` plus a 44-byte WAV header declaring `pcm_u8 / 12288 / 1`. - - `video_fps` on the category is the default for every video in that compo (the - whole `anim` folder shares one pipeline); `FileCfg.fps` overrides it per file. - `Assembly96/anim/01 - Vaapukka - Amis 5000` opts out with `"fps": 24` — it's the - one entry that already ships an `mp2` track at its real rate. + The Assembly '95/'96 animation compos are **video-only MPEG-1 elementary + streams** with the soundtrack in a separate file next to the picture. Without + config they play silent *and* at double speed. The recipe: **unsigned 8-bit + mono PCM at 12288 Hz, picture at 12.5 fps** (documented in + `Assembly96/anim/06 …/VISITOR.TXT`). + - `video_fps` on the category is the default for every video in that compo; + `FileCfg.fps` overrides it per file. - **A category default needs an opt-out for anything in the folder that isn't a - raw stream** — use `"native_fps": true`. An fps override is applied as an ffmpeg - *input* option, which rewrites timestamps as constant-rate; that's exactly right - for an elementary stream (which has none) and wrong for a real container, whose - timestamps are authoritative and may be **variable**. asm95's `anim` holds two - later DivX/mpeg4 re-encodes with audio already muxed: `asm_pulp_divx.avi` runs at - a variable ~10 fps and drifts ~9 s out from its own soundtrack if forced to 12.5. - `MATTER.FLI` (a bonus under `rest/`, not a compo entry) also opts out — its FLIC - speed field is 0, so ffmpeg's own fallback is as good as it gets. Audit the whole - folder before setting `video_fps`: `ffprobe -show_entries stream=codec_type, - avg_frame_rate,duration` over every video in it will show which ones already - carry audio or a non-25 rate, and those are the ones that need an entry. - - **Prefer a container'd sidecar when one exists.** `GBIOS.WAV` and `SOUNDTR.WAV` - need only `"audio"` — ffmpeg reads rate/format/channels from the header, so - there's nothing to author and nothing to get wrong. Only reach for - `audio_format`/`audio_rate`/`audio_channels` for a headerless dump (`.snd`). + raw stream** — `"native_fps": true`. An fps override is an ffmpeg *input* + option that rewrites timestamps as constant-rate: right for an elementary + stream (which has none), wrong for a real container whose timestamps are + authoritative and may be variable (a muxed-audio DivX re-encode drifts + seconds out of sync if forced). Audit the whole folder with + `ffprobe -show_entries stream=codec_type,avg_frame_rate,duration` before + setting `video_fps` — anything already carrying audio or a non-25 rate needs + an opt-out entry. + - **Prefer a container'd sidecar when one exists** — a `.wav` needs only + `"audio"` (ffmpeg reads rate/format/channels from the header). Reach for + `audio_format`/`audio_rate`/`audio_channels` only for a headerless dump + (`.snd`). - **Keys are party-relative and case-sensitive** — `anim/…/GBIOS.MPG`, not `.mpg`. - - **To confirm a rate, compare durations rather than trusting the header**: the raw - ES has no timestamps, so use `frames ÷ fps` for the picture - (`ffprobe -count_frames -select_streams v:0 -show_entries stream=nb_read_frames`) - against `bytes ÷ rate` for the sound. An intact pair lands within a few percent, - the sound running slightly long. Note that halving both fps and rate fits the - ratio equally well, so a ratio alone can't pick between 12288/12.5 and 25000/25 — - which is why the two documented sources above are what settles it. - - Two sources in this archive are **truncated**, which the drift check exposes: - `Assembly95/anim/07 - Artifex - Dawn/dawn.snd` is ~17 s of audio for a 153 s - animation and ends mid-transient at full scale, and `Assembly96/anim/09 - - Delmar/DELMAR.MPG` is only 302 frames (24 s) against 51 s of sound. Both are - configured anyway — worth re-sourcing, harmless meanwhile (no `-shortest`, so - neither stream truncates the other). - - Editing any of these values yields a **new** cache entry rather than the previous - mux (the recipe is hashed into the `derived` ledger key), so retuning is just - edit → `POST /api/rescan` → reload. + - To confirm a rate, compare durations: `frames ÷ fps` for the picture + (`ffprobe -count_frames`) against `bytes ÷ rate` for the sound — an intact + pair lands within a few percent. A ratio alone can't distinguish 12288/12.5 + from a doubled pair, so a documented source is what settles the absolute rate. + - Editing any of these values yields a **new** cache entry (the recipe is + hashed into the `derived` ledger key), so retuning is just edit → + `POST /api/rescan` → reload. - **A DOS demo that needs a specific CPU** — `FileCfg.cputype`, keyed by the demo's **entry folder** (the one `files` key that isn't per-file: a CPU belongs to the production, so every build inside inherits it — the fix, the v2, the extender a @@ -491,18 +427,13 @@ Key rules: the core actually wants. Naming a specific exe still works and beats the folder, for the rare prod whose two builds want different CPUs. An unrecognised value is logged at startup and ignored — but a key that matches *nothing* is silent, so - **renaming the entry folder unpins the CPU** (tidying `11 - byterapers - - protocode0x28` to `11 - Byterapers - Protocode 0x28` did exactly that): move the - key with the folder, and note the rename also changes the production's id, so - reload the SPA rather than trusting an open tab. - - **Neither MMX spelling of DOSBox-X-in-js-dos gives you MMX**, which is why that - one value is remapped: `cputype=pentium_mmx` logs `not supported (using pentium - instead)`, and the fork's own `jsdos_pentium_mmx` logs `pentium_mmx is enabled` - but *still* fails protocode0x28's check ("This machine does not report MMX - support") — its CPUID doesn't advertise MMX. `pentium_ii` is the lowest setting - whose CPUID does, and the demo runs on it, so an authored `pentium_mmx` resolves - to `pentium_ii`. Written out in `cpu_target`; `jsdos_pentium_mmx` remains - available if you name it explicitly. + **renaming the entry folder unpins the CPU**: move the key with the folder, and + note the rename also changes the production's id, so reload the SPA rather than + trusting an open tab. + - **Neither MMX spelling of DOSBox-X-in-js-dos actually advertises MMX in + CPUID**, so `cpu_target` resolves an authored `pentium_mmx` to `pentium_ii` — + the lowest setting whose CPUID does report it. (`jsdos_pentium_mmx` remains + available if you name it explicitly, but demos' own MMX checks still fail on it.) - Copy `assembly95.json` / `assembly96.json` as a starting template. ## Step 6 — Amiga AGA disk images (optional but recommended) @@ -589,8 +520,9 @@ Two macOS traps when scripting the build: per-prod `.support/` (depth ≥ 2) *is* scanned — only the shared root one is not. **Don't trust "it built" — boot it (Step 7½).** A bootable image still drops to an -AmigaDOS CLI or hangs if the launch line is wrong or the machine lacks RAM (see the -fast-RAM quirk in 7½). `fs-uae` confirms in seconds whether it reaches the demo. +AmigaDOS CLI or hangs if the launch line is wrong or the machine lacks RAM (the +fast-RAM default — see `apps/party/AMIGA-ROMS.md`). `fs-uae` confirms in seconds +whether it reaches the demo. ## Step 7 — Index & verify @@ -640,8 +572,7 @@ Then browse the SPA (`just dev party`) and confirm: A prod can be arranged perfectly and still not run — emulation has runtime knobs the filesystem can't express. **Actually boot a sample** of each platform before -shipping, using a native emulator configured like the in-browser core. This caught -every issue below; none were visible from the file tree. +shipping, using a native emulator configured like the in-browser core. ```sh # Amiga — `just amiga` picks the machine + Kickstart from the demo's filename tag, @@ -658,61 +589,40 @@ dosbox-x -conf t.conf & sleep 16; screencapture -x shot.png; pkill -f dosbox Capturing the window to a PNG and reading it back is the reliable way to tell "reached the demo" from "dropped to a CLI / setup menu" without a human watching. -**A slow AGA demo in the SPA is not necessarily a broken one.** The in-browser core -has no JIT, so 68020+ AGA work can crawl there and still be fine natively. `just -amiga` is the control: if it runs at speed in fs-uae, the image is good and the -browser is simply the limit — don't go rebuilding the `.hdf`. See -`apps/party/AMIGA-ROMS.md` for the tag→machine mapping the recipe shares with the app. - -The quirks we hit (all now fixed in the app, but know them when a demo misbehaves): - -- **A wild "demo" that's only a captured video showed no Play button.** `pick_primary` - (`scan.rs`) resolved `medium: demo/intro` to runnable→diskimage→exe and stopped, so a - wild entry shipping only an `.mpg` (TG97 #3 Firestarter) got *no* primary. Fixed: - it now falls back to the largest video. Wild compos mix runnable prods and videos — - expect both. -- **The DOS extender got picked as the primary exe.** Scanner takes the largest `.exe`; - for a prod shipping `DOS4GW.EXE`/`CWSDPMI.EXE` alongside the real (smaller) demo exe, - the extender won and the js-dos bundle ran a do-nothing stub (TG97 Textatic → - `DOS4GW.EXE` instead of `DEMO5.EXE`). Fixed: `scan.rs` now excludes a denylist of - known extenders/stubs from the exe slot. -- **Amiga demos need fast RAM.** EmulatorJS forces `puae_model=A1200` — whose preset - is "2M Chip + **8M Fast**" — but it *also* writes the individual memory options at - the core's default (fast = 0), and those **override the model preset**. Result: any - sizable demo aborts the instant its loader runs — - `: not enough memory available / failed returncode 10`, dropping to the CLI. - Fix (in `frontend/src/lib/EjsEmulator.svelte`): force `puae_fastmem_size = "8"`. - This is the #1 reason a freshly-imaged Amiga demo "doesn't start." -- **A few PC demos need sound setup — bake it, don't make users do it.** A demo that - ships a `SETUP.EXE` writing a `SOUND.CFG` hardcodes the *author's* card settings - (TG96 Inside: GUS at the original DMA 6 / IRQ 11). js-dos's GUS sits elsewhere - (port 240 / DMA 3 / IRQ 5), so GUS init fails → silent (or the demo drops to its - own sound menu). Fix: run the demo's setup once **against the bundle's GUS** (pick - Gravis UltraSound) so it rewrites `SOUND.CFG` to the bundle's IRQ/DMA, and bake - that file. It's **rare** — across all three parties only Inside has a `SETUP.EXE`; - every other `.CFG` either auto-detects (MIDAS, IRQ/DMA = `ffffffff` sentinels) or - uses the SoundBlaster defaults that already match. Scan for the pattern with +For the Amiga runtime facts — the no-JIT browser core (a demo that crawls in the +SPA but runs at speed in fs-uae is fine; don't rebuild the `.hdf`), the forced +8 MB fast-RAM default, and the tag→machine mapping — `apps/party/AMIGA-ROMS.md` +is canonical. `just amiga` is the control run. + +The quirks (fixed in the app where noted — know them when a demo misbehaves): + +- **Wild compos mix runnable prods and captured videos.** `pick_primary` + (`scan.rs`) falls back to the largest video for a `demo`/`intro` entry that + ships only an `.mpg`, so those get a Play button too. +- **The largest `.exe` is not always the demo.** `scan.rs` excludes a denylist of + known DOS extenders/stubs (`DOS4GW.EXE`, `CWSDPMI.EXE`, …) from the primary-exe + slot so they don't beat the real (smaller) demo exe. +- **A demo shipping `SETUP.EXE` + `SOUND.CFG` hardcodes the author's card + settings.** js-dos's GUS sits at port 240 / DMA 3 / IRQ 5, so GUS init fails → + silent (or the demo drops to its own sound menu). Fix: run the demo's setup + once against the bundle's GUS and bake the rewritten `SOUND.CFG`. It's rare — + most `.CFG`s auto-detect (MIDAS, `ffffffff` sentinels) or match the + SoundBlaster defaults. Scan for the pattern with `find -iname setup.exe -o -iname '*.cfg'`. -- **A PC demo that aborts instantly may want MMX.** The default core has none, and - such a demo says so on the way out (protocode0x28: `CPU: no MMX support - - aborting`) — check its `FILE_ID.DIZ`/`.nfo` requirements block, then set - `cputype` for that exe (Step 5) to move it onto DOSBox-X. Don't trust the core's - log line as proof: `jsdos_pentium_mmx` cheerfully reports `pentium_mmx is enabled` - while its CPUID still says no MMX, so the only confirmation that counts is the - demo getting past its own check (Step 5's sub-bullet — this is why an authored - `pentium_mmx` becomes `pentium_ii`). Native `dosbox-x` is unaffected: there you - write `pentium_mmx` normally. -- **js-dos caches bundles by URL.** The backend builds each `.jsdos` bundle *live - from disk*, so a fresh fetch always has the current files — but the browser caches - the zip. Change a bundled file (a corrected `SOUND.CFG`) and clients keep the stale - one. Bump `BUNDLE_CONF_VERSION` in `frontend/src/lib/api.ts` to bust it. -- **Kiosk is immutable — fixes must be in the data, not in a user action.** The - public/kiosk instance serves a read-only data image. A visitor running `SETUP.EXE` - or changing an emulator setting only writes the *local* js-dos overlay / - `localStorage` — per-browser, gone on reload and never seen by the next visitor. - So every playability fix (corrected `SOUND.CFG`, AGA image, fast-RAM default) has - to land in the baked files / app build + a cache-bump. "Just run setup" is not a - fix here. +- **A PC demo that aborts instantly may want MMX.** The default core has none, + and such a demo says so on the way out (`CPU: no MMX support - aborting`) — + check its `FILE_ID.DIZ`/`.nfo` requirements block, then set `cputype` (Step 5) + to move it onto DOSBox-X. The only confirmation that counts is the demo + getting past its own check. Native `dosbox-x` is unaffected: there you write + `pentium_mmx` normally. +- **js-dos caches bundles by URL.** The backend builds each `.jsdos` bundle live + from disk, but the browser caches the zip — change a bundled file and clients + keep the stale one. Bump `BUNDLE_CONF_VERSION` in `frontend/src/lib/api.ts`. +- **Kiosk is immutable — fixes must be in the data, not in a user action.** A + visitor running `SETUP.EXE` or changing an emulator setting only writes the + local js-dos overlay / `localStorage` — per-browser, gone on reload. Every + playability fix has to land in the baked files / app build + a cache-bump; + "just run setup" is not a fix here. ## Step 8 — Package & deploy @@ -730,21 +640,12 @@ tree never changes there, so rescan is disabled. (See `justfile`, ## Gotchas -- **macOS junk** — `._*` / `.DS_Store` from SMB shares. The scanner skips - dot-dirs; `package-party-data` excludes them. Don't commit them into the tree. - **Permissions** — the backend runs as a non-root UID. The NAS source is often `drwx------`/`-rwx------` (root-only); `package-party-data` chmods to `0755`/`0644` so the binary can read the archive (otherwise it indexes 0 files). If running directly off the NAS in dev, make sure your user can traverse it. - **CP437** — results files and many `.nfo`/`FILE_ID.DIZ` are CP437, not UTF-8. Leave them; the backend handles the encoding. -- **Transcoder required for visuals** — without `PARTY_TRANSCODER_URL`, graphics - and animations only offer a download link. Nothing is pre-converted. -- **Kickstart not bundled** — Amiga emulation needs `kick40068.A1200` in - `.support/`, supplied separately (copyrighted). -- **Results join is by `(category, rank)`** — a wrong `categories` key or a rank - that doesn't match the `NN - …` folder silently drops that entry's points. Verify - via the `updated=` log line (Step 7) and that ranks show in the UI after a rescan. - **fish shell** — moving entry folders with hidden files trips the classic fish gotcha: an unmatched glob like `mv rest/x/.[!.]* dst/` *errors* instead of passing through. Wrap such one-liners in `bash -c '…'`, or use `cp -a`/`rsync`. @@ -752,24 +653,6 @@ tree never changes there, so rescan is disabled. (See `justfile`, control-byte names that `unzip` can't create; it then *prompts* ("write error; Continue? y/n") and a non-interactive run blocks forever (or dies on EOF). Run it `//` - names the composer, but `DEMOS/`/`GAMES/` start with a *category*, so the - generic seg[0] rule would file ~4,600 tunes under "DEMOS". - Two traps, both found only by testing against a real release: HVSC's documents - are **Latin-1**, so `read_to_string` fails on the whole file (which silently - cost the version number), and the banner reads `Release 85` — a bare `#` scan - would match the prose "Tunes #1" and report version 1. -- **The source scope is sticky, and defaults to one collection.** A - `Mods · HVSC · All` selector (`SourceSelector.svelte`) sits between the view - tabs and the facet bar — deliberately *not* a fourth tab, since favourites and - playlists cut across sources rather than sitting beside them. It's hidden - while only one root is configured. The whole filter set (collection, group-by, - sorts, facets — not the free-text query) persists in `localStorage`, so a - collection you didn't pick stays out of the list and therefore out of the play - queue; `All` mixes on purpose. `/status` reports a per-root track count to - label it. + `DOCUMENTS/Songlengths.md5` (`hvsc.rs`): one read yields every tune's path, + content MD5 (becomes `content_hash`; 32 hex vs a scanned 64, can't collide) + and per-subtune length — 61k tunes in under a second, no walk or hashing; + the tree is only ever read. `hvsc_state` (version, counts, size+mtime stamp) + makes the boot check one stat — a newly mounted release reindexes + automatically; `/api/rescan/{root}` reindexes rather than walks (400 if not + a collection). The feature flag is the root itself: none configured → no + `hvsc` facts in `/status`, nothing in the SPA. `artist_from_path` is + HVSC-specific — `MUSICIANS/…//` names the composer; `DEMOS/`/ + `GAMES/` start with a category (the seg[0] rule would misfile ~4,600 tunes). +- **The source scope is sticky, defaults to one collection.** `Mods · HVSC · + All` (`SourceSelector.svelte`) sits between the view tabs and facet bar — + deliberately not a fourth tab (favourites/playlists cut across sources); + hidden with one root. The filter set (not the free-text query) persists in + `localStorage`; `All` mixes on purpose. `/status` per-root counts label it. - **Filesystem is the source of truth, artist-primary.** - `/artist/song.ext`. The first path segment is the artist (a file at - the root has none); there is **no path-group** — groups/aliases/albums come from - `library.json` (the manifest), joined onto the artist in the frontend. This is - unconditional; there is no layout switch (the legacy `group/artist` mode was - removed). No sidecar metadata files; files can be freely moved with ordinary - tools and a rescan reconciles. The - list view also renames/moves files in place (`/api/rename`) — handy for - cleaning up names from old CD rips. **This means the collection mount must be - read-write**, not the `:ro` the original deploy plan assumed — the raspi - quadlet must mount `/mnt/mods` writable. Renames never overwrite (409 on - collision) and keep a module extension (so the file stays indexed). + `/artist/song.ext` — seg[0] is the artist (root files have none). + **No path-group**: groups/aliases/albums come from `library.json` (the + manifest), joined onto the artist in the frontend; the legacy `group/artist` + mode was removed. No sidecar metadata; files move with ordinary tools, a + rescan reconciles. The list view renames/moves in place (`/api/rename`), so + the mount must be **read-write** (raspi mounts `/mnt/mods` writable). + Renames never overwrite (409) and keep a module extension. - **The DB is a cache, not state.** `files` is a path index; `meta` is - libopenmpt-parsed enrichment **keyed by content hash** so it follows a file - across moves/renames (the path changes, the bytes don't). Losing - `TRACKER_DB_PATH` only costs a rescan. Idempotent boot migrations (no - `user_version` gating). -- **Don't rehash the NAS every scan.** `content_hash` is reused when - `(rel_path, size, mtime)` is unchanged; only new/changed files are read + - SHA-256'd. First scan of the full collection hashes everything (~2.5 min over - CIFS for 3455 files); later scans are cheap. macOS junk (`._*`, `.DS_Store`, - …) and hidden dirs are skipped. + enrichment **keyed by content hash**, so it follows a file across + moves/renames. Losing `TRACKER_DB_PATH` only costs a rescan; boot migrations + are idempotent (no `user_version` gating). Scans don't rehash the NAS: + `content_hash` is reused when `(rel_path, size, mtime)` is unchanged; only + new/changed files are read + SHA-256'd (first full scan ~2.5 min over CIFS). + macOS junk (`._*`, `.DS_Store`, …) and hidden dirs are skipped. - **One engine, in the browser.** The backend is pure Rust (no native - libopenmpt → clean scratch container). Playback **and** metadata extraction - run in the SPA via libopenmpt WASM. This app vendors a **custom from-source - libopenmpt build** (`wasm/libopenmpt-ext/`) that adds a small C ABI the stock - chiptune3 build lacks: raw **sample extraction** (`smp_*` shim → `CSoundFile`), - per-channel **mute/solo** (`chan_mute`, mirroring libopenmpt_ext's - `set_channel_mute_status` via the same `CSoundFile` accessor — no ext module), - and **structured pattern cells** (the `_openmpt_module_get_pattern_row_channel_command` - export). Jamming itself is **pure Web Audio** (`AudioBufferSource` on the - extracted PCM — no libopenmpt playback/ext engine). **Party vendors the same - custom build** for sample jamming (its shared `PlayerStage` shows the - `SampleBrowser`); the **pattern editor UI is tracker-only** — it lives in this - app's `+page.svelte`, not in `PlayerStage`, so party never surfaces it even - though its build reports `canReadCells`. The frontend POSTs parsed metadata - back to `/api/meta/:hash`. + libopenmpt → clean scratch container); playback **and** metadata extraction + run in the SPA, which POSTs parsed metadata to `/api/meta/{hash}`. The WASM + is the monorepo's custom libopenmpt build — + `../../wasm/libopenmpt-ext/README.md` is canonical (`smp_*`/mute/cell ABI, + pure-Web-Audio jamming, build + bump). Tracker specifics: the chiptune3 *JS* + layer is a hard fork — `static/vendor/chiptune3/` + + `src/lib/vendor/chiptune3.js` (worklet loaded from a fixed `/vendor/…` URL + so Vite doesn't bundle it), both excluded from eslint + prettier, upstream + merges manual; its `getSong` is patched to emit cells as formatted text. + The pattern editor UI is tracker-only (this app's `+page.svelte`, not + `PlayerStage`) — party never surfaces it though its build reports + `canReadCells`. - **Auth is the edge's job.** Sits behind oauth2-proxy forward-auth; the binary only asserts `X-Auth-Request-User` is present (401 otherwise) — no per-user - state, no own login. `DEV_AUTH=1` bypasses for local work. `/status` is unauth. -- **CSP** allows `'wasm-unsafe-eval'` + `worker-src blob:` for the WASM player, - and hashes SvelteKit's inline bootstrap script at boot (no `'unsafe-inline'`). -- **The library index lives server-side.** The SPA no longer holds every track: - it fetches a shaped, ordered **id stream** (`/api/library/ids`) and hydrates - visible windows through `$lib/tracks.svelte` (a `SvelteMap` cache keyed by - `files.id`, filled by `/api/tracks/batch` from the virtualizer's scroll - effect). Rows in the stream can exist before their data arrives — the list - renders a fixed-height skeleton so offsets stay exact. The **queue is refs, - not tracks**: `@scene/player` takes `playRefs(ids, index)` / `cueRefs` and - resolves each id through the host (`peekTrack` for cache hits, `resolveTrack` - to fetch), so shuffle still permutes *indices* and its reproducibility, - prev-history and reload-survival are untouched. Party keeps the in-memory - `playInOrder(list, track)` form; both go through one code path. - **The backend-less (Pages) build shapes in the browser** with the same pure - helpers in `$lib/library`, then seeds the same cache — so every consumer reads - rows identically. Anything that used to scan `library.tracks` (facet options, - un-enriched counts, empty states, dupe playback, deep-link restore) now asks - the backend instead; with a backend that array is empty by design. + state, no own login. `DEV_AUTH=1` bypasses for local work; the LAN-only + deploy runs `TRACKER_OPEN=1` (same bypass). `/status` is unauth. +- **CSP** allows `'wasm-unsafe-eval'` + `'unsafe-eval'` (required by the SID + engine alone — see `lib.rs`) + `worker-src 'self' blob:`, and hashes + SvelteKit's inline bootstrap script at boot (no `'unsafe-inline'`). +- **The library index lives server-side.** The SPA no longer holds every + track: it fetches a shaped, ordered **id stream** (`/api/library/ids`) and + hydrates visible windows through `$lib/tracks.svelte` (a `SvelteMap` cache + keyed by `files.id`, filled by `/api/tracks/batch` from the virtualizer's + scroll effect). Rows can exist before their data arrives — a fixed-height + skeleton keeps offsets exact. The **queue is refs, not tracks**: + `@scene/player` takes `playRefs(ids, index)` / `cueRefs` and resolves ids + through the host (`peekTrack` for cache hits, `resolveTrack` to fetch), so + shuffle still permutes *indices* — reproducibility, prev-history and + reload-survival untouched. Party keeps the in-memory `playInOrder(list, + track)` form; one code path. The backend-less (Pages) build shapes in the + browser with the same pure helpers (`$lib/library`) and seeds the same + cache. Anything that used to scan `library.tracks` asks the backend instead; + with a backend that array is empty by design. - **Type sharing is manual**: `frontend/src/lib/api.ts` mirrors `backend/src/routes.rs` structs by hand. -- **Design.** Icons are **Lucide** (`@lucide/svelte`), squared (CSS overrides the - default round strokes to `square`/`miter`, thicker stroke, small) to sit with - the retro fonts — **not** Material Icons. Fonts are **self-hosted via fontsource** - (no Google CDN): Inter Variable (body + chrome) with Amiga **TopazPlus** on the - player surfaces (`--font-retro`: brand, pattern grid, sample list, ord/pat/row + - time readouts). **halo-design is adopted**: `--halo-*` tokens in - `src/lib/styles/halo.css` (dark-first, flipped by `data-theme`, no Google CDN), - with `+layout.svelte` mapping the app tokens (`--bg/--panel/--accent/--surface-*`) - onto them. Light/dark/auto via `data-theme` (`src/lib/theme.svelte.ts`). See the - monorepo-root `scene-design` skill. Consume tokens, never hard-coded hex. -- **Player control model** (`player.svelte.ts` is a small state machine — - stopped/playing/paused over one loaded `current` module): tapping a track opens - the player (pattern) view and plays it; the already-loaded track just reopens - the view (no rewind). Transport: play/pause toggles in place (and restarts from - the top once the queue has ended — the stopped state); prev/next walk the queue - (the visible grouped+filtered order) with auto-advance; a click-to-seek bar; - **✕** returns to the list (playback continues as a bottom mini-player — tap its - title to reopen the view); **mute** is an orthogonal volume toggle. (No stop - button — pause covers it.) +- **Design.** Icons: **Lucide** (`@lucide/svelte`), squared via CSS + (`square`/`miter`, thicker, small) to sit with the retro fonts — not + Material Icons. Fonts self-hosted via fontsource (no Google CDN): Inter + Variable body/chrome, Amiga **TopazPlus** on player surfaces + (`--font-retro`). `--halo-*` tokens in `src/lib/styles/halo.css` + (dark-first, `data-theme`-flipped) map to the app tokens in + `+layout.svelte`; light/dark/auto via `src/lib/theme.svelte.ts`. See the + `scene-design` skill. Consume tokens, never hard-coded hex. +- **Player control model** (`player.svelte.ts`, stopped/playing/paused over + one loaded `current`): tap a track → player view + play; the already-loaded + track just reopens (no rewind). Play/pause toggles in place (restarts after + the queue ends); prev/next walk the visible grouped+filtered order with + auto-advance; click-to-seek; **✕** returns to the list (playback continues + as a mini-player); mute is an orthogonal volume toggle; no stop button. + Media Session + wake lock are wired; iOS suspends background Web Audio — a + foreground player by design. ## API -- `GET /status` — unauth liveness `{service, version, db_healthy, track_count, root}`. -- `GET /api/tracks` — full library index (path-derived + cached meta, LEFT JOIN). - Fine at module scale; **does not scale to HVSC** (~91k tracks is tens of MB) — - new code should use the shaped endpoints below. -- `GET /api/library/ids?collection&fav&fmt&tracker&q&group_by&track_sort&group_sort` - — the **shaped library**: `{groups:[{name,ids}], total, formats, trackers}`. - Filter/group/sort run server-side in `library.rs` (the Rust twin of the SPA's - `lib/library.ts`), and the ids across all buckets in order *are* the play - queue. Deterministic for a given query, which is what lets the client keep - permuting **indices** for its seeded shuffle (so `prev` retraces the same - history and the order survives a reload). -- `GET /api/tracks/batch?ids=1,2,3` — hydrate a window of that id stream, echoed - back in the requested order (SQL `IN` guarantees none). Capped at 1000 ids; - unknown ids are skipped, not fatal. -- `GET /api/track/{hash}` — one track by content hash, for the `?t=` deep-link - restore (which can't search a list the browser no longer holds, and whose - target may be excluded by a stored filter anyway). -- `GET /api/library/unenriched` — a page of tracks with no parsed metadata plus - the total, driving the bulk-enrich button and its run. Excludes `.sid` (parsed - server-side from the PSID header, so libopenmpt must never see them). -- `GET /api/file/{hash}` — raw module bytes (player + WASM parse). -- `POST /api/meta/{hash}` — store enrichment parsed in the browser. -- `POST /api/rename` — rename / move a module by editing its group/artist/ - filename segments (validates safe segments, refuses overwrite, moves on disk, - updates the index row in place; metadata follows by hash). -- `POST /api/rescan` — re-walk the primary root (synchronous; returns counts). -- `POST /api/rescan/{root}` — re-walk one named root (400 for an `hvsc` root, - which is indexed from its own catalogue rather than walked). -- `GET /status` also reports live scan progress (`scanning`, `scan_total`, - `scan_processed`, `scan_hashed`) from lock-free counters, so the UI can show a - progress bar without touching the scan-locked DB. -- **Playlists** (items keyed by md5/path/url so they follow a module's bytes - across moves): `GET/POST /api/playlists`, - `GET/POST(rename)/DELETE /api/playlists/{id}`, - `POST(add)/PUT(reorder) /api/playlists/{id}/items` — add takes - `{md5|path|url, …}`, reorder takes `{ids:[item_id]}` — - `DELETE /api/playlists/{id}/items/{item_id}`. -- **Import / export** a playlist document (md5 + Modland path + cached display - metadata): `POST /api/playlists/import` (kind `imported`), - `GET /api/playlists/{id}/export`; `GET /api/library/md5` dumps all local md5s - so an external curator can diff before producing an import doc. -- **Fetch missing** (download a playlist's missing items from Modland): - `POST /api/playlists/{id}/fetch-missing` (background — downloads each item by - its Modland `path`, else a generic `url`, into `//`, - records the md5, rescans so items resolve as present), `GET /api/fetch/status` - (lock-free progress `{running, total, fetched, failed}`). The Modland base is - `MODLAND_BASE` (default `https://ftp.modland.com`), env-overridable so the e2e - drives it against a wiremock stub. +`frontend/src/lib/api.ts` mirrors `routes.rs` by hand; auth on all but `/status`. + +- `GET /status` — liveness, roots + counts, hvsc facts, live scan progress. +- `GET /api/library/ids?collection&fav&fmt&tracker&q&group_by&…` — shaped + library `{groups:[{name,ids}], total, formats, trackers}` (`library.rs`); + the ids in order *are* the play queue (deterministic → the client's seeded + shuffle keeps permuting indices). +- `GET /api/tracks/batch?ids=…` — hydrate a window (order echoed, ≤1000 ids). +- `GET /api/track/{hash}` — `?t=` deep-link restore. +- `GET /api/tracks` — full index; module-scale only, does not scale to HVSC. +- `GET /api/library/unenriched` — unparsed-tracks page (excludes `.sid`). +- `GET /api/file/{hash}` — raw bytes; `POST /api/meta/{hash}` — enrichment. +- `POST /api/favorite|play|song-length/{hash}` — hash-keyed listener state. +- `POST /api/rename`, `/api/delete` — organise the collection on disk. +- `POST /api/rescan[/{root}]` — re-walk primary / one root (hvsc reindexes). +- `GET /api/stil/{id}` — STIL notes; `GET /api/roms/{which}` — C64 ROMs. +- Playlists (items keyed md5/path/url so they follow a module's bytes): + `GET/POST /api/playlists`; `GET/POST(rename)/DELETE /api/playlists/{id}`; + `POST(add)/PUT(reorder) …/{id}/items`; `DELETE …/{id}/items/{item_id}`. +- `POST /api/playlists/import`, `GET …/{id}/export`, `GET /api/library/md5` — + curated import/export + an md5 dump for external diffing. +- `POST …/{id}/fetch-missing` + `GET /api/fetch/status` — Modland fetch + (background; `MODLAND_BASE` env-overridable, the e2e stubs it). +- Manifest/curation (write `library.json` atomically, hot-swap, no rescan): + `GET /api/manifest`, `POST /api/library/reload`, `PUT /api/artist/{name}`, + `POST /api/albums`, `PUT/DELETE /api/albums/{id}`, `POST …/{id}/songs`, + `DELETE …/{id}/songs/{md5}`, `PUT /api/song/{md5}`. +- `GET /api/dupes` — duplicate report (exact + likely). ## Working on this repo - Backend `:3010` (`TRACKER_BIND`): `cd backend && cp .env.example .env`, set - `TRACKER_ROOT` / `TRACKER_ROOTS` (dev: `/Volumes/scene/mods` NAS mount), then `cargo run`. Boot only - scans when the cache is **empty** (first run); a normal restart serves the - persisted index instantly without re-walking the NAS. `/api/rescan` (synchronous) + `TRACKER_ROOT`/`TRACKER_ROOTS` (dev: the `/Volumes/scene/mods` NAS mount), + then `cargo run`. Boot only scans when the cache is empty; a restart serves + the persisted index without re-walking the NAS. `/api/rescan` (synchronous) picks up on-disk changes. -- Frontend dev `:5173`: `cd frontend && yarn install && yarn dev`; Vite proxies - `/api` + `/status` to `:3010`. `yarn validate` = typecheck + lint + format. -- integration: `cargo build -p tracker-backend && cargo test -p tracker-integration -- --ignored`. -- Key env: `TRACKER_ROOTS` or `TRACKER_ROOT` (one required), `TRACKER_BIND`, `TRACKER_DB_PATH`, - `STATIC_DIR`, `DEV_AUTH`. See `backend/src/config.rs`. +- Frontend dev `:5173`: `cd frontend && yarn install && yarn dev`; Vite + proxies `/api` + `/status` to `:3010`. `yarn validate` = typecheck + lint + + format. Integration: `cargo build -p tracker-backend && cargo test -p + tracker-integration -- --ignored`. +- Key env: `TRACKER_ROOTS`/`TRACKER_ROOT` (one required), `TRACKER_BIND`, + `TRACKER_DB_PATH`, `TRACKER_ROMS_DIR`, `STATIC_DIR`, + `DEV_AUTH`/`TRACKER_OPEN`. See `backend/src/config.rs`. -## Status / roadmap +## Next / deferred -- **Done:** backend scanner + SQLite cache + API; SvelteKit SPA + library browser - (group/artist/format facets, filter, rescan); **live scan progress bar**; - **in-place rename/move** (inline edit in the list); **iPhone-portrait - responsive UI** ([[feedback_iphone_portrait_ui]]); **libopenmpt WASM playback** - via vendored chiptune3 (play/pause/stop transport, position, order/pattern/row) - + **metadata write-back on play** (`/api/meta`); **live FT2 pattern view** - (full-screen overlay, current row highlighted + auto-scrolled) with an - **instrument/sample-list tab** and a **master oscilloscope** (`Scope.svelte`, - AnalyserNode tap on the output); an **Amiga Boing Ball loader** - (`BoingBall.svelte`, time-driven seamless bounce) shown during the first-run - scan; e2e (7) + unit (11) tests; verified against the real NAS collection - (3455 modules). -- **Playback engine notes:** chiptune3 worklet + embedded-wasm live in - `static/vendor/chiptune3/` (served verbatim, 200 `text/javascript`); the - main-thread class is vendored+patched in `src/lib/vendor/chiptune3.js` (load - the worklet from a fixed `/vendor/...` URL so Vite doesn't bundle it). The - **worklet's `getSong` is patched** to emit each cell as libopenmpt's formatted - text (`format_pattern_row_channel` → "C-4 01 v64 A04") instead of 6 raw command - values — runs once per load, off the audio path. `src/lib/player.svelte.ts` is - the reactive store; `PatternView.svelte` renders the grid. **Vendored worklet - files are excluded from eslint + prettier** (`static/vendor/`, `src/lib/vendor/`) - — prettier silently reformats them otherwise. **Pending acceptance: in-browser - audio + pattern smoke test** (everything else is statically verified). -- **Player/library features done:** queue (next/prev + auto-advance over the - visible order), seek bar, shuffle, repeat, keyboard shortcuts, and **enrich-all** - (parse every un-enriched module's metadata via a parse-only worklet command → - POST /api/meta, with progress + cancel). -- **Keyboard jamming + sample extraction (done).** A **custom libopenmpt WASM** - (`wasm/libopenmpt-ext/` — a from-source emscripten build in an amd64 emsdk container; the old "emcc not installed" note is - stale) adds a tiny `smp_*` C ABI that reads **raw sample PCM + loop points** off - a module (reaching the internal `CSoundFile` via a one-line accessor patch — - libopenmpt's public API exposes sample *names* only). **Jamming is then pure Web - Audio**: `packages/player`'s store builds an `AudioBuffer` from that PCM and - plays it pitched to the key, looped at the sample's loop points — no libopenmpt - playback engine, worker render-loop, or worklet involvement, and fully - independent of the song's transport (`jamNote`/`jamStop` in `player.svelte.ts`; - `JamKeyboard` + `SampleWave` UI gated on `playback.canReadSamples`). Party now - vendors the same custom build, so it gets sample jamming too (the editor UI is - tracker-only — see the engine note above). `decoder.worker.js` gained a `readSample` command - (`smp_*` off the song module) — everything else is unchanged. Gate: - `node wasm/libopenmpt-ext/spike/spike.mjs ` (real PCM + loop points, MOD/XM/IT). - **To bump libopenmpt:** `OMPT_REF=… ./build.sh` + re-run the gate (see - `wasm/libopenmpt-ext/README.md`). **Caveat:** the chiptune3 *JS* layer - (`chiptune3.js`/`decoder.worker.js`/`chiptune3.worklet.js`) is a hard fork — no - upstream auto-sync; updates are a manual merge. -- **Player view modes:** pattern (toggle: locked fixed-centerline + vertical - gradient VU, or free-scroll + header VU — persisted), samples, and a Boing-ball - visualizer (reacts to channel VU). Per-channel VU is the only per-channel signal - libopenmpt gives — true per-channel waveform scopes aren't possible. -- **Deploy:** multi-stage `Dockerfile` (vendored-yarn frontend build → musl - cross-compile → `scratch`, **8.4 MB** `ghcr.io/eetu/tracker`), smoke-tested - (scan, `/status`, SPA fallback, worklet served). **LAN-only, no oauth2-proxy:** - the container runs with **`TRACKER_OPEN=1`** (config bypasses the forward-auth - header assertion — same switch as `DEV_AUTH`); the host is egress-restricted. - raspi wiring done (`../raspi`): `mods` CIFS share **mounted read-write**, - `tasks/tracker.py` quadlet (mirrors `navidrome`), un-gated Traefik route, - `network_restrict` + `RESTIC` entry. The `mods` share **reuses the `music` NAS - login** via a `creds` alias, so no new vault fields are needed before deploy. -- **CI/CD:** `.github/workflows/` — `ci` (frontend lint/format/typecheck/build + - Rust clippy/test/build + e2e), `dockerimage` (paths-gated arm64 → GHCR, prune - untagged), `automerge` (dependabot, skips actions bumps), `cve-scan` (weekly - Trivy → Security tab) + `dependabot.yaml`. Repo is public at `eetu/tracker`. -- **Player/OS integration:** Media Session metadata + transport handlers - (play/pause/prev/next), a screen wake lock while playing, and an - `AudioContext` resume on return to foreground. iOS suspends Web Audio in the - background (only `HTMLMediaElement` survives — a render-to-`