e5d9c7f043
- Add "Connect Spotify" OAuth flow (/connect/spotify) so newly created Spotify apps can read playlists: Spotify now requires a user token for playlist reads, which client-credentials alone can no longer provide. The stored refresh token is rendered as spotify-refresh into each playlist's sldl .conf -- sldl's own docs confirm supplying it skips its interactive login flow, which is what was causing multi-hour hangs on a stuck sync. Grandfathered apps keep working unchanged if no account is connected. - Fix the connect flow's redirect_uri: request.url_for() reflected the raw connection scheme (http) rather than what the reverse proxy actually served over, which Spotify's exact-match redirect_uri check rejected. Force https rather than trust the unproxied scheme. - Add an AzuraCast base URL credential field alongside the API key, with a keyfile fallback so the scheduled enrich-buy-url.py job can actually reach AzuraCast (previously it had no way to receive a base URL at all when run from the scheduler, so the reprocess call was silently always skipped). - Fix build-fingerprint-index.py exiting non-zero on any single fingerprint failure instead of only when nothing was written. - Guard enrich-buy-url.py's AzuraCast reprocess against an empty --azuracast-base (raised ValueError since PD2 removed the hardcoded base). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
111 lines
4.9 KiB
HTML
111 lines
4.9 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Dashboard — alembic{% endblock %}
|
|
{% block content %}
|
|
{% if setup_warnings %}
|
|
<div class="panel" style="border:1px solid #b91c1c;margin-bottom:1.5rem;">
|
|
<strong>Setup needs attention</strong>
|
|
<ul class="muted" style="margin:0.5rem 0 0;padding-left:1.2rem;">
|
|
{% for c in setup_warnings %}<li>{{ c.detail }}</li>{% endfor %}
|
|
</ul>
|
|
<p class="muted" style="margin:0.5rem 0 0;">Full checklist on the <a href="/setup">setup page</a>.</p>
|
|
</div>
|
|
{% endif %}
|
|
<div class="page-header">
|
|
<div>
|
|
<h1>Welcome back, {{ user.name or user.email }}</h1>
|
|
<p class="subtitle muted">Distilling Spotify playlists into a tuned, tagged library.</p>
|
|
</div>
|
|
<div class="actions">
|
|
<a class="btn btn-primary" href="/playlists">Manage playlists</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stat-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-value chrome-text">{{ stats.total_tracks }}</div>
|
|
<div class="stat-label">Tracks in library{% if not stats.db_exists %} <span class="muted">(beets db not found)</span>{% endif %}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ active_playlist_count }}<span class="muted"> / {{ playlist_count }}</span></div>
|
|
<div class="stat-label">Playlists active</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ stats.groupings | length }}</div>
|
|
<div class="stat-label">Distinct groupings</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">{{ total_size_human }}</div>
|
|
<div class="stat-label">Approx. library size</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value mono" style="font-size:1.5rem;"
|
|
title="{% if public_ip.error and not public_ip.ip %}Couldn't check: {{ public_ip.error }}{% elif public_ip.error %}Showing last known good check -- most recent check failed: {{ public_ip.error }}{% else %}alembic's outbound IP. If you route Soulseek through a VPN, confirm this is the VPN's IP and not your home connection's.{% endif %}">
|
|
{% if public_ip.ip %}{{ public_ip.ip }}{% else %}<span class="muted">unknown</span>{% endif %}
|
|
</div>
|
|
<div class="stat-label">
|
|
<span class="status-dot {{ 'status-dot-success' if public_ip.ip and not public_ip.error else ('status-dot-warning' if public_ip.ip else 'status-dot-muted') }}" style="margin-right:0.3rem;"></span>
|
|
Outbound IP
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>File formats</h2>
|
|
{% if stats.formats %}
|
|
<ul class="chip-list">
|
|
{% for f in stats.formats %}
|
|
<li>{{ f.format }} <span class="muted">× {{ f.count }}</span></li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% else %}
|
|
<p class="muted">No format data yet.</p>
|
|
{% endif %}
|
|
|
|
<h2>Credential status</h2>
|
|
<ul class="chip-list auth-chip-list">
|
|
{% for a in auth_states %}
|
|
<li title="{{ a.scope }}: {{ 'configured' if a.configured else 'not configured' }}{% if a.days_left is defined %}, cookie expires in {{ a.days_left }}d{% endif %}{% if a.spotify_connected is defined %}, {{ 'OAuth connected' if a.spotify_connected else 'app-only (client-credentials)' }}{% endif %}"><span class="status-dot {{ 'status-dot-success' if a.configured else 'status-dot-muted' }}{% if a.days_left is defined and a.days_left < 14 %} status-dot-warning{% endif %}"></span>{{ a.scope }}{% if a.days_left is defined %}<span class="muted"> {{ a.days_left }}d</span>{% endif %}{% if a.spotify_connected %}<span class="muted"> (OAuth)</span>{% endif %}</li>
|
|
{% endfor %}
|
|
</ul>
|
|
<p><a href="/settings/credentials">Manage credentials →</a></p>
|
|
|
|
<h2>Downloaded in the last 24 hours</h2>
|
|
<div class="table-wrap scroll">
|
|
<table>
|
|
<thead><tr><th>Artist</th><th>Title</th><th>Playlist</th><th>Format</th></tr></thead>
|
|
<tbody>
|
|
{% for t in recent_tracks %}
|
|
<tr>
|
|
<td>{{ t.artist }}</td>
|
|
<td>{{ t.title }}</td>
|
|
<td class="muted">{{ t.grouping }}</td>
|
|
<td><span class="badge badge-muted">{{ t.format }}</span></td>
|
|
</tr>
|
|
{% else %}
|
|
<tr class="empty-row"><td colspan="4" class="muted">Nothing downloaded in the last 24 hours.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<h2>Recent job runs</h2>
|
|
<div class="table-wrap scroll">
|
|
<table>
|
|
<thead><tr><th>Job</th><th>Status</th><th>Triggered by</th><th>Summary</th></tr></thead>
|
|
<tbody>
|
|
{% set status_badge = {"running": "badge-info badge-pulse", "success": "badge-success", "failed": "badge-danger", "skipped_lock": "badge-muted"} %}
|
|
{% for run in recent_runs %}
|
|
<tr>
|
|
<td>{{ humanize_job_key(run.job_key) }}</td>
|
|
<td><span class="badge {{ status_badge.get(run.status, 'badge-muted') }}">{{ run.status }}</span></td>
|
|
<td class="muted">{{ run.triggered_by }}</td>
|
|
<td>{{ run.summary or "—" }}</td>
|
|
</tr>
|
|
{% else %}
|
|
<tr class="empty-row"><td colspan="4" class="muted">No job runs yet.</td></tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<p><a href="/settings/jobs">See all jobs →</a></p>
|
|
{% endblock %}
|