From a730ab568c2847072962f56bc9378b6f18b848b8 Mon Sep 17 00:00:00 2001 From: AB-UK Date: Thu, 19 Mar 2026 15:28:25 +0000 Subject: [PATCH] Improved admin UI --- furumi-agent/src/db.rs | 14 ++++ furumi-agent/src/web/admin.html | 135 ++++++++++++++++++++++++++++++-- furumi-agent/src/web/api.rs | 99 ++++++++++++++++++++--- furumi-agent/src/web/mod.rs | 2 + 4 files changed, 234 insertions(+), 16 deletions(-) diff --git a/furumi-agent/src/db.rs b/furumi-agent/src/db.rs index 3fe06e8..6182e99 100644 --- a/furumi-agent/src/db.rs +++ b/furumi-agent/src/db.rs @@ -837,6 +837,12 @@ pub struct AlbumTrackRow { pub genre: Option, } +pub async fn set_album_tracks_genre(pool: &PgPool, album_id: i64, genre: &str) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE tracks SET genre = $2 WHERE album_id = $1") + .bind(album_id).bind(genre).execute(pool).await?; + Ok(()) +} + pub async fn get_album_details(pool: &PgPool, id: i64) -> Result, sqlx::Error> { let row: Option<(i64, String, Option, i64, String)> = sqlx::query_as( "SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1" @@ -873,6 +879,14 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result Result, sqlx::Error> { + let row: Option<(String,)> = sqlx::query_as( + "SELECT storage_path FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title LIMIT 1" + ).bind(album_id).fetch_optional(pool).await?; + Ok(row.map(|(p,)| p)) +} + pub async fn get_artist_by_id(pool: &PgPool, id: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1") .bind(id).fetch_optional(pool).await diff --git a/furumi-agent/src/web/admin.html b/furumi-agent/src/web/admin.html index 54f8e72..92b1477 100644 --- a/furumi-agent/src/web/admin.html +++ b/furumi-agent/src/web/admin.html @@ -295,13 +295,14 @@ function renderFilterBar(s) { `; } -function showTab(tab, btn) { +function showTab(tab, btn, noHash) { currentTab = tab; 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 (!noHash) location.hash = tab; if (tab === 'queue') { loadQueue(); loadStats(); } else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); } else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); } @@ -312,7 +313,11 @@ function showTab(tab, btn) { // --- Queue --- async function loadQueue(status, keepSelection) { currentFilter = status; - if (!keepSelection) { clearSelection(); queueOffset = 0; } + if (!keepSelection) { + clearSelection(); + queueOffset = 0; + location.hash = status ? 'queue/' + status : 'queue'; + } const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`; const raw = await api(`/queue${qs}`) || []; const hasMore = raw.length > queuePageSize; @@ -359,6 +364,9 @@ function renderQueue(hasMore) { const artist = it.norm_artist || it.raw_artist || '-'; const title = it.norm_title || it.raw_title || '-'; const album = it.norm_album || it.raw_album || '-'; + const albumCoverUrl = (it.norm_album || it.raw_album) && (it.norm_artist || it.raw_artist) + ? `${API}/albums/cover-by-name?artist=${encodeURIComponent(it.norm_artist||it.raw_artist||'')}&name=${encodeURIComponent(it.norm_album||it.raw_album||'')}` + : null; const year = it.norm_year || it.raw_year || ''; const tnum = it.norm_track_number || it.raw_track_number || ''; const canApprove = it.status === 'review'; @@ -368,7 +376,7 @@ function renderQueue(hasMore) { ${it.status} ${esc(artist)} ${esc(title)} - ${esc(album)} + ${albumCoverUrl?``:''}${esc(album)} ${year} ${tnum} ${conf} @@ -1163,6 +1171,37 @@ function openTrackEditForArtist(trackId, artistId) { openTrackEdit(trackId, () => openArtistForm(artistId)); } +// --- Album inline meta edit (from artist form) --- +async function saveAlbumMeta(albumId, artistId) { + const name = document.getElementById(`alb-name-${albumId}`)?.value?.trim(); + const yearRaw = document.getElementById(`alb-year-${albumId}`)?.value; + if (!name) return; + await api(`/albums/${albumId}/edit`, { + method: 'PUT', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ name, year: parseInt(yearRaw) || null, artist_id: artistId }), + }); + // Update header display in place + const block = document.getElementById(`album-block-${albumId}`); + if (block) { + const nameSpan = block.querySelector('.ab-name'); + if (nameSpan) nameSpan.textContent = name; + const yearSpan = block.querySelector('.ab-year'); + if (yearSpan) yearSpan.textContent = yearRaw || ''; + } +} + +async function applyAlbumGenre(albumId) { + const genre = document.getElementById(`alb-genre-${albumId}`)?.value?.trim(); + if (!genre) return; + await api(`/albums/${albumId}/genre`, { + method: 'PUT', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ genre }), + }); + document.getElementById(`alb-genre-${albumId}`).value = ''; +} + // --- Helpers --- function openModal() { document.getElementById('modalOverlay').classList.add('visible'); } function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); } @@ -1495,7 +1534,20 @@ async function openArtistForm(id) { `).join(''); - body.innerHTML = tracks; + const albumMeta = `
+
+ + + + | + + +
+
`; + body.innerHTML = albumMeta + tracks; if (openAlbumBlocks.has(alb.id)) body.classList.add('open'); } @@ -1586,9 +1638,82 @@ async function removeAppearance(artistId, trackId, btn) { btn.closest('.appearance-row').remove(); } +// --- Cover preview --- +(function() { + const box = document.createElement('div'); + box.id = 'cover-preview'; + box.style.cssText = [ + 'display:none', 'position:fixed', 'z-index:9999', 'pointer-events:none', + 'border-radius:10px', 'overflow:hidden', + 'box-shadow:0 12px 40px rgba(0,0,0,0.85)', + 'border:1px solid rgba(255,255,255,0.08)', + 'background:#0a0c12', 'transition:opacity 0.1s', + ].join(';'); + const img = document.createElement('img'); + img.style.cssText = 'display:block;width:280px;height:280px;object-fit:contain'; + box.appendChild(img); + document.body.appendChild(box); + + let showTimer = null; + + function isCoverImg(el) { + return el && el.tagName === 'IMG' && el.src && el.src.includes('/cover'); + } + + function place(e) { + const margin = 16, pw = 280, ph = 280; + const vw = window.innerWidth, vh = window.innerHeight; + let x = e.clientX + margin, y = e.clientY + margin; + if (x + pw > vw - 8) x = e.clientX - pw - margin; + if (y + ph > vh - 8) y = e.clientY - ph - margin; + box.style.left = x + 'px'; + box.style.top = y + 'px'; + } + + document.addEventListener('mouseover', e => { + if (!isCoverImg(e.target)) return; + clearTimeout(showTimer); + showTimer = setTimeout(() => { + img.src = e.target.src; + box.style.display = 'block'; + place(e); + }, 120); + }); + + document.addEventListener('mousemove', e => { + if (box.style.display === 'none') return; + if (!isCoverImg(e.target)) { clearTimeout(showTimer); box.style.display = 'none'; return; } + place(e); + }); + + document.addEventListener('mouseout', e => { + if (!isCoverImg(e.target)) return; + clearTimeout(showTimer); + box.style.display = 'none'; + }); +})(); + // --- Init --- +(function restoreFromHash() { + const hash = location.hash.slice(1); // strip # + if (!hash) return; + const [tab, filter] = hash.split('/'); + const validTabs = ['queue','tracks','albums','artists','merges']; + if (!validTabs.includes(tab)) return; + const btn = Array.from(document.querySelectorAll('nav button')) + .find(b => (b.getAttribute('onclick') || '').includes(`'${tab}'`)); + if (!btn) return; + // Switch tab without overwriting the hash + showTab(tab, btn, true); + // For queue, also restore the filter + if (tab === 'queue' && filter) { + currentFilter = filter; + loadQueue(filter); + } +})(); + loadStats(); -loadQueue(); +if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue(); setInterval(loadStats, 5000); // Auto-refresh queue when on queue tab setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000); diff --git a/furumi-agent/src/web/api.rs b/furumi-agent/src/web/api.rs index d7e2cca..9fe52b0 100644 --- a/furumi-agent/src/web/api.rs +++ b/furumi-agent/src/web/api.rs @@ -528,6 +528,20 @@ pub async fn update_album_full( } } +#[derive(Deserialize)] +pub struct SetGenreBody { pub genre: String } + +pub async fn set_album_tracks_genre( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + match db::set_album_tracks_genre(&state.pool, id, &body.genre).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + #[derive(Deserialize)] pub struct ReorderBody { pub orders: Vec<(i64, i32)>, @@ -544,19 +558,82 @@ pub async fn reorder_album_tracks( } } -pub async fn album_cover(State(state): State, Path(id): Path) -> impl IntoResponse { - let cover = match db::get_album_cover(&state.pool, id).await { - Ok(Some(c)) => c, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), - Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), +/// Cover by artist+album name — used for queue items that may not have an album_id yet. +#[derive(Deserialize)] +pub struct CoverByNameQuery { + #[serde(default)] pub artist: String, + #[serde(default)] pub name: String, +} +pub async fn album_cover_by_name(State(state): State, Query(q): Query) -> impl IntoResponse { + let album_id = match db::find_album_id(&state.pool, &q.artist, &q.name).await { + Ok(Some(id)) => id, + _ => return StatusCode::NOT_FOUND.into_response(), }; - match tokio::fs::read(&cover.0).await { - Ok(bytes) => ( - [(axum::http::header::CONTENT_TYPE, cover.1)], - bytes, - ).into_response(), - Err(_) => StatusCode::NOT_FOUND.into_response(), + album_cover_by_id(&state, album_id).await +} + +pub async fn album_cover(State(state): State, Path(id): Path) -> impl IntoResponse { + album_cover_by_id(&state, id).await +} + +async fn album_cover_by_id(state: &super::AppState, id: i64) -> axum::response::Response { + // 1. Try album_images table + if let Ok(Some((file_path, mime_type))) = db::get_album_cover(&state.pool, id).await { + if let Ok(bytes) = tokio::fs::read(&file_path).await { + return ([(axum::http::header::CONTENT_TYPE, mime_type)], bytes).into_response(); + } } + + // 2. Fallback: extract embedded cover from first track in album + if let Ok(Some(track_path)) = db::get_album_first_track_path(&state.pool, id).await { + let path = std::path::PathBuf::from(track_path); + if path.exists() { + let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await; + if let Ok(Some((bytes, mime))) = result { + return ([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response(); + } + } + } + + StatusCode::NOT_FOUND.into_response() +} + +fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec, String)> { + use symphonia::core::{ + formats::FormatOptions, + io::MediaSourceStream, + meta::MetadataOptions, + probe::Hint, + }; + + let file = std::fs::File::open(path).ok()?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let mut probed = symphonia::default::get_probe() + .format( + &hint, + mss, + &FormatOptions { enable_gapless: false, ..Default::default() }, + &MetadataOptions::default(), + ) + .ok()?; + + if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) { + if let Some(v) = rev.visuals().first() { + return Some((v.data.to_vec(), v.media_type.clone())); + } + } + if let Some(rev) = probed.format.metadata().current() { + if let Some(v) = rev.visuals().first() { + return Some((v.data.to_vec(), v.media_type.clone())); + } + } + None } #[derive(Deserialize)] diff --git a/furumi-agent/src/web/mod.rs b/furumi-agent/src/web/mod.rs index 6d6ede1..cf85239 100644 --- a/furumi-agent/src/web/mod.rs +++ b/furumi-agent/src/web/mod.rs @@ -41,10 +41,12 @@ pub fn build_router(state: Arc) -> Router { .route("/tracks/:id", get(api::get_track).put(api::update_track)) .route("/tracks/:id/hidden", put(api::set_track_hidden)) .route("/albums/search", get(api::search_albums_for_artist)) + .route("/albums/cover-by-name", get(api::album_cover_by_name)) .route("/albums/:id/cover", get(api::album_cover)) .route("/albums/:id/full", get(api::get_album_full)) .route("/albums/:id/reorder", put(api::reorder_album_tracks)) .route("/albums/:id/edit", put(api::update_album_full)) + .route("/albums/:id/genre", put(api::set_album_tracks_genre)) .route("/albums/:id/hidden", put(api::set_album_hidden)) .route("/albums/:id/release_type", put(api::set_album_release_type)) .route("/albums/:id", put(api::update_album))