Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcb5d378c8 | ||
|
|
59345a8d22 | ||
|
|
0975f3851a | ||
|
|
bded5a79cc | ||
|
|
2aede192fe | ||
|
|
2199fda240 | ||
|
|
8f7c0d231f |
No files matched your search
@@ -5,3 +5,4 @@
|
||||
/federation
|
||||
/similarity-models
|
||||
/federation-cache
|
||||
*.DS_Store
|
||||
Generated
+1
-1
@@ -1988,7 +1988,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.11.1"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
+6
-1
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user