Update metadata jobs and player library

This commit is contained in:
Ultradesu
2026-06-03 03:39:16 +03:00
parent d2a8f301b8
commit 1e1453e465
16 changed files with 1803 additions and 159 deletions
+43 -5
View File
@@ -1434,7 +1434,7 @@ tbody tr:hover {
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
<i data-lucide="inbox"></i>
<span>Review Queue</span>
<span class="nav-count" x-text="reviews.total || 0"></span>
<span class="nav-count" x-text="reviewTotalAll()"></span>
</button>
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
<i data-lucide="calendar-clock"></i>
@@ -1526,7 +1526,7 @@ tbody tr:hover {
<template x-for="status in reviewStatuses" :key="status.value">
<button class="seg-btn" :class="{active: reviewFilter.status === status.value}" @click="setReviewStatus(status.value)">
<span x-text="status.label"></span>
<span class="muted" x-text="status.value ? statusCount(status.value) : reviews.total"></span>
<span class="muted" x-text="status.value ? statusCount(status.value) : reviewTotalAll()"></span>
</button>
</template>
</div>
@@ -1715,12 +1715,19 @@ tbody tr:hover {
<label><input type="checkbox" x-model="metadataBackfillOptions.duration_seconds" /> duration_seconds</label>
<label><input type="checkbox" x-model="metadataBackfillOptions.local_genres" /> local genres from files</label>
<label><input type="checkbox" x-model="metadataBackfillOptions.lastfm_tags" /> Last.fm tags</label>
<label><input type="checkbox" x-model="metadataBackfillOptions.musicbrainz_tags" /> MusicBrainz tags</label>
</div>
<div class="mode-row">
<label><input type="radio" value="fill_missing" x-model="metadataBackfillOptions.mode" /> Fill missing only</label>
<label><input type="radio" value="overwrite" x-model="metadataBackfillOptions.mode" /> Overwrite existing values</label>
</div>
</div>
<div class="metadata-backfill-options" x-show="isArtworkBackfillJob(activeJob)">
<div class="mode-row">
<label><input type="radio" value="missing" x-model="artworkBackfillOptions.mode" /> Missing images only</label>
<label><input type="radio" value="overwrite" x-model="artworkBackfillOptions.mode" /> Search all and replace existing</label>
</div>
</div>
<div class="job-param-note" x-show="activeJob && !jobHasParameterForm(activeJob)">
This task has no manual parameters.
</div>
@@ -2609,7 +2616,7 @@ function adminV2() {
stats: {},
runtime: { agent: {}, storage: [], node: {} },
libraryOverview: {},
reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] },
reviews: { items: [], total: 0, total_all: 0, limit: 80, offset: 0, status_counts: [] },
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
usersLoading: false,
userSearch: '',
@@ -2656,8 +2663,12 @@ function adminV2() {
duration_seconds: true,
local_genres: true,
lastfm_tags: true,
musicbrainz_tags: true,
mode: 'fill_missing'
},
artworkBackfillOptions: {
mode: 'missing'
},
libraryKind: 'artists',
librarySearch: '',
library: { items: [], total: 0, limit: 40, offset: 0 },
@@ -3180,6 +3191,11 @@ function adminV2() {
method: 'POST',
body: JSON.stringify(this.metadataBackfillPayload())
})
: this.isArtworkBackfillJob(job)
? await this.request(`${this.apiBase}/jobs/artwork_backfill/run-options`, {
method: 'POST',
body: JSON.stringify(this.artworkBackfillPayload())
})
: await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/run`, { method: 'POST' });
this.showToast(`Run #${result.run_id} started`);
this.activeJobName = job.name;
@@ -3199,8 +3215,12 @@ function adminV2() {
return job && job.name === 'metadata_backfill';
},
isArtworkBackfillJob(job) {
return job && job.name === 'artwork_backfill';
},
jobHasParameterForm(job) {
return this.isMetadataBackfillJob(job);
return this.isMetadataBackfillJob(job) || this.isArtworkBackfillJob(job);
},
metadataBackfillPayload() {
@@ -3212,10 +3232,18 @@ function adminV2() {
duration_seconds: Boolean(options.duration_seconds),
local_genres: Boolean(options.local_genres),
lastfm_tags: Boolean(options.lastfm_tags),
musicbrainz_tags: Boolean(options.musicbrainz_tags),
overwrite: options.mode === 'overwrite'
};
},
artworkBackfillPayload() {
const options = this.artworkBackfillOptions || {};
return {
overwrite_existing: options.mode === 'overwrite'
};
},
async toggleJob(job) {
try {
const result = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/toggle`, { method: 'POST' });
@@ -3844,7 +3872,11 @@ function adminV2() {
},
reviewPanelSubtitle() {
return `${this.fmt(this.reviews.total || 0)} rows · ${this.reviewFilter.status || 'all statuses'}`;
const total = this.reviews.total || 0;
const totalAll = this.reviewTotalAll();
const status = this.reviewFilter.status || 'all statuses';
if (this.reviewFilter.status) return `${this.fmt(total)} ${status} · ${this.fmt(totalAll)} total`;
return `${this.fmt(totalAll)} rows · ${status}`;
},
jobPanelSubtitle() {
@@ -3955,6 +3987,12 @@ function adminV2() {
return row ? row.count : 0;
},
reviewTotalAll() {
const explicit = Number(this.reviews.total_all || 0);
if (explicit > 0 || Object.prototype.hasOwnProperty.call(this.reviews || {}, 'total_all')) return explicit;
return (this.reviews.status_counts || []).reduce((sum, row) => sum + Number(row.count || 0), 0);
},
formatConfidence(value) {
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
},
+18 -5
View File
@@ -2025,23 +2025,26 @@ document.addEventListener('alpine:init', () => {
this._artistsLoadToken = token;
try {
const mine = filter === 'uploads' ? '&mine=true' : '';
const res = await fetch(`/api/player/artists?page=${page}&limit=60${mine}`);
const res = await fetch(`/api/player/artists?page=${page}&limit=80${mine}`);
if (!res.ok) throw new Error('failed');
const data = await res.json();
if (token !== this._artistsLoadToken || filter !== this.artistFilter) return;
const incoming = Array.isArray(data.items) ? data.items : [];
if (page === 1) {
this.artists = data.items;
this.artists = incoming;
} else {
this.artists = [...this.artists, ...data.items];
const existing = new Set(this.artists.map(artist => artist.id));
this.artists = [...this.artists, ...incoming.filter(artist => !existing.has(artist.id))];
}
this.artistsPage = data.page;
this.artistsTotal = data.total;
if (this.artists.length >= data.total) {
if (data.has_more === false || this.artists.length >= data.total) {
this._allLoaded = true;
}
} catch {}
if (token === this._artistsLoadToken) {
this.loading = false;
this.$nextTick(() => { this._fillArtistViewport(); });
}
},
@@ -2400,11 +2403,21 @@ document.addEventListener('alpine:init', () => {
if (entries[0].isIntersecting && !this.loading && !this._allLoaded) {
this.loadArtists(this.artistsPage + 1);
}
}, { root: document.getElementById('center-scroll'), threshold: 0.1 });
}, { root: document.getElementById('center-scroll'), rootMargin: '900px 0px', threshold: 0.01 });
this._observer.observe(sentinel);
this._fillArtistViewport();
});
},
_fillArtistViewport() {
if (!(this.view === 'artists' || this.view === 'my_uploads') || this.loading || this._allLoaded) return;
const el = this._scrollElement();
if (!el) return;
if (el.scrollHeight <= el.clientHeight + 900) {
this.loadArtists(this.artistsPage + 1);
}
},
$nextTick(fn) {
setTimeout(fn, 50);
},
+14 -6
View File
@@ -60,7 +60,7 @@
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
{{ t.player_artists }}
{{ t.player_global_library }}
</div>
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'my_uploads' }"
@@ -174,7 +174,7 @@
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
{{ t.player_artists }}
{{ t.player_global_library }}
</div>
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'my_uploads' }"
@@ -521,7 +521,7 @@
<!-- Artists Grid -->
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
<div>
<h1 class="section-title" x-text="$store.library.view === 'my_uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></h1>
<h1 class="section-title" x-text="$store.library.view === 'my_uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></h1>
<div class="card-grid">
<template x-for="artist in $store.library.artists" :key="artist.id">
<div class="card" @click="$store.library.openArtist(artist.id)">
@@ -554,9 +554,17 @@
<p x-text="$store.library.view === 'my_uploads' ? '{{ t.player_no_uploaded_tracks }}' : '{{ t.artists_empty }}'"></p>
</div>
</template>
<template x-if="$store.library.loading">
<template x-if="$store.library.loading && $store.library.artists.length === 0">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<div class="scroll-load-indicator"
x-show="$store.library.loading && $store.library.artists.length > 0"
x-cloak
role="status"
aria-live="polite">
<div class="scroll-load-spinner"></div>
<span>{{ t.player_loading_more }}</span>
</div>
<div id="artist-sentinel" style="height:1px"></div>
</div>
</template>
@@ -566,7 +574,7 @@
<div>
<div class="breadcrumb">
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></a>
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
<span>/</span>
<span x-text="$store.library.currentArtist.name"></span>
</div>
@@ -727,7 +735,7 @@
<div>
<div class="breadcrumb">
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_artists }}'"></a>
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
<span>/</span>
<template x-if="$store.library.currentRelease.artists.length > 0">
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
+20
View File
@@ -1814,6 +1814,26 @@ button.user-stat:hover {
animation: spin 0.8s linear infinite;
}
.scroll-load-indicator {
min-height: 56px;
margin: 18px 0 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: var(--text-secondary);
font-size: 13px;
}
.scroll-load-spinner {
width: 18px;
height: 18px;
border: 2px solid var(--bg-active);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Empty state */