diff --git a/furumi-agent/src/db.rs b/furumi-agent/src/db.rs index 3600c78..cba098f 100644 --- a/furumi-agent/src/db.rs +++ b/furumi-agent/src/db.rs @@ -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 { @@ -576,7 +577,139 @@ pub async fn get_stats(pool: &PgPool) -> Result { 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, + pub year: Option, + pub track_number: Option, + pub duration_secs: Option, + pub genre: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AlbumRow { + pub id: i64, + pub name: String, + pub artist_name: String, + pub year: Option, + 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, 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 { + 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, 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 { + 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, 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 { + 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 =================== diff --git a/furumi-agent/src/web/admin.html b/furumi-agent/src/web/admin.html index 7818e69..b2f5fdd 100644 --- a/furumi-agent/src/web/admin.html +++ b/furumi-agent/src/web/admin.html @@ -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; } @@ -155,6 +170,8 @@ details.llm-expand pre { background: var(--bg-card); border: 1px solid var(--bor

Furumi Agent

@@ -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,26 +606,179 @@ 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'); - const el = document.getElementById('content'); - if (!artists || !artists.length) { el.innerHTML = '
No artists yet
'; return; } - let html = ''; - for (const a of artists) { - html += ` - - - - - `; - } - html += '
IDNameActions
${a.id}${esc(a.name)} - -
'; - el.innerHTML = html; +// --- 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 = ``; + if (start > 0) html += `${start>1?'':''}`; + for (let i = start; i <= end; i++) html += ``; + if (end < pages - 1) html += `${end…':''}`; + html += ``; + 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 => + `${f.label} + ` + ).join(''); + html += `${totalLabel}`; + bar.innerHTML = ``; +} + +// ---- 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 (!data.items.length) { + el.innerHTML = '
No tracks found
'; + } else { + let html = ``; + for (const t of data.items) { + html += ` + + + + + + + + `; + } + el.innerHTML = html + '
#TitleArtistAlbumYearDurGenre
${t.track_number ?? ''}${esc(t.title)}${esc(t.artist_name)}${esc(t.album_name ?? '')}${t.year ?? ''}${fmtDuration(t.duration_secs)}${esc(t.genre ?? '')}
'; + } + 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 = '
No albums found
'; + } else { + let html = ``; + for (const a of data.items) { + html += ` + + + + + `; + } + el.innerHTML = html + '
AlbumArtistYearTracks
${esc(a.name)}${esc(a.artist_name)}${a.year ?? ''}${a.track_count}
'; + } + 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 = '
No artists found
'; + } else { + let html = ` + + + + `; + for (const a of data.items) { + html += ` + + + + + + + `; + } + el.innerHTML = html + '
IDNameAlbumsTracksActions
${a.id}${esc(a.name)}${a.album_count}${a.track_count}
'; + } + 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; const current = td.textContent.trim(); diff --git a/furumi-agent/src/web/api.rs b/furumi-agent/src/web/api.rs index dab1877..f7ec1dc 100644 --- a/furumi-agent/src/web/api.rs +++ b/furumi-agent/src/web/api.rs @@ -430,6 +430,56 @@ pub async fn retry_merge(State(state): State, Path(id): Path) -> 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, Query(q): Query) -> 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, Query(q): Query) -> 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, Query(q): Query) -> 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 { diff --git a/furumi-agent/src/web/mod.rs b/furumi-agent/src/web/mod.rs index 544506f..7b08388 100644 --- a/furumi-agent/src/web/mod.rs +++ b/furumi-agent/src/web/mod.rs @@ -37,7 +37,10 @@ pub fn build_router(state: Arc) -> 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))