Compare commits

..
7 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov bcb5d378c8 Add bulk approval for selected admin reviews
Build and Publish / Build and Publish Docker Image (push) Successful in 6m10s
2026-10-23 01:12:54 +01:00
Ultradesu 59345a8d22 Added AI chat with metadata
Build and Publish / Build and Publish Docker Image (push) Successful in 4m18s
2026-10-19 13:35:05 +01:00
Ultradesu 0975f3851a Added AI chat with metadata
Build and Publish / Build and Publish Docker Image (push) Successful in 4m1s
2026-10-19 13:13:10 +01:00
Ultradesu bded5a79cc Accept partial agent metadata plans
Build and Publish / Build and Publish Docker Image (push) Successful in 3m49s
2026-10-19 12:28:20 +01:00
Ultradesu 2aede192fe Added AI chat with metadata
Build and Publish / Build and Publish Docker Image (push) Successful in 3m50s
2026-10-19 12:13:14 +01:00
Ultradesu 2199fda240 Avoid stripped proc macros in release builds 2026-10-19 12:10:24 +01:00
Ultradesu 8f7c0d231f Add guided metadata repair tool to admin 2026-10-19 12:05:56 +01:00
9 changed files with 2367 additions and 67 deletions

No files matched your search

+1
View File
@@ -5,3 +5,4 @@
/federation
/similarity-models
/federation-cache
*.DS_Store
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.11.1"
version = "0.12.0"
dependencies = [
"anyhow",
"async-stream",
+6 -1
View File
@@ -1,9 +1,14 @@
[package]
name = "furumusic"
version = "0.11.1"
version = "0.12.0"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
# macOS 27 rejects some stripped proc-macro dylibs with a misaligned LINKEDIT
# string pool. Build scripts and proc macros are compile-time artifacts only.
[profile.release.build-override]
strip = false
[dependencies]
# default-features off: cot's defaults include the sqlite backend, whose old
# libsqlite3-sys collides with music-dht's rusqlite (one native sqlite3 per
File diff suppressed because it is too large. Load diff
+81
View File
@@ -1,3 +1,4 @@
mod metadata_fix;
mod v2;
pub mod views;
@@ -807,6 +808,86 @@ impl App for AdminApp {
},
"admin_v2_library_releases_merge",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/expand",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::ExpandRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
metadata_fix::expand(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_expand",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/plan",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::PlanRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
metadata_fix::propose(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_plan",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/apply",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::ApplyRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
metadata_fix::apply(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_apply",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/history",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
metadata_fix::history(session, db, pg_pool).await
}
})
},
"admin_v2_metadata_fix_history",
),
Route::with_handler_and_name(
"/v2/api/metadata-fix/undo",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
cot::router::method::post(move |session: Session, db: Database, json: Json<metadata_fix::UndoRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool.get_or_init(|| async { sqlx::postgres::PgPoolOptions::new().max_connections(5).connect(&pool_config.database_url).await.expect("admin pool") }).await;
metadata_fix::undo(session, db, pg_pool, json).await
}
})
},
"admin_v2_metadata_fix_undo",
),
// -- Dashboard ----------------------------------------------------
Route::with_handler_and_name(
"/",
+134 -28
View File
@@ -432,6 +432,8 @@ struct JobRunDetailDto {
struct BulkReviewsResponse {
ok: bool,
affected: u64,
failed: u64,
skipped: u64,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -989,10 +991,14 @@ pub async fn bulk_reviews(
}
let action = body.action.trim();
if action != "delete" && action != "requeue" {
if action != "delete" && action != "requeue" && action != "approve" {
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown bulk action"));
}
if action == "approve" {
return Json(approve_selected_reviews(&db, pool, &body).await?).into_response();
}
let mode = body.mode.as_deref().unwrap_or("ids");
let affected = if mode == "filter" {
apply_review_filter_action(pool, action, body.filter.unwrap_or_default()).await?
@@ -1016,7 +1022,99 @@ pub async fn bulk_reviews(
}
};
Json(BulkReviewsResponse { ok: true, affected }).into_response()
Json(BulkReviewsResponse {
ok: true,
affected,
failed: 0,
skipped: 0,
})
.into_response()
}
fn selected_reviews_query(body: &BulkReviewsRequest) -> QueryBuilder<'static, Postgres> {
let mut qb = QueryBuilder::new("SELECT id FROM furumusic__pending_review WHERE 1=1");
if body.mode.as_deref() == Some("filter") {
let filter = body.filter.clone().unwrap_or_default();
push_review_filters(
&mut qb,
normalize_status(filter.status.as_deref()),
clean_search(filter.search.as_deref()).map(|s| format!("%{s}%")),
);
} else {
qb.push(" AND id = ANY(");
qb.push_bind(body.ids.clone().unwrap_or_default());
qb.push(")");
}
qb.push(" ORDER BY id");
qb
}
async fn approve_selected_reviews(
db: &Database,
pool: &PgPool,
body: &BulkReviewsRequest,
) -> cot::Result<BulkReviewsResponse> {
let ids = selected_reviews_query(body)
.build_query_as::<IdRow>()
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let (config, _) = AppConfig::load_with_db(db).await;
let mut result = BulkReviewsResponse {
ok: true,
affected: 0,
failed: 0,
skipped: 0,
};
for row in ids {
let Some(mut review) = crate::scheduler::PendingReview::get_by_id(db, row.id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
else {
result.skipped += 1;
continue;
};
if review.status_str() != "pending" {
result.skipped += 1;
continue;
}
// Claim one row at a time so overlapping bulk requests cannot import it twice.
let claimed = sqlx::query(
"UPDATE furumusic__pending_review SET status = 'processing', updated_at = $2 \
WHERE id = $1 AND status = 'pending'",
)
.bind(row.id)
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if claimed.rows_affected() == 0 {
result.skipped += 1;
continue;
}
let approval = async {
let normalized = serde_json::from_str::<crate::agent::dto::NormalizedFields>(
review.result_json_str(),
)?;
finalize_review_approval(db, pool, &config, &mut review, &normalized).await
}
.await;
match approval {
Ok(()) => result.affected += 1,
Err(error) => {
tracing::error!(review_id = row.id, ?error, "bulk review approval failed");
review
.set_failed(db, &format!("Review approval failed: {error:#}"))
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
result.failed += 1;
}
}
}
result.ok = result.failed == 0;
Ok(result)
}
pub async fn approve_review(
@@ -1042,31 +1140,9 @@ pub async fn approve_review(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let context: serde_json::Value =
serde_json::from_str(review.context_json_str()).unwrap_or_default();
let input_path = review.input_path_str().to_owned();
let (live_config, _) = AppConfig::load_with_db(&db).await;
let stats = crate::scheduler::ProcessingStats::get_by_review_id(&db, review_id)
.await
.unwrap_or(None);
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
match crate::jobs::inbox_process::finalize_approved(
&db,
pool,
&live_config,
&input_path,
&normalized,
&context,
&live_config.agent_storage_dir,
model_name.as_deref(),
)
.await
{
Ok(()) => {
let _ = review.set_approved(&db).await;
Json(serde_json::json!({ "ok": true })).into_response()
}
match finalize_review_approval(&db, pool, &live_config, &mut review, &normalized).await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(error) => {
tracing::error!(?error, "review approval failed");
let _ = review.set_rejected(&db).await;
@@ -1078,6 +1154,36 @@ pub async fn approve_review(
}
}
async fn finalize_review_approval(
db: &Database,
pool: &PgPool,
config: &AppConfig,
review: &mut crate::scheduler::PendingReview,
normalized: &crate::agent::dto::NormalizedFields,
) -> anyhow::Result<()> {
let context: serde_json::Value =
serde_json::from_str(review.context_json_str()).unwrap_or_default();
let input_path = review.input_path_str().to_owned();
let stats = crate::scheduler::ProcessingStats::get_by_review_id(db, review.id_val())
.await
.unwrap_or(None);
let model_name = stats.as_ref().map(|s| s.model_name.to_string());
crate::jobs::inbox_process::finalize_approved(
db,
pool,
config,
&input_path,
normalized,
&context,
&config.agent_storage_dir,
model_name.as_deref(),
)
.await?;
review.set_approved(db).await?;
Ok(())
}
pub async fn jobs(
session: Session,
db: Database,
@@ -2461,7 +2567,7 @@ pub async fn merge_releases(
.into_response()
}
async fn require_admin_json(
pub(super) async fn require_admin_json(
session: &Session,
db: &Database,
) -> Result<AuthenticatedUser, cot::response::Response> {
@@ -2474,7 +2580,7 @@ async fn require_admin_json(
Ok(user)
}
fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
pub(super) fn json_error(status: StatusCode, message: &str) -> cot::response::Response {
let body = serde_json::json!({ "error": message });
cot::http::Response::builder()
.status(status)
+24
View File
@@ -2852,6 +2852,29 @@ pub mod db_migrations {
&[Operation::custom(add_lastfm_lookup_state).build()];
}
#[cot::db::migrations::migration_op]
async fn create_metadata_fix_history(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db.raw("CREATE TABLE furumusic__metadata_fix_history (id BIGSERIAL PRIMARY KEY, instruction TEXT NOT NULL, before_json TEXT NOT NULL, after_json TEXT NOT NULL, created_release_ids BIGINT[] NOT NULL DEFAULT '{}', created_by BIGINT NOT NULL, created_at VARCHAR(32) NOT NULL, undone_by BIGINT, undone_at VARCHAR(32))").await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0050CreateMetadataFixHistory;
impl migrations::Migration for M0050CreateMetadataFixHistory {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0050_create_metadata_fix_history";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0049_add_lastfm_lookup_state",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_metadata_fix_history).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -2892,5 +2915,6 @@ pub mod db_migrations {
&M0047CreateYouTubeImportMediaLinks,
&M0048AddPlaybackCoordinationColumns,
&M0049AddLastfmLookupState,
&M0050CreateMetadataFixHistory,
];
}
+338 -37
View File
@@ -183,6 +183,35 @@ button {
padding: 18px;
}
.repair-shell { display:grid;grid-template-columns:290px minmax(0,1fr);gap:14px;height:calc(100dvh - 116px);min-height:520px; }
.repair-side,.repair-work { min-height:0;display:flex;flex-direction:column;gap:12px; }
.repair-side .panel,.repair-plan,.repair-chat { min-height:0;display:flex;flex-direction:column; }
.repair-side .panel { flex:1; }
.repair-scroll { overflow:auto;min-height:0;padding:12px; }
.repair-work .repair-plan { flex:1; }
.repair-work .repair-chat { height:220px;flex:none; }
.repair-group { margin:12px 0 5px;color:var(--text-secondary);font-size:11px;font-weight:800;text-transform:uppercase; }
.repair-entity { display:flex;align-items:flex-start;gap:8px;padding:6px 0;font-size:12px; }
.repair-entity span { overflow-wrap:anywhere; }
.repair-search-results { max-height:160px;overflow:auto;border:1px solid var(--border-color);border-radius:6px;margin-top:6px; }
.repair-search-results button { display:block;width:100%;padding:8px;background:transparent;color:var(--text-primary);text-align:left;cursor:pointer; }
.repair-search-results button:hover { background:var(--bg-hover); }
.repair-diff { width:100%;border-collapse:collapse;font-size:12px; }
.repair-diff th,.repair-diff td { text-align:left;border-bottom:1px solid var(--border-color);padding:7px;vertical-align:top; }
.repair-diff th { color:var(--text-subdued);font-size:10px;text-transform:uppercase; }
.repair-diff input,.repair-diff select { width:100%;min-width:90px;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:5px;padding:6px; }
.repair-diff .compact { min-width:65px;max-width:90px; }
.repair-message { padding:7px 9px;border-radius:7px;max-width:86%;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere; }
.repair-message.user { background:#16452a;align-self:flex-end; }
.repair-message.assistant { background:var(--bg-elevated);align-self:flex-start; }
.repair-composer { border-top:1px solid var(--border-color);padding:9px;display:flex;gap:8px;align-items:end; }
.repair-composer textarea { flex:1;min-width:0;resize:vertical;max-height:100px;background:var(--bg-primary);color:var(--text-primary);border:1px solid var(--border-color);border-radius:6px;padding:8px; }
.repair-thinking { display:flex;align-items:center;gap:8px;color:var(--amber);font-size:12px;padding:10px; }
.repair-thinking::before { content:"";width:10px;height:10px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:repair-spin .8s linear infinite; }
@keyframes repair-spin { to { transform:rotate(360deg); } }
.repair-section-title { margin:16px 0 6px;font-size:12px;font-weight:800;color:var(--text-secondary); }
.repair-history { font-size:11px;line-height:1.5; }
.stats-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
@@ -2012,7 +2041,7 @@ tbody tr:hover {
</button>
<button class="nav-btn" :class="{active: activeView === 'tools'}" @click="openTools()">
<i data-lucide="wrench"></i>
<span>Future Tools</span>
<span>Library Tools</span>
</button>
<button class="nav-btn" :class="{active: activeView === 'users'}" @click="openUsers()">
<i data-lucide="users"></i>
@@ -2100,26 +2129,30 @@ tbody tr:hover {
<div class="action-strip">
<div class="toolbar">
<button class="btn" @click="selectVisibleReviews()">
<button class="btn" @click="selectVisibleReviews()" :disabled="reviewBulkBusy">
<i data-lucide="check-square"></i>
Select visible
</button>
<button class="btn" @click="selectReviewFilter()" :disabled="reviews.total === 0">
<button class="btn" @click="selectReviewFilter()" :disabled="reviewBulkBusy || reviews.total === 0">
<i data-lucide="list-checks"></i>
<span x-text="`Select all (${fmt(reviews.total)})`"></span>
</button>
<button class="btn" @click="clearReviewSelection()" :disabled="selectedReviewCount() === 0">
<button class="btn" @click="clearReviewSelection()" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="x"></i>
Clear
</button>
</div>
<div class="toolbar">
<span class="selection-summary" x-text="reviewSelectionSummary()"></span>
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="selectedReviewCount() === 0">
<button class="btn primary" @click="bulkReviews('approve')" :disabled="reviewBulkBusy || selectedPendingReviewCount() === 0">
<i data-lucide="check"></i>
<span x-text="reviewBulkBusy && reviewBulkAction === 'approve' ? 'Approving…' : 'Approve selected'"></span>
</button>
<button class="btn warn" @click="bulkReviews('requeue')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="rotate-ccw"></i>
Requeue selected
</button>
<button class="btn danger" @click="bulkReviews('delete')" :disabled="selectedReviewCount() === 0">
<button class="btn danger" @click="bulkReviews('delete')" :disabled="reviewBulkBusy || selectedReviewCount() === 0">
<i data-lucide="trash-2"></i>
Delete selected
</button>
@@ -2143,7 +2176,7 @@ tbody tr:hover {
<template x-for="row in reviews.items" :key="row.id">
<tr @click="openReview(row)">
<td class="col-check" @click.stop>
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" />
<input class="check" type="checkbox" :checked="isReviewSelected(row.id)" @change="toggleReview(row)" :disabled="reviewBulkBusy" />
</td>
<td><span class="badge" :class="row.status" x-text="row.status"></span></td>
<td>
@@ -2531,28 +2564,55 @@ tbody tr:hover {
</div>
<div class="content" x-show="activeView === 'tools'">
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>Future Tools</strong>
<span>Reserved operation slots for complex library maintenance</span>
</div>
<div class="repair-shell">
<aside class="repair-side">
<section class="panel">
<div class="panel-head"><div class="panel-title"><strong>Metadata Repair</strong><span>Select problematic entities</span></div></div>
<div class="repair-scroll">
<div class="toolbar"><select class="search" x-model="repairSearchKind" :disabled="repairBusy"><option value="artists">Artists</option><option value="releases">Releases</option><option value="tracks">Tracks</option></select></div>
<input class="search" style="width:100%;margin-top:8px" placeholder="Search library…" x-model="repairSearch" @input.debounce.300ms="searchRepair()" :disabled="repairBusy" />
<div class="repair-search-results" x-show="repairSearchResults.length">
<template x-for="item in repairSearchResults" :key="item.kind + ':' + item.id"><button type="button" @click="addRepairEntity(item)" x-text="item.title + (item.subtitle ? ' · ' + item.subtitle : '')"></button></template>
</div>
<div class="repair-group">Selected and related · <span x-text="repairIncludedCount()"></span></div>
<div class="muted" x-show="!repairExpanded && repairSelected.length === 0">Search and select an artist, release, or track.</div>
<template x-for="kind in ['artists','releases','tracks']" :key="kind">
<div x-show="repairRows(kind).length">
<div class="repair-group" x-text="kind"></div>
<template x-for="item in repairRows(kind)" :key="kind + item.id">
<label class="repair-entity"><input type="checkbox" :checked="repairIncluded[kind + ':' + item.id] !== false" @change="setRepairIncluded(kind,item.id,$event.target.checked)" :disabled="repairBusy" /><span x-text="(item.name || item.title) + ' #' + item.id"></span></label>
</template>
</div>
</template>
<div class="toolbar" style="margin-top:12px"><button class="btn" @click="expandRepair()" :disabled="repairSelected.length === 0 || repairBusy">Show related</button><button class="btn" @click="clearRepair()" :disabled="repairBusy">Clear</button></div>
<div class="muted" style="margin-top:8px">Uncheck any related item to exclude it from the plan. Audio files stay in their current folders.</div>
</div>
</section>
<section class="panel" style="max-height:160px"><div class="panel-head"><div class="panel-title"><strong>History</strong><span>Recent applied plans</span></div></div><div class="repair-scroll repair-history"><template x-for="entry in repairHistory" :key="entry.id"><div class="repair-entity"><span style="flex:1" x-text="'#' + entry.id + ' · ' + entry.instruction.slice(0,55) + (entry.undone_at ? ' · undone' : '')"></span><button class="btn" x-show="!entry.undone_at" @click="undoRepair(entry.id)" :disabled="repairBusy">Undo</button></div></template><div class="muted" x-show="repairHistory.length === 0">No applied plans yet.</div></div></section>
</aside>
<div class="repair-work">
<section class="panel repair-plan">
<div class="panel-head"><div class="panel-title"><strong>Proposed changes</strong><span x-text="repairIncomplete ? (repairBusy ? 'Processing tracks: ' : 'Paused at track: ') + repairTrackOffset + ' / ' + repairTrackTotal : (repairPlan ? 'Review and edit before applying' : 'The agent’s plan will appear here')"></span></div><div class="toolbar"><span class="selection-summary" x-show="repairPlan" x-text="repairChangeCount() + ' changes'"></span><button class="btn primary" x-show="repairPlan" @click="applyRepair()" :disabled="repairBusy || repairIncomplete || !repairChangeCount()">Apply plan</button></div></div>
<div class="repair-scroll" :style="repairBusy ? 'pointer-events:none' : ''">
<div class="repair-thinking" x-show="repairBusy && repairStage === 'thinking'">Agent is reviewing the selected metadata…</div>
<div class="empty" x-show="!repairPlan && !(repairBusy && repairStage === 'thinking')">Select entities and describe the correction in the chat below.</div>
<template x-if="repairPlan"><div>
<div class="repair-section-title" x-show="repairPlan.questions.length">Questions to review</div>
<template x-for="question in repairPlan.questions" :key="question"><div class="repair-entity" style="color:var(--amber)" x-text="question"></div></template>
<div class="repair-section-title" x-show="repairPlan.artists.length">Artists</div>
<table class="repair-diff" x-show="repairPlan.artists.length"><thead><tr><th>Before</th><th>After</th><th></th></tr></thead><tbody><template x-for="(edit,index) in repairPlan.artists" :key="edit.id"><tr><td x-text="repairBefore('artists',edit.id)?.name || ''"></td><td><input x-model="edit.name" /></td><td><button class="btn" @click="repairPlan.artists.splice(index,1)">Remove</button></td></tr></template></tbody></table>
<div class="repair-section-title" x-show="repairPlan.releases.length">Releases</div>
<table class="repair-diff" x-show="repairPlan.releases.length"><thead><tr><th>Before</th><th>After</th><th>Type</th><th>Year</th><th>Artist IDs</th><th></th></tr></thead><tbody><template x-for="(edit,index) in repairPlan.releases" :key="edit.id || edit.key"><tr><td><div x-text="edit.id ? (repairBefore('releases',edit.id)?.title || '') : 'New release'"></div><small class="muted" x-show="edit.id" x-text="edit.id ? [repairBefore('releases',edit.id)?.release_type,repairBefore('releases',edit.id)?.year].filter(x => x != null).join(' · ') : ''"></small></td><td><input x-model="edit.title" /></td><td><select x-model="edit.release_type"><template x-for="type in ['album','single','ep','compilation','mixtape','live','soundtrack','remix','demo']" :key="type"><option :value="type" x-text="type"></option></template></select></td><td><input class="compact" type="number" x-model.number="edit.year" /></td><td><input :value="edit.artist_ids.join(', ')" @change="edit.artist_ids = parseRepairIds($event.target.value)" /></td><td><button class="btn" @click="repairPlan.releases.splice(index,1)">Remove</button></td></tr></template></tbody></table>
<div class="toolbar" x-show="repairPlan"><button class="btn" @click="addRepairRelease()">+ New release</button></div>
<div class="repair-section-title" x-show="repairPlan.tracks.length">Tracks</div>
<table class="repair-diff" x-show="repairPlan.tracks.length"><thead><tr><th>Before</th><th>After</th><th>Release</th><th>#</th><th>Disc</th><th>Year</th><th>Artist IDs</th><th></th></tr></thead><tbody><template x-for="(edit,index) in repairPlan.tracks" :key="edit.id"><tr><td><div x-text="repairBefore('tracks',edit.id)?.title || ''"></div><small class="muted" x-text="'#' + (repairBefore('tracks',edit.id)?.track_number ?? '—') + ' · release #' + (repairBefore('tracks',edit.id)?.release_id || '')"></small></td><td><input x-model="edit.title" /></td><td><select x-model="edit.target_release"><template x-for="release in repairTargetReleases()" :key="release.value"><option :value="release.value" x-text="release.label"></option></template></select></td><td><input class="compact" type="number" x-model.number="edit.track_number" /></td><td><input class="compact" type="number" x-model.number="edit.disc_number" /></td><td><input class="compact" type="number" x-model.number="edit.year" /></td><td><input :value="edit.artist_ids.join(', ')" @change="edit.artist_ids = parseRepairIds($event.target.value)" /></td><td><button class="btn" @click="repairPlan.tracks.splice(index,1)">Remove</button></td></tr></template></tbody></table>
<div class="toolbar"><button class="btn" @click="addRepairTrack()">+ Track edit</button></div>
</div></template>
</div>
</section>
<section class="panel repair-chat"><div class="repair-scroll" style="display:flex;flex-direction:column;gap:7px" x-ref="repairChat"><template x-for="(turn,index) in repairChat" :key="index"><div class="repair-message" :class="turn.role" x-text="turn.content"></div></template><div class="muted" x-show="repairChat.length === 0">Describe what should change. The agent will respond in your language.</div></div><form class="repair-composer" @submit.prevent="sendRepair()"><textarea rows="2" placeholder="Describe the metadata problem…" x-model="repairDraft" @keydown.ctrl.enter.prevent="sendRepair()"></textarea><button class="btn primary" type="submit" :disabled="repairBusy || !repairDraft.trim() || !repairIncludedCount()" x-text="repairIncomplete && repairDraft.trim() === repairPendingInstruction ? 'Retry' : 'Send'"></button></form></section>
</div>
<div style="padding:14px;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px">
<button class="btn" style="height:72px" @click="mockAction('Artist merge wizard placeholder')">
<i data-lucide="git-merge"></i>
Artist merge
</button>
<button class="btn" style="height:72px" @click="mockAction('Release split/move tracks placeholder')">
<i data-lucide="scissors"></i>
Split release
</button>
<button class="btn" style="height:72px" @click="mockAction('Metadata enrichment rerun placeholder')">
<i data-lucide="sparkles"></i>
Enrich metadata
</button>
</div>
</section>
</div>
</div>
<div class="content" x-show="activeView === 'settings'">
@@ -3778,6 +3838,8 @@ function adminV2() {
reviewFilter: { status: null, search: '' },
selectedReviewIds: {},
reviewSelectionScope: 'ids',
reviewBulkBusy: false,
reviewBulkAction: null,
activeReview: null,
reviewDraft: {
title: '',
@@ -3847,6 +3909,27 @@ function adminV2() {
mergeDetails: [],
mergeArtistSearch: '',
mergeDraft: { release_ids: [], target_release_id: null, title: '', release_type: 'album', year: '', hidden: 'false', cover_file_id: null, artist_ids: [], tracks: [] },
repairSearchKind: 'releases',
repairSearch: '',
repairSearchResults: [],
repairSearchToken: 0,
repairSelected: [],
repairExpanded: null,
repairIncluded: {},
repairDraft: '',
repairChat: [],
repairPlan: null,
repairSnapshot: null,
repairInstruction: '',
repairBusy: false,
repairStage: '',
repairIncomplete: false,
repairTrackOffset: 0,
repairTrackTotal: 0,
repairPendingInstruction: '',
repairReference: '',
repairExpandedReference: '',
repairHistory: [],
settings: { values: {}, sources: {}, youtube_cookie_files: [], lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
settingsDraft: {
auth_password_enabled: false,
@@ -4033,8 +4116,9 @@ function adminV2() {
} else if (this.activeView === 'users') {
if (updateRoute) this.setRoute('#users');
await this.loadUsers(false);
} else if (this.activeView === 'tools' && updateRoute) {
this.setRoute('#tools');
} else if (this.activeView === 'tools') {
if (updateRoute) this.setRoute('#tools');
await this.loadRepairHistory();
}
},
@@ -4073,6 +4157,212 @@ function adminV2() {
openTools() {
this.activeView = 'tools';
this.setRoute('#tools');
this.loadRepairHistory();
},
async searchRepair() {
const token = ++this.repairSearchToken;
const search = this.repairSearch.trim();
if (!search) { this.repairSearchResults = []; return; }
try {
const params = new URLSearchParams({kind:this.repairSearchKind,search,limit:'25',offset:'0'});
const result = await this.request(`${this.apiBase}/library?${params}`);
if (token === this.repairSearchToken) this.repairSearchResults = result.items || [];
} catch (error) { this.showToast(error.message); }
},
addRepairEntity(item) {
if (this.repairBusy) return;
if (this.repairSelected.some(x => x.kind === item.kind && Number(x.id) === Number(item.id))) return;
this.repairSelected.push({kind:item.kind,id:Number(item.id),title:item.title});
this.repairExpanded = null;
this.repairPlan = null;
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairPendingInstruction = '';
this.repairReference = '';
this.repairExpandedReference = '';
this.repairSearch = '';
this.repairSearchResults = [];
},
clearRepair() {
this.repairSelected = [];
this.repairExpanded = null;
this.repairIncluded = {};
this.repairPlan = null;
this.repairSnapshot = null;
this.repairChat = [];
this.repairDraft = '';
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairTrackTotal = 0;
this.repairPendingInstruction = '';
this.repairReference = '';
this.repairExpandedReference = '';
},
repairSelection(expanded = false) {
const selection = {artists:[],releases:[],tracks:[]};
if (expanded && this.repairExpanded) {
for (const kind of Object.keys(selection)) {
for (const item of this.repairExpanded.snapshot[kind] || []) {
if (this.repairIncluded[kind + ':' + item.id] !== false) selection[kind].push(item.id);
}
}
} else {
for (const item of this.repairSelected) if (this.repairIncluded[item.kind + ':' + item.id] !== false) selection[item.kind].push(item.id);
}
return selection;
},
repairRows(kind) {
if (this.repairExpanded) return this.repairExpanded.snapshot[kind] || [];
return this.repairSelected.filter(item => item.kind === kind);
},
repairIncludedCount() {
return Object.values(this.repairSelection(true)).reduce((total, ids) => total + ids.length, 0);
},
setRepairIncluded(kind,id,checked) {
if (this.repairBusy) return;
this.repairIncluded[kind + ':' + id] = checked;
this.repairPlan = null;
this.repairSnapshot = null;
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairPendingInstruction = '';
this.repairReference = '';
this.repairExpandedReference = '';
},
async expandRepair() {
if (this.repairSelected.length === 0) return;
this.repairBusy = true;
this.repairStage = 'expanding';
try {
this.repairExpanded = await this.request(`${this.apiBase}/metadata-fix/expand`, {
method:'POST',body:JSON.stringify({selection:this.repairSelection(false),reference:this.repairReference || null})
});
this.repairExpandedReference = this.repairReference;
this.repairPlan = null;
this.repairSnapshot = null;
this.repairIncluded = {};
this.repairIncomplete = false;
this.repairTrackOffset = 0;
this.repairPendingInstruction = '';
} catch (error) { this.showToast(error.message); }
finally { this.repairBusy = false; this.repairStage = ''; }
},
repairBefore(kind,id) { return (this.repairSnapshot?.[kind] || []).find(item => Number(item.id) === Number(id)); },
repairArtistIds(kind,id) {
const relations = this.repairSnapshot?.[kind === 'releases' ? 'release_artists' : 'track_artists'] || [];
return relations.filter(row => Number(row.owner_id) === Number(id)).map(row => Number(row.artist_id));
},
repairTargetReleases() {
const existing = (this.repairSnapshot?.releases || []).map(row => ({value:'existing:' + row.id,label:row.title + ' #' + row.id}));
for (const track of this.repairSnapshot?.tracks || []) {
if (!existing.some(row => row.value === 'existing:' + track.release_id)) existing.push({value:'existing:' + track.release_id,label:'Current release #' + track.release_id});
}
const created = (this.repairPlan?.releases || []).filter(row => row.key && !row.id).map(row => ({value:'new:' + row.key,label:(row.title || 'New release') + ' · new'}));
return existing.concat(created);
},
parseRepairIds(value) { return [...new Set(String(value).split(',').map(x => Number(x.trim())).filter(x => Number.isInteger(x) && x > 0))]; },
addRepairRelease() {
if (!this.repairPlan) return;
const key = 'manual_' + Date.now();
const first = this.repairSnapshot?.releases?.[0];
this.repairPlan.releases.push({id:null,key,title:'',release_type:'album',year:first?.year ?? null,artist_ids:first ? this.repairArtistIds('releases',first.id) : []});
},
addRepairTrack() {
if (!this.repairPlan || !this.repairSnapshot) return;
const row = this.repairSnapshot.tracks.find(x => !this.repairPlan.tracks.some(edit => edit.id === x.id));
if (!row) { this.showToast('All selected tracks are already in the plan'); return; }
this.repairPlan.tracks.push({id:row.id,title:row.title,target_release:'existing:' + row.release_id,track_number:row.track_number,disc_number:row.disc_number,year:row.year,artist_ids:this.repairArtistIds('tracks',row.id)});
},
repairChangeCount() { return this.repairPlan ? this.repairPlan.artists.length + this.repairPlan.releases.length + this.repairPlan.tracks.length : 0; },
repairPlanPayload() {
const plan = JSON.parse(JSON.stringify(this.repairPlan));
for (const edit of [...plan.releases, ...plan.tracks]) {
for (const field of ['year','track_number','disc_number']) {
if (field in edit && (edit[field] === '' || edit[field] == null)) edit[field] = null;
}
}
return plan;
},
async sendRepair() {
const instruction = this.repairDraft.trim();
if (!instruction || this.repairBusy || !this.repairIncludedCount()) return;
if (!this.repairReference) {
const source = this.repairChat.find(turn => turn.role === 'user' && turn.content.includes('### ') && /^\s*\d+\.\s+\S/m.test(turn.content));
if (source) this.repairReference = source.content;
}
const resuming = this.repairIncomplete && this.repairPendingInstruction === instruction;
if (instruction.includes('### ') && /^\s*\d+\.\s+\S/m.test(instruction)) this.repairReference = instruction;
if (!resuming) {
this.repairTrackOffset = 0;
this.repairTrackTotal = 0;
}
this.repairBusy = true;
this.repairStage = 'thinking';
this.repairIncomplete = true;
this.repairPendingInstruction = instruction;
this.repairTrackTotal = this.repairSelection(true).tracks.length;
this.repairDraft = '';
const previous = this.repairChat.slice(-6);
if (!resuming) this.repairChat.push({role:'user',content:instruction});
try {
if (!this.repairExpanded || this.repairExpandedReference !== this.repairReference) {
this.repairExpanded = await this.request(`${this.apiBase}/metadata-fix/expand`, {method:'POST',body:JSON.stringify({selection:this.repairSelection(false),reference:this.repairReference || null})});
this.repairExpandedReference = this.repairReference;
}
while (true) {
const result = await this.request(`${this.apiBase}/metadata-fix/plan`, {method:'POST',body:JSON.stringify({selection:this.repairSelection(true),instruction,reference:this.repairReference || null,primary_artist_ids:this.repairSelected.filter(item => item.kind === 'artists').map(item => item.id),conversation:previous,current_plan:this.repairPlan ? this.repairPlanPayload() : null,track_offset:this.repairTrackOffset})});
this.repairPlan = result.plan;
this.repairSnapshot = result.snapshot;
this.repairTrackTotal = result.total_tracks;
this.repairTrackOffset = result.next_track_offset ?? result.total_tracks;
if (result.next_track_offset == null) {
this.repairChat.push({role:'assistant',content:result.message});
this.repairIncomplete = false;
this.repairPendingInstruction = '';
break;
}
}
this.repairInstruction = instruction;
} catch (error) {
this.repairDraft = instruction;
this.showToast(error.message);
} finally {
this.repairBusy = false;
this.repairStage = '';
this.$nextTick(() => { if (this.$refs.repairChat) this.$refs.repairChat.scrollTop = this.$refs.repairChat.scrollHeight; });
}
},
async applyRepair() {
if (!this.repairPlan || !this.repairSnapshot || this.repairBusy || this.repairIncomplete || !this.repairChangeCount()) return;
this.repairBusy = true;
this.repairStage = 'applying';
try {
const result = await this.request(`${this.apiBase}/metadata-fix/apply`, {method:'POST',body:JSON.stringify({snapshot:this.repairSnapshot,plan:this.repairPlanPayload(),instruction:this.repairInstruction})});
this.showToast('Applied · history #' + result.history_id);
this.repairPlan = null;
this.repairSnapshot = null;
this.repairExpanded = null;
await this.loadRepairHistory();
await this.loadLibrary(false);
} catch (error) { this.showToast(error.message); }
finally { this.repairBusy = false; this.repairStage = ''; }
},
async loadRepairHistory() {
try { this.repairHistory = await this.request(`${this.apiBase}/metadata-fix/history`); }
catch (error) { this.showToast(error.message); }
},
async undoRepair(historyId) {
if (this.repairBusy || !confirm('Undo this metadata repair?')) return;
this.repairBusy = true;
try {
await this.request(`${this.apiBase}/metadata-fix/undo`, {method:'POST',body:JSON.stringify({history_id:historyId})});
await this.loadRepairHistory();
this.repairExpanded = null;
this.repairPlan = null;
this.showToast('Metadata repair undone');
} catch (error) { this.showToast(error.message); }
finally { this.repairBusy = false; }
},
openUsers() {
@@ -4546,6 +4836,11 @@ function adminV2() {
return Object.keys(this.selectedReviewIds).length;
},
selectedPendingReviewCount() {
if (this.reviewSelectionScope === 'filter') return this.filterStatusCounts().pending || 0;
return Object.values(this.selectedReviewIds).filter(status => status === 'pending').length;
},
reviewSelectionSummary() {
const total = this.selectedReviewCount();
if (!total) return 'Nothing selected';
@@ -4568,8 +4863,10 @@ function adminV2() {
},
async bulkReviews(action) {
if (this.reviewBulkBusy) return;
const count = this.selectedReviewCount();
if (!count) return;
if (action === 'approve' && !this.selectedPendingReviewCount()) return;
if (action === 'delete' && !confirm(`Delete ${count} review row(s)?`)) return;
const payload = {
action,
@@ -4580,16 +4877,24 @@ function adminV2() {
search: this.reviewFilter.search || null
}
};
this.reviewBulkBusy = true;
this.reviewBulkAction = action;
try {
const result = await this.request(`${this.apiBase}/reviews/bulk`, {
method: 'POST',
body: JSON.stringify(payload)
});
this.showToast(`${result.affected} review row(s) updated`);
this.showToast(action === 'approve'
? `${result.affected} review(s) approved · ${result.failed || 0} failed · ${result.skipped || 0} skipped`
: `${result.affected} review row(s) updated`);
this.clearReviewSelection();
await this.loadReviews(false);
if (action === 'approve') await this.loadLibrary(false);
} catch (error) {
this.showToast(error.message);
} finally {
this.reviewBulkBusy = false;
this.reviewBulkAction = null;
}
},
@@ -5730,7 +6035,7 @@ function adminV2() {
pageTitle() {
if (this.activeView === 'library') return 'Library Workbench';
if (this.activeView === 'jobs') return 'Tasks';
if (this.activeView === 'tools') return 'Future Tools';
if (this.activeView === 'tools') return 'Library Tools';
if (this.activeView === 'settings') return 'Settings';
if (this.activeView === 'users') return 'Users';
return 'Review Queue';
@@ -5739,7 +6044,7 @@ function adminV2() {
pageSubtitle() {
if (this.activeView === 'library') return 'Fast entity control surface for artists, releases, tracks, and playlists';
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
if (this.activeView === 'tools') return 'Guided metadata repair and maintenance history';
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
return 'Full-screen review triage with filter-aware bulk actions';
@@ -5914,10 +6219,6 @@ function adminV2() {
return new Intl.NumberFormat().format(value || 0);
},
mockAction(message) {
this.showToast(message);
},
showToast(message) {
this.toastMessage = message;
setTimeout(() => {
+104
View File
@@ -0,0 +1,104 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const { test } = require('node:test');
const vm = require('node:vm');
const template = readFileSync(`${__dirname}/../templates/admin/v2.html`, 'utf8');
const script = template.match(/<script>\s*(function adminV2\(\)[\s\S]*?)<\/script>/)[1];
function createApp() {
const context = vm.createContext({});
vm.runInContext(script, context);
const app = context.adminV2();
app.showToast = message => { app.toastMessage = message; };
app.loadReviews = async () => { app.reviewsReloaded = true; };
app.loadLibrary = async () => { app.libraryReloaded = true; };
return app;
}
test('approves individually selected reviews and reports partial failures', async () => {
const app = createApp();
app.toggleReview({ id: 12, status: 'pending' });
app.toggleReview({ id: 34, status: 'pending' });
app.toggleReview({ id: 56, status: 'failed' });
assert.equal(app.selectedPendingReviewCount(), 2);
app.request = async (url, options) => {
assert.equal(url, '/admin/v2/api/reviews/bulk');
assert.equal(options.method, 'POST');
assert.deepEqual(JSON.parse(options.body), {
action: 'approve', mode: 'ids', ids: [12, 34, 56],
filter: { status: null, search: null }
});
assert.equal(app.reviewBulkBusy, true);
return { ok: false, affected: 1, failed: 1, skipped: 1 };
};
await app.bulkReviews('approve');
assert.equal(app.toastMessage, '1 review(s) approved · 1 failed · 1 skipped');
assert.equal(app.selectedReviewCount(), 0);
assert.equal(app.reviewsReloaded, true);
assert.equal(app.libraryReloaded, true);
assert.equal(app.reviewBulkBusy, false);
});
test('select all approves the whole searched filter across pages', async () => {
const app = createApp();
app.reviewFilter = { status: 'pending', search: 'Album' };
app.reviews = { total: 320, items: [{ id: 1, status: 'pending' }] };
app.selectReviewFilter();
assert.equal(app.selectedPendingReviewCount(), 320);
app.request = async (_url, options) => {
assert.deepEqual(JSON.parse(options.body), {
action: 'approve', mode: 'filter', ids: [],
filter: { status: 'pending', search: 'Album' }
});
return { affected: 320, failed: 0, skipped: 0 };
};
await app.bulkReviews('approve');
assert.equal(app.toastMessage, '320 review(s) approved · 0 failed · 0 skipped');
});
test('mixed status filter enables approval only when pending reviews exist', () => {
const app = createApp();
app.reviews = { total: 10, status_counts: [{ status: 'pending', count: 3 }, { status: 'queued', count: 7 }] };
app.selectReviewFilter();
assert.equal(app.selectedPendingReviewCount(), 3);
app.reviewFilter.status = 'queued';
assert.equal(app.selectedPendingReviewCount(), 0);
});
test('empty and non-pending selections do not submit approval', async () => {
const app = createApp();
app.request = async () => { assert.fail('unexpected request'); };
await app.bulkReviews('approve');
app.toggleReview({ id: 1, status: 'queued' });
await app.bulkReviews('approve');
});
test('blocks duplicate bulk submissions while approval is running', async () => {
const app = createApp();
app.toggleReview({ id: 1, status: 'pending' });
let finish;
let calls = 0;
app.request = async () => {
calls += 1;
return new Promise(resolve => { finish = resolve; });
};
const approval = app.bulkReviews('approve');
await app.bulkReviews('approve');
await app.bulkReviews('requeue');
assert.equal(calls, 1);
finish({ affected: 1 });
await approval;
assert.equal(app.reviewBulkBusy, false);
});
test('request failure retains selection and restores bulk controls', async () => {
const app = createApp();
app.toggleReview({ id: 1, status: 'pending' });
app.request = async () => { throw new Error('Connection lost'); };
await app.bulkReviews('approve');
assert.equal(app.selectedReviewCount(), 1);
assert.equal(app.toastMessage, 'Connection lost');
assert.equal(app.reviewBulkBusy, false);
assert.equal(app.reviewBulkAction, null);
});