aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-24 19:46:06 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-24 19:46:06 +0200
commit62253b9592a83ce152d0c64471e20514218fb132 (patch)
treed41b84661da3252045937f5a90dc45ca7b029edc
parentd053d083627f1f9f010752f8ad67941e22d49f27 (diff)
downloadmeshbay-62253b9592a83ce152d0c64471e20514218fb132.tar.gz
feat(hub): consolidate loose tracks, "&"/"and" fold, player close/queue
Album-grid readability, part two: - groupMusicEntries (music-app.js): an album bucket left with exactly one track - a real album tag, but only one song from it, not the whole release - clutters the grid the same way an untagged loose track does. Both kinds now fold into one "<artist> - Various" tile per artist, unless there is only one leftover track overall, where relabeling buys nothing and the track keeps its own name (or the generic placeholder, if it never had one). - foldKey also normalizes "&" vs "and" ("Artist & The Band" / "Artist and The Band" is one act, tagged both ways across different rips of the same catalogue) alongside the existing case/whitespace fold. - music-player.js: a close button pauses and tears the player down; an unmount cleanup effect (pause, revoke every cached blob URL) fires either way, whether that's the close button or the shell tearing the bar down on its own. A "current queue" button opens an overlay listing the whole playing queue with the current track highlighted, click any to jump to it - works identically regardless of how the queue was built (an album, the consolidated misc bucket, a single standalone track), since it only ever reads the player's own live tracks/order/pos. - group-page.js: this component is not remounted when switching to a *different* group on the same /group/:id route (only the groupId prop changes) - so without an explicit reset, music from one group would carry into the next one opened. Resets musicQueue to null on groupId change; a tab switch inside one group still leaves it alone. - Scrubbed real artist/band names that had leaked into code comments and test fixtures (enrich_audio.py's docstrings, several test_enrich_audio.py assertions, a music-app.js comment) - replaced with generic placeholders, no behavioural change. - i18n: music.various, music.player_close, music.player_queue, music.queue_title added across all ten locales. Client-side only except none of this touches the node at all. npm run sync-ui re-run. Full suite: 1129 passed, no regressions. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KBi7ALLGfwcjBXt57yNMcy
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app.js56
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-player.js66
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css3
-rw-r--r--packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py15
-rw-r--r--packages/meshbay-node/tests/test_enrich_audio.py39
16 files changed, 188 insertions, 45 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
index 01ab4e0..d2d56b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js
@@ -99,6 +99,12 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
const onPlayQueue = useCallback((tracks, startIndex) => {
setMusicQueue({ tracks, startIndex, nonce: Date.now() });
}, []);
+ // This component instance is not remounted when switching to a *different*
+ // group on the same /group/:id route (only groupId as a prop changes, see
+ // the connect effect's own comment below) — so leaving music playing here
+ // would carry it into whatever group is opened next. Tab switches inside
+ // one group must not stop it; leaving the group itself must.
+ useEffect(() => { setMusicQueue(null); }, [groupId]);
// Paired ≠ operator account. `is_node_admin` says the hub account owning this
// node is the one connecting; this says the node pinned *this browser's* key
// as an operator key. Only the second one lets you sign an invite, and only
@@ -595,9 +601,13 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs,
once something has been played this session, the bar stays
mounted and keeps playing regardless of which tab is active —
switching to Chat or Files must not stop the music. Renders
- nothing of its own until onPlayQueue has been called once. */
+ nothing of its own until onPlayQueue has been called once.
+ Its own close button, and the effect above on leaving the
+ group, both go through the same setMusicQueue(null) — the
+ bar's own unmount cleanup is what actually stops playback. */
musicQueue && html`
- <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue} />
+ <${MusicPlayerBar} transportRef=${transportRef} gekRef=${gekRef} queue=${musicQueue}
+ onClose=${() => setMusicQueue(null)} />
`}
</div>
`;
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 cb542a6..70d2b95 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -182,6 +182,7 @@ export default {
'music.mode_flat': 'Flache Liste',
'music.empty': 'Keine Musik gefunden.',
'music.unknown_album': 'Unbekanntes Album',
+ 'music.various': 'Verschiedenes',
'music.play_all': 'Alle abspielen',
'music.n_tracks': {
one: '{n} Titel',
@@ -197,6 +198,9 @@ export default {
'music.player_repeat_all': 'Alle wiederholen',
'music.player_repeat_one': 'Einzelnen wiederholen',
'music.player_volume': 'Lautstärke',
+ 'music.player_close': 'Player schließen',
+ 'music.player_queue': 'Aktuelle Wiedergabeliste',
+ 'music.queue_title': 'Wird wiedergegeben',
// LAN-Cast
'cast.start': 'Auf Gerät übertragen',
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 e05b398..0ee0972 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -180,6 +180,7 @@ export default {
'music.mode_flat': 'Flat list',
'music.empty': 'No music found.',
'music.unknown_album': 'Unknown album',
+ 'music.various': 'Various',
'music.play_all': 'Play all',
'music.n_tracks': {
one: '{n} track',
@@ -195,6 +196,9 @@ export default {
'music.player_repeat_all': 'Repeat all',
'music.player_repeat_one': 'Repeat one',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Close player',
+ 'music.player_queue': 'Current queue',
+ 'music.queue_title': 'Playing now',
// LAN cast
'cast.start': 'Cast to device',
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 113ae7a..69e2687 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -180,6 +180,7 @@ export default {
'music.mode_flat': 'Lista plana',
'music.empty': 'No se encontró música.',
'music.unknown_album': 'Álbum desconocido',
+ 'music.various': 'Varios',
'music.play_all': 'Reproducir todo',
'music.n_tracks': {
one: '{n} pista',
@@ -195,6 +196,9 @@ export default {
'music.player_repeat_all': 'Repetir todo',
'music.player_repeat_one': 'Repetir una',
'music.player_volume': 'Volumen',
+ 'music.player_close': 'Cerrar reproductor',
+ 'music.player_queue': 'Cola actual',
+ 'music.queue_title': 'Reproduciendo ahora',
// LAN cast
'cast.start': 'Enviar a dispositivo',
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 6a57174..8d52d37 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -181,6 +181,7 @@ export default {
'music.mode_flat': 'Liste à plat',
'music.empty': 'Aucune musique trouvée.',
'music.unknown_album': 'Album inconnu',
+ 'music.various': 'Divers',
'music.play_all': 'Tout lire',
'music.n_tracks': {
one: '{n} piste',
@@ -196,6 +197,9 @@ export default {
'music.player_repeat_all': 'Tout répéter',
'music.player_repeat_one': 'Répéter le morceau',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Fermer le lecteur',
+ 'music.player_queue': 'File en cours',
+ 'music.queue_title': 'En cours de lecture',
// LAN cast
'cast.start': 'Diffuser sur un appareil',
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 257318a..dfd4bbc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -181,6 +181,7 @@ export default {
'music.mode_flat': 'Elenco semplice',
'music.empty': 'Nessuna musica trovata.',
'music.unknown_album': 'Album sconosciuto',
+ 'music.various': 'Vari',
'music.play_all': 'Riproduci tutto',
'music.n_tracks': {
one: '{n} traccia',
@@ -196,6 +197,9 @@ export default {
'music.player_repeat_all': 'Ripeti tutto',
'music.player_repeat_one': 'Ripeti brano',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Chiudi lettore',
+ 'music.player_queue': 'Coda attuale',
+ 'music.queue_title': 'In riproduzione',
// LAN cast
'cast.start': 'Trasmetti al dispositivo',
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 44f9037..10d2088 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -178,6 +178,7 @@ export default {
'music.mode_flat': 'フラットリスト',
'music.empty': '音楽が見つかりません。',
'music.unknown_album': '不明なアルバム',
+ 'music.various': 'その他',
'music.play_all': 'すべて再生',
'music.n_tracks': {
one: '{n}曲',
@@ -193,6 +194,9 @@ export default {
'music.player_repeat_all': 'すべてリピート',
'music.player_repeat_one': '1曲リピート',
'music.player_volume': '音量',
+ 'music.player_close': 'プレーヤーを閉じる',
+ 'music.player_queue': '再生中のキュー',
+ 'music.queue_title': '再生中',
// LAN cast
'cast.start': 'デバイスにキャスト',
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 2fedd70..3342559 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -182,6 +182,7 @@ export default {
'music.mode_flat': 'Platte lijst',
'music.empty': 'Geen muziek gevonden.',
'music.unknown_album': 'Onbekend album',
+ 'music.various': 'Diversen',
'music.play_all': 'Alles afspelen',
'music.n_tracks': {
one: '{n} nummer',
@@ -197,6 +198,9 @@ export default {
'music.player_repeat_all': 'Alles herhalen',
'music.player_repeat_one': 'Nummer herhalen',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Speler sluiten',
+ 'music.player_queue': 'Huidige wachtrij',
+ 'music.queue_title': 'Nu aan het afspelen',
// LAN cast
'cast.start': 'Naar apparaat casten',
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 dac2359..1b52a8b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -189,6 +189,7 @@ export default {
'music.mode_flat': 'Lista płaska',
'music.empty': 'Nie znaleziono muzyki.',
'music.unknown_album': 'Nieznany album',
+ 'music.various': 'Różne',
'music.play_all': 'Odtwórz wszystko',
'music.n_tracks': {
one: '{n} utwór',
@@ -206,6 +207,9 @@ export default {
'music.player_repeat_all': 'Powtórz wszystko',
'music.player_repeat_one': 'Powtórz utwór',
'music.player_volume': 'Głośność',
+ 'music.player_close': 'Zamknij odtwarzacz',
+ 'music.player_queue': 'Aktualna kolejka',
+ 'music.queue_title': 'Teraz odtwarzane',
// LAN cast
'cast.start': 'Przesyłaj na urządzenie',
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 4251a4b..1b8c50c 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
@@ -182,6 +182,7 @@ export default {
'music.mode_flat': 'Lista simples',
'music.empty': 'Nenhuma música encontrada.',
'music.unknown_album': 'Álbum desconhecido',
+ 'music.various': 'Diversos',
'music.play_all': 'Reproduzir tudo',
'music.n_tracks': {
one: '{n} faixa',
@@ -197,6 +198,9 @@ export default {
'music.player_repeat_all': 'Repetir tudo',
'music.player_repeat_one': 'Repetir faixa',
'music.player_volume': 'Volume',
+ 'music.player_close': 'Fechar player',
+ 'music.player_queue': 'Fila atual',
+ 'music.queue_title': 'Tocando agora',
// LAN cast
'cast.start': 'Transmitir para dispositivo',
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 7e7676b..5cc6cb3 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
@@ -175,6 +175,7 @@ export default {
'music.mode_flat': '平铺列表',
'music.empty': '未找到音乐。',
'music.unknown_album': '未知专辑',
+ 'music.various': '其他',
'music.play_all': '全部播放',
'music.n_tracks': {
one: '{n} 首曲目',
@@ -190,6 +191,9 @@ export default {
'music.player_repeat_all': '全部重复',
'music.player_repeat_one': '单曲重复',
'music.player_volume': '音量',
+ 'music.player_close': '关闭播放器',
+ 'music.player_queue': '当前队列',
+ 'music.queue_title': '正在播放',
// LAN cast
'cast.start': '投射到设备',
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 87e1319..7e16b53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app.js
@@ -36,13 +36,15 @@ function saveViewMode(mode) {
// -- grouping -------------------------------------------------------------
-// Two tags differing only in case ("Racing With The Sun" vs "Racing with
-// the sun" -- found live, a single mistagged track split one real album
-// into two cards) are the same artist/album for grouping purposes. Folded
-// for the *key* only; the first-seen spelling is kept as the display
-// string, so this never rewrites anyone's tags.
+// Two tags differing only in case (found live: a single mistagged track
+// split one real album into two cards) are the same artist/album for
+// grouping purposes. Same for an "&" vs "and" spelling of the same act,
+// tagged both ways across different rips of the same catalogue. Folded for
+// the *key* only; the first-seen spelling is kept as the display string, so
+// this never rewrites anyone's tags.
function foldKey(s) {
- return (s || '').trim().replace(/\s+/g, ' ').toLowerCase();
+ return (s || '').trim().replace(/\s+/g, ' ').toLowerCase()
+ .replace(/\s*&\s*/g, ' and ').replace(/\s+/g, ' ').trim();
}
function groupMusicEntries(entries) {
@@ -74,18 +76,42 @@ function groupMusicEntries(entries) {
tracks.sort(byTitle);
const artists = [...byArtistKey.values()].map(({ artist, albumsByKey, loose }) => {
- const albums = [...albumsByKey.values()].sort((a, b) => a.album.localeCompare(b.album));
- for (const album of albums) {
+ const sortedAlbums = [...albumsByKey.values()].sort((a, b) => a.album.localeCompare(b.album));
+
+ // A "singleton" -- an album bucket down to exactly one track, because
+ // that's a real album tag but this person only has one song from it,
+ // not the whole release -- clutters the grid exactly the way an
+ // untagged loose track does. Found live: one artist's folder listing a
+ // dozen near-empty one-track album cards alongside the genuine
+ // multi-track albums. Both kinds fold into a single "<artist> -
+ // Various" tile, unless there is only one leftover track overall, where
+ // relabeling away a real album name (or minting "Various" for one
+ // file) buys nothing.
+ const realAlbums = [];
+ const misc = [...loose];
+ const miscSourceAlbums = [];
+ for (const album of sortedAlbums) {
+ if (album.tracks.length > 1) { realAlbums.push(album); continue; }
+ misc.push(...album.tracks);
+ miscSourceAlbums.push(album);
+ }
+ for (const album of realAlbums) {
album.tracks.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
}
- if (loose.length) {
- // Appended after every real album, never sorted in among them by
- // whatever the placeholder text happens to alphabetize to -- it
- // isn't a release, and translating "Unknown album" must not move it.
- loose.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
- albums.push({ artist, album: t('music.unknown_album'), isUnknown: true, tracks: loose });
+
+ if (misc.length === 1) {
+ // Keep the one leftover's own real album name if it had one; the
+ // generic placeholder only for a single untagged track with nothing
+ // else to call it.
+ realAlbums.push(miscSourceAlbums[0]
+ || { artist, album: t('music.unknown_album'), isUnknown: true, tracks: misc });
+ } else if (misc.length > 1) {
+ misc.sort((a, b) => (trackNo(a) - trackNo(b)) || byTitle(a, b));
+ realAlbums.push({
+ artist, album: `${artist} - ${t('music.various')}`, isUnknown: true, tracks: misc,
+ });
}
- return { artist, albums };
+ return { artist, albums: realAlbums };
}).sort((a, b) => a.artist.localeCompare(b.artist));
const albums = artists.flatMap((a) => a.albums);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
index 386d3f6..eac011a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-player.js
@@ -86,7 +86,39 @@ function saveRepeat(v) {
try { localStorage.setItem('meshbay_music_repeat', v); } catch { /* per-device only */ }
}
-function MusicPlayerBar({ transportRef, gekRef, queue }) {
+// The current queue, tracklist form -- "go back to what's playing" without
+// switching tabs or hunting for the album/folder it came from. Works the
+// same regardless of how the queue was built (an album, a consolidated
+// misc/loose bucket, a single standalone track).
+function QueuePanel({ tracks, order, pos, onSelect, onClose }) {
+ return html`
+ <div class="video-overlay" onClick=${(e) => {
+ if (e.target.classList.contains('video-overlay')) onClose();
+ }}>
+ <div class="music-detail">
+ <div class="video-top-bar">
+ <span class="video-title">${t('music.queue_title')}</span>
+ <button class="video-close" onClick=${onClose} title=${t('video.close')}>
+ <${Icon} name="close" /></button>
+ </div>
+ <div class="music-detail-body">
+ <div class="music-tracklist">
+ ${order.map((idx, i) => html`
+ <button class="music-track-row ${i === pos ? 'active' : ''}" key=${tracks[idx].id}
+ onClick=${() => { onSelect(i); onClose(); }}>
+ <span class="music-track-no">${i + 1}</span>
+ <span class="music-track-title">${tracks[idx].display_title || tracks[idx].name}</span>
+ <span class="music-track-duration">${formatTime(tracks[idx].duration || 0)}</span>
+ </button>
+ `)}
+ </div>
+ </div>
+ </div>
+ </div>
+ `;
+}
+
+function MusicPlayerBar({ transportRef, gekRef, queue, onClose }) {
const audioRef = useRef(null);
const blobCacheRef = useRef(new Map()); // file id -> { url, order: insertion index }
const blobInsertRef = useRef(0);
@@ -103,9 +135,23 @@ function MusicPlayerBar({ transportRef, gekRef, queue }) {
const [volume, setVolume] = useState(loadVolume);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
+ const [showQueue, setShowQueue] = useState(false);
const currentTrack = tracks[order[pos]] || null;
+ // Stops playback the moment this bar goes away for any reason -- the
+ // close button below, or the shell tearing it down on its own (leaving
+ // the group, switching to a different one). A component removed from the
+ // DOM should already stop an <audio> element, but that's the browser's
+ // behaviour to rely on, not this app's to assert; pausing explicitly, and
+ // releasing every cached blob URL rather than leaving them for the tab's
+ // lifetime, costs nothing and isn't optional either way.
+ useEffect(() => () => {
+ const audio = audioRef.current;
+ if (audio) { audio.pause(); audio.src = ''; }
+ for (const { url } of blobCacheRef.current.values()) URL.revokeObjectURL(url);
+ }, []);
+
const evictOldBlobs = useCallback(() => {
const cache = blobCacheRef.current;
while (cache.size > MAX_CACHED_BLOBS) {
@@ -258,6 +304,12 @@ function MusicPlayerBar({ transportRef, gekRef, queue }) {
if (audio && isFinite(audio.duration)) audio.currentTime = parseFloat(e.target.value);
}, []);
+ const handleClose = useCallback(() => {
+ const audio = audioRef.current;
+ if (audio) audio.pause();
+ if (onClose) onClose();
+ }, [onClose]);
+
if (!currentTrack) return null;
const title = currentTrack.display_title || currentTrack.name;
@@ -312,8 +364,20 @@ function MusicPlayerBar({ transportRef, gekRef, queue }) {
title=${t('music.player_volume')}
onInput=${(e) => setVolume(parseFloat(e.target.value))} />
</div>
+ <div class="music-player-extra">
+ <button class="music-player-btn" title=${t('music.player_queue')} onClick=${() => setShowQueue(true)}>
+ <${Icon} name="menu" />
+ </button>
+ <button class="music-player-btn" title=${t('music.player_close')} onClick=${handleClose}>
+ <${Icon} name="close" />
+ </button>
+ </div>
${error && html`<div class="music-player-error">${error}</div>`}
</div>
+ ${showQueue && html`
+ <${QueuePanel} tracks=${tracks} order=${order} pos=${pos}
+ onSelect=${skipTo} onClose=${() => setShowQueue(false)} />
+ `}
`;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 3d411f7..59ec919 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -2821,6 +2821,7 @@ a.transfer-name {
text-align: left;
}
.music-track-row:hover { background: var(--bg-raised); border-color: var(--border); }
+.music-track-row.active { border-color: var(--accent); color: var(--accent); }
.music-track-no { width: 24px; flex-shrink: 0; color: var(--text-dim); text-align: right; }
.music-track-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.music-track-duration { color: var(--text-dim); font-size: 0.9em; flex-shrink: 0; }
@@ -2947,6 +2948,8 @@ a.transfer-name {
.music-player-volume .icon { width: 16px; height: 16px; color: var(--text-dim); }
.music-player-volume input[type="range"] { width: 80px; }
+.music-player-extra { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
+
.music-player-error { flex-basis: 100%; font-size: 0.78em; color: var(--error); }
@media (max-width: 640px) {
diff --git a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
index 9ecf1b9..5225d4f 100644
--- a/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
+++ b/packages/meshbay-node/src/meshbay_node/indexer/enrich_audio.py
@@ -198,8 +198,8 @@ def _read_tags_and_cover(path: Path) -> tuple[dict, float | None, bytes | None]:
# A folder name used as a last-resort artist/album, cleaned of the
# punctuation-as-separator and release-tag noise this era of rip is full of
-# ("L_Oeuf_Raide_-_Berlin_Eggsile", "Sinsemilia - Premiere Recolte [MP3
-# 320kbps Album]"). Same spirit as title_parse.naive_title for video, kept
+# (underscores standing in for spaces, a bitrate/quality tag still attached
+# to the name). Same spirit as title_parse.naive_title for video, kept
# separate because the junk vocabulary differs (bitrates and rip tags, not
# edition/language tags).
# A whole bracketed/parenthesized group is dropped if it contains any rip-tag
@@ -228,12 +228,11 @@ def _split_top_level_folder(name: str) -> tuple[str, str | None]:
directly under the group's root (§ below) — there is no further
ancestor to call "artist" without leaving the root entirely. The common
convention for a single-release folder at that level is
- "Artist - Album ...junk..." ("GHOST DOG - Soundtrack",
- "cypress_hill_-los_grandes__xitos_en_espa_ol"); split on the first
- " - " when the cleaned name has one. Otherwise the whole (cleaned) name
- becomes the artist alone, which is the *more* common real shape here —
- a flat per-artist folder with no album subfolder at all ("Ben Harper",
- "bob_marley", "Renaud").
+ "Artist - Album ...junk..." (e.g. a soundtrack folder named after the
+ film, or a release folder with the ripper's tags still attached); split
+ on the first " - " when the cleaned name has one. Otherwise the whole
+ (cleaned) name becomes the artist alone, which is the *more* common real
+ shape here — a flat per-artist folder with no album subfolder at all.
"""
cleaned = _clean_folder_name(name)
m = re.match(r"^(.{2,60}?)\s*-\s*(.{2,80})$", cleaned)
diff --git a/packages/meshbay-node/tests/test_enrich_audio.py b/packages/meshbay-node/tests/test_enrich_audio.py
index 221f1c6..0fd8e17 100644
--- a/packages/meshbay-node/tests/test_enrich_audio.py
+++ b/packages/meshbay-node/tests/test_enrich_audio.py
@@ -68,18 +68,18 @@ def test_artist_album_from_ancestors_refuses_to_name_the_root_as_artist(tmp_path
The regression this whole revision exists for: a flat `Artist/track.mp3`
layout (no album subfolder) used to read the *root's own directory
name* as the artist, because the walk always climbed two levels with no
- idea where the root was. Measured live: 289 tracks across 41 real,
- unrelated artists collapsed into one fake "artist" this way — the
+ idea where the root was. Measured live against a real library: dozens
+ of unrelated artists collapsed into one fake "artist" this way — the
single biggest bucket in the whole library.
"""
- folder = tmp_path / "Ben Harper"
+ folder = tmp_path / "Some Flat Artist"
folder.mkdir(parents=True)
- track = folder / "Ashes.mp3"
+ track = folder / "A Track.mp3"
track.touch()
artist, album = _artist_album_from_ancestors(track, tmp_path)
- assert artist == "Ben Harper"
+ assert artist == "Some Flat Artist"
assert album is None, "no album folder exists — must not invent one, or swap artist/album"
@@ -106,22 +106,22 @@ def test_artist_album_from_ancestors_without_a_root_keeps_the_old_two_level_beha
def test_split_top_level_folder_splits_artist_dash_album():
- artist, album = _split_top_level_folder("GHOST DOG - Soundtrack")
- assert artist == "GHOST DOG"
+ artist, album = _split_top_level_folder("Some Movie - Soundtrack")
+ assert artist == "Some Movie"
assert album == "Soundtrack"
def test_split_top_level_folder_with_no_separator_is_artist_only():
- artist, album = _split_top_level_folder("Ben Harper")
- assert artist == "Ben Harper"
+ artist, album = _split_top_level_folder("Some Flat Artist")
+ assert artist == "Some Flat Artist"
assert album is None
def test_split_top_level_folder_cleans_rip_tag_noise():
artist, album = _split_top_level_folder(
- "Sinsemilia - Premiere Recolte [MP3 320kbps Album]")
- assert artist == "Sinsemilia"
- assert album == "Premiere Recolte"
+ "Some Artist - Some Release [MP3 320kbps Album]")
+ assert artist == "Some Artist"
+ assert album == "Some Release"
def test_clean_tag_filters_known_placeholders():
@@ -139,16 +139,17 @@ def test_clean_tag_keeps_various_artists_as_a_real_credit():
def test_clean_tag_keeps_a_real_value():
- assert _clean_tag("Björk") == "Björk"
+ # Non-ASCII must not be mistaken for a placeholder pattern.
+ assert _clean_tag("Ünïqùé Ärtïst") == "Ünïqùé Ärtïst"
def test_strip_track_prefix_removes_a_leaked_filename_number():
- assert strip_track_prefix("01 - Venus As A Boy (Edited Lp Version)") == \
- "Venus As A Boy (Edited Lp Version)"
+ assert strip_track_prefix("01 - Some Track (Edited Version)") == \
+ "Some Track (Edited Version)"
def test_strip_track_prefix_is_a_noop_on_a_clean_title():
- assert strip_track_prefix("Venus As A Boy") == "Venus As A Boy"
+ assert strip_track_prefix("Some Track") == "Some Track"
# ── end-to-end against a real (tiny, synthetic) MP3 file ────────────────────
@@ -234,9 +235,9 @@ async def test_enricher_falls_back_to_filename_and_folder_when_tags_absent(tmp_p
@pytest.mark.asyncio
async def test_enricher_falls_back_to_artist_only_for_a_flat_top_level_dir(tmp_path, media_cache):
"""The real-world regression case, end to end through the whole pool."""
- folder = tmp_path / "Ben Harper"
+ folder = tmp_path / "Some Flat Artist"
folder.mkdir(parents=True)
- clip = folder / "Ashes.mp3"
+ clip = folder / "A Track.mp3"
_make_clip(clip) # no metadata tags at all
entry = IndexEntry(id="fileid3", name=clip.name,
path=str(clip.relative_to(tmp_path)),
@@ -251,7 +252,7 @@ async def test_enricher_falls_back_to_artist_only_for_a_flat_top_level_dir(tmp_p
enricher.spawn(entry, clip, on_done, tmp_path)
_, fields = await asyncio.wait_for(done, timeout=30)
- assert fields["artist"] == "Ben Harper"
+ assert fields["artist"] == "Some Flat Artist"
assert fields["album"] is None
assert fields["artist"] != tmp_path.name, \
"must never fall back to the shared root's own directory name"