Credential enable/disable + descriptions, artist casing settings page, playlist compare view

Credentials page now shows a fact-checked one-line description per
service and marks Spotify/Soulseek/Navidrome as Required (no toggle --
they're load-bearing for every playlist sync). The four optional
integrations (Bandcamp, AzuraCast, Qobuz, Telegram) get a real on/off
switch: Bandcamp pauses its scheduled job (same mechanism as the Jobs
page), the other three just remove their rendered credential file, which
the scripts that read them already treat as "not configured, skip" --
no script changes needed. Disabled optional scopes also drop off the
dashboard's credential status row.

New Settings > Artist Casing page to view/add/remove entries in
artist-canonical.list without shelling in -- adding a name that already
matches case-insensitively replaces its casing in place instead of
duplicating.

Playlist detail page's status section is now two side-by-side columns:
the original Spotify tracklist on the left, and a tabbed Downloaded /
Waiting-to-download view on the right (client-side tabs via Alpine),
both with an internal scroll area so long playlists don't pull the
columns out of sync. Uses the existing status_service reconciliation
(sldl index + beets + quarantine) -- no backend changes needed there.
This commit is contained in:
andrew
2026-07-09 10:52:08 -06:00
parent 76e5b32687
commit 9a116f3da4
10 changed files with 459 additions and 19 deletions
+55
View File
@@ -0,0 +1,55 @@
from pathlib import Path
from app.settings import settings
# Instance state, not baked into the image -- seeded from
# pipeline/configs/artist-canonical.list.example on first boot (see
# entrypoint.sh) and edited from here after that. Read directly by
# normalize-artist-casing.py at run time, same file, no caching.
def _path() -> Path:
return settings.pipeline_config_dir / "artist-canonical.list"
def _is_entry_line(line: str) -> bool:
stripped = line.strip()
return bool(stripped) and not stripped.startswith("#")
def list_entries() -> list[str]:
path = _path()
if not path.exists():
return []
return [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if _is_entry_line(line)]
def add_entry(name: str) -> None:
"""Append a canonical name, or if one already matches case-insensitively
(the whole point of this list is one canonical casing per artist),
replace that line's casing instead of adding a duplicate."""
name = name.strip()
if not name:
raise ValueError("artist name cannot be empty")
path = _path()
path.parent.mkdir(parents=True, exist_ok=True)
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
for i, line in enumerate(lines):
if _is_entry_line(line) and line.strip().lower() == name.lower():
lines[i] = name
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
lines.append(name)
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def remove_entry(name: str) -> None:
path = _path()
if not path.exists():
return
lines = path.read_text(encoding="utf-8").splitlines()
lines = [line for line in lines if not (_is_entry_line(line) and line.strip() == name)]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")