9a116f3da4
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.
137 lines
5.8 KiB
HTML
137 lines
5.8 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ playlist.name }} — alembic{% endblock %}
|
|
{% block content %}
|
|
<a class="back-link" href="/playlists">← back to playlists</a>
|
|
|
|
<div class="page-header">
|
|
<div>
|
|
<h1>{{ playlist.name }}</h1>
|
|
<p class="subtitle muted"><a href="{{ playlist.spotify_url }}">{{ playlist.spotify_url }}</a></p>
|
|
</div>
|
|
<div class="actions">
|
|
<form method="post" action="/settings/jobs/playlist:{{ playlist.name }}/run" class="inline">
|
|
<button type="submit" class="btn btn-primary">Run now</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel" style="max-width:560px;">
|
|
<form method="post" action="/playlists/{{ playlist.id }}">
|
|
<label class="checkbox-field"><input type="checkbox" name="active" value="true" {{ "checked" if playlist.active }}> Active</label>
|
|
<div class="field">
|
|
<label for="sync_time">Sync time (daily)</label>
|
|
<input type="time" id="sync_time" name="sync_time" value="{{ sync_time }}">
|
|
<span class="hint">Leave blank for unscheduled/manual-only.</span>
|
|
</div>
|
|
<label class="checkbox-field"><input type="checkbox" name="no_m3u" value="true" {{ "checked" if playlist.no_m3u }}> Skip M3U</label>
|
|
<div class="field wide">
|
|
<label for="notes">Notes</label>
|
|
<textarea id="notes" name="notes" rows="3">{{ playlist.notes or '' }}</textarea>
|
|
</div>
|
|
<button type="submit" class="btn">Save</button>
|
|
</form>
|
|
</div>
|
|
|
|
<h2>Status</h2>
|
|
{% if error %}
|
|
<div class="notice warning">Couldn't fetch live status: {{ error }}</div>
|
|
{% elif status %}
|
|
{% set status_meta = {
|
|
"IN_LIBRARY": ("var(--status-success)", "In library"),
|
|
"QUARANTINED": ("var(--status-warning)", "Quarantined"),
|
|
"DOWNLOADED_PENDING_IMPORT": ("var(--status-info)", "Downloaded, pending import"),
|
|
"NOT_YET_ATTEMPTED": ("var(--status-muted)", "Not yet attempted")
|
|
} %}
|
|
{% set total = status.counts.values() | sum %}
|
|
<div class="status-bar">
|
|
{% for key, count in status.counts.items() %}
|
|
{% if count %}
|
|
<span style="width: {{ (count / total * 100) if total else 0 }}%; background: {{ status_meta.get(key, ('var(--status-muted)', key))[0] }};"></span>
|
|
{% endif %}
|
|
{% endfor %}
|
|
</div>
|
|
<div class="status-legend">
|
|
{% for key, count in status.counts.items() %}
|
|
<span class="item"><span class="dot" style="background: {{ status_meta.get(key, ('var(--status-muted)', key))[0] }}; color: {{ status_meta.get(key, ('var(--status-muted)', key))[0] }};"></span><strong>{{ count }}</strong> {{ status_meta.get(key, (None, key))[1] }}</span>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
{% set badge_class = {"IN_LIBRARY": "badge-success", "QUARANTINED": "badge-warning", "DOWNLOADED_PENDING_IMPORT": "badge-info", "NOT_YET_ATTEMPTED": "badge-muted"} %}
|
|
{% 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 %}
|