summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md18
-rw-r--r--docs/MESHBAY_DESIGN.md1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js39
-rw-r--r--packages/meshbay-hub/tests/harness/music_untagged_probe.py283
-rw-r--r--packages/meshbay-hub/tests/test_music_untagged.py93
15 files changed, 439 insertions, 5 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 9aac263..694f7b2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -493,6 +493,24 @@ do. Read them before writing anything that touches the same mechanism.
viewport from the layout one), which was plausible, cost a round trip, and
was wrong; the screenshot showing no keyboard was already in hand.
+- **A view that draws one shape of unit, beside an emptiness test that counts
+ every shape.** The Music grid's unit is an album; a track whose artist tag is
+ empty belongs to none, so it was drawn nowhere — while `empty` counted it and
+ therefore stayed false. Neither "no music" nor anything else appeared: a
+ toolbar over a blank page, with every track one mode-switch away and nothing
+ saying so. Both halves read as correct alone, which is why no amount of
+ re-reading the file found it; what found it was rendering the component
+ against entries with no artist and looking (`music_untagged_probe.py`).
+ **Whenever a mode filters the units it draws, the emptiness test has to be
+ asked of that mode, not of the library** — or the two disagree and the
+ disagreement is silent. Fixed by giving the untagged pile a card of its own,
+ the same shape the singleton folding already minted, so the two agree by
+ construction rather than by a second condition someone has to maintain. That
+ card also stopped a cover lookup going out for a release name the browser
+ itself wrote: a third-party request, on the operator's connection, that
+ cannot match anything, seen in a live log naming a placeholder as both artist
+ and release
+
- **Three headers decide whether a page may frame itself, and they must agree.**
The same streamed download navigates a hidden iframe to `/_mbdl/<id>`.
`frame-src` was reCAPTCHA's two origins with no `'self'`, `frame-ancestors`
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index e3049a7..8baf984 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -3130,7 +3130,6 @@ process runs it — `systemctl --user` on Linux, Task Scheduler on Windows.
| **The relay registry** | **Closed in the code**: `relay.RELAYS_ENABLED` is False and every `/v1/relays` route answers 503, as federation does. Nothing in the tree calls them, node or client, and §11.1 measured two ISPs with no TURN relay needed. Kept code that nothing calls is what **L7** says not to keep; it stays only as the proof-of-possession design (**AV6**) until a node needs a relay or it is deleted |
| **Per-device revocation has no CLI** | A device is revoked over MNP (`roster.revoke_device`), from a device the node has already pinned. On a headless node the operator's only lever is `member unpin`, which removes **every** device of that account — so the per-device control the roster is built around is reachable from an interface and from nowhere else. §6.7 listed a `meshbay-node member device list\|revoke` verb that was never written, and that listing is how this was found: `USERGUIDE.md` was the first document written by reading the CLI rather than this specification, and the verb it copied out did not run |
| **Music is the one app whose index-time enrichment is not persisted** | `media_cache.db` holds `video_meta`, `photo_meta`, `file_tmdb`, `file_mbid` and `thumbs`; there is no audio table, so `artist`/`album`/`track_no` live only in the in-memory `IndexEntry`. Every node start re-reads every audio file's tags (measured: 6176 files, 6.4s of `mutagen` reads, ~25s with cover work) and until that pass lands the index it serves has no artist on any track — the window a client reconnecting to a just-restarted node arrives in. Cheap to read and therefore never noticed, right up until something reads the index during it |
-| **The album grid draws nothing, and says nothing, for untagged tracks** | `music-app.js` builds the grid's units from `albums` alone, while a track with no artist at all lands in `tracks` — which grid mode never draws. `empty` counts both, so it is false, and neither `music.empty` nor any other message appears: the toolbar sits over a blank page. A real library of untagged files reads as a broken tab, with no node restart involved, and switching to the flat list shows every one of them |
| **Migrations run on SQLite only** | The chain reaches head and agrees with the models there (§12), which is not where it ships. **The exposure is one revision deep, not the whole chain**: every revision behind the first packaged release was development that no installation ever ran, so nothing replays them on PostgreSQL. What is unguarded is the *next* migration — a default, an index type or a constraint PostgreSQL refuses reaches a deploy without the suite saying so |
---
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 2d925e1..aea1196 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -302,6 +302,7 @@ export default {
'music.mode_flat': 'Flache Liste',
'music.empty': 'Keine Musik gefunden.',
'music.unknown_album': 'Unbekanntes Album',
+ 'music.unknown_artist': 'Unbekannter Künstler',
'music.various': 'Verschiedenes',
'music.play_all': 'Alle abspielen',
'music.menu_more': 'Mehr…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 690a70b..d9172a6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -300,6 +300,7 @@ export default {
'music.mode_flat': 'Flat list',
'music.empty': 'No music found.',
'music.unknown_album': 'Unknown album',
+ 'music.unknown_artist': 'Unknown artist',
'music.various': 'Various',
'music.play_all': 'Play all',
'music.menu_more': 'More…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index c48f942..fc0e639 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -300,6 +300,7 @@ export default {
'music.mode_flat': 'Lista plana',
'music.empty': 'No se encontró música.',
'music.unknown_album': 'Álbum desconocido',
+ 'music.unknown_artist': 'Artista desconocido',
'music.various': 'Varios',
'music.play_all': 'Reproducir todo',
'music.menu_more': 'Más…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index 6d148d6..5eb8d60 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -301,6 +301,7 @@ export default {
'music.mode_flat': 'Liste à plat',
'music.empty': 'Aucune musique trouvée.',
'music.unknown_album': 'Album inconnu',
+ 'music.unknown_artist': 'Artiste inconnu',
'music.various': 'Divers',
'music.play_all': 'Tout lire',
'music.menu_more': 'Plus…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index c619d61..6d12cb3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -301,6 +301,7 @@ export default {
'music.mode_flat': 'Elenco semplice',
'music.empty': 'Nessuna musica trovata.',
'music.unknown_album': 'Album sconosciuto',
+ 'music.unknown_artist': 'Artista sconosciuto',
'music.various': 'Vari',
'music.play_all': 'Riproduci tutto',
'music.menu_more': 'Altro…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index eac90dc..f4ced88 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -298,6 +298,7 @@ export default {
'music.mode_flat': 'フラットリスト',
'music.empty': '音楽が見つかりません。',
'music.unknown_album': '不明なアルバム',
+ 'music.unknown_artist': '不明なアーティスト',
'music.various': 'その他',
'music.play_all': 'すべて再生',
'music.menu_more': 'その他…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 65bf4f5..31a2321 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -302,6 +302,7 @@ export default {
'music.mode_flat': 'Platte lijst',
'music.empty': 'Geen muziek gevonden.',
'music.unknown_album': 'Onbekend album',
+ 'music.unknown_artist': 'Onbekende artiest',
'music.various': 'Diversen',
'music.play_all': 'Alles afspelen',
'music.menu_more': 'Meer…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 264d442..463a6a9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -309,6 +309,7 @@ export default {
'music.mode_flat': 'Lista płaska',
'music.empty': 'Nie znaleziono muzyki.',
'music.unknown_album': 'Nieznany album',
+ 'music.unknown_artist': 'Nieznany wykonawca',
'music.various': 'Różne',
'music.play_all': 'Odtwórz wszystko',
'music.menu_more': 'Więcej…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 0fbfb1e..20822ee 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -302,6 +302,7 @@ export default {
'music.mode_flat': 'Lista simples',
'music.empty': 'Nenhuma música encontrada.',
'music.unknown_album': 'Álbum desconhecido',
+ 'music.unknown_artist': 'Artista desconhecido',
'music.various': 'Diversos',
'music.play_all': 'Reproduzir tudo',
'music.menu_more': 'Mais…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index ab75b11..b2d7591 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -295,6 +295,7 @@ export default {
'music.mode_flat': '平铺列表',
'music.empty': '未找到音乐。',
'music.unknown_album': '未知专辑',
+ 'music.unknown_artist': '未知艺术家',
'music.various': '其他',
'music.play_all': '全部播放',
'music.menu_more': '更多…',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
index e0308cb..1dbecbb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -256,7 +256,13 @@ function AlbumCard({ album, transportRef, gekRef, musicbrainzEnabled, onOpen, on
const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0];
const tRef = repTrack._tRef || transportRef;
const gRef = repTrack._gRef || gekRef;
- const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash;
+ // Never for an album this file minted itself (`isUnknown`): the release
+ // name is one we wrote -- '<artist> - Various', or the untagged pile
+ // below -- so the lookup is a third-party request, on the operator's
+ // connection, that cannot match anything. Confirmed in a node's log:
+ // queries went out naming a placeholder as both the artist and the
+ // release, once per card.
+ const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash && !album.isUnknown;
const meta = useMusicMeta(tRef, repTrack.id, needsLookup);
const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null;
@@ -287,7 +293,7 @@ function MusicDetailModal({ album, transportRef, gekRef, musicbrainzEnabled, onC
const repTrack = album.tracks.find((tr) => tr.thumb_hash) || album.tracks[0];
const tRef = repTrack._tRef || transportRef;
const gRef = repTrack._gRef || gekRef;
- const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash;
+ const needsLookup = musicbrainzEnabled && !repTrack.thumb_hash && !album.isUnknown;
const meta = useMusicMeta(tRef, repTrack.id, needsLookup);
const coverHash = repTrack.thumb_hash || (meta && meta.cover_thumb_hash) || null;
@@ -672,17 +678,39 @@ function MusicApp({
const filteredTracks = useMemo(() => (!needle ? tracks : tracks.filter(
(tr) => (tr.display_title || tr.name).toLowerCase().includes(needle))), [tracks, needle]);
+ // A track whose artist tag is empty *and* whose folder gave nothing to fall
+ // back on. The flat list has always drawn these as its own top-level rows;
+ // the grid, whose unit is an album, drew them nowhere at all -- and `empty`
+ // below counts them, so it stayed false and no message appeared either. A
+ // library nothing has tagged therefore rendered a toolbar over a blank page,
+ // with every one of its tracks one mode-switch away and nothing saying so.
+ // Reported after a node restart, where the index is briefly served without
+ // the tags it re-reads at start-up, and true of a genuinely untagged library
+ // with no restart involved.
+ //
+ // One card, the same shape the singleton folding above already mints for an
+ // artist's leftovers: it names what it is, and the tracks are playable from
+ // it. Not a card each -- that is the wall of one-track tiles this file
+ // exists to avoid -- and not sorted in among the artists, because it is not
+ // a name anybody chose and the alphabet is no place for it.
+ const untagged = useMemo(() => (filteredTracks.length
+ ? { artist: t('music.unknown_artist'), album: t('music.unknown_album'),
+ isUnknown: true, tracks: filteredTracks }
+ : null), [filteredTracks]);
+
// What this mode draws, in drawing order: albums under their artists in the
// grid, and in the flat list loose tracks and artist folders sorted together.
const units = useMemo(() => {
if (mode === 'grid') {
- return filteredArtists.flatMap((a) => a.albums.map((album) => ({ artist: a.artist, album })));
+ const byArtist = filteredArtists.flatMap(
+ (a) => a.albums.map((album) => ({ artist: a.artist, album })));
+ return untagged ? [...byArtist, { artist: untagged.artist, album: untagged }] : byArtist;
}
return [
...filteredTracks.map((tr) => ({ key: tr.display_title || tr.name, kind: 'track', track: tr })),
...filteredArtists.map((a) => ({ key: a.artist, kind: 'artist', artist: a })),
].sort((a, b) => a.key.localeCompare(b.key));
- }, [mode, filteredArtists, filteredTracks]);
+ }, [mode, filteredArtists, filteredTracks, untagged]);
const pager = usePager(units.length, pageSizeFrom(userPrefs),
`${groupId}|${mode}|${needle}|${pageResetKey || ''}`);
@@ -690,6 +718,9 @@ function MusicApp({
return units.slice(pager.start, pager.end);
}, [units, pager.start, pager.end]);
+ // True exactly when the library has nothing, and — now that every track
+ // reaches a card — exactly when the grid has nothing to draw either. The two
+ // used to disagree, which is the whole of the defect above.
const empty = albums.length === 0 && tracks.length === 0;
return html`
diff --git a/packages/meshbay-hub/tests/harness/music_untagged_probe.py b/packages/meshbay-hub/tests/harness/music_untagged_probe.py
new file mode 100644
index 0000000..f4c484c
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/music_untagged_probe.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+"""
+What the Music tab draws when a track carries no artist tag.
+
+The grid's unit is an album, and a track with no artist at all belongs to
+none — so it was drawn nowhere, while `empty` counted it and therefore stayed
+false. A library nothing has tagged rendered a toolbar over a blank page with
+no message, every one of its tracks reachable only by switching to the flat
+list and nothing on screen saying so.
+
+Reading `music-app.js` does not show this: both halves are correct on their own
+and the fault is that they disagree about what "nothing" means. So this renders
+the shipped `GroupPage` against a stub node and reads back what is actually on
+the page — cards, messages, and the rows the flat list draws for the same
+entries.
+
+ music_untagged_probe.py
+"""
+
+import http.server
+import json
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+PORT = 8759
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+# (name, tagged albums of three tracks, tracks with no artist at all,
+# one-track albums under a single artist)
+CASES = [
+ # The reported shape: a node serving an index it has not re-read the tags
+ # for yet, and a genuinely untagged library, are the same page.
+ ("nothing tagged", 0, 7, 0),
+ # The ordinary shape — the pile must not displace real albums.
+ ("some tagged", 3, 4, 0),
+ # Nothing at all: the one case that really is empty, and must say so.
+ ("no audio", 0, 0, 0),
+ # The *other* album this file invents: one artist's one-track albums, folded
+ # into a single "<artist> - Various" card. A real release name nowhere, so
+ # a cover lookup for it cannot match — and this case is the only one that
+ # can tell that gate from a card that was simply never drawn.
+ ("singletons only", 0, 0, 3),
+]
+
+FRAME = r"""<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>
+<nav class="nav"><div class="nav-left"><a class="nav-brand" href="#/">MeshBay</a></div></nav>
+<div class="layout"><main class="main"><div id="root"></div></main></div>
+<script>
+const ENTRIES = [];
+let n = 0;
+// Tagged: three tracks per album, so none of them folds into the singleton
+// pile and each is a card of its own.
+for (let a = 1; a <= %(albums)d; a++) {
+ for (let i = 0; i < 3; i++) {
+ ENTRIES.push({ id: 'a' + (++n), name: (i + 1) + ' - t.flac',
+ display_title: 'disque ' + a + ' ' + (i + 1),
+ path: 'musique/artiste ' + a + '/disque ' + a, type: 'audio',
+ artist: 'artiste ' + a, album: 'disque ' + a, track_no: i + 1,
+ duration: 200, size: 1024, added_at: 1750000000 + n });
+ }
+}
+// One artist, several albums of one track each: the folding above turns these
+// into a single "<artist> - Various" card whose name this file wrote.
+for (let i = 1; i <= %(singles)d; i++) {
+ ENTRIES.push({ id: 's' + (++n), name: 'unique ' + i + '.flac',
+ display_title: 'unique ' + i, path: 'musique/solo/album ' + i, type: 'audio',
+ artist: 'solo', album: 'album ' + i, track_no: 1,
+ duration: 200, size: 1024, added_at: 1750000000 + n });
+}
+// Untagged: no artist, no album, and sitting straight under the configured
+// folder so the node's own ancestor walk would have had nothing to offer
+// either. `display_title` is what the flat list shows.
+for (let i = 0; i < %(loose)d; i++) {
+ ENTRIES.push({ id: 'u' + (++n), name: 'piste ' + (i + 1) + '.flac',
+ display_title: 'piste ' + (i + 1), path: 'musique', type: 'audio',
+ duration: 200, size: 1024, added_at: 1750000000 + n });
+}
+
+const ACK = {
+ is_node_admin: false,
+ enabled_apps: ['files', 'music'],
+ tmdb_enabled: false, musicbrainz_enabled: true,
+ video_directories: [], music_directories: ['musique'], photo_directories: [],
+};
+
+// Counted, not stubbed away: a cover lookup for an album this page invented is
+// a third-party request on the operator's connection that cannot match
+// anything, and the count is the only way to say it did not happen.
+let musicMetaCalls = 0;
+window.MeshBayTransport = function () {
+ const self = {
+ connected: false, memberRole: 'member', supportsAppOps: true,
+ sessionKeys: null, gekRaw: null,
+ newNodeBundle: null, newNodeBundleRecovery: null,
+ async connect() { self.connected = true; return ACK; },
+ async fetchIndex() {
+ return { entries: ENTRIES, dirs: ['musique'],
+ roots: [{ name: 'musique', available: true, writable: false,
+ removable: false }] };
+ },
+ async fetchMusicMeta() { musicMetaCalls += 1; return { confidence: 0 }; },
+ async fetchChatHistory() { return { messages: [], hasMore: false }; },
+ async fetchLinkPreview() { return { ok: false }; },
+ addReconnectListener() { return () => {}; },
+ close() {},
+ };
+ return new Proxy(self, {
+ get(target, prop) {
+ if (prop in target) return target[prop];
+ if (typeof prop === 'string' && prop.startsWith('on')) return undefined;
+ if (typeof prop === 'symbol') return undefined;
+ return () => new Promise(() => {});
+ },
+ set(target, prop, value) { target[prop] = value; return true; },
+ });
+};
+</script>
+<script type="module">
+import { html, render } from '/vendor/htm-preact.js';
+import { initLocale } from '/i18n.js';
+import { GroupPage } from '/group-page.js';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// The view mode is a per-device convenience in localStorage; a case that
+// inherits the previous one's would measure whichever ran first.
+try { localStorage.removeItem('meshbay_music_view_mode'); } catch {}
+
+const read = () => ({
+ cards: [...document.querySelectorAll('.music-card')].map((c) => ({
+ title: c.querySelector('.music-card-title').textContent,
+ sub: c.querySelector('.music-card-sub').textContent,
+ })),
+ // Every message the panel can put up instead of content.
+ messages: [...document.querySelectorAll('.page-message')].map((p) => p.textContent.trim()),
+ toolbar: !!document.querySelector('.video-toolbar'),
+});
+
+(async () => {
+ const fail = (why) => parent.postMessage(
+ { case: %(index)d, error: why,
+ text: (document.getElementById('root').textContent || '').slice(0, 400) }, '*');
+ try {
+ await initLocale();
+ render(html`<${GroupPage} groupId="g1" token="t" username="me" userId="u1"
+ group=${{ id: 'g1', name: 'un groupe', owner_username: 'me', is_admin: false }}
+ userPrefs=${{ default_tab: 'music', media_page_size: '50' }} />`,
+ document.getElementById('root'));
+
+ // Tiles mount on intersection; walk the page as a reader would.
+ for (let i = 0; i < 40; i++) {
+ scrollTo(0, document.documentElement.scrollHeight);
+ await sleep(60);
+ if (document.querySelector('.music-card') || document.querySelector('.page-message')) break;
+ }
+ scrollTo(0, 0);
+ await sleep(400);
+ const grid = read();
+
+ // The same entries in the flat list, which is where these tracks were
+ // always reachable — the point is that the grid now reaches them too, not
+ // that the list stopped.
+ const buttons = [...document.querySelectorAll('.video-toolbar .tb-btn')];
+ if (buttons[1]) { buttons[1].click(); await sleep(400); }
+ const flatRows = document.querySelectorAll('.video-flat-list .video-flat-row').length;
+ // An untagged track is a top-level row in the flat list, not a folder.
+ const flatTracks = document.querySelectorAll('.video-flat-list .music-flat-track').length;
+
+ parent.postMessage({
+ case: %(index)d,
+ ...grid,
+ flatRows,
+ flatTracks,
+ musicMetaCalls,
+ }, '*');
+ } catch (err) {
+ fail(String((err && err.stack) || err));
+ }
+})();
+</script></body></html>"""
+
+PAGE = r"""<!doctype html><html><head><meta charset=utf-8></head>
+<body style="margin:0"><div id="frames"></div><script>
+const N = %(count)d;
+const seen = [];
+addEventListener('message', (e) => {
+ seen.push(e.data);
+ if (seen.length === N) fetch('/log', { method: 'POST', body: JSON.stringify(seen) });
+});
+// One at a time: every case clears the same localStorage key on start-up, and
+// two frames doing that at once measure each other.
+let i = 0;
+const next = () => {
+ if (i >= N) return;
+ const f = document.createElement('iframe');
+ f.src = '/case?n=' + (i++);
+ f.style.cssText = 'width:1100px;height:800px;border:0;display:block';
+ document.getElementById('frames').appendChild(f);
+};
+addEventListener('message', () => next());
+next();
+</script></body></html>"""
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length") or 0)
+ if self.path == "/log":
+ RECORDS.extend(json.loads(self.rfile.read(length).decode()))
+ else:
+ self.rfile.read(length)
+ self.send_response(204)
+ self.end_headers()
+
+ def _send(self, body: bytes, ctype: str) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self):
+ path = self.path.split("?")[0]
+ if path == "/":
+ self._send((PAGE % {"count": len(CASES)}).encode(), "text/html; charset=utf-8")
+ elif path == "/case":
+ n = int(self.path.split("n=")[1])
+ _, albums, loose, singles = CASES[n]
+ body = FRAME % {"index": n, "albums": albums, "loose": loose,
+ "singles": singles}
+ self._send(body.encode(), "text/html; charset=utf-8")
+ elif path == "/v1/groups/g1/nodes":
+ self._send(b'{"nodes": [{"node_id": "n1"}]}', "application/json")
+ else:
+ asset = (STATIC / path.lstrip("/")).resolve()
+ if not str(asset).startswith(str(STATIC)) or not asset.is_file():
+ self.send_response(404)
+ self.end_headers()
+ return
+ self._send(asset.read_bytes(),
+ "text/css" if asset.suffix == ".css"
+ else "text/javascript" if asset.suffix == ".js"
+ else "application/octet-stream")
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as profile:
+ proc = subprocess.Popen(
+ ["google-chrome", "--headless=new", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,900",
+ f"http://127.0.0.1:{PORT}/"],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ deadline = time.time() + 120
+ while len(RECORDS) < len(CASES) and time.time() < deadline:
+ time.sleep(0.2)
+ proc.terminate()
+ proc.wait(timeout=20)
+ if len(RECORDS) < len(CASES):
+ print(f"only {len(RECORDS)} of {len(CASES)} cases reported", file=sys.stderr)
+ print(json.dumps(RECORDS, indent=1), file=sys.stderr)
+ return 1
+ out = []
+ for rec in sorted(RECORDS, key=lambda r: r["case"]):
+ out.append({**rec, "name": CASES[rec["case"]][0]})
+ print(json.dumps(out))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_music_untagged.py b/packages/meshbay-hub/tests/test_music_untagged.py
new file mode 100644
index 0000000..b27b6a3
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_music_untagged.py
@@ -0,0 +1,93 @@
+"""
+A track with no artist tag is still music, and the grid has to say so.
+
+The album grid drew `albums` and nothing else, so a track with no artist at all
+— which belongs to no album — appeared nowhere in it. `empty` counted those
+tracks, so it stayed false and no message was drawn either: a library nothing
+has tagged rendered a toolbar over a blank page, with every one of its tracks a
+mode-switch away and nothing on screen saying so. Found after a node restart,
+where the index is briefly served before its tags have been re-read, and true
+of a genuinely untagged library with no restart involved.
+
+Measured rather than read: both halves of `music-app.js` are correct on their
+own, and the fault is that they disagree about what "nothing" means.
+"""
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "music_untagged_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "music-app.js").exists(),
+ reason="Chrome or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def cases():
+ run = subprocess.run(["python3", str(HARNESS)], capture_output=True, timeout=240)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ return {c["name"]: c for c in json.loads(run.stdout.decode())}
+
+
+def test_no_case_draws_a_blank_panel(cases):
+ """The bug, stated once: a toolbar with nothing under it and nothing said."""
+ for name, case in cases.items():
+ assert "error" not in case, f"{name}: {case.get('error')}"
+ assert case["cards"] or case["messages"], (
+ f"{name}: the panel drew neither a card nor a message")
+
+
+def test_untagged_tracks_reach_the_grid(cases):
+ case = cases["nothing tagged"]
+ assert len(case["cards"]) == 1, "seven untagged tracks are one card, not seven"
+ assert case["messages"] == [], "there is music here — saying otherwise is the old lie"
+
+
+def test_the_card_names_what_it_is(cases):
+ """Not a real release, and it must not read like one."""
+ card = cases["nothing tagged"]["cards"][0]
+ assert card["title"] and card["sub"]
+ assert card["title"] != card["sub"]
+
+
+def test_the_pile_does_not_displace_real_albums(cases):
+ case = cases["some tagged"]
+ assert len(case["cards"]) == 4, "three tagged albums and one pile"
+ # Last, not sorted in among the artists: it is not a name anybody chose.
+ assert case["cards"][-1]["sub"] == cases["nothing tagged"]["cards"][0]["sub"]
+ assert [c["title"] for c in case["cards"][:3]] == ["disque 1", "disque 2", "disque 3"]
+
+
+def test_an_empty_library_still_says_so(cases):
+ """The one case that really is empty. Widening `empty` would have broken this."""
+ case = cases["no audio"]
+ assert case["cards"] == []
+ assert len(case["messages"]) == 1
+
+
+def test_the_flat_list_still_lists_every_untagged_track(cases):
+ """The grid reaching them must not cost the list what it always drew."""
+ assert cases["nothing tagged"]["flatTracks"] == 7
+ assert cases["some tagged"]["flatTracks"] == 4
+ assert cases["some tagged"]["flatRows"] == 3, "the three tagged artists, as folders"
+
+
+def test_no_cover_is_looked_up_for_an_album_this_page_invented(cases):
+ """
+ A third-party request, on the operator's connection, that cannot match
+ anything: the release name is one the browser wrote. Seen in a live node's
+ log, going out with a placeholder as both artist and release.
+ """
+ # The discriminating one: this card is drawn with or without the fix, so it
+ # is the only case where the count says anything about the gate itself.
+ assert cases["singletons only"]["cards"], "the folded card must still be drawn"
+ assert cases["singletons only"]["musicMetaCalls"] == 0
+
+ assert cases["nothing tagged"]["musicMetaCalls"] == 0
+ assert cases["some tagged"]["musicMetaCalls"] == 3, (
+ "the three real albums are still looked up — only the invented ones are not")