diff options
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" |