Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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(
|
||||
"/",
|
||||
|
||||
+2
-2
@@ -2461,7 +2461,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 +2474,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,
|
||||
];
|
||||
}
|
||||
+310
-30
@@ -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>
|
||||
@@ -2531,28 +2560,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'">
|
||||
@@ -3847,6 +3903,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 +4110,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 +4151,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() {
|
||||
@@ -5730,7 +6014,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 +6023,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 +6198,6 @@ function adminV2() {
|
||||
return new Intl.NumberFormat().format(value || 0);
|
||||
},
|
||||
|
||||
mockAction(message) {
|
||||
this.showToast(message);
|
||||
},
|
||||
|
||||
showToast(message) {
|
||||
this.toastMessage = message;
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in new issue
Block a user