Added merge
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m7s
Publish Web Player Image / build-and-push-image (push) Successful in 1m9s
Publish Server Image / build-and-push-image (push) Successful in 2m15s

This commit is contained in:
2026-03-19 02:09:04 +00:00
parent 12d28170d2
commit e1210e6e20
4 changed files with 382 additions and 22 deletions

View File

@@ -566,6 +566,7 @@ pub struct Stats {
pub review_count: i64,
pub error_count: i64,
pub merged_count: i64,
pub active_merges: i64,
}
pub async fn get_stats(pool: &PgPool) -> Result<Stats, sqlx::Error> {
@@ -576,7 +577,139 @@ pub async fn get_stats(pool: &PgPool) -> Result<Stats, sqlx::Error> {
let (review_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'review'").fetch_one(pool).await?;
let (error_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'error'").fetch_one(pool).await?;
let (merged_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM pending_tracks WHERE status = 'merged'").fetch_one(pool).await?;
Ok(Stats { total_tracks, total_artists, total_albums, pending_count, review_count, error_count, merged_count })
let (active_merges,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM artist_merges WHERE status IN ('pending','processing')").fetch_one(pool).await?;
Ok(Stats { total_tracks, total_artists, total_albums, pending_count, review_count, error_count, merged_count, active_merges })
}
// =================== Library search ===================
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct TrackRow {
pub id: i64,
pub title: String,
pub artist_name: String,
pub album_name: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub duration_secs: Option<f64>,
pub genre: Option<String>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AlbumRow {
pub id: i64,
pub name: String,
pub artist_name: String,
pub year: Option<i32>,
pub track_count: i64,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ArtistRow {
pub id: i64,
pub name: String,
pub album_count: i64,
pub track_count: i64,
}
pub async fn search_tracks(
pool: &PgPool,
q: &str, artist: &str, album: &str,
limit: i64, offset: i64,
) -> Result<Vec<TrackRow>, sqlx::Error> {
sqlx::query_as::<_, TrackRow>(
r#"SELECT t.id, t.title, ar.name AS artist_name, al.name AS album_name,
al.year, t.track_number, t.duration_secs, t.genre
FROM tracks t
JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'primary'
JOIN artists ar ON ar.id = ta.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE ($1 = '' OR t.title ILIKE '%' || $1 || '%')
AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%')
AND ($3 = '' OR al.name ILIKE '%' || $3 || '%')
ORDER BY ar.name, al.name NULLS LAST, t.track_number NULLS LAST, t.title
LIMIT $4 OFFSET $5"#,
)
.bind(q).bind(artist).bind(album).bind(limit).bind(offset)
.fetch_all(pool).await
}
pub async fn count_tracks(pool: &PgPool, q: &str, artist: &str, album: &str) -> Result<i64, sqlx::Error> {
let (n,): (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM tracks t
JOIN track_artists ta ON ta.track_id = t.id AND ta.role = 'primary'
JOIN artists ar ON ar.id = ta.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE ($1 = '' OR t.title ILIKE '%' || $1 || '%')
AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%')
AND ($3 = '' OR al.name ILIKE '%' || $3 || '%')"#,
)
.bind(q).bind(artist).bind(album)
.fetch_one(pool).await?;
Ok(n)
}
pub async fn search_albums(
pool: &PgPool,
q: &str, artist: &str,
limit: i64, offset: i64,
) -> Result<Vec<AlbumRow>, sqlx::Error> {
sqlx::query_as::<_, AlbumRow>(
r#"SELECT a.id, a.name, ar.name AS artist_name, a.year,
COUNT(t.id) AS track_count
FROM albums a
JOIN artists ar ON ar.id = a.artist_id
LEFT JOIN tracks t ON t.album_id = a.id
WHERE ($1 = '' OR a.name ILIKE '%' || $1 || '%')
AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%')
GROUP BY a.id, a.name, ar.name, a.year
ORDER BY ar.name, a.year NULLS LAST, a.name
LIMIT $3 OFFSET $4"#,
)
.bind(q).bind(artist).bind(limit).bind(offset)
.fetch_all(pool).await
}
pub async fn count_albums(pool: &PgPool, q: &str, artist: &str) -> Result<i64, sqlx::Error> {
let (n,): (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM albums a
JOIN artists ar ON ar.id = a.artist_id
WHERE ($1 = '' OR a.name ILIKE '%' || $1 || '%')
AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%')"#,
)
.bind(q).bind(artist)
.fetch_one(pool).await?;
Ok(n)
}
pub async fn search_artists_lib(
pool: &PgPool,
q: &str,
limit: i64, offset: i64,
) -> Result<Vec<ArtistRow>, sqlx::Error> {
sqlx::query_as::<_, ArtistRow>(
r#"SELECT ar.id, ar.name,
COUNT(DISTINCT al.id) AS album_count,
COUNT(DISTINCT ta.track_id) AS track_count
FROM artists ar
LEFT JOIN albums al ON al.artist_id = ar.id
LEFT JOIN track_artists ta ON ta.artist_id = ar.id AND ta.role = 'primary'
WHERE ($1 = '' OR ar.name ILIKE '%' || $1 || '%')
GROUP BY ar.id, ar.name
ORDER BY ar.name
LIMIT $2 OFFSET $3"#,
)
.bind(q).bind(limit).bind(offset)
.fetch_all(pool).await
}
pub async fn count_artists_lib(pool: &PgPool, q: &str) -> Result<i64, sqlx::Error> {
let (n,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM artists WHERE ($1 = '' OR name ILIKE '%' || $1 || '%')"
)
.bind(q)
.fetch_one(pool).await?;
Ok(n)
}
// =================== Artist Merges ===================

View File

@@ -147,6 +147,21 @@ details.llm-expand pre { background: var(--bg-card); border: 1px solid var(--bor
.artist-select-bar { display: none; position: fixed; bottom: 24px; right: 24px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px 16px; display: none; align-items: center; gap: 10px; box-shadow: 0 8px 32px rgba(0,0,0,0.6); z-index: 50; }
.artist-select-bar.visible { display: flex; }
.modal select { width: 100%; background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; padding: 7px 9px; color: var(--text); font-family: inherit; font-size: 12px; }
/* Search bar */
.search-bar { display: flex; gap: 6px; padding: 10px 24px; border-bottom: 1px solid var(--border); flex-shrink: 0; align-items: center; flex-wrap: wrap; }
.search-bar input { background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; padding: 5px 9px; color: var(--text); font-family: inherit; font-size: 12px; min-width: 140px; }
.search-bar input:focus { border-color: var(--accent); outline: none; }
.search-bar input::placeholder { color: var(--text-muted); }
.search-bar .search-label { font-size: 11px; color: var(--text-muted); }
.search-bar .total-label { margin-left: auto; font-size: 11px; color: var(--text-muted); }
/* Pagination */
.pagination { display: flex; gap: 3px; padding: 10px 24px; justify-content: center; flex-shrink: 0; border-top: 1px solid var(--border); }
.pagination button { background: var(--bg-card); border: 1px solid var(--border); border-radius: 4px; padding: 3px 9px; color: var(--text-muted); font-size: 11px; font-family: inherit; cursor: pointer; }
.pagination button:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.pagination button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.pagination button:disabled { opacity: 0.3; cursor: default; }
</style>
</head>
<body>
@@ -155,6 +170,8 @@ details.llm-expand pre { background: var(--bg-card); border: 1px solid var(--bor
<h1>Furumi Agent</h1>
<nav>
<button class="active" onclick="showTab('queue',this)">Queue</button>
<button onclick="showTab('tracks',this)">Tracks</button>
<button onclick="showTab('albums',this)">Albums</button>
<button onclick="showTab('artists',this)">Artists</button>
<button onclick="showTab('merges',this)">Merges</button>
</nav>
@@ -217,7 +234,7 @@ async function loadStats() {
`;
// Agent status
const el = document.getElementById('agentStatus');
if (s.pending_count > 0) { el.textContent = 'Processing...'; el.className = 'agent-status busy'; }
if (s.pending_count > 0 || s.active_merges > 0) { el.textContent = 'Processing...'; el.className = 'agent-status busy'; }
else { el.textContent = 'Idle'; el.className = 'agent-status idle'; }
// Update filter counts if on queue tab
@@ -244,8 +261,12 @@ function showTab(tab, btn) {
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
clearSelection();
const pag = document.getElementById('lib-pagination');
if (pag) pag.style.display = 'none';
if (tab === 'queue') { loadQueue(); loadStats(); }
else if (tab === 'artists') { loadArtists(); document.getElementById('filterBar').innerHTML = ''; }
else if (tab === 'artists') { libPage.artists = 0; loadLibArtists(); }
else if (tab === 'tracks') { libPage.tracks = 0; loadLibTracks(); }
else if (tab === 'albums') { libPage.albums = 0; loadLibAlbums(); }
else if (tab === 'merges') { loadMerges(); document.getElementById('filterBar').innerHTML = ''; }
}
@@ -585,25 +606,178 @@ function onFeatKey(e) {
}
function closeFeatDropdown() { const dd = document.getElementById('feat-dropdown'); if (dd) dd.classList.remove('open'); }
// --- Artists tab ---
async function loadArtists() {
const artists = await api('/artists');
// --- Library tabs (Tracks / Albums / Artists) ---
const LIB_LIMIT = 50;
const libPage = { tracks: 0, albums: 0, artists: 0 };
const libSearch = {
tracks: { q: '', artist: '', album: '' },
albums: { q: '', artist: '' },
artists: { q: '' },
};
const libTotal = { tracks: 0, albums: 0, artists: 0 };
function fmtDuration(s) {
if (s == null) return '';
const m = Math.floor(s / 60), ss = Math.floor(s % 60);
return m + ':' + (ss < 10 ? '0' : '') + ss;
}
function renderPagination(container, total, page, onGo) {
const pages = Math.max(1, Math.ceil(total / LIB_LIMIT));
if (pages <= 1) { container.innerHTML = ''; return; }
const maxBtn = 7, half = Math.floor(maxBtn / 2);
let start = Math.max(0, page - half), end = Math.min(pages - 1, start + maxBtn - 1);
if (end - start < maxBtn - 1) start = Math.max(0, end - maxBtn + 1);
let html = `<button ${page===0?'disabled':''} onclick="${onGo}(${page-1})"></button>`;
if (start > 0) html += `<button onclick="${onGo}(0)">1</button>${start>1?'<button disabled>…</button>':''}`;
for (let i = start; i <= end; i++) html += `<button class="${i===page?'active':''}" onclick="${onGo}(${i})">${i+1}</button>`;
if (end < pages - 1) html += `${end<pages-2?'<button disabled>…</button>':''}<button onclick="${onGo}(${pages-1})">${pages}</button>`;
html += `<button ${page===pages-1?'disabled':''} onclick="${onGo}(${page+1})"></button>`;
container.innerHTML = html;
}
function libSearchInput(tab, field, val) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
libSearch[tab][field] = val;
libPage[tab] = 0;
if (tab === 'tracks') loadLibTracks();
else if (tab === 'albums') loadLibAlbums();
else if (tab === 'artists') loadLibArtists();
}, 300);
}
function renderLibSearchBar(fields, totalLabel) {
// fields: [{id, label, key, tab, placeholder}]
const bar = document.getElementById('filterBar');
let html = fields.map(f =>
`<span class="search-label">${f.label}</span>
<input value="${esc(f.value)}" placeholder="${f.placeholder}"
oninput="libSearchInput('${f.tab}','${f.key}',this.value)"
style="min-width:150px">`
).join('');
html += `<span class="total-label">${totalLabel}</span>`;
bar.innerHTML = `<div class="search-bar" style="padding:0;border:none;width:100%">${html}</div>`;
}
// ---- Tracks ----
async function loadLibTracks() {
const s = libSearch.tracks, p = libPage.tracks;
const params = new URLSearchParams({ q: s.q, artist: s.artist, album: s.album, limit: LIB_LIMIT, offset: p * LIB_LIMIT });
const data = await api('/library/tracks?' + params);
if (!data) return;
renderLibSearchBar([
{ label: 'Title', key: 'q', tab: 'tracks', value: s.q, placeholder: 'search title…' },
{ label: 'Artist', key: 'artist', tab: 'tracks', value: s.artist, placeholder: 'search artist…' },
{ label: 'Album', key: 'album', tab: 'tracks', value: s.album, placeholder: 'search album…' },
], `${data.total} tracks`);
const el = document.getElementById('content');
if (!artists || !artists.length) { el.innerHTML = '<div class="empty">No artists yet</div>'; return; }
let html = '<table><tr><th style="width:30px"></th><th>ID</th><th>Name</th><th>Actions</th></tr>';
for (const a of artists) {
if (!data.items.length) {
el.innerHTML = '<div class="empty">No tracks found</div>';
} else {
let html = `<table><tr><th style="width:30px">#</th><th>Title</th><th>Artist</th><th>Album</th><th style="width:40px">Year</th><th style="width:45px">Dur</th><th>Genre</th></tr>`;
for (const t of data.items) {
html += `<tr>
<td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td>
<td>${a.id}</td>
<td class="editable" ondblclick="inlineEditArtist(this,${a.id})">${esc(a.name)}</td>
<td class="actions">
<button class="btn btn-edit" onclick="editArtist(${a.id},'${esc(a.name)}')">Rename</button>
</td>
<td style="color:var(--text-muted)">${t.track_number ?? ''}</td>
<td>${esc(t.title)}</td>
<td>${esc(t.artist_name)}</td>
<td>${esc(t.album_name ?? '')}</td>
<td>${t.year ?? ''}</td>
<td style="color:var(--text-muted)">${fmtDuration(t.duration_secs)}</td>
<td style="color:var(--text-muted)">${esc(t.genre ?? '')}</td>
</tr>`;
}
html += '</table>';
el.innerHTML = html;
el.innerHTML = html + '</table>';
}
renderLibPagination(data.total, p, 'goLibTracks');
}
function goLibTracks(page) { libPage.tracks = page; loadLibTracks(); }
// ---- Albums ----
async function loadLibAlbums() {
const s = libSearch.albums, p = libPage.albums;
const params = new URLSearchParams({ q: s.q, artist: s.artist, limit: LIB_LIMIT, offset: p * LIB_LIMIT });
const data = await api('/library/albums?' + params);
if (!data) return;
renderLibSearchBar([
{ label: 'Album', key: 'q', tab: 'albums', value: s.q, placeholder: 'search album…' },
{ label: 'Artist', key: 'artist', tab: 'albums', value: s.artist, placeholder: 'search artist…' },
], `${data.total} albums`);
const el = document.getElementById('content');
if (!data.items.length) {
el.innerHTML = '<div class="empty">No albums found</div>';
} else {
let html = `<table><tr><th>Album</th><th>Artist</th><th style="width:50px">Year</th><th style="width:60px">Tracks</th></tr>`;
for (const a of data.items) {
html += `<tr>
<td>${esc(a.name)}</td>
<td>${esc(a.artist_name)}</td>
<td>${a.year ?? ''}</td>
<td style="color:var(--text-muted)">${a.track_count}</td>
</tr>`;
}
el.innerHTML = html + '</table>';
}
renderLibPagination(data.total, p, 'goLibAlbums');
}
function goLibAlbums(page) { libPage.albums = page; loadLibAlbums(); }
// ---- Artists ----
async function loadLibArtists() {
const s = libSearch.artists, p = libPage.artists;
const params = new URLSearchParams({ q: s.q, limit: LIB_LIMIT, offset: p * LIB_LIMIT });
const data = await api('/library/artists?' + params);
if (!data) return;
renderLibSearchBar([
{ label: 'Artist', key: 'q', tab: 'artists', value: s.q, placeholder: 'search artist…' },
], `${data.total} artists`);
const el = document.getElementById('content');
if (!data.items.length) {
el.innerHTML = '<div class="empty">No artists found</div>';
} else {
let html = `<table><tr>
<th style="width:30px"></th><th style="width:50px">ID</th><th>Name</th>
<th style="width:60px">Albums</th><th style="width:60px">Tracks</th>
<th style="width:80px">Actions</th>
</tr>`;
for (const a of data.items) {
html += `<tr>
<td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td>
<td style="color:var(--text-muted)">${a.id}</td>
<td class="editable" ondblclick="inlineEditArtist(this,${a.id})">${esc(a.name)}</td>
<td style="color:var(--text-muted)">${a.album_count}</td>
<td style="color:var(--text-muted)">${a.track_count}</td>
<td class="actions"><button class="btn btn-edit" onclick="editArtist(${a.id},'${esc(a.name)}')">Rename</button></td>
</tr>`;
}
el.innerHTML = html + '</table>';
}
renderLibPagination(data.total, p, 'goLibArtists');
}
function goLibArtists(page) { libPage.artists = page; loadLibArtists(); }
function renderLibPagination(total, page, goFn) {
// Render pagination bar below main content
let pag = document.getElementById('lib-pagination');
if (!pag) {
pag = document.createElement('div');
pag.id = 'lib-pagination';
pag.className = 'pagination';
document.querySelector('body').appendChild(pag);
}
renderPagination(pag, total, page, goFn);
// hide if not needed
pag.style.display = total <= LIB_LIMIT ? 'none' : 'flex';
}
// Keep alias for clearArtistSelection
function loadArtists() { loadLibArtists(); }
function inlineEditArtist(td, id) {
if (td.querySelector('.inline-input')) return;

View File

@@ -430,6 +430,56 @@ pub async fn retry_merge(State(state): State<S>, Path(id): Path<Uuid>) -> impl I
}
}
// --- Library search ---
#[derive(Deserialize)]
pub struct LibraryQuery {
#[serde(default)]
pub q: String,
#[serde(default)]
pub artist: String,
#[serde(default)]
pub album: String,
#[serde(default = "default_lib_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
fn default_lib_limit() -> i64 { 50 }
pub async fn library_tracks(State(state): State<S>, Query(q): Query<LibraryQuery>) -> impl IntoResponse {
let (tracks, total) = tokio::join!(
db::search_tracks(&state.pool, &q.q, &q.artist, &q.album, q.limit, q.offset),
db::count_tracks(&state.pool, &q.q, &q.artist, &q.album),
);
match (tracks, total) {
(Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(),
(Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn library_albums(State(state): State<S>, Query(q): Query<LibraryQuery>) -> impl IntoResponse {
let (albums, total) = tokio::join!(
db::search_albums(&state.pool, &q.q, &q.artist, q.limit, q.offset),
db::count_albums(&state.pool, &q.q, &q.artist),
);
match (albums, total) {
(Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(),
(Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn library_artists(State(state): State<S>, Query(q): Query<LibraryQuery>) -> impl IntoResponse {
let (artists, total) = tokio::join!(
db::search_artists_lib(&state.pool, &q.q, q.limit, q.offset),
db::count_artists_lib(&state.pool, &q.q),
);
match (artists, total) {
(Ok(rows), Ok(n)) => (StatusCode::OK, Json(serde_json::json!({"total": n, "items": rows}))).into_response(),
(Err(e), _) | (_, Err(e)) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
// --- Helpers ---
fn error_response(status: StatusCode, message: &str) -> axum::response::Response {

View File

@@ -37,7 +37,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/merges/:id", get(api::get_merge).put(api::update_merge))
.route("/merges/:id/approve", post(api::approve_merge))
.route("/merges/:id/reject", post(api::reject_merge))
.route("/merges/:id/retry", post(api::retry_merge));
.route("/merges/:id/retry", post(api::retry_merge))
.route("/library/tracks", get(api::library_tracks))
.route("/library/albums", get(api::library_albums))
.route("/library/artists", get(api::library_artists));
Router::new()
.route("/", get(admin_html))