From c513e164f8a421b1a1c2a68d414b99a30800f934 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 23 Sep 2026 17:06:18 +0100 Subject: [PATCH 1/2] Remove the Recent section from the library It listed the three newest tracks above the folders, and every one of them is also in its folder, where a new import already lands at the top. So each new track appeared twice in one panel, and two imports of the same song four times, which is what the #656 reporter's screenshot shows: Hallelujah four times, and a library that has to scroll to get past it. Nothing it offered is lost. Recent imports are at the top of the first folder, and a track still being processed says so in its own row, which is the inline, Moises-style handling the reporter asked for. The one thing it did that a folder does not is reach across folders, which is a sort order rather than a section if it turns out to matter. The no-match message for a library search was hard-coded English and keyed on the Recent list being empty. It is translated now, beside the Favorites and Trash messages it mirrors, and library.recent has nothing left to label. --- static/js/catalog.js | 31 +++++++++--------------------- static/js/i18n.js | 20 +++++++++---------- tests/e2e/composer-arming.spec.mjs | 3 +-- 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/static/js/catalog.js b/static/js/catalog.js index fae15a0f..7b1775fc 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -1241,14 +1241,6 @@ function wireLibraryDeleteKeys() { // ─── Rendering helpers ─── -function getRecentTracks(trashIds, n = 3) { - return Object.entries(tracks) - .filter(([id, t]) => !trashIds.has(id) && t.title) - .sort(([, a], [, b]) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) - .slice(0, n) - .map(([id]) => id); -} - function getAllTags(trashIds) { const counts = {}; for (const [id, track] of Object.entries(tracks)) { @@ -1695,18 +1687,13 @@ function render() { return; } - // ── Library view — Recent · Stem Collections · Tags ── - - // Recent section - const recentIds = getRecentTracks(trashIds).filter((id) => trackMatchesSearch(tracks[id])); - if (recentIds.length) { - const section = makeSectionEl(i18nT("library.recent")); - for (const id of recentIds) { - const item = renderRecentItem(id); - if (item) section.appendChild(item); - } - list.appendChild(section); - } + // ── Library view — Stem Collections · Tags ── + // + // There was a Recent section above the folders: the three newest tracks, + // every one of which is also in its folder, where a new import already lands + // at the top. It showed the same track twice for no gain, and with two + // imports of one song, four times in one panel (#656). Tracks still being + // processed show that in their own row, which is the useful part of it. // Stem Collections section const collectionsSection = makeSectionEl(i18nT("library.stemCollections")); @@ -1728,8 +1715,8 @@ function render() { if (hasCollections) list.appendChild(collectionsSection); // Empty state when search yields nothing - if (catalogSearchQuery && !recentIds.length && !hasCollections) { - list.innerHTML = 'No tracks match your search'; + if (catalogSearchQuery && !hasCollections) { + list.innerHTML = `${esc(i18nT("library.noSearchMatch"))}`; return; } diff --git a/static/js/i18n.js b/static/js/i18n.js index 7f8b62dc..9c56eb51 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -602,7 +602,6 @@ const en = { "library.importedFile": "Imported file", "library.syncing": "Syncing…", "library.syncFailed": "Sync failed — check your connection.", - "library.recent": "Recent", "library.stemCollections": "Stem Collections", "library.tags": "Tags", "library.newFolder": "New folder", @@ -628,6 +627,7 @@ const en = { "folder.deleteFolder": "Delete folder", "folder.unsorted": "Unsorted", "favorites.noSearchMatch": "No favorites match your search", + "library.noSearchMatch": "No tracks match your search", "favorites.empty": "No favorites yet — click ♥ on a track to save it", "queue.extractNext": "Extract this one next", "queue.moveToFront": "Move {title} to the front of the queue", @@ -1221,7 +1221,6 @@ const pl = { "library.syncing": "Synchronizowanie…", "library.syncFailed": "Synchronizacja nie powiodła się — sprawdź połączenie.", "library.deleteFailed": "Nie udało się usunąć {count} utworów. Nie ma ich już w bibliotece, ale ich pliki mogą nadal być na dysku.", - "library.recent": "Ostatnie", "library.stemCollections": "Kolekcje ścieżek", "library.tags": "Tagi", "library.newFolder": "Nowy folder", @@ -1248,6 +1247,7 @@ const pl = { "folder.deleteFolder": "Usuń folder", "folder.unsorted": "Nieposortowane", "favorites.noSearchMatch": "Żadne ulubione nie pasują do wyszukiwania", + "library.noSearchMatch": "Żadne utwory nie pasują do wyszukiwania", "favorites.empty": "Brak ulubionych — kliknij ♥ przy utworze, aby go zapisać", "queue.extractNext": "Wyodrębnij ten jako następny", "queue.moveToFront": "Przenieś {title} na początek kolejki", @@ -1822,7 +1822,6 @@ const ja = { "library.syncing": "同期中…", "library.syncFailed": "同期に失敗しました — 接続を確認してください。", "library.deleteFailed": "{count} 曲を削除できませんでした。ライブラリからは外れていますが、ファイルはディスクに残っている可能性があります。", - "library.recent": "最近", "library.stemCollections": "パートコレクション", "library.tags": "タグ", "library.newFolder": "新規フォルダ", @@ -1847,6 +1846,7 @@ const ja = { "folder.deleteFolder": "フォルダを削除", "folder.unsorted": "未分類", "favorites.noSearchMatch": "検索に一致するお気に入りはありません", + "library.noSearchMatch": "検索に一致するトラックはありません", "favorites.empty": "お気に入りはまだありません — トラックの♥をクリックして保存", "queue.extractNext": "次にこれを抽出", "queue.moveToFront": "{title}をキューの先頭に移動", @@ -2405,7 +2405,6 @@ const zhHans = { "library.syncing": "正在同步…", "library.syncFailed": "同步失败 — 请检查你的网络连接。", "library.deleteFailed": "有 {count} 首曲目未能删除。它们已不在音乐库中,但文件可能仍在磁盘上。", - "library.recent": "最近", "library.stemCollections": "音轨合集", "library.tags": "标签", "library.newFolder": "新建文件夹", @@ -2430,6 +2429,7 @@ const zhHans = { "folder.deleteFolder": "删除文件夹", "folder.unsorted": "未分类", "favorites.noSearchMatch": "没有收藏与你的搜索匹配", + "library.noSearchMatch": "没有曲目与你的搜索匹配", "favorites.empty": "还没有收藏 — 点击曲目上的 ♥ 即可保存", "queue.extractNext": "接下来提取这一首", "queue.moveToFront": "将 {title} 移到队列最前面", @@ -2992,7 +2992,6 @@ const de = { "library.syncing": "Wird synchronisiert…", "library.syncFailed": "Synchronisierung fehlgeschlagen — Verbindung prüfen.", "library.deleteFailed": "{count} Titel konnten nicht gelöscht werden. Sie bleiben aus deiner Bibliothek entfernt, ihre Dateien liegen aber möglicherweise noch auf der Festplatte.", - "library.recent": "Zuletzt verwendet", "library.stemCollections": "Stem-Sammlungen", "library.tags": "Tags", "library.newFolder": "Neuer Ordner", @@ -3018,6 +3017,7 @@ const de = { "folder.deleteFolder": "Ordner löschen", "folder.unsorted": "Unsortiert", "favorites.noSearchMatch": "Keine Favoriten entsprechen deiner Suche", + "library.noSearchMatch": "Keine Titel entsprechen deiner Suche", "favorites.empty": "Noch keine Favoriten — klicke auf ♥ bei einem Track, um ihn zu speichern", "queue.extractNext": "Diesen als Nächstes extrahieren", "queue.moveToFront": "{title} an den Anfang der Warteschlange verschieben", @@ -3586,7 +3586,6 @@ const pt = { "library.syncing": "Sincronizando…", "library.syncFailed": "Falha na sincronização — verifique sua conexão.", "library.deleteFailed": "Não foi possível excluir {count} faixa(s). Elas ficam fora da sua biblioteca, mas os arquivos podem continuar no disco.", - "library.recent": "Recentes", "library.stemCollections": "Coleções de stems", "library.tags": "Tags", "library.newFolder": "Nova pasta", @@ -3612,6 +3611,7 @@ const pt = { "folder.deleteFolder": "Excluir pasta", "folder.unsorted": "Não organizado", "favorites.noSearchMatch": "Nenhum favorito corresponde à sua pesquisa", + "library.noSearchMatch": "Nenhuma faixa corresponde à sua pesquisa", "favorites.empty": "Ainda sem favoritos — clique no ♥ de uma faixa para salvá-la", "queue.extractNext": "Extrair esta em seguida", "queue.moveToFront": "Mover {title} para o início da fila", @@ -4178,7 +4178,6 @@ const id = { "library.syncing": "Menyinkronkan…", "library.syncFailed": "Sinkronisasi gagal — periksa koneksi Anda.", "library.deleteFailed": "{count} lagu tidak dapat dihapus. Lagu-lagu itu tetap di luar pustaka Anda, tetapi berkasnya mungkin masih ada di disk.", - "library.recent": "Terbaru", "library.stemCollections": "Koleksi Stem", "library.tags": "Tag", "library.newFolder": "Folder baru", @@ -4203,6 +4202,7 @@ const id = { "folder.deleteFolder": "Hapus folder", "folder.unsorted": "Belum Diurutkan", "favorites.noSearchMatch": "Tidak ada favorit yang cocok dengan pencarian Anda", + "library.noSearchMatch": "Tidak ada trek yang cocok dengan pencarian Anda", "favorites.empty": "Belum ada favorit — klik ♥ pada trek untuk menyimpannya", "queue.extractNext": "Ekstrak ini berikutnya", "queue.moveToFront": "Pindahkan {title} ke depan antrean", @@ -4766,7 +4766,6 @@ const fr = { "library.syncing": "Synchronisation…", "library.syncFailed": "Échec de la synchronisation — vérifiez votre connexion.", "library.deleteFailed": "{count} morceau(x) n'ont pas pu être supprimés. Ils restent hors de votre bibliothèque, mais leurs fichiers peuvent encore se trouver sur le disque.", - "library.recent": "Récents", "library.stemCollections": "Collections de pistes", "library.tags": "Tags", "library.newFolder": "Nouveau dossier", @@ -4791,6 +4790,7 @@ const fr = { "folder.deleteFolder": "Supprimer le dossier", "folder.unsorted": "Non classés", "favorites.noSearchMatch": "Aucun favori ne correspond à votre recherche", + "library.noSearchMatch": "Aucun morceau ne correspond à votre recherche", "favorites.empty": "Aucun favori pour l'instant — cliquez sur ♥ sur un morceau pour l'enregistrer", "queue.extractNext": "Extraire celui-ci ensuite", "queue.moveToFront": "Placer {title} en tête de file", @@ -5479,7 +5479,6 @@ const es = { "library.syncing": "Sincronizando…", "library.syncFailed": "La sincronización falló — revisa tu conexión.", "library.deleteFailed": "No se pudieron eliminar {count} pista(s). Se quedan fuera de tu biblioteca, pero sus archivos pueden seguir en el disco.", - "library.recent": "Recientes", "library.stemCollections": "Colecciones de stems", "library.tags": "Etiquetas", "library.newFolder": "Nueva carpeta", @@ -5505,6 +5504,7 @@ const es = { "folder.deleteFolder": "Eliminar carpeta", "folder.unsorted": "Sin ordenar", "favorites.noSearchMatch": "Ningún favorito coincide con tu búsqueda", + "library.noSearchMatch": "Ninguna pista coincide con tu búsqueda", "favorites.empty": "Aún no hay favoritos: pulsa ♥ en una pista para guardarla", "queue.extractNext": "Extraer esta a continuación", "queue.moveToFront": "Mover {title} al principio de la cola", @@ -6091,7 +6091,6 @@ const ko = { "library.syncing": "동기화 중…", "library.syncFailed": "동기화하지 못했어요. 연결을 확인해 주세요.", "library.deleteFailed": "{count}곡을 삭제하지 못했습니다. 라이브러리에서는 빠졌지만 파일은 디스크에 남아 있을 수 있습니다.", - "library.recent": "최근", "library.stemCollections": "스템 모음", "library.tags": "태그", "library.newFolder": "새 폴더", @@ -6116,6 +6115,7 @@ const ko = { "folder.deleteFolder": "폴더 삭제", "folder.unsorted": "미분류", "favorites.noSearchMatch": "검색과 맞는 즐겨찾기가 없어요", + "library.noSearchMatch": "검색과 맞는 트랙이 없어요", "favorites.empty": "아직 즐겨찾기가 없어요. 트랙의 ♥를 누르면 저장돼요", "queue.extractNext": "이 트랙을 다음으로 추출", "queue.moveToFront": "{title}을(를) 대기열 맨 앞으로", diff --git a/tests/e2e/composer-arming.spec.mjs b/tests/e2e/composer-arming.spec.mjs index af77004a..c93b7adc 100644 --- a/tests/e2e/composer-arming.spec.mjs +++ b/tests/e2e/composer-arming.spec.mjs @@ -103,8 +103,7 @@ test.describe("composer arming", () => { ); await page.locator("#submit").click(); - // A row renders in both Recent and the folder tree, so count is not the - // question -- presence is. + // Presence is the question here, not placement or count. await expect(page.locator(`.cat-item[data-id="cafebabe0003"]`).first()).toBeVisible({ timeout: 10000, }); From 54f37cd97de84d1e5560f6adc4fb2ccc832db5ba Mon Sep 17 00:00:00 2001 From: Thales <> Date: Wed, 23 Sep 2026 17:06:18 +0100 Subject: [PATCH 2/2] Build the library row's second line from the track, not a stored label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two copies of the same song sat in the library reading "Extracted · 6 stems" and " · 6 stems" (#656). The line under a title was `channel · N stems`, and channel was never a channel. Six code paths stored a status label in it, and they disagreed: foreground and background completion "Extracted", translated queued imports "Processing", hard-coded English playlist imports "Processing", translated startup sync of a server job nothing The last is the empty one, and it is what the reporter saw: a job the local library did not know about is adopted on startup with no label, so the line began with a bare dot. Reproduced by starting with an empty library. Its count was wrong too, taken from the stems the job had asked for, which an adopted job may not have recorded, so it read "0 stems". The label also froze in whatever language was active when it was written, so switching language left old rows behind, and the key it borrowed is the "Extracted" that precedes a date in the track panel: 抽出日時, "extraction date", in Japanese. Nothing is stored now. The line is built when the row is drawn: length and stem count for a finished track, Processing or Import failed while it is not, the re-import warning when its files are gone, Removed in the Trash. The count is of the stem files that exist, the same rule the track panel already used, now in one helper both share. Every writer of the label is gone, and search no longer matches it, since tracks saved before this still carry it. Seven tests, all failing against main: a track adopted from the server reads like any other, an old stored label is neither shown nor searched, processing and failed tracks say so, the line follows the language, each track is listed once, and the empty search message is translated. --- static/js/catalog.js | 69 ++++++++++++---- static/js/job.js | 5 -- tests/e2e/library-rows.spec.mjs | 140 ++++++++++++++++++++++++++++++++ 3 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 tests/e2e/library-rows.spec.mjs diff --git a/static/js/catalog.js b/static/js/catalog.js index 7b1775fc..be494ad9 100644 --- a/static/js/catalog.js +++ b/static/js/catalog.js @@ -289,9 +289,10 @@ function trackMatchesSearch(track) { if (!tag) return true; return (track?.tags ?? []).some((t) => String(t).toLowerCase().includes(tag)); } + // Not `channel`: it was a stored status label, not something a person would + // search by, and old tracks still carry it (#656). return [ track?.title, - track?.channel, track?.sourceUrl, ...(track?.stems || []), ...(track?.tags || []), @@ -398,7 +399,7 @@ function saveState() { // ─── Track management ─── export function addTrackToLibrary(track) { - // track: { id, title, channel, thumb, stems, status, sourceUrl } + // track: { id, title, thumb, stems, status, sourceUrl } const existingId = findTrackBySource(track.sourceUrl, track.id); // A match already in the Trash is left exactly where it is. It is a distinct // job with its own files on disk, and the user is the only one who decides @@ -571,7 +572,7 @@ function applyTrackInfoToPanel(track) { thumbnail: track.thumb, key: track.key, bpm: track.bpm, - stemCount: (track.audioStems || track.stems || []).filter((s) => (s.name ?? s) !== "original").length || null, + stemCount: stemCountOf(track) || null, }); applyStemPresenceCards(track.stemPresence); @@ -883,7 +884,6 @@ export function addPlaylistToLibrary(playlistTitle, jobs) { addTrackToLibrary({ id: job.job_id, title: job.title || job.source_url || i18nT("job.queuedTrack"), - channel: i18nT("job.processing"), thumb: "", stems: [...selectedStems], selectedStems: [...selectedStems], @@ -1276,6 +1276,53 @@ function unavailableWarningHtml(track) { return `${esc(label)}`; } +/** + * How many stems a track has: the stem files that exist once it is done, and + * the ones asked for before then. The mix itself ("original") is not a stem. + */ +function stemCountOf(track) { + const stems = track?.audioStems?.length ? track.audioStems : track?.stems || []; + return stems.filter((s) => (s?.name ?? s) !== "original").length; +} + +/** + * The line under a track's title, built from what is true of the track now. + * + * It used to read a `channel` field that was never a channel: six code paths + * stored a status label in it, as text in whatever language was active at the + * time, and the path that adopts a server job the library did not know about + * stored nothing. So two copies of the same song could read "Extracted · + * 6 stems" and " · 6 stems" side by side (#656), and a label written in one + * language stayed in it after the user switched. Nothing is stored now, and + * every path that creates a track gets the same line. + * + * Returns HTML: an unavailable track's line is a warning, not plain text. + */ +function trackSublineHtml(track, { inTrash = false } = {}) { + if (track.status === "unavailable") return unavailableWarningHtml(track); + let parts; + if (PROCESSING_STATUSES.has(track.status)) { + parts = [i18nT("job.processing")]; + } else if (track.status === "error") { + parts = [i18nT("notifKind.importFailed")]; + } else { + const stems = stemCountOf(track); + // A done track that knows neither its length nor its stems says nothing + // rather than "0 stems", which would be a claim and a wrong one. + parts = [ + track.duration ? fmtTime(track.duration) : "", + stems ? i18nPlural("footer.stemsCount", stems) : "", + ]; + } + if (inTrash) parts.push(i18nT("track.removed")); + // Spaced as text as well as by the row's flex gap, so what a screen reader + // or a copy gets reads "05:57 · 6 stems" rather than "05:57·6 stems". + return parts + .filter(Boolean) + .map((part) => `${esc(part)}`) + .join(' · '); +} + function renderRecentItem(trackId) { const track = tracks[trackId]; if (!track) return null; @@ -1283,14 +1330,11 @@ function renderRecentItem(trackId) { const isUnavailable = track.status === "unavailable"; el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; el.dataset.id = trackId; - const duration = track.duration ? fmtTime(track.duration) : ""; - const stemCount = track.stems?.length ?? 0; - const sub = [duration, i18nPlural("footer.stemsCount", stemCount)].filter(Boolean).join(" · "); el.innerHTML = `
${thumbHtml(track)}
${esc(displayTitle(track.title))}
-
${isUnavailable ? unavailableWarningHtml(track) : `${esc(sub)}`}
+
${trackSublineHtml(track)}
`; @@ -1378,17 +1422,11 @@ function renderTrackItem(trackId, { inTrash = false } = {}) { el.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}`; el.dataset.id = trackId; - const stemCount = track.stems?.length ?? 0; - const subHtml = isUnavailable - ? unavailableWarningHtml(track) - : `${esc(track.channel ?? "")} - · - ${inTrash ? esc(i18nT("track.removed")) : esc(i18nPlural("footer.stemsCount", stemCount))}`; el.innerHTML = `
${thumbHtml(track)}
${esc(displayTitle(track.title))}
-
${subHtml}
+
${trackSublineHtml(track, { inTrash })}
${inTrash ? "" : `