feat: add Bulma loader and client-side DB bootstrap (fflate + sql.js); download/unzip/cache DB and show stub UI
This commit is contained in:
parent
f73717c31c
commit
669e73f065
5 changed files with 505 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
# Large artifacts (served separately)
|
||||
db.zip
|
||||
/assets/*.sqlite
|
214
AGENTS.md
Normal file
214
AGENTS.md
Normal file
|
@ -0,0 +1,214 @@
|
|||
This repository contains a **static web app** for searching a large music metadata database (no server/API calls). It uses:
|
||||
- **Bulma** (`bulma.min.css`) for UI (https://bulma.io/documentation/)
|
||||
- A single HTML file (`index.html`)
|
||||
- A single JS file (`script.js`)
|
||||
- A client-side SQLite database (WASM) fetched as a static asset (paged with HTTP Range if available)
|
||||
|
||||
> Keep implementation details minimal; this file orients agents to project structure, constraints, and the data model.
|
||||
|
||||
---
|
||||
|
||||
## Goals (for agents)
|
||||
- Provide a responsive search UI over a large SQLite metadata DB entirely in-browser.
|
||||
- No server-side code; all queries run client-side.
|
||||
- Fast startup, predictable UX, minimal dependencies.
|
||||
|
||||
## Non-Goals
|
||||
- Hosting or streaming audio files.
|
||||
- Write access to the DB.
|
||||
- Complex build systems or frameworks.
|
||||
|
||||
---
|
||||
|
||||
## Repository Layout (expected)
|
||||
```
|
||||
|
||||
/
|
||||
+- index.html # Single-page app shell
|
||||
+- bulma.min.css # Bulma CSS (pinned)
|
||||
+- script.js # All app logic
|
||||
+- /assets/
|
||||
¦ +- mp3com-meta.sqlite # Metadata DB (read-only)
|
||||
+- AGENTS.md
|
||||
|
||||
````
|
||||
|
||||
- If subfolders are added later (icons, fonts), prefer `/assets/...`.
|
||||
- Do **not** introduce bundlers unless requested.
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Conventions
|
||||
- Use **Bulma** components (containers, navbar, form controls, tables, pagination).
|
||||
- Default to semantic HTML + Bulma classes; avoid inline styles.
|
||||
- Accessibility: ensure focus states, label–input associations, and ARIA for dynamic content.
|
||||
|
||||
---
|
||||
|
||||
## Data Access (Client-Side)
|
||||
- Use **SQLite compiled to WebAssembly** in the browser.
|
||||
- Prefer an **HTTP VFS** (range requests) to avoid fetching the entire DB up front.
|
||||
- Queries should be read-only; no schema migrations/run-time writes.
|
||||
|
||||
**Minimal runtime expectations**
|
||||
- One network fetch for the WASM runtime.
|
||||
- The DB file (`/assets/mp3com-meta.sqlite`) fetched lazily by page(s) as needed.
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (authoritative)
|
||||
|
||||
```sql
|
||||
PRAGMA foreign_keys=ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS artists(
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL COLLATE NOCASE UNIQUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS albums(
|
||||
id INTEGER PRIMARY KEY,
|
||||
artist_id INTEGER NOT NULL REFERENCES artists(id),
|
||||
title TEXT COLLATE NOCASE,
|
||||
year INTEGER,
|
||||
UNIQUE(artist_id, title, year)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tracks(
|
||||
id INTEGER PRIMARY KEY,
|
||||
artist_id INTEGER NOT NULL REFERENCES artists(id),
|
||||
album_id INTEGER REFERENCES albums(id),
|
||||
title TEXT NOT NULL COLLATE NOCASE,
|
||||
track_no INTEGER,
|
||||
year INTEGER,
|
||||
genre TEXT,
|
||||
duration_sec INTEGER,
|
||||
bitrate_kbps INTEGER,
|
||||
samplerate_hz INTEGER,
|
||||
channels INTEGER,
|
||||
filesize_bytes INTEGER,
|
||||
sha1 TEXT, -- optional; may be NULL
|
||||
relpath TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- External-content FTS5 (search over key text fields)
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS fts_tracks USING fts5(
|
||||
title, artist, album, genre,
|
||||
content='tracks', content_rowid='id',
|
||||
tokenize = 'unicode61 remove_diacritics 2',
|
||||
prefix='2 3 4'
|
||||
);
|
||||
|
||||
-- Keep FTS index in sync
|
||||
CREATE TRIGGER IF NOT EXISTS tracks_ai AFTER INSERT ON tracks BEGIN
|
||||
INSERT INTO fts_tracks(rowid,title,artist,album,genre)
|
||||
VALUES (new.id,
|
||||
new.title,
|
||||
(SELECT name FROM artists WHERE id=new.artist_id),
|
||||
(SELECT title FROM albums WHERE id=new.album_id),
|
||||
new.genre);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS tracks_ad AFTER DELETE ON tracks BEGIN
|
||||
INSERT INTO fts_tracks(fts_tracks, rowid) VALUES('delete', old.id);
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS tracks_au AFTER UPDATE ON tracks BEGIN
|
||||
INSERT INTO fts_tracks(fts_tracks, rowid) VALUES('delete', old.id);
|
||||
INSERT INTO fts_tracks(rowid,title,artist,album,genre)
|
||||
VALUES (new.id,
|
||||
new.title,
|
||||
(SELECT name FROM artists WHERE id=new.artist_id),
|
||||
(SELECT title FROM albums WHERE id=new.album_id),
|
||||
new.genre);
|
||||
END;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_artist_id ON tracks(artist_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks(album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tracks_year ON tracks(year);
|
||||
````
|
||||
|
||||
**Notes**
|
||||
|
||||
* Text search should use `fts_tracks MATCH ?` and join back to `tracks` for details.
|
||||
* Sorting for display can use `ORDER BY rank` (FTS) or `artist,title,year`.
|
||||
|
||||
---
|
||||
|
||||
## Example Queries (for agents)
|
||||
|
||||
* Free text:
|
||||
|
||||
```sql
|
||||
SELECT t.id, a.name AS artist, t.title, IFNULL(al.title,'') AS album, t.year, t.genre
|
||||
FROM fts_tracks f
|
||||
JOIN tracks t ON t.id=f.rowid
|
||||
JOIN artists a ON a.id=t.artist_id
|
||||
LEFT JOIN albums al ON al.id=t.album_id
|
||||
WHERE f MATCH ? -- e.g., 'queen "bohemian rhapsody"'
|
||||
ORDER BY rank LIMIT 50;
|
||||
```
|
||||
* By artist prefix (fast via FTS `prefix`):
|
||||
|
||||
```sql
|
||||
WHERE f MATCH 'artist:beatl*'
|
||||
```
|
||||
* Count by year:
|
||||
|
||||
```sql
|
||||
SELECT year, COUNT(*) FROM tracks GROUP BY year ORDER BY year;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
* Serve statically (to enable range requests and avoid file:// issues):
|
||||
|
||||
* Python: `python3 -m http.server 8000`
|
||||
* Node: `npx http-server -p 8000`
|
||||
* Open `http://localhost:8000/`
|
||||
* Ensure the server sends `Accept-Ranges: bytes` for `/assets/mp3com-meta.sqlite`.
|
||||
|
||||
---
|
||||
|
||||
## Performance Guidance
|
||||
|
||||
* Keep initial DOM minimal; render results incrementally (virtualized list if needed).
|
||||
* Debounce search input (e.g., 200–300 ms).
|
||||
* Use `LIMIT`/pagination; avoid SELECT \* on large result sets.
|
||||
* Cache prepared statements in JS if the WASM wrapper allows.
|
||||
|
||||
---
|
||||
|
||||
## Quality Bar
|
||||
|
||||
* No console errors.
|
||||
* Basic keyboard navigation works.
|
||||
* Layout adapts from mobile ? desktop via Bulma columns.
|
||||
* Reasonable query latency for common searches (<300ms after warm-up).
|
||||
|
||||
---
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
* No third-party trackers.
|
||||
* Only static file loads; no credentials.
|
||||
* If adding analytics, prefer privacy-preserving, self-hosted options and document them.
|
||||
|
||||
---
|
||||
|
||||
## How to Extend (if requested later)
|
||||
|
||||
* Autocomplete table (prebuilt) for artists/albums.
|
||||
* Sharded DBs by initial letter with a tiny manifest.
|
||||
* Export results (CSV) client-side.
|
||||
|
||||
---
|
||||
|
||||
## Agent Etiquette
|
||||
|
||||
* Do not introduce new build steps or frameworks unless explicitly asked.
|
||||
* Keep diffs small and focused.
|
||||
* When editing `index.html`/`script.js`, include inline comments explaining assumptions.
|
||||
* Verify changes against the schema above.
|
3
bulma.min.css
vendored
Normal file
3
bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
61
index.html
Normal file
61
index.html
Normal file
|
@ -0,0 +1,61 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MP3.com Metadata Browser</title>
|
||||
<link rel="stylesheet" href="./bulma.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Simple header (Bulma navbar not needed yet) -->
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<h1 class="title">MP3.com Metadata Browser</h1>
|
||||
<p class="subtitle">Client-side, static, zero-API search.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Loader/progress area: shown while DB is fetched/unpacked/initialized -->
|
||||
<section id="loader" class="section" aria-live="polite">
|
||||
<div class="container">
|
||||
<div class="box">
|
||||
<p id="loader-step" class="mb-2">Preparing…</p>
|
||||
<!-- Bulma progress bar: we update value/max via JS. No inline styles. -->
|
||||
<progress id="loader-progress" class="progress is-primary" value="0" max="100">0%</progress>
|
||||
<p id="loader-detail" class="is-size-7 has-text-grey">Starting…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Main app UI: hidden until DB is ready -->
|
||||
<section id="app" class="section" hidden>
|
||||
<div class="container">
|
||||
<div class="box">
|
||||
<form id="search-form" class="mb-4" role="search" aria-label="Track search">
|
||||
<div class="field">
|
||||
<label class="label" for="q">Search</label>
|
||||
<div class="control">
|
||||
<input id="q" name="q" class="input" type="search" placeholder="Search artist, title, album, genre…" disabled aria-disabled="true" />
|
||||
</div>
|
||||
<p class="help">Powered by in-browser SQLite FTS; no network queries.</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Results stub for now -->
|
||||
<div id="results" class="content">
|
||||
<p class="has-text-grey">Database not initialized yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Third-party libs loaded from CDNs (no trackers). Pinned versions. -->
|
||||
<!-- fflate: tiny ZIP library used to unzip the downloaded DB archive client-side. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js" crossorigin="anonymous"></script>
|
||||
<!-- sql.js (WASM SQLite) loader; WASM resolved via locateFile in script.js. -->
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
|
||||
<!-- App logic -->
|
||||
<script src="./script.js"></script>
|
||||
</body>
|
||||
</html>
|
224
script.js
Normal file
224
script.js
Normal file
|
@ -0,0 +1,224 @@
|
|||
/*
|
||||
Application bootstrap for the static, client-side metadata browser.
|
||||
- Downloads a zipped SQLite DB (db.zip) with progress and unzips it in-memory
|
||||
- Caches the uncompressed DB bytes in IndexedDB for reuse on next load
|
||||
- Loads sql.js (WASM) and opens the database from the cached bytes
|
||||
- Hides the loader and shows a stub UI once ready
|
||||
|
||||
Assumptions:
|
||||
- A ZIP archive is hosted at `/db.zip` containing a single `.sqlite` file.
|
||||
- We use `fflate` (UMD) from a CDN to unzip after download (keeps implementation minimal).
|
||||
- We use `sql.js` from a CDN; the WASM is located via `locateFile` (see initSql()).
|
||||
- We store raw DB bytes in IndexedDB (localStorage is too small for large DBs).
|
||||
*/
|
||||
|
||||
(function () {
|
||||
const DB_ZIP_URL = '/db.zip'; // Adjust if you relocate the archive (e.g., /assets/db.zip)
|
||||
const IDB_NAME = 'mp3com-meta-browser';
|
||||
const IDB_STORE = 'files';
|
||||
const IDB_KEY = 'mp3com-db-bytes-v1'; // bump if format changes
|
||||
|
||||
// UI elements
|
||||
const $loader = document.getElementById('loader');
|
||||
const $loaderStep = document.getElementById('loader-step');
|
||||
const $loaderDetail = document.getElementById('loader-detail');
|
||||
const $loaderProgress = document.getElementById('loader-progress');
|
||||
const $app = document.getElementById('app');
|
||||
const $q = document.getElementById('q');
|
||||
const $results = document.getElementById('results');
|
||||
|
||||
function setStep(text) {
|
||||
$loaderStep.textContent = text;
|
||||
}
|
||||
function setDetail(text) {
|
||||
$loaderDetail.textContent = text;
|
||||
}
|
||||
function setProgress(value, max) {
|
||||
if (typeof max === 'number' && max > 0) {
|
||||
$loaderProgress.max = max;
|
||||
$loaderProgress.value = value;
|
||||
const pct = Math.floor((value / max) * 100);
|
||||
$loaderProgress.textContent = pct + '%';
|
||||
} else {
|
||||
// Unknown total; use a simple textual fallback
|
||||
$loaderProgress.removeAttribute('max');
|
||||
$loaderProgress.value = 0;
|
||||
$loaderProgress.textContent = '…';
|
||||
}
|
||||
}
|
||||
|
||||
// --- IndexedDB helpers (minimal, no external deps) ---
|
||||
function idbOpen() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(IDB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE);
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
async function idbGet(key) {
|
||||
const db = await idbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(IDB_STORE, 'readonly');
|
||||
const req = tx.objectStore(IDB_STORE).get(key);
|
||||
req.onsuccess = () => resolve(req.result ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
async function idbSet(key, value) {
|
||||
const db = await idbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
const req = tx.objectStore(IDB_STORE).put(value, key);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Download and unzip db.zip ---
|
||||
async function fetchZipWithProgress(url) {
|
||||
setStep('Downloading database…');
|
||||
setDetail('Starting download');
|
||||
|
||||
const resp = await fetch(url, { cache: 'force-cache' });
|
||||
if (!resp.ok || !resp.body) throw new Error('Failed to fetch DB archive');
|
||||
const total = parseInt(resp.headers.get('content-length') || '0', 10) || 0;
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
setProgress(0, total || undefined);
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
received += value.byteLength;
|
||||
setProgress(received, total || undefined);
|
||||
if (total) setDetail(`${((received / total) * 100).toFixed(1)}% • ${formatBytes(received)} / ${formatBytes(total)}`);
|
||||
else setDetail(`${formatBytes(received)} downloaded`);
|
||||
}
|
||||
const zipBytes = concatUint8(chunks, received);
|
||||
return zipBytes;
|
||||
}
|
||||
|
||||
function concatUint8(chunks, totalLen) {
|
||||
const out = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
out.set(c, offset);
|
||||
offset += c.byteLength;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
let val = bytes;
|
||||
while (val >= 1024 && i < units.length - 1) {
|
||||
val /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
async function unzipSqlite(zipBytes) {
|
||||
setStep('Unpacking database…');
|
||||
setDetail('Decompressing ZIP');
|
||||
// fflate is provided globally as window.fflate
|
||||
const { unzipSync } = window.fflate || {};
|
||||
if (!unzipSync) throw new Error('Unzip library not loaded');
|
||||
const files = unzipSync(zipBytes); // returns { [name]: Uint8Array }
|
||||
// Heuristic: pick the first .sqlite (or first entry if none matches)
|
||||
const names = Object.keys(files);
|
||||
if (!names.length) throw new Error('Empty ZIP archive');
|
||||
let choice = names.find((n) => /\.sqlite$/i.test(n)) || names[0];
|
||||
const dbBytes = files[choice];
|
||||
setDetail(`Unpacked ${choice} • ${formatBytes(dbBytes.byteLength)}`);
|
||||
setProgress(100, 100);
|
||||
return dbBytes;
|
||||
}
|
||||
|
||||
// --- SQL.js init ---
|
||||
async function initSql() {
|
||||
// sql.js (UMD) exposes initSqlJs globally; WASM resolved via locateFile
|
||||
if (typeof initSqlJs !== 'function') throw new Error('sql.js not loaded');
|
||||
setStep('Initializing SQLite…');
|
||||
setDetail('Loading WASM');
|
||||
const SQL = await initSqlJs({
|
||||
// Pin to the same CDN base as the script tag above
|
||||
locateFile: (file) => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}`,
|
||||
});
|
||||
return SQL;
|
||||
}
|
||||
|
||||
// --- Main bootstrap ---
|
||||
(async function main() {
|
||||
try {
|
||||
// 1) Try cache first
|
||||
setStep('Checking cache…');
|
||||
setDetail('Looking for cached database');
|
||||
let dbBytes = await idbGet(IDB_KEY);
|
||||
if (dbBytes) {
|
||||
setStep('Using cached database');
|
||||
setDetail(`Found ${formatBytes(dbBytes.byteLength)} in IndexedDB`);
|
||||
setProgress(100, 100);
|
||||
} else {
|
||||
// 2) Fetch and unzip, then cache
|
||||
const zipBytes = await fetchZipWithProgress(DB_ZIP_URL);
|
||||
dbBytes = await unzipSqlite(zipBytes);
|
||||
setDetail('Caching database for future loads');
|
||||
await idbSet(IDB_KEY, dbBytes);
|
||||
}
|
||||
|
||||
// 3) Initialize SQL.js and open the DB
|
||||
const SQL = await initSql();
|
||||
setDetail('Opening database');
|
||||
const db = new SQL.Database(dbBytes);
|
||||
|
||||
// Very small sanity check (does not assume tables exist yet). If desired, we could
|
||||
// run `SELECT name FROM sqlite_master LIMIT 1` here. Keep minimal per project goals.
|
||||
|
||||
// 4) Reveal app UI
|
||||
$loader.hidden = true;
|
||||
$app.hidden = false;
|
||||
$q.disabled = false;
|
||||
$q.removeAttribute('aria-disabled');
|
||||
$results.innerHTML = '<p class="has-text-success">Database initialized. Stub UI ready.</p>';
|
||||
|
||||
// 5) Wire a minimal, no-op search stub (ready for future expansion)
|
||||
document.getElementById('search-form').addEventListener('submit', (e) => e.preventDefault());
|
||||
$q.addEventListener('input', () => {
|
||||
// Placeholder behavior; later this will issue FTS queries via db.prepare / db.exec
|
||||
const q = $q.value.trim();
|
||||
if (!q) {
|
||||
$results.innerHTML = '<p class="has-text-grey">Type to search…</p>';
|
||||
return;
|
||||
}
|
||||
$results.innerHTML = `<p class="has-text-grey">Search stub — ready for query: <code>${escapeHtml(q)}</code></p>`;
|
||||
});
|
||||
|
||||
// Expose db for future debugging in console (non-production convenience)
|
||||
window.__db = db;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setStep('Initialization failed');
|
||||
setDetail(String(err && err.message ? err.message : err));
|
||||
$loaderProgress.classList.remove('is-primary');
|
||||
$loaderProgress.classList.add('is-danger');
|
||||
}
|
||||
})();
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
})();
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue