From 7216f43f9a80dcab541880311686d08796e1b015 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 30 Sep 2026 12:25:54 +0100
Subject: [PATCH 1/9] fix(backend): start on CPU when torch is installed but
cannot load
available_torch_devices() caught only ImportError. A torch whose DLLs
fail to load raises OSError instead (WinError 127 from a CUDA DLL left
behind on Windows, #723), which killed startup when the device setting
was "auto" and made every GET /api/settings a 500 otherwise.
The probe now catches any failure, falls back to ["cpu"], logs the
cause once with its traceback, and remembers it. /api/settings carries
it as torch_error, and Settings replaces the device description with a
warning in red: separation cannot run on any device until torch is
repaired, CPU included, so a quiet fallback would hide the real state.
tests/test_torch_unloadable.py installs an import hook that makes
`import torch` raise that OSError; the probe and settings tests fail on
the old code.
Closes #730
---
app/core/config.py | 25 ++++++++++
app/main.py | 4 ++
static/css/daw.css | 2 +
static/js/catalog.js | 8 +++-
static/js/i18n.js | 10 ++++
tests/test_torch_unloadable.py | 87 ++++++++++++++++++++++++++++++++++
6 files changed, 135 insertions(+), 1 deletion(-)
create mode 100644 tests/test_torch_unloadable.py
diff --git a/app/core/config.py b/app/core/config.py
index 104f67cb..63ebf7c4 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -28,11 +28,23 @@ def _env_path_opt(name: str) -> Path | None:
return Path(raw).expanduser().resolve() if raw else None
+# Why torch could not be loaded at the last probe, or None if it loaded (#730).
+# Refreshed on every probe rather than decided once, so a repaired install
+# clears it without a restart.
+_torch_load_error: str | None = None
+
+
+def torch_load_error() -> str | None:
+ """The last probe's reason torch is installed but unusable, if any."""
+ return _torch_load_error
+
+
def available_torch_devices() -> list[str]:
"""Compute devices this machine can actually use, best-first. CPU is always
present; cuda/mps depend on the hardware + installed torch build. The
Settings UI uses this to disable options that aren't available/detected so
a user can't pick an impossible device."""
+ global _torch_load_error
devices: list[str] = []
try:
import torch
@@ -41,8 +53,21 @@ def available_torch_devices() -> list[str]:
devices.append("cuda")
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
devices.append("mps")
+ _torch_load_error = None
except ImportError:
pass
+ except Exception as exc: # noqa: BLE001 -- any failure here must not stop the server
+ # Installed but unloadable: on Windows a CUDA DLL left behind by a
+ # half-reverted install raises OSError (WinError 127) from torch's own
+ # DLL loader (#723). Catching only ImportError let that kill startup
+ # and every /api/settings call. The server has to come up so it can
+ # say what is wrong. Logged loudly once per distinct cause, because
+ # separation cannot run on any device until torch is repaired, CPU
+ # included, and a quiet fallback would hide exactly that.
+ reason = f"{type(exc).__name__}: {exc}"
+ if reason != _torch_load_error:
+ logger.error("torch is installed but could not be loaded: %s", reason, exc_info=True)
+ _torch_load_error = reason
devices.append("cpu")
return devices
diff --git a/app/main.py b/app/main.py
index 2035d665..4d5fde4f 100644
--- a/app/main.py
+++ b/app/main.py
@@ -39,6 +39,7 @@
available_torch_devices,
configure_portable_environment,
ensure_runtime_dirs,
+ torch_load_error,
)
from app.core.logging_setup import configure_logging
from app.core.process import process_exists as _process_exists
@@ -393,6 +394,9 @@ def _settings_payload() -> dict[str, object]:
"demucs_device": get_demucs_device_choice(),
"demucs_device_resolved": get_demucs_device(),
"demucs_devices_available": available_torch_devices(),
+ # Set when torch is installed but cannot load, which stops separation
+ # on every device (#730). Read after the probe above refreshed it.
+ "torch_error": torch_load_error(),
}
diff --git a/static/css/daw.css b/static/css/daw.css
index 8370923a..b945da7e 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -996,6 +996,8 @@ input, textarea { font-family: inherit; }
.folder-editor-colors { display: flex; align-items: center; gap: 9px; }
.folder-editor-colors .folder-color-dot { width: 22px; height: 22px; }
.folder-editor-msg { color: var(--danger); font-size: 11px; margin-top: 8px; }
+/* The separation engine cannot load at all (#730). */
+.set-demucs-desc.is-broken { color: var(--danger); }
.folder-editor-msg:empty { display: none; }
.folder-editor-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.folder-editor-actions button { min-height: 30px; border-radius: 7px; font-family: var(--font-mono); font-size: 11px; cursor: pointer; padding: 0 11px; }
diff --git a/static/js/catalog.js b/static/js/catalog.js
index bf2c0c70..2faad25e 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -4006,7 +4006,13 @@ async function wireGeneralSettings(overlay) {
}
if (deviceDesc) {
const resolved = d.demucs_device_resolved ? i18nT("settings.device.currently", { device: d.demucs_device_resolved }) : "";
- deviceDesc.textContent = i18nT("settings.device.desc", { resolved });
+ // torch installed but unloadable stops separation on every device, CPU
+ // included, so this replaces the usual line rather than sitting under a
+ // device list that looks healthy (#730).
+ deviceDesc.textContent = d.torch_error
+ ? i18nT("settings.device.torchBroken")
+ : i18nT("settings.device.desc", { resolved });
+ deviceDesc.classList.toggle("is-broken", !!d.torch_error);
}
};
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 35f8785b..3428135d 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -763,6 +763,7 @@ const en = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (currently: {device})",
"settings.device.notAvailable": " — not available",
+ "settings.device.torchBroken": "The separation engine could not start on this computer, so tracks cannot be separated on any device. Reinstalling StemDeck repairs it. The reason is in the log.",
"settings.quality.title": "Separation quality",
"settings.quality.desc": "Best runs the separator twice with randomized shifts and averages the result — cleaner stems, twice the time.",
"settings.quality.standard": "Standard",
@@ -1560,6 +1561,7 @@ const pl = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (obecnie: {device})",
"settings.device.notAvailable": " — niedostępne",
+ "settings.device.torchBroken": "Silnik rozdzielania nie mógł się uruchomić na tym komputerze, więc utworów nie da się rozdzielić na żadnym urządzeniu. Ponowna instalacja StemDeck to naprawia. Przyczyna jest w logu.",
"settings.quality.title": "Jakość rozdzielania",
"settings.quality.desc": "Best uruchamia separator dwukrotnie z losowymi przesunięciami i uśrednia wynik — czystsze ścieżki, dwa razy dłużej.",
"settings.quality.standard": "Standardowa",
@@ -2343,6 +2345,7 @@ const ja = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (現在: {device})",
"settings.device.notAvailable": " — 利用不可",
+ "settings.device.torchBroken": "分離エンジンがこのコンピューターで起動できなかったため、どのデバイスでも曲を分離できません。StemDeck を再インストールすると修復されます。原因はログに記録されています。",
"settings.quality.title": "分離品質",
"settings.quality.desc": "Bestはランダムなシフトで分離を2回実行し結果を平均化します — よりクリーンなパートになりますが時間は2倍。",
"settings.quality.standard": "標準",
@@ -3106,6 +3109,7 @@ const zhHans = {
"settings.device.cpu": "CPU",
"settings.device.currently": "(当前:{device})",
"settings.device.notAvailable": " — 不可用",
+ "settings.device.torchBroken": "分离引擎无法在这台电脑上启动,因此在任何设备上都无法分离曲目。重新安装 StemDeck 即可修复。原因已写入日志。",
"settings.quality.title": "分离质量",
"settings.quality.desc": "Best 会以随机偏移运行分离器两次并取平均结果 — 音轨更干净,但耗时翻倍。",
"settings.quality.standard": "标准",
@@ -3872,6 +3876,7 @@ const de = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (aktuell: {device})",
"settings.device.notAvailable": " — nicht verfügbar",
+ "settings.device.torchBroken": "Die Trenn-Engine konnte auf diesem Computer nicht starten, daher lassen sich Titel auf keinem Gerät trennen. Eine Neuinstallation von StemDeck behebt das. Der Grund steht im Protokoll.",
"settings.quality.title": "Trennqualität",
"settings.quality.desc": "„Best“ führt die Trennung zweimal mit zufälligen Verschiebungen aus und mittelt das Ergebnis — sauberere Stems, doppelte Zeit.",
"settings.quality.standard": "Standard",
@@ -4646,6 +4651,7 @@ const pt = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (atualmente: {device})",
"settings.device.notAvailable": " — indisponível",
+ "settings.device.torchBroken": "O mecanismo de separação não conseguiu iniciar neste computador, então nenhuma faixa pode ser separada em nenhum dispositivo. Reinstalar o StemDeck corrige isso. O motivo está no log.",
"settings.quality.title": "Qualidade de separação",
"settings.quality.desc": "\"Best\" executa o separador duas vezes com deslocamentos aleatórios e calcula a média do resultado — stems mais limpos, o dobro do tempo.",
"settings.quality.standard": "Padrão",
@@ -5419,6 +5425,7 @@ const id = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (saat ini: {device})",
"settings.device.notAvailable": " — tidak tersedia",
+ "settings.device.torchBroken": "Mesin pemisah tidak dapat berjalan di komputer ini, jadi lagu tidak dapat dipisahkan di perangkat mana pun. Memasang ulang StemDeck akan memperbaikinya. Penyebabnya ada di log.",
"settings.quality.title": "Kualitas pemisahan",
"settings.quality.desc": "Terbaik menjalankan pemisah dua kali dengan pergeseran acak dan merata-ratakan hasilnya — stem lebih bersih, dua kali lebih lama.",
"settings.quality.standard": "Standar",
@@ -6186,6 +6193,7 @@ const fr = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (actuellement : {device})",
"settings.device.notAvailable": " — indisponible",
+ "settings.device.torchBroken": "Le moteur de séparation n’a pas pu démarrer sur cet ordinateur : aucun morceau ne peut être séparé, quel que soit l’appareil. Réinstaller StemDeck corrige le problème. La cause figure dans le journal.",
"settings.quality.title": "Qualité de séparation",
"settings.quality.desc": "« Meilleure » exécute le séparateur deux fois avec des décalages aléatoires et fait la moyenne du résultat — des pistes plus nettes, deux fois plus de temps.",
"settings.quality.standard": "Standard",
@@ -7132,6 +7140,7 @@ const es = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (actualmente: {device})",
"settings.device.notAvailable": " — no disponible",
+ "settings.device.torchBroken": "El motor de separación no pudo iniciarse en este equipo, así que no se pueden separar pistas en ningún dispositivo. Reinstalar StemDeck lo repara. El motivo está en el registro.",
"settings.quality.title": "Calidad de la separación",
"settings.quality.desc": "«Máxima» ejecuta el separador dos veces con desplazamientos aleatorios y promedia el resultado: stems más limpios, el doble de tiempo.",
"settings.quality.standard": "Estándar",
@@ -7925,6 +7934,7 @@ const ko = {
"settings.device.cpu": "CPU",
"settings.device.currently": " (현재: {device})",
"settings.device.notAvailable": " (사용할 수 없음)",
+ "settings.device.torchBroken": "이 컴퓨터에서 분리 엔진을 시작할 수 없어 어떤 장치에서도 곡을 분리할 수 없습니다. StemDeck을 다시 설치하면 해결됩니다. 원인은 로그에 기록되어 있습니다.",
"settings.quality.title": "분리 품질",
"settings.quality.desc": "최고 품질은 분리기를 두 번 돌려 무작위로 어긋낸 결과를 평균해요. 스템이 더 깨끗해지지만 시간은 두 배 걸려요.",
"settings.quality.standard": "표준",
diff --git a/tests/test_torch_unloadable.py b/tests/test_torch_unloadable.py
new file mode 100644
index 00000000..f1d088ff
--- /dev/null
+++ b/tests/test_torch_unloadable.py
@@ -0,0 +1,87 @@
+"""A torch that is installed but cannot load must not take the server down (#730).
+
+On Windows, a CUDA DLL left behind by a half-reverted CUDA install makes torch's
+own DLL loader raise OSError (WinError 127) from `import torch` (#723). The
+device probe caught only ImportError, so with the device on "auto" the backend
+died at startup, and with it forced to CPU every /api/settings call was a 500.
+
+These install an import hook that makes `import torch` raise exactly that, so
+they run whether or not a real torch is present.
+"""
+
+from __future__ import annotations
+
+import importlib.abc
+import importlib.machinery
+import sys
+
+import pytest
+from fastapi.testclient import TestClient
+
+from app.core import config
+
+REASON = "[WinError 127] The specified procedure could not be found. Error loading c10_cuda.dll"
+
+
+class _BrokenTorchLoader(importlib.abc.Loader):
+ def create_module(self, spec):
+ return None
+
+ def exec_module(self, module):
+ raise OSError(REASON)
+
+
+class _BrokenTorchFinder(importlib.abc.MetaPathFinder):
+ def find_spec(self, name, path=None, target=None):
+ if name == "torch":
+ return importlib.machinery.ModuleSpec(name, _BrokenTorchLoader())
+ return None
+
+
+@pytest.fixture
+def broken_torch(monkeypatch):
+ for name in [m for m in sys.modules if m == "torch" or m.startswith("torch.")]:
+ monkeypatch.delitem(sys.modules, name)
+ monkeypatch.setattr(sys, "meta_path", [_BrokenTorchFinder(), *sys.meta_path])
+ monkeypatch.setattr(config, "_torch_load_error", None)
+ yield
+
+
+def test_the_probe_falls_back_to_cpu(broken_torch):
+ assert config.available_torch_devices() == ["cpu"]
+ assert config.detect_torch_device() == "cpu"
+
+
+def test_the_probe_says_why(broken_torch):
+ config.available_torch_devices()
+ assert config.torch_load_error() == f"OSError: {REASON}"
+
+
+def test_the_reason_is_logged_once_per_cause(broken_torch, caplog):
+ with caplog.at_level("ERROR", logger="stemdeck.config"):
+ config.available_torch_devices()
+ config.available_torch_devices()
+ assert len([r for r in caplog.records if "could not be loaded" in r.getMessage()]) == 1
+
+
+def test_settings_still_answer_and_carry_the_error(broken_torch):
+ from app.main import app
+
+ with TestClient(app) as c:
+ resp = c.get("/api/settings")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["demucs_devices_available"] == ["cpu"]
+ assert body["torch_error"] == f"OSError: {REASON}"
+
+
+def test_a_working_torch_reports_no_error(monkeypatch):
+ # The healthy path: whatever this machine has, a torch that imports (or is
+ # simply absent) leaves no error behind.
+ monkeypatch.setattr(config, "_torch_load_error", "OSError: stale")
+ try:
+ import torch # noqa: F401
+ except ImportError:
+ monkeypatch.setattr(config, "_torch_load_error", None)
+ config.available_torch_devices()
+ assert config.torch_load_error() is None
From e529e3a46c468b28e3d1b27cc9b6b21f664e1e56 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Wed, 30 Sep 2026 12:56:16 +0100
Subject: [PATCH 2/9] fix(library): favourite a track from its library row
The Now Playing heart was the only way to favourite, and daw.css hides
that whole card below 1460 px. A maximised laptop window is under that,
so there was no way to favourite at all, and no way to un-favourite
from the Favorites view.
- Library and Favorites rows get a heart next to the delete button.
- Every heart goes through toggleFavorite(), which saves, repaints the
Now Playing heart when it is the open track, and re-renders the list,
so the hearts cannot disagree with each other or the store.
- Row actions float over the row's right edge on hover or keyboard
focus. In the flow the hidden delete button took about 29 px from
every title at rest; a second button would have taken it again.
Delete was also invisible under keyboard focus before.
- A favourite shows a small heart in its subline at rest.
tests/e2e/favorite-reach.spec.mjs runs at 1280 and 1600 px; all four
fail on the old code.
Closes #724
---
static/css/daw.css | 35 ++++++++++++---
static/js/catalog.js | 71 +++++++++++++++++++++++------
static/js/i18n.js | 40 +++++++++++++++++
tests/e2e/favorite-reach.spec.mjs | 74 +++++++++++++++++++++++++++++++
4 files changed, 200 insertions(+), 20 deletions(-)
create mode 100644 tests/e2e/favorite-reach.spec.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index b945da7e..4f27b334 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -782,6 +782,7 @@ input, textarea { font-family: inherit; }
/* cat-item (legacy catalog items JS creates) */
.cat-item {
+ position: relative;
display: flex;
gap: 9px;
align-items: center;
@@ -905,14 +906,36 @@ input, textarea { font-family: inherit; }
.queue-row:hover .queue-top { opacity: 0.65; }
.queue-top:hover { background: rgba(232, 168, 56, 0.16); color: var(--accent); opacity: 1; }
.queue-top:disabled { opacity: 0.3; cursor: default; }
-.cat-del {
+/* A row's actions float over its right edge and show on hover or keyboard
+ focus (#724). In the flow they cost the title their width on every row at
+ rest, which is where titles were being cut short, and a second button in
+ the flow would have cost it again. The fade lets a long title run under
+ them instead of stopping at a hard edge. */
+.cat-actions {
+ position: absolute; top: 50%; right: 6px; transform: translateY(-50%);
+ display: flex; gap: 2px; padding-left: 16px;
+ background: linear-gradient(to right, transparent, var(--panel-2) 14px);
+ opacity: 0; pointer-events: none;
+ transition: opacity var(--t-fast);
+}
+.cat-item:hover .cat-actions,
+.cat-item:focus-within .cat-actions { opacity: 1; pointer-events: auto; }
+.cat-del, .cat-fav {
width: 20px; height: 20px; border: 0; border-radius: 5px;
background: transparent; color: var(--muted); cursor: pointer;
- opacity: 0; display: flex; align-items: center; justify-content: center;
- padding: 0; flex-shrink: 0; transition: opacity var(--t-fast), background var(--t-fast);
+ display: flex; align-items: center; justify-content: center;
+ padding: 0; flex-shrink: 0; transition: background var(--t-fast), color var(--t-fast);
}
-.cat-item:hover .cat-del { opacity: 1; }
.cat-del:hover { background: rgba(21,31,39,0.8); color: var(--danger); }
+.cat-fav:hover { background: rgba(21,31,39,0.8); color: var(--fg); }
+.cat-fav.active, .cat-fav.active:hover { color: #e54e4e; }
+/* A favourite says so at rest, in the subline, where it takes no title room. */
+.cat-item.is-fav .cat-sub::before {
+ content: ""; flex-shrink: 0; align-self: center;
+ width: 9px; height: 9px; background: #e54e4e;
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E") center / contain no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E") center / contain no-repeat;
+}
/* Folder items (legacy catalog) */
.folder { display: flex; flex-direction: column; }
@@ -5433,8 +5456,8 @@ input, textarea { font-family: inherit; }
* So the two things that were moved up here give way, in the order they can be
* done without: the card first, since the track is also named in the library
* and the mixer, then the toggles drop to a line of their own, which is where
- * they lived before. Nothing is lost at any width, it just stops being one
- * line.
+ * they lived before. The card's favourite heart is also on every library row
+ * (#724). Its About this song button has no second home yet (#735).
*/
@media (max-width: 1460px) {
.daw-composer > #nowPlayingPanel { display: none; }
diff --git a/static/js/catalog.js b/static/js/catalog.js
index 2faad25e..a5c28321 100644
--- a/static/js/catalog.js
+++ b/static/js/catalog.js
@@ -894,16 +894,9 @@ function applyTrackInfoToPanel(track) {
// The now-playing square shows the same format icon the library row does.
paintNowPlayingArt(trackFormat(track));
if (favBtn) {
- favBtn.classList.toggle("active", Boolean(track.favorite));
- favBtn.setAttribute("aria-pressed", String(Boolean(track.favorite)));
+ paintFavButton(favBtn, Boolean(track.favorite));
favBtn.onclick = () => {
- if (!_currentTrackId) return;
- const t = tracks[_currentTrackId];
- if (!t) return;
- t.favorite = !t.favorite;
- favBtn.classList.toggle("active", t.favorite);
- favBtn.setAttribute("aria-pressed", String(t.favorite));
- saveState();
+ if (_currentTrackId) toggleFavorite(_currentTrackId);
};
}
@@ -1425,6 +1418,53 @@ function dropOnFolder(folderId, trackId) {
render();
}
+function paintFavButton(btn, on) {
+ btn.classList.toggle("active", on);
+ btn.setAttribute("aria-pressed", String(on));
+}
+
+/**
+ * Flip a track's favourite, from whichever heart was pressed.
+ *
+ * The Now Playing heart used to be the only one, and it disappears with its
+ * card below 1460 px (#724), which left no way to favourite at all. Library
+ * rows have one too now, and every heart goes through here so none of them can
+ * disagree with the store or with each other.
+ */
+export function toggleFavorite(trackId) {
+ const track = tracks[trackId];
+ if (!track) return;
+ track.favorite = !track.favorite;
+ saveState();
+ if (trackId === _currentTrackId) {
+ const favBtn = document.getElementById("fav-btn");
+ if (favBtn) paintFavButton(favBtn, track.favorite);
+ }
+ // Rows show the state at rest, and the Favorites view lists by it.
+ render();
+}
+
+function favButtonHtml(track) {
+ const on = Boolean(track.favorite);
+ const label = i18nT(on ? "track.unfavoriteTitle" : "track.favoriteTitle", {
+ title: track.title ?? i18nT("track.unknown"),
+ });
+ return ``;
+}
+
+function wireFavButton(el, trackId) {
+ el.querySelector(".cat-fav")?.addEventListener("click", (e) => {
+ // The row itself loads the track on click.
+ e.stopPropagation();
+ toggleFavorite(trackId);
+ });
+}
+
function wireTrackDragAndLoad(el, trackId) {
el.draggable = true;
el.addEventListener("dragstart", (e) => {
@@ -1432,7 +1472,7 @@ function wireTrackDragAndLoad(el, trackId) {
});
el.addEventListener("dragend", () => endDrag(el));
el.addEventListener("click", (e) => {
- if (e.target.closest(".cat-del")) return;
+ if (e.target.closest(".cat-del, .cat-fav")) return;
loadTrackIntoStudio(trackId);
});
}
@@ -1635,7 +1675,7 @@ function renderRecentItem(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.className = `cat-item${trackId === _currentTrackId ? " active" : ""}${isUnavailable ? " unavailable" : ""}${track.favorite ? " is-fav" : ""}`;
el.dataset.id = trackId;
el.innerHTML = `
${thumbHtml(track)}
@@ -1644,7 +1684,9 @@ function renderRecentItem(trackId) {