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
+31 -2
View File
@@ -6,9 +6,38 @@
<div class="cred-grid">
{% for scope, fields in scope_fields.items() %}
<div class="cred-scope">
<h2 class="scope-header">{{ scope }}</h2>
{% set is_core = scope in core_scopes %}
{% set is_enabled = enabled.get(scope, True) %}
<div class="cred-scope {{ 'cred-disabled' if not is_core and not is_enabled else '' }}">
<div class="panel">
<div class="cred-scope-header">
<div>
<h2>
{{ scope }}
{% if is_core %}
<span class="badge badge-info" title="Needed for the core pipeline to work">Required</span>
{% elif configured.get(scope) %}
<span class="badge badge-success">Configured</span>
{% else %}
<span class="badge badge-muted">Not configured</span>
{% endif %}
</h2>
<p class="muted cred-desc">{{ scope_descriptions.get(scope, "") }}</p>
</div>
{% if not is_core %}
<form method="post" action="/settings/credentials/{{ scope }}/toggle" class="inline">
<label class="switch" title="{{ 'Disable' if is_enabled else 'Enable' }} {{ scope }}">
<input type="checkbox" {{ "checked" if is_enabled else "" }} onchange="this.form.submit()">
<span class="switch-track"><span class="switch-thumb"></span></span>
</label>
</form>
{% endif %}
</div>
{% if not is_core and not is_enabled %}
<p class="cred-disabled-note">Disabled — its scheduled automation is paused (or, for buy-link sources, its rendered credential file is removed) until you turn this back on. Saved values below are kept.</p>
{% endif %}
<form method="post" action="/settings/credentials/{{ scope }}">
{% for field in fields %}
<div class="field wide">
+74 -13
View File
@@ -57,19 +57,80 @@
</div>
{% set badge_class = {"IN_LIBRARY": "badge-success", "QUARANTINED": "badge-warning", "DOWNLOADED_PENDING_IMPORT": "badge-info", "NOT_YET_ATTEMPTED": "badge-muted"} %}
<div class="table-wrap scroll">
<table>
<thead><tr><th>Artist</th><th>Title</th><th>Status</th></tr></thead>
<tbody>
{% for t in status.tracks %}
<tr>
<td>{{ t.artist }}</td>
<td>{{ t.title }}</td>
<td><span class="badge {{ badge_class.get(t.status, 'badge-muted') }}">{{ status_meta.get(t.status, (None, t.status))[1] }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% set dot_class = {"IN_LIBRARY": "status-dot-success", "QUARANTINED": "status-dot-warning", "DOWNLOADED_PENDING_IMPORT": "status-dot-info", "NOT_YET_ATTEMPTED": "status-dot-muted"} %}
{% set downloaded = status.tracks | rejectattr("status", "equalto", "NOT_YET_ATTEMPTED") | list %}
{% set waiting = status.tracks | selectattr("status", "equalto", "NOT_YET_ATTEMPTED") | list %}
<div class="playlist-compare">
<div class="compare-col">
<div class="compare-col-header">
<h3>Original playlist</h3>
<span class="muted">{{ status.tracks | length }} track{{ "s" if status.tracks | length != 1 else "" }}</span>
</div>
<div class="table-wrap compare-scroll">
<table>
<thead><tr><th></th><th>Artist</th><th>Title</th></tr></thead>
<tbody>
{% for t in status.tracks %}
<tr>
<td><span class="status-dot {{ dot_class.get(t.status, 'status-dot-muted') }}" title="{{ status_meta.get(t.status, (None, t.status))[1] }}"></span></td>
<td>{{ t.artist }}</td>
<td>{{ t.title }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="3" class="muted">No tracks found in this playlist.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="compare-col" x-data="{ tab: 'downloaded' }" x-cloak>
<div class="compare-col-header">
<h3>By download status</h3>
</div>
<div class="seg-tabs">
<button type="button" :class="{ active: tab === 'downloaded' }" @click="tab = 'downloaded'">Downloaded ({{ downloaded | length }})</button>
<button type="button" :class="{ active: tab === 'waiting' }" @click="tab = 'waiting'">Waiting to download ({{ waiting | length }})</button>
</div>
<div x-show="tab === 'downloaded'">
<div class="table-wrap compare-scroll">
<table>
<thead><tr><th>Artist</th><th>Title</th><th>Status</th></tr></thead>
<tbody>
{% for t in downloaded %}
<tr>
<td>{{ t.artist }}</td>
<td>{{ t.title }}</td>
<td><span class="badge {{ badge_class.get(t.status, 'badge-muted') }}">{{ status_meta.get(t.status, (None, t.status))[1] }}</span></td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="3" class="muted">Nothing downloaded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div x-show="tab === 'waiting'">
<div class="table-wrap compare-scroll">
<table>
<thead><tr><th>Artist</th><th>Title</th></tr></thead>
<tbody>
{% for t in waiting %}
<tr>
<td>{{ t.artist }}</td>
<td>{{ t.title }}</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="2" class="muted">Everything's been attempted at least once.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
+2 -1
View File
@@ -4,7 +4,7 @@
<div class="page-header">
<div>
<h1>Settings</h1>
<p class="subtitle muted">Credentials and background jobs.</p>
<p class="subtitle muted">Credentials, background jobs, and library conventions.</p>
</div>
</div>
@@ -12,6 +12,7 @@
<nav class="settings-nav">
<a href="/settings/credentials" class="{{ 'active' if request.url.path.startswith('/settings/credentials') else '' }}">Credentials</a>
<a href="/settings/jobs" class="{{ 'active' if request.url.path.startswith('/settings/jobs') else '' }}">Jobs</a>
<a href="/settings/artist-casing" class="{{ 'active' if request.url.path.startswith('/settings/artist-casing') else '' }}">Artist Casing</a>
</nav>
<div class="settings-content">
{% block settings_content %}{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "settings/_layout.html" %}
{% block settings_title %}Artist Casing{% endblock %}
{% block settings_content %}
<h2 style="margin-top:0;">Artist casing</h2>
<p class="muted" style="margin-top:-0.75rem; max-width:60ch;">
Canonical spelling/casing for artist names, exactly as they're stylized on Spotify.
The <span class="mono">Normalize artist casing</span> job (Settings &rarr; <a href="/settings/jobs">Jobs</a>)
rewrites any album artist tag that matches a name here case-insensitively but differs
in casing &mdash; e.g. <span class="mono">MALL GRAB</span> &rarr; <span class="mono">Mall Grab</span>.
This prevents Linux and Windows from disagreeing about whether two folder names are
the same, which breaks Syncthing.
</p>
<div class="panel" style="max-width:560px;">
<form method="post" action="/settings/artist-casing/add" style="display:flex; gap:0.6rem; align-items:flex-end;">
<div class="field wide" style="margin-bottom:0; flex:1;">
<label for="name">Artist name</label>
<input type="text" id="name" name="name" placeholder="exactly as Spotify displays it" required>
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
</div>
<h2>Canonical list <span class="muted" style="font-weight:400;">({{ entries | length }})</span></h2>
<div class="table-wrap scroll">
<table>
<thead><tr><th>Artist</th><th></th></tr></thead>
<tbody>
{% for name in entries | sort(case_sensitive=False) %}
<tr>
<td>{{ name }}</td>
<td class="actions-cell">
<form method="post" action="/settings/artist-casing/remove" class="inline"
onsubmit="return confirm('Remove {{ name }} from the canonical list?')">
<input type="hidden" name="name" value="{{ name }}">
<button type="submit" class="btn btn-sm btn-ghost">Remove</button>
</form>
</td>
</tr>
{% else %}
<tr class="empty-row"><td colspan="2">
<div class="empty-state">
<span class="sparkle" style="position:relative; display:inline-block; margin-bottom:0.5rem;"></span>
<p class="muted" style="margin:0;">No canonical names yet. Add one above.</p>
</div>
</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}