Files
alembic/app/templates/dashboard.html
T
andrew 59c369c9e2 Fix playlist status page, dedup delete-by-path bug, duplicate scan rows; add public IP dashboard card
Playlist detail page's status section (and the new two-column compare
view) was never rendering: the router awaited status_service.playlist_status(),
but that function is plain sync, so awaiting its dict return raised
"object dict can't be used in 'await' expression" on every load, silently
caught and shown as a generic fetch error. Removed the erroneous await.

find-fuzzy-dupes.py's --apply and --apply-pairs both deleted by a `path::`
regex query even though the beets id was already available in scope --
same mixed-path-storage issue (pre/post beets-2.11-upgrade items store
absolute vs. library-relative paths) dedup-library.sh already worked
around by deleting via id instead. This silently failed to match for most
confirmed fuzzy-audio deletions (verified: 19 of 20 in one run). Added a
delete_id column to dedup_candidates, threaded through the scan/apply
pipeline, and switched both delete call sites to `id:`.

Dedup scans also never checked whether a pair was already sitting in the
pending list, so every re-scan (including the daily schedule) added a new
row for the same unreviewed duplicate -- cleaned up 38 redundant rows
already in production and added a check so future scans skip a pair
that's already pending.

Dashboard gets a Public IP stat card (cached 10 min, fetched via ipify)
as a quick confidence check that outbound traffic is actually routed
through gluetun's VPN and not the home connection.
2026-07-09 11:13:58 -06:00

102 lines
4.2 KiB
HTML

{% extends "base.html" %}
{% block title %}Dashboard — alembic{% endblock %}
{% block content %}
<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 %}This should be gluetun's VPN IP, 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>
Public IP <span class="muted">(via gluetun)</span>
</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">&times; {{ 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 %}"><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 %}</li>
{% endfor %}
</ul>
<p><a href="/settings/credentials">Manage credentials &rarr;</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 &rarr;</a></p>
{% endblock %}