Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 62 additions & 38 deletions static/js/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || []),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1284,21 +1276,65 @@ function unavailableWarningHtml(track) {
return `<span class="cat-unavailable-warning">${esc(label)}</span>`;
}

/**
* 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) => `<span>${esc(part)}</span>`)
.join(' <span class="dot">·</span> ');
}

function renderRecentItem(trackId) {
const track = tracks[trackId];
if (!track) return null;
const el = document.createElement("div");
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 = `
<div class="cat-thumb">${thumbHtml(track)}</div>
<div class="cat-meta">
<div class="cat-title">${esc(displayTitle(track.title))}</div>
<div class="cat-sub">${isUnavailable ? unavailableWarningHtml(track) : `<span>${esc(sub)}</span>`}</div>
<div class="cat-sub">${trackSublineHtml(track)}</div>
</div>
<div class="cat-status${PROCESSING_STATUSES.has(track.status) ? " processing" : isUnavailable ? " unavailable" : ""}"></div>
`;
Expand Down Expand Up @@ -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)
: `<span>${esc(track.channel ?? "")}</span>
<span class="dot">·</span>
<span>${inTrash ? esc(i18nT("track.removed")) : esc(i18nPlural("footer.stemsCount", stemCount))}</span>`;
el.innerHTML = `
<div class="cat-thumb">${thumbHtml(track)}</div>
<div class="cat-meta">
<div class="cat-title">${esc(displayTitle(track.title))}</div>
<div class="cat-sub">${subHtml}</div>
<div class="cat-sub">${trackSublineHtml(track, { inTrash })}</div>
</div>
<div class="cat-status${PROCESSING_STATUSES.has(track.status) ? " processing" : isUnavailable ? " unavailable" : ""}"></div>
${inTrash ? "" : `<button class="cat-del" type="button" title="${esc(i18nT("track.moveToTrash"))}">
Expand Down Expand Up @@ -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"));
Expand All @@ -1728,8 +1753,8 @@ function render() {
if (hasCollections) list.appendChild(collectionsSection);

// Empty state when search yields nothing
if (catalogSearchQuery && !recentIds.length && !hasCollections) {
list.innerHTML = '<span class="folder-empty trash-empty">No tracks match your search</span>';
if (catalogSearchQuery && !hasCollections) {
list.innerHTML = `<span class="folder-empty trash-empty">${esc(i18nT("library.noSearchMatch"))}</span>`;
return;
}

Expand Down Expand Up @@ -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);
Expand Down
20 changes: 10 additions & 10 deletions static/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -1822,7 +1822,6 @@ const ja = {
"library.syncing": "同期中…",
"library.syncFailed": "同期に失敗しました — 接続を確認してください。",
"library.deleteFailed": "{count} 曲を削除できませんでした。ライブラリからは外れていますが、ファイルはディスクに残っている可能性があります。",
"library.recent": "最近",
"library.stemCollections": "パートコレクション",
"library.tags": "タグ",
"library.newFolder": "新規フォルダ",
Expand All @@ -1847,6 +1846,7 @@ const ja = {
"folder.deleteFolder": "フォルダを削除",
"folder.unsorted": "未分類",
"favorites.noSearchMatch": "検索に一致するお気に入りはありません",
"library.noSearchMatch": "検索に一致するトラックはありません",
"favorites.empty": "お気に入りはまだありません — トラックの♥をクリックして保存",
"queue.extractNext": "次にこれを抽出",
"queue.moveToFront": "{title}をキューの先頭に移動",
Expand Down Expand Up @@ -2405,7 +2405,6 @@ const zhHans = {
"library.syncing": "正在同步…",
"library.syncFailed": "同步失败 — 请检查你的网络连接。",
"library.deleteFailed": "有 {count} 首曲目未能删除。它们已不在音乐库中,但文件可能仍在磁盘上。",
"library.recent": "最近",
"library.stemCollections": "音轨合集",
"library.tags": "标签",
"library.newFolder": "新建文件夹",
Expand All @@ -2430,6 +2429,7 @@ const zhHans = {
"folder.deleteFolder": "删除文件夹",
"folder.unsorted": "未分类",
"favorites.noSearchMatch": "没有收藏与你的搜索匹配",
"library.noSearchMatch": "没有曲目与你的搜索匹配",
"favorites.empty": "还没有收藏 — 点击曲目上的 ♥ 即可保存",
"queue.extractNext": "接下来提取这一首",
"queue.moveToFront": "将 {title} 移到队列最前面",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -6091,7 +6091,6 @@ const ko = {
"library.syncing": "동기화 중…",
"library.syncFailed": "동기화하지 못했어요. 연결을 확인해 주세요.",
"library.deleteFailed": "{count}곡을 삭제하지 못했습니다. 라이브러리에서는 빠졌지만 파일은 디스크에 남아 있을 수 있습니다.",
"library.recent": "최근",
"library.stemCollections": "스템 모음",
"library.tags": "태그",
"library.newFolder": "새 폴더",
Expand All @@ -6116,6 +6115,7 @@ const ko = {
"folder.deleteFolder": "폴더 삭제",
"folder.unsorted": "미분류",
"favorites.noSearchMatch": "검색과 맞는 즐겨찾기가 없어요",
"library.noSearchMatch": "검색과 맞는 트랙이 없어요",
"favorites.empty": "아직 즐겨찾기가 없어요. 트랙의 ♥를 누르면 저장돼요",
"queue.extractNext": "이 트랙을 다음으로 추출",
"queue.moveToFront": "{title}을(를) 대기열 맨 앞으로",
Expand Down
5 changes: 0 additions & 5 deletions static/js/job.js
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -594,7 +592,6 @@ function registerUploadRow(jobId, file) {
addTrackToLibrary({
id: jobId,
title,
channel: "Processing",
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -854,7 +850,6 @@ export function wireJobForm() {
addTrackToLibrary({
id: jobId,
title: displayTitle,
channel: t("job.processing"),
thumb: "",
stems: [...selectedStems],
selectedStems: [...selectedStems],
Expand Down
3 changes: 1 addition & 2 deletions tests/e2e/composer-arming.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
Loading
Loading