Initial import: Music_Server, MusicFree, catalog-sync

This commit is contained in:
2026-05-23 16:51:14 +08:00
commit 069af30dba
847 changed files with 179878 additions and 0 deletions
@@ -0,0 +1,450 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ title or "Catalogsync Ops" }}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.35;
margin: 0;
background: #f5f7fb;
color: #1b2533;
}
[hidden] {
display: none !important;
}
nav {
background: #0f172a;
padding: 0.65rem 0.85rem;
}
nav a {
color: #dbeafe;
text-decoration: none;
margin-right: 0.85rem;
font-size: 0.9rem;
}
main {
padding: 0.85rem;
}
table {
border-collapse: collapse;
width: 100%;
background: #fff;
}
th, td {
border: 1px solid #dbe2ea;
padding: 0.32rem 0.42rem;
text-align: left;
vertical-align: top;
font-size: 0.86rem;
}
.playlist-sort-th {
padding: 0;
}
.playlist-sort-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.35rem;
width: 100%;
box-sizing: border-box;
color: inherit;
text-decoration: none;
padding: 0.32rem 0.42rem;
}
.playlist-sort-link:hover {
text-decoration: underline;
background: #f8fafc;
}
.playlist-sort-indicator {
color: #475569;
font-size: 0.75rem;
line-height: 1;
}
h1 {
margin-top: 0;
margin-bottom: 0.7rem;
font-size: 1.35rem;
}
h2, h3 {
margin-top: 0;
margin-bottom: 0.55rem;
}
.card {
background: #fff;
border: 1px solid #dbe2ea;
border-radius: 6px;
padding: 0.7rem;
margin-bottom: 0.8rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.8rem;
}
form {
display: grid;
gap: 0.55rem;
}
input, select, button, textarea {
font: inherit;
}
input, select, textarea {
width: 100%;
box-sizing: border-box;
padding: 0.38rem 0.48rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
}
button {
width: fit-content;
padding: 0.38rem 0.6rem;
border: 0;
border-radius: 6px;
background: #0f172a;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
line-height: 1.2;
}
button.secondary {
background: #475569;
}
.button-grid {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.muted {
color: #64748b;
}
.progress-cell {
min-width: 180px;
}
.progress-meta {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.2rem;
font-size: 0.78rem;
}
.progress-bar {
width: 100%;
height: 0.5rem;
background: #e2e8f0;
border-radius: 999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #0f766e, #14b8a6);
}
.progress-note {
margin-top: 0.25rem;
font-size: 0.85rem;
}
.task-playlist-tree {
margin-top: 0.9rem;
}
.task-tree-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 0.85rem;
align-items: start;
}
.task-tree-panel {
display: grid;
gap: 0.55rem;
min-width: 0;
align-content: start;
align-self: start;
}
.task-tree-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-wrap: wrap;
}
.task-tree-panel-head h3 {
margin: 0;
font-size: 1rem;
}
.task-tree {
display: grid;
gap: 0.45rem;
}
.task-tree-node {
border: 1px solid #dbe2ea;
border-radius: 6px;
background: #f8fafc;
}
.task-tree-node-playlist,
.task-tree-song {
border-color: #e2e8f0;
background: #fff;
}
.task-tree-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(180px, 250px) auto;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 0.6rem;
}
.task-tree-row-child {
padding-left: 1.1rem;
}
.task-tree-main {
min-width: 0;
}
.task-tree-title-line {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.task-tree-title-line strong {
font-size: 0.88rem;
line-height: 1.2;
}
.task-tree-meta-inline {
flex: 1 1 180px;
min-width: 0;
font-size: 0.72rem;
line-height: 1.15;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-tree-progress {
min-width: 0;
}
.task-tree-state {
max-width: 280px;
font-size: 0.74rem;
line-height: 1.2;
}
.task-tree-actions {
display: flex;
justify-content: flex-end;
}
.task-tree-children {
display: grid;
gap: 0.4rem;
padding: 0 0.6rem 0.55rem 0.6rem;
}
.task-tree-children-songs {
padding-left: 2rem;
}
.task-tree-song {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto minmax(100px, 210px);
gap: 0.45rem;
align-items: center;
padding: 0.45rem 0.55rem;
}
.task-tree-song-index {
color: #64748b;
font-size: 0.75rem;
}
.task-tree-song-note {
color: #334155;
font-size: 0.74rem;
line-height: 1.2;
}
.tree-toggle {
min-width: 1.55rem;
padding: 0.16rem 0.32rem;
font-size: 0.78rem;
line-height: 1.05;
border-radius: 4px;
}
.tree-spacer {
display: block;
width: 1.55rem;
height: 1.45rem;
}
.inline-tree .tree-toggle {
min-width: 2rem;
padding: 0.25rem 0.5rem;
}
.tree-row-detail > td {
background: #f8fafc;
}
.song-progress-table {
margin-top: 0.35rem;
}
.song-note {
color: #334155;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.playlist-name-button {
border: 0;
padding: 0;
margin: 0;
background: transparent;
color: #0f4c81;
text-decoration: underline;
cursor: pointer;
font: inherit;
line-height: inherit;
}
.playlist-name-button:hover {
color: #0b3a62;
}
.playlist-modal {
position: fixed;
inset: 0;
z-index: 2000;
}
.playlist-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.45);
}
.playlist-modal-panel {
position: relative;
z-index: 1;
width: min(96vw, 1440px);
max-height: 88vh;
margin: 2.2vh auto;
background: #fff;
border: 1px solid #dbe2ea;
border-radius: 10px;
box-shadow: 0 22px 70px rgba(15, 23, 42, 0.28);
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
.playlist-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.8rem;
padding: 0.8rem 0.9rem 0.65rem 0.9rem;
border-bottom: 1px solid #e2e8f0;
}
.playlist-modal-header h2 {
margin-bottom: 0.2rem;
}
.playlist-modal-meta {
margin: 0;
font-size: 0.78rem;
}
.playlist-modal-body {
padding: 0.75rem 0.9rem 0.9rem 0.9rem;
overflow: auto;
}
.playlist-modal-table-wrap {
overflow: auto;
}
.playlist-song-locations {
min-width: 160px;
font-size: 0.74rem;
line-height: 1.25;
color: #334155;
word-break: break-all;
}
.playlist-song-locations .muted {
display: block;
font-size: 0.72rem;
}
.playlist-modal-close {
min-width: 2rem;
}
.status-tag {
display: inline-flex;
align-items: center;
padding: 0.08rem 0.34rem;
border-radius: 999px;
border: 1px solid #cbd5e1;
font-size: 0.68rem;
line-height: 1.05;
margin-right: 0;
margin-bottom: 0;
background: #f8fafc;
color: #334155;
}
.status-downloaded {
background: #dcfce7;
border-color: #86efac;
color: #166534;
}
.status-running {
background: #dbeafe;
border-color: #93c5fd;
color: #1d4ed8;
}
.status-pending {
background: #f1f5f9;
border-color: #cbd5e1;
color: #334155;
}
.status-failed {
background: #fee2e2;
border-color: #fca5a5;
color: #991b1b;
}
.status-skipped {
background: #fef3c7;
border-color: #fcd34d;
color: #92400e;
}
.status-tag.non-music {
background: #fff7ed;
border-color: #fdba74;
color: #9a3412;
}
pre {
background: #0f172a;
color: #e2e8f0;
padding: 0.8rem;
overflow: auto;
}
code {
background: #eef2f7;
padding: 0.1rem 0.3rem;
}
@media (max-width: 900px) {
.task-tree-columns {
grid-template-columns: 1fr;
}
.task-tree-row,
.task-tree-song {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.task-tree-actions {
justify-content: flex-start;
}
.task-tree-children-songs {
padding-left: 1rem;
}
.task-tree-meta-inline {
flex-basis: 100%;
white-space: normal;
}
}
</style>
<script src="/static/ops/app.js?v=20260418_playlist_playcount_v1" defer></script>
</head>
<body{% if sse_url %} data-sse-url="{{ sse_url }}"{% endif %}{% if dashboard_api_url %} data-dashboard-api="{{ dashboard_api_url }}"{% endif %}>
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/jobs">Jobs</a>
<a href="/playlists">Playlists</a>
<a href="/songs">Songs</a>
<a href="/logs">Logs</a>
<a href="/config">Config</a>
</nav>
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,57 @@
{% extends "ops/base.html" %}
{% block content %}
<h1>Config</h1>
<div class="card">
<h2>Current Env</h2>
<pre>{{ env_content }}</pre>
</div>
<div class="card">
<h2>Parsed Values</h2>
<table>
<thead>
<tr>
<th>Key</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for key, value in env_values.items() %}
<tr>
<td><code>{{ key }}</code></td>
<td>{{ value }}</td>
</tr>
{% else %}
<tr><td colspan="2">No parsed values.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Revisions</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Created</th>
<th>Applied</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{% for revision in revisions %}
<tr>
<td>{{ revision.id }}</td>
<td>{{ revision.created_at }}</td>
<td>{{ revision.applied_at or "-" }}</td>
<td>{{ revision.note or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="4">No revisions.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,273 @@
{% extends "ops/base.html" %}
{% block content %}
{% set done_statuses = ("completed", "completed_with_errors", "failed", "canceled") %}
{% macro render_task_tree_node(row) -%}
{% set row_status = row.status or "" %}
{% set toggle_command = "resume" if row_status in ("paused", "pause_requested") else "pause" if row_status in ("queued", "running") else "" %}
{% set can_cancel = row_status in ("queued", "running", "paused", "pause_requested") %}
<section class="task-tree-node task-tree-node-task" data-task-node="{{ row.id }}">
<div class="task-tree-row">
<button type="button" class="tree-toggle" data-task-toggle="{{ row.id }}" aria-expanded="false" aria-label="Expand task {{ row.id }}">+</button>
<div class="task-tree-main">
<div class="task-tree-title-line">
<strong data-task-name>{{ row.display_name }}</strong>
<span class="muted task-tree-meta-inline" data-task-meta-inline>#{{ row.id }} / {{ row.job_type }} / {{ row.scope_summary }} / {{ row.queue_label or row.lane_type or "-" }} / workers {{ row.active_worker_count }}</span>
<span class="status-tag status-{{ row.status }}" data-task-status>{{ row.status }}</span>
</div>
</div>
<div class="task-tree-progress" data-task-progress>
<div class="progress-meta">
<span>{{ row.primary_progress_text or "-" }}</span>
<strong>{{ row.primary_progress_percent or 0 }}%</strong>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ row.primary_progress_percent or 0 }}%;"></div>
</div>
</div>
<div class="task-tree-actions">
<div class="button-grid">
{% if toggle_command %}
<button
type="button"
data-task-command-toggle="{{ row.id }}"
data-task-command-type="{{ toggle_command }}"
>
{% if toggle_command == "resume" %}&gt;{% else %}||{% endif %}
</button>
{% else %}
<span class="muted">-</span>
{% endif %}
{% if can_cancel %}
<button type="button" class="secondary" data-task-command-cancel="{{ row.id }}">x</button>
{% endif %}
</div>
</div>
</div>
<div class="task-tree-children" data-task-children="{{ row.id }}" hidden>
<p class="muted">Expand to load playlists...</p>
</div>
</section>
{%- endmacro %}
<h1>Task Center</h1>
<div class="card">
<div data-live-status>Live snapshot: waiting...</div>
</div>
<div class="grid">
<div class="card">
<h2>Summary</h2>
<table>
<tr><th>Total Jobs</th><td data-summary-field="total_jobs">{{ summary.total_jobs }}</td></tr>
<tr><th>Queued</th><td data-summary-field="queued_jobs">{{ summary.queued_jobs }}</td></tr>
<tr><th>Queued Download Jobs</th><td data-summary-field="queued_download_jobs">{{ summary.queued_download_jobs }}</td></tr>
<tr><th>Running</th><td data-summary-field="running_jobs">{{ summary.running_jobs }}</td></tr>
<tr><th>Paused</th><td data-summary-field="paused_jobs">{{ summary.paused_jobs }}</td></tr>
<tr><th>Failed / Errors</th><td data-summary-field="failed_jobs">{{ summary.failed_jobs }}</td></tr>
<tr><th>Downloaded Songs</th><td data-download-field="downloaded_songs">{{ download_stats.downloaded_songs }}</td></tr>
<tr><th>Running Songs</th><td data-download-field="running_song_items">{{ download_stats.running_song_items }}</td></tr>
</table>
</div>
<div class="card">
<h2>Quick Actions</h2>
<div class="button-grid">
<form action="/api/jobs" method="post" data-json-form data-success="reload">
<input type="hidden" name="job_type" value="catalog_sync" />
<input type="hidden" name="requested_by" value="ops-console" />
<input type="hidden" name="sources" value="{{ default_sources }}" />
<input type="hidden" name="download_sources" value="{{ default_download_sources }}" />
<button type="submit">Full Pipeline</button>
</form>
<form action="/api/jobs" method="post" data-json-form data-success="reload">
<input type="hidden" name="job_type" value="collect_only" />
<input type="hidden" name="requested_by" value="ops-console" />
<input type="hidden" name="sources" value="{{ default_sources }}" />
<button type="submit">Collect</button>
</form>
<form action="/api/jobs" method="post" data-json-form data-success="reload">
<input type="hidden" name="job_type" value="sync_only" />
<input type="hidden" name="requested_by" value="ops-console" />
<input type="hidden" name="sources" value="{{ default_sources }}" />
<button type="submit">Sync</button>
</form>
<form action="/api/jobs" method="post" data-json-form data-success="reload">
<input type="hidden" name="job_type" value="download_only" />
<input type="hidden" name="requested_by" value="ops-console" />
<input type="hidden" name="download_sources" value="{{ default_download_sources }}" />
<button type="submit">Download</button>
</form>
<form action="/api/jobs" method="post" data-json-form data-success="reload">
<input type="hidden" name="job_type" value="upload_only" />
<input type="hidden" name="requested_by" value="ops-console" />
<input type="hidden" name="download_sources" value="{{ default_download_sources }}" />
<button type="submit">Upload</button>
</form>
</div>
</div>
<div class="card">
<h2>Create Job</h2>
<form action="/api/jobs" method="post" data-json-form data-success="reload">
<label>
Job Type
<select name="job_type">
{% for value, label in job_type_options %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
Requested By
<input type="text" name="requested_by" value="ops-console" />
</label>
<label>
Collect Sources
<input type="text" name="sources" value="{{ default_sources }}" />
</label>
<label>
Download Sources
<input type="text" name="download_sources" value="{{ default_download_sources }}" />
</label>
<button type="submit">Create Job</button>
</form>
</div>
<div class="card">
<h2>Playlist Coverage</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Pool Kind</th>
<th>Pool Name</th>
<th>Playlists</th>
</tr>
</thead>
<tbody data-playlist-sources-body>
{% for row in playlist_sources %}
<tr>
<td>{{ row.platform }}</td>
<td>{{ row.pool_kind }}</td>
<td>{{ row.pool_name }}</td>
<td>{{ row.playlist_count }}</td>
</tr>
{% else %}
<tr><td colspan="4">No playlist sources collected yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div
class="card"
data-maintenance-panel="local-duplicates"
data-scan-api="{{ maintenance_local_duplicates_scan_api }}"
data-dedupe-api="{{ maintenance_local_duplicates_dedupe_api }}"
>
<h2>Maintenance</h2>
<div class="button-grid">
<button type="button" data-maintenance-action="scan">Scan Duplicate Local Copies</button>
<button type="button" class="secondary" data-maintenance-action="dedupe">Run Local Dedupe</button>
</div>
<p class="muted" data-maintenance-status>No local duplicate scan has been run yet.</p>
<div data-maintenance-result>
<p class="muted">Scan first to inspect duplicate local file copies before dedupe.</p>
</div>
</div>
</div>
<div class="card">
<div class="task-tree-panel-head">
<h2>Task Center</h2>
<span class="muted" data-task-center-transfer>Down {{ transfer_stats.download_speed_text }} | Up {{ transfer_stats.upload_speed_text }}</span>
</div>
<div class="task-tree-columns">
<section class="task-tree-panel">
<div class="task-tree-panel-head">
<h3>Doing</h3>
<span class="muted">Task -> Playlist -> Song</span>
</div>
<div class="task-tree" data-task-tree-root="doing">
{% for row in doing_task_rows %}
{{ render_task_tree_node(row) }}
{% else %}
<p class="muted" data-task-tree-empty>No active tasks.</p>
{% endfor %}
</div>
</section>
<section class="task-tree-panel">
<div class="task-tree-panel-head">
<h3>Recent Done</h3>
<span class="muted">Task -> Playlist</span>
</div>
<div class="task-tree" data-task-tree-root="done">
{% for row in done_task_rows %}
{{ render_task_tree_node(row) }}
{% else %}
<p class="muted" data-task-tree-empty>No recently finished tasks.</p>
{% endfor %}
</div>
</section>
</div>
</div>
<div class="grid">
<div class="card">
<h2>Active Workers</h2>
<table>
<thead>
<tr>
<th>Worker</th>
<th>Status</th>
<th>Stage</th>
<th>Current Item</th>
<th>Progress</th>
</tr>
</thead>
<tbody data-workers-body>
{% for worker in workers %}
<tr>
<td>{{ worker.worker_name }}</td>
<td>{{ worker.status }}</td>
<td>{{ worker.stage_type or "-" }}</td>
<td>{{ worker.display_text or "-" }}</td>
<td>{{ worker.last_progress_text or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="5">No active workers.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Running Items</h2>
<table>
<thead>
<tr>
<th>Job</th>
<th>Worker</th>
<th>Stage</th>
<th>Item</th>
<th>Started</th>
</tr>
</thead>
<tbody data-running-items-body>
{% for item in running_items %}
<tr>
<td><a href="/jobs/{{ item.job_run_id }}">{{ item.job_run_id }}</a></td>
<td>{{ item.worker_name or "-" }}</td>
<td>{{ item.stage_type }}</td>
<td>{{ item.display_name }}</td>
<td>{{ item.started_at or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="5">No running items.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
@@ -0,0 +1,223 @@
{% extends "ops/base.html" %}
{% block content %}
<p><a href="/dashboard">Back to Dashboard</a></p>
<h1>Job {{ job.id }}</h1>
<div class="grid">
<div class="card">
<table>
<tr><th>Type</th><td>{{ job.job_type }}</td></tr>
<tr><th>Status</th><td>{{ job.status }}</td></tr>
<tr><th>Requested By</th><td>{{ job.requested_by or "-" }}</td></tr>
<tr><th>Created</th><td>{{ job.created_at or "-" }}</td></tr>
<tr><th>Started</th><td>{{ job.started_at or "-" }}</td></tr>
<tr><th>Ended</th><td>{{ job.ended_at or "-" }}</td></tr>
</table>
</div>
<div class="card">
<h2>Job Commands</h2>
<div class="button-grid">
<form action="{{ command_endpoint }}" method="post" data-json-form data-success="reload">
<input type="hidden" name="command_type" value="pause" />
<button type="submit">暂停任务</button>
</form>
<form action="{{ command_endpoint }}" method="post" data-json-form data-success="reload">
<input type="hidden" name="command_type" value="resume" />
<button type="submit">继续任务</button>
</form>
<form action="{{ command_endpoint }}" method="post" data-json-form data-success="reload">
<input type="hidden" name="command_type" value="cancel" />
<button type="submit" class="secondary">取消任务</button>
</form>
</div>
<form action="{{ command_endpoint }}" method="post" data-json-form data-success="reload">
<input type="hidden" name="command_type" value="retry_item" />
<label>
Retry Item Id
<input type="number" name="target_item_id" min="1" />
</label>
<button type="submit">Retry Item</button>
</form>
<form action="{{ command_endpoint }}" method="post" data-json-form data-success="reload">
<input type="hidden" name="command_type" value="force_retry_item" />
<label>
Force Retry Item Id
<input type="number" name="target_item_id" min="1" />
</label>
<button type="submit">Force Retry Item</button>
<p class="muted">Use this when a single item needs to be replayed from scratch.</p>
</form>
</div>
<div class="card">
<h2>Download Stats</h2>
<table>
<tr><th>Total Songs</th><td>{{ download_stats.total_songs }}</td></tr>
<tr><th>Downloaded Songs</th><td>{{ download_stats.downloaded_songs }}</td></tr>
<tr><th>Local Files</th><td>{{ download_stats.local_file_locations }}</td></tr>
<tr><th>Running Songs</th><td>{{ download_stats.running_song_items }}</td></tr>
</table>
</div>
</div>
<div class="card">
<h2>Stages</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Stage</th>
<th>Status</th>
<th>Total</th>
<th>Pending</th>
<th>Running</th>
<th>Succeeded</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
{% for stage in stages %}
<tr>
<td>{{ stage.id }}</td>
<td>{{ stage.stage_type }}</td>
<td>{{ stage.status }}</td>
<td>{{ stage.total_items }}</td>
<td>{{ stage.pending_items }}</td>
<td>{{ stage.running_items }}</td>
<td>{{ stage.success_items }}</td>
<td>{{ stage.failed_items }}</td>
</tr>
{% else %}
<tr><td colspan="8">No stages.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Playlist Progress</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Playlist</th>
<th>Progress</th>
<th>Total Songs</th>
<th>Downloaded</th>
<th>Running</th>
<th>Pending</th>
<th>Failed</th>
<th>Skipped</th>
</tr>
</thead>
<tbody>
{% for playlist in playlist_progress %}
<tr>
<td>{{ playlist.playlist_id }}</td>
<td>{{ playlist.playlist_name }}</td>
<td class="progress-cell">
<div class="progress-meta">
<span>{{ playlist.downloaded_songs or 0 }} / {{ playlist.total_songs or 0 }}</span>
<strong>{{ playlist.progress_percent or 0 }}%</strong>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ playlist.progress_percent or 0 }}%;"></div>
</div>
</td>
<td>{{ playlist.total_songs or 0 }}</td>
<td>{{ playlist.downloaded_songs or 0 }}</td>
<td>{{ playlist.running_songs or 0 }}</td>
<td>{{ playlist.pending_songs or 0 }}</td>
<td>{{ playlist.failed_songs or 0 }}</td>
<td>{{ playlist.skipped_songs or 0 }}</td>
</tr>
{% else %}
<tr><td colspan="9">No playlist-scoped progress for this job.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Workers</h2>
<table>
<thead>
<tr>
<th>Worker</th>
<th>Status</th>
<th>Stage</th>
<th>Current Song / Playlist</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{% for worker in workers %}
<tr>
<td>{{ worker.worker_name }}</td>
<td>{{ worker.status }}</td>
<td>{{ worker.stage_type or "-" }}</td>
<td>{{ worker.display_text or "-" }}</td>
<td>{{ worker.last_progress_text or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="5">No workers recorded yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Running Items</h2>
<table>
<thead>
<tr>
<th>Worker</th>
<th>Stage</th>
<th>Item</th>
<th>Started</th>
</tr>
</thead>
<tbody>
{% for item in running_items %}
<tr>
<td>{{ item.worker_name or "-" }}</td>
<td>{{ item.stage_type }}</td>
<td>{{ item.display_name }}</td>
<td>{{ item.started_at or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="4">No running items.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Commands</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Status</th>
<th>Created</th>
<th>Applied</th>
</tr>
</thead>
<tbody>
{% for command in commands %}
<tr>
<td>{{ command.id }}</td>
<td>{{ command.command_type }}</td>
<td>{{ command.status }}</td>
<td>{{ command.created_at }}</td>
<td>{{ command.applied_at or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="5">No commands.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,30 @@
{% extends "ops/base.html" %}
{% block content %}
<h1>Jobs Archive</h1>
<p class="muted">Use <a href="/dashboard">Dashboard</a> for the main task center. This page stays available for fallback browsing.</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Type</th>
<th>Status</th>
<th>Requested By</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for job in jobs %}
<tr>
<td><a href="/jobs/{{ job.id }}">{{ job.id }}</a></td>
<td>{{ job.job_type }}</td>
<td>{{ job.status }}</td>
<td>{{ job.requested_by or "-" }}</td>
<td>{{ job.created_at or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="5">No jobs found.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "ops/base.html" %}
{% block content %}
<h1>Events</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Job ID</th>
<th>Event Type</th>
<th>Message</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for event in events %}
<tr>
<td>{{ event.id }}</td>
<td>{{ event.job_run_id }}</td>
<td>{{ event.event_type }}</td>
<td>{{ event.message or "-" }}</td>
<td>{{ event.created_at }}</td>
</tr>
{% else %}
<tr><td colspan="5">No events.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
@@ -0,0 +1,276 @@
{% extends "ops/base.html" %}
{% block content %}
<section data-playlists-page>
<h1>Playlists</h1>
<div class="card">
<h2>Playlist Coverage</h2>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Pool Kind</th>
<th>Pool Name</th>
<th>Playlists</th>
</tr>
</thead>
<tbody>
{% for row in playlist_sources %}
<tr>
<td>{{ row.platform }}</td>
<td>{{ row.pool_kind }}</td>
<td>{{ row.pool_name }}</td>
<td>{{ row.playlist_count }}</td>
</tr>
{% else %}
<tr><td colspan="4">No playlist sources collected yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Filters</h2>
<form method="get" action="/playlists">
<input type="hidden" name="sort_by" value="{{ filters.sort_by }}" />
<input type="hidden" name="sort_dir" value="{{ filters.sort_dir }}" />
<div class="grid">
<label>
Keyword
<input type="text" name="keyword" value="{{ filters.keyword }}" placeholder="Name / remote id" />
</label>
<label>
Platform
<select name="platform">
<option value="">All</option>
{% for option in filter_options.platforms %}
<option value="{{ option }}" {% if filters.platform == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
Pool Kind
<select name="pool_kind">
<option value="">All</option>
{% for option in filter_options.pool_kinds %}
<option value="{{ option }}" {% if filters.pool_kind == option %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
</label>
<label>
Status
<select name="status">
<option value="" {% if not filters.status %}selected{% endif %}>All</option>
<option value="unsynced" {% if filters.status == "unsynced" %}selected{% endif %}>Unsynced</option>
<option value="not_downloaded" {% if filters.status == "not_downloaded" %}selected{% endif %}>Not Downloaded</option>
<option value="downloading" {% if filters.status == "downloading" %}selected{% endif %}>Downloading</option>
<option value="partial" {% if filters.status == "partial" %}selected{% endif %}>Partial</option>
<option value="downloaded" {% if filters.status == "downloaded" %}selected{% endif %}>Downloaded</option>
</select>
</label>
<label>
Wanted
<select name="wanted_only">
<option value="" {% if not filters.wanted_only %}selected{% endif %}>All</option>
<option value="1" {% if filters.wanted_only %}selected{% endif %}>Wanted only</option>
</select>
</label>
<label>
Page Size
<select name="page_size">
<option value="20" {% if filters.page_size == 20 %}selected{% endif %}>20</option>
<option value="50" {% if filters.page_size == 50 %}selected{% endif %}>50</option>
<option value="100" {% if filters.page_size == 100 %}selected{% endif %}>100</option>
</select>
</label>
</div>
<button type="submit">Apply Filters</button>
</form>
</div>
<div class="card">
<div class="button-grid">
<button type="button" data-playlist-select-all>Select All On Page</button>
<button type="button" class="secondary" data-playlist-clear-selection>Clear Selection</button>
<span>Selected: <strong data-playlist-selection-count>0</strong></span>
<form action="/api/jobs" method="post" data-json-form data-success="reload">
<input type="hidden" name="job_type" value="collect_only" />
<input type="hidden" name="requested_by" value="ops-console" />
<input type="hidden" name="sources" value="{{ default_sources }}" />
<button type="submit" class="secondary">Collect Playlist Sources</button>
</form>
</div>
<div class="button-grid" style="margin-top: 0.8rem;">
<button type="button" data-playlist-action="sync">Sync Selected Playlists</button>
<button type="button" data-playlist-action="download">Download Selected Playlists</button>
<button type="button" class="secondary" data-playlist-action="export-selected">Export Selected</button>
<button type="button" class="secondary" data-playlist-action="mark-wanted">Mark Wanted</button>
<button type="button" class="secondary" data-playlist-action="unmark-wanted">Unmark Wanted</button>
</div>
</div>
<table>
<thead>
<tr>
<th>Select</th>
<th class="playlist-sort-th">
<a class="playlist-sort-link" data-playlist-sort-link="id" href="{{ sort_links.id.href }}">
<span>ID</span>
{% if sort_links.id.indicator %}
<span class="playlist-sort-indicator" data-playlist-sort-indicator="id">{{ sort_links.id.indicator }}</span>
{% endif %}
</a>
</th>
<th class="playlist-sort-th">
<a class="playlist-sort-link" data-playlist-sort-link="platform" href="{{ sort_links.platform.href }}">
<span>Platform</span>
{% if sort_links.platform.indicator %}
<span class="playlist-sort-indicator" data-playlist-sort-indicator="platform">{{ sort_links.platform.indicator }}</span>
{% endif %}
</a>
</th>
<th>Remote ID</th>
<th class="playlist-sort-th">
<a class="playlist-sort-link" data-playlist-sort-link="name" href="{{ sort_links.name.href }}">
<span>Name</span>
{% if sort_links.name.indicator %}
<span class="playlist-sort-indicator" data-playlist-sort-indicator="name">{{ sort_links.name.indicator }}</span>
{% endif %}
</a>
</th>
<th class="playlist-sort-th">
<a class="playlist-sort-link" data-playlist-sort-link="play_count" href="{{ sort_links.play_count.href }}">
<span>&#28909;&#24230;/&#25773;&#25918;&#37327;</span>
{% if sort_links.play_count.indicator %}
<span class="playlist-sort-indicator" data-playlist-sort-indicator="play_count">{{ sort_links.play_count.indicator }}</span>
{% endif %}
</a>
</th>
<th>Pools</th>
<th>Songs</th>
<th>Downloaded</th>
<th>Progress</th>
<th>Status</th>
<th>Wanted</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{% for playlist in playlists %}
<tr>
<td>
<input type="checkbox" data-playlist-checkbox value="{{ playlist.id }}" />
</td>
<td>{{ playlist.id }}</td>
<td>{{ playlist.platform }}</td>
<td>{{ playlist.remote_playlist_id }}</td>
<td>
{% if (playlist.song_count or 0) > 0 %}
<button
type="button"
class="playlist-name-button"
data-playlist-open-songs="{{ playlist.id }}"
data-playlist-name="{{ playlist.name }}"
data-playlist-platform="{{ playlist.platform }}"
data-playlist-remote-id="{{ playlist.remote_playlist_id }}"
>{{ playlist.name }}</button>
{% else %}
{{ playlist.name }}
{% endif %}
</td>
<td>{{ playlist.play_count if playlist.play_count is not none else "-" }}</td>
<td>{{ playlist.pool_names or "-" }}</td>
<td>
<div>{{ playlist.display_song_count or 0 }}</div>
{% if playlist.is_song_count_estimated %}
<div class="muted">Collected {{ playlist.collected_song_count }}</div>
{% endif %}
</td>
<td>{{ playlist.downloaded_song_count or 0 }}</td>
<td class="progress-cell">
<div class="progress-meta">
<span>{{ playlist.downloaded_song_count or 0 }} / {{ playlist.song_count or 0 }}</span>
<strong>{{ playlist.progress_percent or 0 }}%</strong>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: {{ playlist.progress_percent or 0 }}%;"></div>
</div>
{% if (playlist.song_count or 0) == 0 or playlist.running_download_song_count %}
<div class="progress-note muted">
{% if (playlist.song_count or 0) == 0 and playlist.collected_song_count is not none %}
Collected {{ playlist.collected_song_count }}, sync recommended
{% elif (playlist.song_count or 0) == 0 %}
0 songs, sync recommended
{% elif playlist.running_download_song_count %}
Running {{ playlist.running_download_song_count }}
{% endif %}
</div>
{% endif %}
</td>
<td>{{ playlist.state_label or playlist.state_code or "-" }}</td>
<td>{% if playlist.is_wanted %}Yes{% else %}No{% endif %}</td>
<td>{{ playlist.updated_at }}</td>
</tr>
{% else %}
<tr><td colspan="13">No playlists.</td></tr>
{% endfor %}
</tbody>
</table>
<div class="card" data-playlist-pagination>
<p>
Page {{ playlist_page.page }} / {{ playlist_page.total_pages if playlist_page.total_pages > 0 else 1 }}
- Total {{ playlist_page.total_count }} playlists
</p>
<div class="button-grid">
{% if previous_page_url %}
<a href="{{ previous_page_url }}">Previous</a>
{% else %}
<span class="muted">Previous</span>
{% endif %}
{% if next_page_url %}
<a href="{{ next_page_url }}">Next</a>
{% else %}
<span class="muted">Next</span>
{% endif %}
</div>
</div>
<div class="playlist-modal" data-playlist-songs-modal hidden>
<div class="playlist-modal-backdrop" data-playlist-modal-close></div>
<div class="playlist-modal-panel" role="dialog" aria-modal="true" aria-labelledby="playlist-modal-title">
<div class="playlist-modal-header">
<div>
<h2 id="playlist-modal-title" data-playlist-modal-title>Playlist Songs</h2>
<p class="playlist-modal-meta muted" data-playlist-modal-meta>-</p>
</div>
<div class="button-grid">
<button type="button" class="secondary" data-playlist-export disabled>Export</button>
<button type="button" class="secondary playlist-modal-close" data-playlist-modal-close aria-label="Close">x</button>
</div>
</div>
<div class="playlist-modal-body">
<p class="muted" data-playlist-modal-state>Select a playlist to preview songs.</p>
<div class="playlist-modal-table-wrap" data-playlist-modal-table-wrap hidden>
<table>
<thead>
<tr>
<th>Song ID</th>
<th>Name</th>
<th>Singers</th>
<th>Size</th>
<th>Format</th>
<th>Local</th>
<th>Uploaded</th>
</tr>
</thead>
<tbody data-playlist-songs-body>
<tr><td colspan="7">No songs.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{% endblock %}
@@ -0,0 +1,97 @@
{% extends "ops/base.html" %}
{% block content %}
<h1>Songs</h1>
<div class="grid">
<div class="card">
<h2>Download Stats</h2>
<table>
<tr><th>Total Songs</th><td>{{ download_stats.total_songs }}</td></tr>
<tr><th>Downloaded Songs</th><td>{{ download_stats.downloaded_songs }}</td></tr>
<tr><th>Local Files</th><td>{{ download_stats.local_file_locations }}</td></tr>
</table>
</div>
<div class="card">
<h2>Active Workers</h2>
<table>
<thead>
<tr>
<th>Worker</th>
<th>Status</th>
<th>Stage</th>
<th>Current Song / Playlist</th>
</tr>
</thead>
<tbody>
{% for worker in workers %}
<tr>
<td>{{ worker.worker_name }}</td>
<td>{{ worker.status }}</td>
<td>{{ worker.stage_type or "-" }}</td>
<td>{{ worker.display_text or "-" }}</td>
</tr>
{% else %}
<tr><td colspan="4">No active workers.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card">
<h2>Running Items</h2>
<table>
<thead>
<tr>
<th>Job</th>
<th>Worker</th>
<th>Stage</th>
<th>Item</th>
</tr>
</thead>
<tbody>
{% for item in running_items %}
<tr>
<td><a href="/jobs/{{ item.job_run_id }}">{{ item.job_run_id }}</a></td>
<td>{{ item.worker_name or "-" }}</td>
<td>{{ item.stage_type }}</td>
<td>{{ item.display_name }}</td>
</tr>
{% else %}
<tr><td colspan="4">No running items.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card">
<h2>Song Catalog</h2>
<table>
<thead>
<tr>
<th>ID</th>
<th>Platform</th>
<th>Remote ID</th>
<th>Name</th>
<th>Singers</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
{% for song in songs %}
<tr>
<td>{{ song.id }}</td>
<td>{{ song.platform }}</td>
<td>{{ song.remote_song_id }}</td>
<td>{{ song.name }}</td>
<td>{{ song.singers or "-" }}</td>
<td>{{ song.updated_at }}</td>
</tr>
{% else %}
<tr><td colspan="6">No songs.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}