Reworked Reviews page
Build and Publish / Build and Publish Docker Image (push) Successful in 2m47s

This commit is contained in:
2026-05-25 13:50:24 +03:00
parent e9e16dd807
commit dcc665563a
31 changed files with 2674 additions and 1137 deletions
+41 -5
View File
@@ -13,9 +13,12 @@
</table>
<div style="margin: 1rem 0; display: flex; gap: .5rem; align-items: flex-end;">
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.4rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.4rem 1rem; background:#dc3545; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_disable }}</button>
@@ -23,14 +26,47 @@
<button type="submit" style="padding:.4rem 1rem; background:#28a745; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</div>
<h2>{{ t.jobs_cron }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
{% if job.name_str() == "metadata_backfill" %}
<h2>{{ t.jobs_metadata_backfill_options }}</h2>
<form method="post" action="/admin/jobs/metadata_backfill/run-options" style="margin:0 0 1.5rem; padding:1rem; background:#fff; border:1px solid #e0e0e0; border-radius:6px;">
<fieldset style="border:0; margin:0 0 .75rem; padding:0;">
<legend style="font-weight:600; margin-bottom:.5rem;">{{ t.jobs_metadata_backfill_fields }}</legend>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_bitrate" checked> audio_bitrate
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_sample_rate" checked> audio_sample_rate
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="audio_bit_depth" checked> audio_bit_depth
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
<input type="checkbox" name="duration_seconds" checked> duration_seconds
</label>
</fieldset>
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
<label style="display:inline-flex; gap:.35rem; align-items:center;">
<input type="radio" name="mode" value="fill_missing" checked> {{ t.jobs_metadata_backfill_fill_missing }}
</label>
<label style="display:inline-flex; gap:.35rem; align-items:center;">
<input type="radio" name="mode" value="overwrite"> {{ t.jobs_metadata_backfill_overwrite }}
</label>
</div>
<button type="submit" style="padding:.45rem 1rem; background:#007bff; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_metadata_backfill_run }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<h2>{{ t.jobs_cron }}</h2>
<p style="font-size:.85rem;color:#666;margin-bottom:.5rem;">{{ t.jobs_cron_help }}</p>
<form method="post" action="/admin/jobs/{{ job.name_str() }}/cron" style="display:flex;gap:.5rem;align-items:center;margin-bottom:1.5rem;">
<input name="cron_expression" value="{{ job.cron_expression_str() }}" style="width:20em;font-family:monospace;">
<button type="submit" style="padding:.4rem 1rem; background:#6c757d; color:#fff; border:none; border-radius:4px; cursor:pointer;">{{ t.jobs_cron_update }}</button>
</form>
{% endif %}
<h2>{{ t.jobs_run_history }}</h2>
{% if runs.is_empty() %}
+6
View File
@@ -23,9 +23,14 @@
<td>{{ job.last_run_at_str() }}</td>
<td>{{ job.next_run_at_str() }}</td>
<td style="display:flex;gap:.3rem;">
{% if job.name_str() == "metadata_backfill" %}
<a href="/admin/jobs/{{ job.name_str() }}" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer; text-decoration:none;">{{ t.jobs_metadata_backfill_options }}</a>
{% else %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/run" style="margin:0;">
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #007bff; background:#007bff; color:#fff; cursor:pointer;">{{ t.jobs_run_now }}</button>
</form>
{% endif %}
{% if job.name_str() != "metadata_backfill" %}
<form method="post" action="/admin/jobs/{{ job.name_str() }}/toggle" style="margin:0;">
{% if job.enabled() %}
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{{ t.jobs_disable }}</button>
@@ -33,6 +38,7 @@
<button type="submit" style="padding:.3rem .6rem; border-radius:4px; border:1px solid #28a745; background:#fff; color:#28a745; cursor:pointer;">{{ t.jobs_enable }}</button>
{% endif %}
</form>
{% endif %}
</td>
</tr>
{% endfor %}
+2 -1
View File
@@ -9,6 +9,7 @@
body { font-family: system-ui, -apple-system, sans-serif; display: flex; min-height: 100vh; background: #f5f5f5; color: #333; }
nav.sidebar { width: 220px; background: #1a1a2e; color: #eee; padding: 1rem; }
nav.sidebar h2 { font-size: 1.1rem; margin-bottom: 1.5rem; }
.admin-version { display: inline-block; margin-left: .35rem; color: #999; font-size: .72rem; font-weight: 500; vertical-align: baseline; }
nav.sidebar a { color: #ccc; text-decoration: none; display: block; padding: .4rem .6rem; border-radius: 4px; }
nav.sidebar a:hover { background: #16213e; color: #fff; }
.main-wrap { flex: 1; display: flex; flex-direction: column; }
@@ -36,7 +37,7 @@
{% block body %}
<nav class="sidebar">
<h2>{{ t.site_name }} {{ t.nav_admin }}</h2>
<h2>{{ t.site_name }} {{ t.nav_admin }} <span class="admin-version">v{{ t.app_version() }}</span></h2>
<a href="/admin/">{{ t.nav_dashboard }}</a>
<a href="/admin/artists">{{ t.nav_artists }}</a>
<a href="/admin/releases">{{ t.nav_releases }}</a>
+215 -12
View File
@@ -4,7 +4,7 @@
{% block content %}
<h1>{{ t.reviews_heading }}</h1>
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center;">
<div style="margin-bottom: 1rem; display: flex; gap: .5rem; align-items: center; flex-wrap: wrap;">
<a href="/admin/reviews" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "" %} #333; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_all }}</a>
<a href="/admin/reviews?status=pending" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "pending" %} #ffc107; color: #000{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_pending }}</a>
<a href="/admin/reviews?status=approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_approved }}</a>
@@ -13,7 +13,7 @@
<a href="/admin/reviews?status=processing" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "processing" %} #007bff; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_processing }}</a>
<a href="/admin/reviews?status=auto_approved" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "auto_approved" %} #28a745; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_auto_approved }}</a>
<a href="/admin/reviews?status=failed" style="padding: .3rem .8rem; border-radius: 4px; text-decoration: none; background:{% if status_filter == "failed" %} #dc3545; color: #fff{% else %} #e9ecef; color: #333{% endif %};">{{ t.reviews_filter_failed }}</a>
{% if !reviews.is_empty() %}
{% if !rows.is_empty() %}
<span style="flex:1;"></span>
<form method="post" action="/admin/reviews/clear{% if !status_filter.is_empty() %}?status={{ status_filter }}{% endif %}" style="margin:0;" onsubmit="return confirm('{{ t.reviews_clear_confirm }}');">
<button type="submit" style="padding:.3rem .8rem; border-radius:4px; border:1px solid #dc3545; background:#fff; color:#dc3545; cursor:pointer;">{% if status_filter.is_empty() %}{{ t.reviews_clear_all }}{% else %}{{ t.reviews_clear_filtered }}{% endif %}</button>
@@ -21,15 +21,27 @@
{% endif %}
</div>
{% if reviews.is_empty() %}
{% if rows.is_empty() %}
<p>{{ t.reviews_empty }}</p>
{% else %}
<form id="reviews-bulk-form" method="post" action="/admin/reviews/bulk" style="margin:0;">
<input type="hidden" name="selected_ids" id="selected-review-ids" value="">
<input type="hidden" name="status_filter" value="{{ status_filter }}">
<div class="review-bulk-toolbar">
<button type="button" id="select-shown-reviews" class="review-toolbar-button">{{ t.reviews_select_all }}</button>
<button type="button" id="clear-review-selection" class="review-toolbar-button">{{ t.reviews_clear_selection }}</button>
<button type="submit" name="action" value="delete" class="review-danger-button" disabled>{{ t.reviews_delete_selected }}</button>
<button type="submit" name="action" value="requeue" class="review-primary-button" disabled>{{ t.reviews_requeue_selected }}</button>
<span id="review-selection-summary" class="review-selection-summary">{{ t.reviews_selected_none }}</span>
</div>
<table>
<tr>
<th class="review-select-cell"></th>
<th>ID</th>
<th>{{ t.reviews_status }}</th>
<th>{{ t.reviews_type }}</th>
<th>{{ t.reviews_input_path }}</th>
<th>{{ t.reviews_tags }}</th>
<th>{{ t.reviews_confidence }}</th>
<th>{{ t.reviews_model }}</th>
<th>{{ t.reviews_llm_duration }}</th>
@@ -37,14 +49,22 @@
<th>{{ t.reviews_created }}</th>
<th>{{ t.jobs_actions }}</th>
</tr>
{% for review in reviews %}
{% for row in rows %}
<tr>
<td><a href="/admin/reviews/{{ review.id_val() }}">{{ review.id_val() }}</a></td>
<td><span class="badge {{ review.status_badge_class() }}">{{ review.status_str() }}</span></td>
<td>{{ review.review_type_str() }}</td>
<td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="{{ review.input_path_str() }}">{{ review.input_path_str() }}</td>
<td>{% match review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&review.id_val()) %}
<td class="review-select-cell">
<input type="checkbox" class="review-select" value="{{ row.review.id_val() }}" data-status="{{ row.review.status_str() }}" aria-label="Select review {{ row.review.id_val() }}">
</td>
<td><a href="/admin/reviews/{{ row.review.id_val() }}">{{ row.review.id_val() }}</a></td>
<td><span class="badge {{ row.review.status_badge_class() }}">{{ row.review.status_str() }}</span></td>
<td>{{ row.review.review_type_str() }}</td>
<td class="review-input-path" title="{{ row.review.input_path_str() }}">{{ row.display_input_path }}</td>
<td class="review-tag-cell">
{% for tag in row.media_tags %}
<span class="review-tag review-tag-{{ tag.kind }}">{{ tag.label }}</span>
{% endfor %}
</td>
<td>{% match row.review.confidence() %}{% when Some with (c) %}{{ c }}{% when None %}-{% endmatch %}</td>
{% match stats_map.get(&row.review.id_val()) %}
{% when Some with (s) %}
<td>{{ s.model_name }}</td>
<td>{{ s.duration_display() }}</td>
@@ -54,20 +74,203 @@
<td>-</td>
<td>-</td>
{% endmatch %}
<td>{{ review.created_at_str() }}</td>
<td>{{ row.review.created_at_str() }}</td>
<td>
<a href="/admin/reviews/{{ review.id_val() }}">{{ t.reviews_view }}</a>
<a href="/admin/reviews/{{ row.review.id_val() }}">{{ t.reviews_view }}</a>
</td>
</tr>
{% endfor %}
</table>
</form>
{% endif %}
<style>
.review-bulk-toolbar {
margin-bottom: .75rem;
display: flex;
gap: .5rem;
align-items: center;
flex-wrap: wrap;
}
.review-toolbar-button,
.review-danger-button,
.review-primary-button {
padding: .35rem .7rem;
border-radius: 4px;
border: 1px solid #ced4da;
background: #fff;
color: #212529;
cursor: pointer;
}
.review-danger-button {
border-color: #dc3545;
color: #dc3545;
}
.review-primary-button {
border-color: #17a2b8;
color: #0c5460;
}
.review-danger-button:disabled,
.review-primary-button:disabled {
cursor: not-allowed;
opacity: .45;
}
.review-selection-summary {
min-height: 1.7rem;
padding: .35rem .6rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .9rem;
white-space: nowrap;
}
.review-select-cell {
width: 2.25rem;
text-align: center;
}
.review-select {
width: 1rem;
height: 1rem;
}
.review-input-path {
max-width: 34rem;
white-space: normal;
overflow-wrap: anywhere;
}
.review-tag-cell {
max-width: 18rem;
}
.review-tag {
display: inline-block;
margin: .1rem .15rem .1rem 0;
padding: .12rem .35rem;
border: 1px solid #ced4da;
border-radius: 4px;
background: #f8f9fa;
color: #495057;
font-size: .8rem;
white-space: nowrap;
}
.review-tag-format { border-color: #9ec5fe; background: #e7f1ff; color: #084298; }
.review-tag-bitrate { border-color: #a3cfbb; background: #d1e7dd; color: #0f5132; }
.review-tag-sample { border-color: #ffda6a; background: #fff3cd; color: #664d03; }
.review-tag-depth { border-color: #d0bfff; background: #f0e7ff; color: #3d246c; }
.review-tag-size { border-color: #ced4da; background: #f8f9fa; color: #495057; }
.badge-completed { background: #d4edda; color: #155724; }
.badge-failed { background: #f8d7da; color: #721c24; }
.badge-pending { background: #fff3cd; color: #856404; }
.badge-queued { background: #d1ecf1; color: #0c5460; }
.badge-processing { background: #cce5ff; color: #004085; }
</style>
<script>
(() => {
const form = document.getElementById("reviews-bulk-form");
if (!form) {
return;
}
const checkboxes = Array.from(form.querySelectorAll(".review-select"));
const selectedIdsInput = document.getElementById("selected-review-ids");
const summary = document.getElementById("review-selection-summary");
const selectShownButton = document.getElementById("select-shown-reviews");
const clearSelectionButton = document.getElementById("clear-review-selection");
const submitButtons = Array.from(form.querySelectorAll("button[type='submit']"));
const selected = new Set();
const statusCounts = new Map();
const labels = {
pending: "{{ t.reviews_filter_pending }}",
approved: "{{ t.reviews_filter_approved }}",
rejected: "{{ t.reviews_filter_rejected }}",
queued: "{{ t.reviews_filter_queued }}",
processing: "{{ t.reviews_filter_processing }}",
auto_approved: "{{ t.reviews_filter_auto_approved }}",
failed: "{{ t.reviews_filter_failed }}"
};
function setStatusCount(status, delta) {
const next = (statusCounts.get(status) || 0) + delta;
if (next > 0) {
statusCounts.set(status, next);
} else {
statusCounts.delete(status);
}
}
function syncControls() {
selectedIdsInput.value = Array.from(selected).join(",");
const total = selected.size;
for (const button of submitButtons) {
button.disabled = total === 0;
}
if (total === 0) {
summary.textContent = "{{ t.reviews_selected_none }}";
return;
}
const parts = Array.from(statusCounts.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([status, count]) => `${labels[status] || status}: ${count}`);
summary.textContent = `{{ t.reviews_selected_prefix }}: ${total} (${parts.join(", ")})`;
}
function setChecked(checkbox, checked) {
const id = checkbox.value;
const isSelected = selected.has(id);
checkbox.checked = checked;
if (isSelected === checked) {
return;
}
const status = checkbox.dataset.status || "unknown";
if (checked) {
selected.add(id);
setStatusCount(status, 1);
} else {
selected.delete(id);
setStatusCount(status, -1);
}
}
for (const checkbox of checkboxes) {
checkbox.addEventListener("change", () => {
setChecked(checkbox, checkbox.checked);
syncControls();
});
}
selectShownButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, true);
}
syncControls();
});
clearSelectionButton.addEventListener("click", () => {
for (const checkbox of checkboxes) {
setChecked(checkbox, false);
}
syncControls();
});
form.addEventListener("submit", (event) => {
syncControls();
if (selected.size === 0) {
event.preventDefault();
alert("{{ t.reviews_none_selected_confirm }}");
return;
}
const action = event.submitter ? event.submitter.value : "";
const message = action === "requeue"
? "{{ t.reviews_requeue_selected_confirm }}"
: "{{ t.reviews_delete_selected_confirm }}";
if (!confirm(message)) {
event.preventDefault();
}
});
syncControls();
})();
</script>
{% endblock content %}
+85 -26
View File
@@ -273,6 +273,22 @@ body {
.artist-header .artist-img svg { width: 80px; height: 80px; color: var(--text-subdued); }
.artist-header .artist-name { font-size: 48px; font-weight: 900; line-height: 1.1; }
.artist-stats {
color: var(--text-subdued);
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 14px;
}
.artist-release-group { margin-top: 28px; }
.artist-release-group:first-of-type { margin-top: 0; }
.artist-release-group-title {
font-size: 20px;
font-weight: 700;
margin-bottom: 14px;
text-transform: capitalize;
}
/* Release detail header */
.release-header {
@@ -1355,35 +1371,45 @@ body {
</div>
<div>
<div class="artist-header .artist-name" x-text="$store.library.currentArtist.name" style="font-size:48px;font-weight:900;line-height:1.1"></div>
<div style="color:var(--text-subdued);margin-top:8px" x-text="$store.library.currentArtist.releases.length + ' releases'"></div>
<div class="artist-stats">
<span x-text="$store.library.currentArtist.releases.length + ' releases'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_track_count + ' tracks'"></span>
<span></span>
<span x-text="$store.library.currentArtist.total_play_count + ' plays'"></span>
</div>
</div>
</div>
<h2 class="section-title" style="font-size:20px">Releases</h2>
<div class="card-grid">
<template x-for="release in $store.library.currentArtist.releases" :key="release.id">
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
</template>
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.release_type"></span>
</div>
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
<section class="artist-release-group">
<h2 class="artist-release-group-title" x-text="group.label"></h2>
<div class="card-grid">
<template x-for="release in group.releases" :key="release.id">
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg></span>
</template>
<button class="card-enqueue-btn" @click.stop="$store.library.enqueueRelease(release.id)" title="Add to queue">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="card-play-btn" @click.stop="$store.library.playRelease(release.id)">
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
</div>
<div class="card-title" x-text="release.title"></div>
<div class="card-subtitle">
<span x-text="release.year || ''"></span>
<span x-text="release.track_count + ' tracks'"></span>
</div>
</div>
</template>
</div>
</template>
</div>
</section>
</template>
</div>
</template>
@@ -2179,6 +2205,39 @@ document.addEventListener('alpine:init', () => {
} catch {}
},
artistReleaseGroups() {
const releases = this.currentArtist?.releases || [];
const order = ['album', 'ep', 'single', 'compilation', 'mixtape', 'live', 'soundtrack'];
const labels = {
album: 'Albums',
ep: 'EPs',
single: 'Singles',
compilation: 'Compilations',
mixtape: 'Mixtapes',
live: 'Live releases',
soundtrack: 'Soundtracks',
};
const groups = new Map();
for (const release of releases) {
const type = (release.release_type || 'other').toLowerCase();
if (!groups.has(type)) {
groups.set(type, []);
}
groups.get(type).push(release);
}
return Array.from(groups.entries())
.sort(([a], [b]) => {
const ai = order.includes(a) ? order.indexOf(a) : order.length;
const bi = order.includes(b) ? order.indexOf(b) : order.length;
return ai === bi ? a.localeCompare(b) : ai - bi;
})
.map(([type, groupReleases]) => ({
type,
label: labels[type] || type,
releases: groupReleases,
}));
},
async openRelease(id) {
this.searchQuery = '';
this.searchResults = null;