13 KiB
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 small site stylesheet (
site.css
) for project‑specific tweaks (no frameworks) - 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)
-
site.css # Site-specific styles (tiny utilities like transitions)
-
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.
- Place any site-specific CSS (e.g., small transitions like fade utilities) in
site.css
. - 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)
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 totracks
for details. - Sorting for display can use
ORDER BY rank
(FTS) orartist,title,year
.
Example Queries (for agents)
Free text:
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
):
WHERE f MATCH 'artist:beatl*'
Count by year:
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
- Python:
- 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; avoidSELECT *
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.
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.
TODOs / UX Elements Roadmap
Overview
- Views are "base" (replace main content) or "overlay" (stacked dialog) via
UX.replace(...)
andUX.openOverlay(...)
inscript.js
. - Each view pairs an HTML template in
index.html
(e.g.,tpl-...
) with a creator inscript.js
(e.g.,create...View(db)
), returning{ kind, el, onShow?, destroy? }
. - Use Bulma form/table/pagination patterns. Keep DOM small; paginate and debounce queries.
Shared Tasks
- Add small table/list renderer util in
script.js
to build rows safely (usesescapeHtml
). Implemented viacreateTableRenderer
. - Add shared pagination component (Prev/Next, page size select). Propagate
LIMIT/OFFSET
. Implemented viacreatePagination
. - Add common keyboard handlers: Enter to open selection; Esc to close overlays (already wired globally). Implemented via shared
Keyboard
helper inscript.js
. - Add loading/empty-state helpers for lists. Implemented via
createAsyncListState
utility.
Primary Navigation (Hub)
tpl-nav
(base): Landing hub to choose "Search", "Browse Artists", "Browse Albums", "Browse Years", "Browse Genres", "Stats".createNavView(db)
: Buttons/cards triggerUX.replace(...)
to corresponding base views.- Accessibility: initial focus on first action; arrow-key navigation across items; visible focus states.
Search (Existing)
tpl-search
(base): Input is focusable; shows results area.- Implement query execution with FTS join; debounce 250 ms; paginate results. Wired into the new table + pagination helpers.
- 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 ? ORDER BY rank LIMIT ? OFFSET ?
. - Column sorts (toggle rank vs artist,title,year).
- Row activation opens Track overlay.
Browse Artists
tpl-browse-artists
(base): Alphabetical list, A–Z quick jump, mini filter box; paginated.createBrowseArtistsView(db)
: Loads pages; clicking row opens Artist overlay.- SQL:
SELECT id, name FROM artists ORDER BY name LIMIT ? OFFSET ?
. - Optional prefix search using FTS prefix (
WHERE f MATCH 'artist:abc*'
) to accelerate filter. Browse Artists view now favors FTS when filters include ≥2 characters.
Browse Albums
tpl-browse-albums
(base): List or grid with title, artist badge, year; paginated and sortable by artist/year/title.createBrowseAlbumsView(db)
: Clicking item opens Album overlay.- SQL:
SELECT al.id, al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id=al.artist_id ORDER BY a.name, al.year, al.title LIMIT ? OFFSET ?
.
Browse Years
tpl-browse-years
(base): Year histogram (counts) with list of tracks/albums when a year is selected; paginated.createBrowseYearsView(db)
: Selecting a year shows tracks; rows open Album/Track overlays.- SQL (counts):
SELECT year, COUNT(*) AS cnt FROM tracks WHERE year IS NOT NULL GROUP BY year ORDER BY year
. - SQL (tracks by year):
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title,'') AS album, t.genre FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.year=? ORDER BY a.name, t.title LIMIT ? OFFSET ?
.
Browse Genres
tpl-browse-genres
(base): Genre chips with counts → selecting shows paginated tracks.createBrowseGenresView(db)
: Genre list with counts; selecting lists tracks; rows open Track overlay.- SQL (counts):
SELECT genre, COUNT(*) AS cnt FROM tracks WHERE genre IS NOT NULL AND genre!='' GROUP BY genre ORDER BY cnt DESC, genre
. - SQL (tracks by genre):
SELECT t.id, t.title, a.name AS artist, IFNULL(al.title,'') AS album, t.year FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.genre=? ORDER BY a.name, t.title LIMIT ? OFFSET ?
.
Stats
tpl-stats
(base): Lightweight metrics (totals, top artists, year distribution) linking into browse views.createStatsView(db)
: Render summary cards; links navigate viaUX.replace(...)
with preselected filters.- SQL (examples): totals from
COUNT(*)
on artists/albums/tracks; top artists viaSELECT a.name, COUNT(*) cnt FROM tracks t JOIN artists a ON a.id=t.artist_id GROUP BY a.id ORDER BY cnt DESC LIMIT 20
.
Artist Overlay
tpl-artist
(overlay): Header: name + counts; tabs: Albums | Top Tracks.createArtistOverlay(db, artistId)
: Load artist name, counts, then tab content.- SQL (albums):
SELECT id, title, year FROM albums WHERE artist_id=? ORDER BY year, title
. - SQL (top tracks):
SELECT id, title, year, genre FROM tracks WHERE artist_id=? ORDER BY year, title LIMIT 100
. - Actions: clicking album opens Album overlay; clicking track opens Track overlay.
Album Overlay
tpl-album
(overlay): Header with album title, artist, year; tracklist table withtrack_no
,title
,duration_sec
,bitrate_kbps
.createAlbumOverlay(db, albumId)
: Load album+artist header; then tracklist.- SQL (header):
SELECT al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id=al.artist_id WHERE al.id=?
. - SQL (tracks):
SELECT id, track_no, title, duration_sec, bitrate_kbps FROM tracks WHERE album_id=? ORDER BY track_no, title
. - Row activation opens Track overlay.
Track Overlay
tpl-track
(overlay): Show title, artist, album, year, genre, duration, bitrate, samplerate, channels, filesize, sha1 (if present), andrelpath
with a Copy button.createTrackOverlay(db, trackId)
: Load detail from join; add Copy action forrelpath
.- SQL:
SELECT t.*, a.name AS artist, al.title AS album FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.id=?
.
Filters (Optional)
tpl-filters
(overlay): Advanced filters (year range, min bitrate, genre multi-select) applied to current base view.createFiltersOverlay(db, onApply)
: Applies constraints and refreshes the invoking view.
Help/Meta Overlays
tpl-keyboard-shortcuts
(overlay): " / focus search", "Esc close overlay", "j/k navigate" if list navigation is added.tpl-about
(overlay): About/help, privacy note.tpl-error
(overlay): Friendly error with retry; used by views on failure.
Implementation Notes
- Use template IDs in
index.html
and instantiate via existinginstantiateTemplate
helper. - Overlays must add
ux-view--overlay
class (done by UX manager) and include a close button that callsUX.closeTop()
. - Keep queries read-only; always
LIMIT ? OFFSET ?
for lists; avoidSELECT *
except for single-row detail. - Respect accessibility: label–input associations,
aria-live
only for async status, focus returned to opener on overlay close. - Performance: debounce search 200–300 ms; cache prepared statements if beneficial; do not pre-render large lists.
- UX manager now guards against race conditions during fades:
UX.replace
updatescurrentBase
before the fade-in completes and checks that the previous element is still connected before trying to animate it out.