diff --git a/static/js/catalog.js b/static/js/catalog.js
index fae15a0f..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],
@@ -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)) {
@@ -1284,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;
@@ -1291,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)}
`;
@@ -1386,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)}
${inTrash ? "" : `
@@ -1695,18 +1725,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 +1753,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;
}
@@ -1874,7 +1899,6 @@ async function completeSettledJob(jobId) {
const finalState = state.status === "done" ? await runVocalSplitIfWanted(state) : state;
const track = stateMetadataToTrack(finalState, { ...existing, id: jobId });
track.id = jobId;
- track.channel = finalState.status === "done" ? i18nT("footer.extractedLabel") : existing.channel;
addTrackToLibrary(track);
} catch (e) {
console.warn("[catalog] could not finish background job", jobId, e);
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/static/js/job.js b/static/js/job.js
index 5036adcd..72c081bc 100644
--- a/static/js/job.js
+++ b/static/js/job.js
@@ -60,7 +60,6 @@ export async function runVocalSplitIfWanted(state) {
addTrackToLibrary({
id: finalState.job_id,
title: finalState.title || "",
- channel: t("footer.extractedLabel"),
thumb: finalState.thumbnail,
stems: finalState.selected_stems || [...selectedStems],
selectedStems: finalState.selected_stems || [...selectedStems],
@@ -361,7 +360,6 @@ function applyState(state) {
// runs the user may already be typing the next URL in there, and it must
// not end up as some other track's title or source.
title: state.title || (isForeground ? urlInput.value : "") || t("job.processingTrackTitle"),
- channel: state.status === "done" ? t("footer.extractedLabel") : t("job.processing"),
thumb: state.thumbnail,
stems: state.selected_stems || state.stems?.map((stem) => stem.name) || [...selectedStems],
selectedStems: state.selected_stems || [...selectedStems],
@@ -594,7 +592,6 @@ function registerUploadRow(jobId, file) {
addTrackToLibrary({
id: jobId,
title,
- channel: "Processing",
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
@@ -656,7 +653,6 @@ export async function importFromUrl(url, { title, stems } = {}) {
addTrackToLibrary({
id: jobId,
title: title || url || t("job.processingTrackTitle"),
- channel: "Processing",
thumb: "",
stems: stemSel,
selectedStems: stemSel,
@@ -854,7 +850,6 @@ export function wireJobForm() {
addTrackToLibrary({
id: jobId,
title: displayTitle,
- channel: t("job.processing"),
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
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,
});
diff --git a/tests/e2e/library-rows.spec.mjs b/tests/e2e/library-rows.spec.mjs
new file mode 100644
index 00000000..e37b93d7
--- /dev/null
+++ b/tests/e2e/library-rows.spec.mjs
@@ -0,0 +1,140 @@
+// A library row says the same thing about a track however it got there, and
+// each track appears once.
+//
+// The line under a title used to read a `channel` field that six code paths
+// filled with a status label, as text in whatever language was active, and
+// the path that adopts a server job the library did not know about filled with
+// nothing. So two copies of one song read "Extracted · 6 stems" and " · 6
+// stems" side by side (#656), and the adopted one's count came from the stems
+// it had asked for rather than the ones it has, which can be none at all.
+//
+// And a Recent section above the folders listed the three newest tracks, each
+// of which is also in its folder, so one import appeared twice and two imports
+// of the same song four times.
+import { test, expect } from "@playwright/test";
+import {
+ JOB_ID,
+ SIBLING_JOB_ID,
+ fixtureTrack,
+ seedCatalogState,
+ seedLibrary,
+ stubExportEndpoints,
+ stubUpdateCheck,
+} from "./helpers.mjs";
+
+const rowText = (page, id) =>
+ page
+ .locator(`.cat-item[data-id="${id}"] .cat-sub`)
+ .first()
+ .evaluate((el) => el.textContent.replace(/\s+/g, " ").trim());
+
+async function open(page) {
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.locator(".cat-item").first().waitFor({ timeout: 20000 });
+}
+
+/** The stems a job really has, from the server, not counting the mix. */
+async function stemFiles(page, id) {
+ const res = await page.request.get(`/api/jobs/${id}`);
+ const state = await res.json();
+ return (state.stems || []).filter((s) => (s.name ?? s) !== "original").length;
+}
+
+// Both fixture jobs, as seedLibrary has them, with the first one changed.
+// Seeding only the first would not work: the startup sync adopts the second
+// from the server, and as they share a source the catalog's dedup (#542)
+// replaces the first with it.
+function withTracks(first) {
+ const tracks = {
+ [JOB_ID]: first,
+ [SIBLING_JOB_ID]: fixtureTrack(SIBLING_JOB_ID, "E2E Fixture Track (again)"),
+ };
+ return {
+ folders: [
+ { id: "f-unsorted", name: "Unsorted", items: Object.keys(tracks), color: null },
+ { id: "trash", name: "Trash", items: [], color: null },
+ ],
+ tracks,
+ };
+}
+
+test.describe("library rows", () => {
+ test("a track adopted from the server reads like any other", async ({ page }) => {
+ // No local library at all: both jobs arrive through the startup sync, the
+ // path that used to leave the line as " · 0 stems".
+ await open(page);
+ const expected = await stemFiles(page, SIBLING_JOB_ID);
+ expect(expected).toBeGreaterThan(0);
+
+ const line = await rowText(page, SIBLING_JOB_ID);
+ expect(line).not.toMatch(/^·/);
+ expect(line).toMatch(new RegExp(`(^|· )${expected} stems$`));
+ });
+
+ test("an old stored label is not shown, and not searched", async ({ page }) => {
+ // Tracks saved before this change still carry the label in `channel`.
+ await seedCatalogState(
+ page,
+ withTracks({ ...fixtureTrack(JOB_ID, "Labelled"), channel: "Extracted" }),
+ );
+ await open(page);
+
+ expect(await rowText(page, JOB_ID)).not.toContain("Extracted");
+
+ await page.locator("#catalogSearch, .cat-search input, input[type=search]").first().fill("Extracted");
+ await expect(page.locator(`.cat-item[data-id="${JOB_ID}"]`)).toHaveCount(0);
+ });
+
+ test("a track still being processed says so", async ({ page }) => {
+ await seedCatalogState(
+ page,
+ withTracks({ ...fixtureTrack(JOB_ID, "Busy"), status: "separating" }),
+ );
+ await open(page);
+ expect(await rowText(page, JOB_ID)).toBe("Processing");
+ });
+
+ test("a failed import says so", async ({ page }) => {
+ await seedCatalogState(
+ page,
+ withTracks({ ...fixtureTrack(JOB_ID, "Broken"), status: "error" }),
+ );
+ await open(page);
+ expect(await rowText(page, JOB_ID)).toBe("Import failed");
+ });
+
+ test("the line follows the language rather than keeping the one it was saved in", async ({ page }) => {
+ await page.addInitScript(() =>
+ localStorage.setItem("stemdeck.language", JSON.stringify("de")),
+ );
+ await seedCatalogState(
+ page,
+ withTracks({ ...fixtureTrack(JOB_ID, "Beschriftet"), channel: "Extracted", status: "separating" }),
+ );
+ await open(page);
+ // German, and nothing left over from the English it was stored in.
+ expect(await rowText(page, JOB_ID)).toBe("Wird verarbeitet");
+ });
+
+ test("each track is listed once", async ({ page }) => {
+ await seedLibrary(page);
+ await open(page);
+
+ await expect(page.locator(`.cat-item[data-id="${JOB_ID}"]`)).toHaveCount(1);
+ await expect(page.locator(`.cat-item[data-id="${SIBLING_JOB_ID}"]`)).toHaveCount(1);
+ await expect(page.locator(".lib-section-head", { hasText: /^Recent$/i })).toHaveCount(0);
+ });
+
+ test("a search that matches nothing says so in the chosen language", async ({ page }) => {
+ await page.addInitScript(() =>
+ localStorage.setItem("stemdeck.language", JSON.stringify("de")),
+ );
+ await seedLibrary(page);
+ await open(page);
+
+ await page.locator("#catalogSearch, .cat-search input, input[type=search]").first().fill("zzzz-nothing");
+ await expect(page.getByText("Keine Titel entsprechen deiner Suche")).toBeVisible();
+ });
+});