diff --git a/furumi-agent/src/db.rs b/furumi-agent/src/db.rs index cba098f..8ef0a08 100644 --- a/furumi-agent/src/db.rs +++ b/furumi-agent/src/db.rs @@ -588,6 +588,7 @@ pub struct TrackRow { pub id: i64, pub title: String, pub artist_name: String, + pub album_id: Option, pub album_name: Option, pub year: Option, pub track_number: Option, @@ -618,7 +619,7 @@ pub async fn search_tracks( 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, + r#"SELECT t.id, t.title, ar.name AS artist_name, t.album_id, 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' @@ -712,6 +713,160 @@ pub async fn count_artists_lib(pool: &PgPool, q: &str) -> Result, + pub album_name: Option, + pub track_number: Option, + pub duration_secs: Option, + pub genre: Option, + pub file_hash: String, + pub file_size: i64, + pub storage_path: String, + pub featured_artists: Vec, +} + +pub async fn get_track_full(pool: &PgPool, id: i64) -> Result, sqlx::Error> { + #[derive(sqlx::FromRow)] + struct Row { + id: i64, title: String, artist_id: i64, artist_name: String, + album_id: Option, album_name: Option, + track_number: Option, duration_secs: Option, + genre: Option, file_hash: String, file_size: i64, storage_path: String, + } + let row: Option = sqlx::query_as( + r#"SELECT t.id, t.title, + ta_p.artist_id, ar.name AS artist_name, + t.album_id, al.name AS album_name, + t.track_number, t.duration_secs, t.genre, + t.file_hash, t.file_size, t.storage_path + FROM tracks t + JOIN track_artists ta_p ON ta_p.track_id = t.id AND ta_p.role = 'primary' + JOIN artists ar ON ar.id = ta_p.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.id = $1"#, + ).bind(id).fetch_optional(pool).await?; + + let row = match row { Some(r) => r, None => return Ok(None) }; + + let feat: Vec<(String,)> = sqlx::query_as( + "SELECT ar.name FROM track_artists ta JOIN artists ar ON ar.id=ta.artist_id WHERE ta.track_id=$1 AND ta.role='featured' ORDER BY ta.id" + ).bind(id).fetch_all(pool).await?; + + Ok(Some(TrackFull { + id: row.id, title: row.title, artist_id: row.artist_id, artist_name: row.artist_name, + album_id: row.album_id, album_name: row.album_name, track_number: row.track_number, + duration_secs: row.duration_secs, genre: row.genre, file_hash: row.file_hash, + file_size: row.file_size, storage_path: row.storage_path, + featured_artists: feat.into_iter().map(|(n,)| n).collect(), + })) +} + +#[derive(Deserialize)] +pub struct TrackUpdateFields { + pub title: String, + pub artist_id: i64, + pub album_id: Option, + pub track_number: Option, + pub genre: Option, + #[serde(default)] + pub featured_artists: Vec, +} + +pub async fn update_track_metadata(pool: &PgPool, id: i64, f: &TrackUpdateFields) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE tracks SET title=$2, album_id=$3, track_number=$4, genre=$5 WHERE id=$1") + .bind(id).bind(&f.title).bind(f.album_id).bind(f.track_number).bind(&f.genre) + .execute(pool).await?; + sqlx::query("UPDATE track_artists SET artist_id=$2 WHERE track_id=$1 AND role='primary'") + .bind(id).bind(f.artist_id).execute(pool).await?; + // Rebuild featured artists + sqlx::query("DELETE FROM track_artists WHERE track_id=$1 AND role='featured'") + .bind(id).execute(pool).await?; + for name in &f.featured_artists { + let feat_id = upsert_artist(pool, name).await?; + link_track_artist(pool, id, feat_id, "featured").await?; + } + Ok(()) +} + +// --- Album full details --- + +#[derive(Debug, Serialize)] +pub struct AlbumDetails { + pub id: i64, + pub name: String, + pub year: Option, + pub artist_id: i64, + pub artist_name: String, + pub tracks: Vec, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AlbumTrackRow { + pub id: i64, + pub title: String, + pub track_number: Option, + pub duration_secs: Option, + pub artist_name: String, + pub genre: Option, +} + +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" + ).bind(id).fetch_optional(pool).await?; + let (aid, aname, ayear, artist_id, artist_name) = match row { Some(r) => r, None => return Ok(None) }; + let tracks: Vec = sqlx::query_as( + r#"SELECT t.id, t.title, t.track_number, t.duration_secs, ar.name AS artist_name, 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 + WHERE t.album_id=$1 ORDER BY t.track_number NULLS LAST, t.title"# + ).bind(id).fetch_all(pool).await?; + Ok(Some(AlbumDetails { id: aid, name: aname, year: ayear, artist_id, artist_name, tracks })) +} + +pub async fn update_album_full(pool: &PgPool, id: i64, name: &str, year: Option, artist_id: i64) -> Result<(), sqlx::Error> { + sqlx::query("UPDATE albums SET name=$2, year=$3, artist_id=$4 WHERE id=$1") + .bind(id).bind(name).bind(year).bind(artist_id).execute(pool).await?; + Ok(()) +} + +pub async fn reorder_tracks(pool: &PgPool, orders: &[(i64, i32)]) -> Result<(), sqlx::Error> { + for &(track_id, track_number) in orders { + sqlx::query("UPDATE tracks SET track_number=$2 WHERE id=$1") + .bind(track_id).bind(track_number).execute(pool).await?; + } + Ok(()) +} + +pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result, sqlx::Error> { + let row: Option<(String, String)> = sqlx::query_as( + "SELECT file_path, mime_type FROM album_images WHERE album_id=$1 LIMIT 1" + ).bind(album_id).fetch_optional(pool).await?; + Ok(row) +} + +pub async fn search_albums_for_artist(pool: &PgPool, q: &str, artist_id: Option) -> Result, sqlx::Error> { + if let Some(aid) = artist_id { + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, name FROM albums WHERE artist_id=$1 AND ($2='' OR name ILIKE '%'||$2||'%') ORDER BY year NULLS LAST, name LIMIT 15" + ).bind(aid).bind(q).fetch_all(pool).await?; + Ok(rows) + } else { + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, name FROM albums WHERE $1='' OR name ILIKE '%'||$1||'%' ORDER BY name LIMIT 15" + ).bind(q).fetch_all(pool).await?; + Ok(rows) + } +} + // =================== Artist Merges =================== #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/furumi-agent/src/web/admin.html b/furumi-agent/src/web/admin.html index b2f5fdd..d0384de 100644 --- a/furumi-agent/src/web/admin.html +++ b/furumi-agent/src/web/admin.html @@ -55,7 +55,7 @@ main { flex: 1; overflow-y: auto; } /* Table */ table { width: 100%; border-collapse: collapse; font-size: 12px; } th { text-align: left; padding: 6px 10px; color: var(--text-muted); font-weight: 500; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--bg-base); z-index: 2; font-size: 11px; } -td { padding: 5px 10px; border-bottom: 1px solid var(--border); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +td { padding: 4px 10px; border-bottom: 1px solid var(--border); max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; height: 30px; max-height: 30px; } tr:hover td { background: var(--bg-hover); } tr.selected td { background: var(--bg-active); } @@ -212,6 +212,9 @@ let selected = new Set(); let searchTimer = null; let editFeatured = []; let statsCache = null; +let queuePageSize = 50; +let queueOffset = 0; +let queueTotal = 0; async function api(path, opts) { const r = await fetch(API + path, opts); @@ -253,6 +256,11 @@ function renderFilterBar(s) { + `; } @@ -264,27 +272,32 @@ function showTab(tab, btn) { const pag = document.getElementById('lib-pagination'); if (pag) pag.style.display = 'none'; if (tab === 'queue') { loadQueue(); loadStats(); } - 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 === '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(); } + else if (tab === 'albums') { libPage.albums = 0; renderLibSearchBar([{ label: 'Album', key: 'q', tab: 'albums', value: libSearch.albums.q, placeholder: 'search album…' }, { label: 'Artist', key: 'artist', tab: 'albums', value: libSearch.albums.artist, placeholder: 'search artist…' }], ''); loadLibAlbums(); } else if (tab === 'merges') { loadMerges(); document.getElementById('filterBar').innerHTML = ''; } } // --- Queue --- async function loadQueue(status, keepSelection) { currentFilter = status; - if (!keepSelection) clearSelection(); - const qs = status ? `?status=${status}&limit=200` : '?limit=200'; - queueItems = await api(`/queue${qs}`) || []; + if (!keepSelection) { clearSelection(); queueOffset = 0; } + const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`; + const raw = await api(`/queue${qs}`) || []; + const hasMore = raw.length > queuePageSize; + queueItems = hasMore ? raw.slice(0, queuePageSize) : raw; // Prune selection: remove ids no longer in the list const currentIds = new Set(queueItems.map(i => i.id)); for (const id of [...selected]) { if (!currentIds.has(id)) selected.delete(id); } updateBatchBar(); - renderQueue(); + renderQueue(hasMore); if (statsCache) renderFilterBar(statsCache); } -function renderQueue() { +function setQueuePageSize(n) { queuePageSize = n; queueOffset = 0; loadQueue(currentFilter); } +function queueGo(offset) { queueOffset = Math.max(0, offset); loadQueue(currentFilter, true); } + +function renderQueue(hasMore) { const el = document.getElementById('content'); if (!queueItems.length) { el.innerHTML = '
No items in queue
'; return; } @@ -358,7 +371,11 @@ function renderQueue() { } html += ''; - el.innerHTML = html; + let pagHtml = ''; + el.innerHTML = html + pagHtml; } // --- Selection --- @@ -607,7 +624,7 @@ function onFeatKey(e) { function closeFeatDropdown() { const dd = document.getElementById('feat-dropdown'); if (dd) dd.classList.remove('open'); } // --- Library tabs (Tracks / Albums / Artists) --- -const LIB_LIMIT = 50; +let LIB_LIMIT = 50; const libPage = { tracks: 0, albums: 0, artists: 0 }; const libSearch = { tracks: { q: '', artist: '', album: '' }, @@ -656,10 +673,23 @@ function renderLibSearchBar(fields, totalLabel) { oninput="libSearchInput('${f.tab}','${f.key}',this.value)" style="min-width:150px">` ).join(''); - html += `${totalLabel}`; + html += `${totalLabel}`; + html += ``; bar.innerHTML = ``; } +function setLibPageSize(n) { + LIB_LIMIT = n; + libPage[currentTab] = 0; + if (currentTab === 'tracks') loadLibTracks(); + else if (currentTab === 'albums') loadLibAlbums(); + else if (currentTab === 'artists') loadLibArtists(); +} + // ---- Tracks ---- async function loadLibTracks() { const s = libSearch.tracks, p = libPage.tracks; @@ -667,26 +697,24 @@ async function loadLibTracks() { 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 tot = document.getElementById('lib-total'); + if (tot) tot.textContent = `${data.total} tracks`; const el = document.getElementById('content'); if (!data.items.length) { el.innerHTML = '
No tracks found
'; } else { - let html = ``; + let html = `
#TitleArtistAlbumYearDurGenre
`; for (const t of data.items) { html += ` - + + `; } el.innerHTML = html + '
#TitleArtistAlbumYearDurGenreEdit
${t.track_number ?? ''} ${esc(t.title)} ${esc(t.artist_name)}${esc(t.album_name ?? '')}${t.album_id ? `` : ''}${esc(t.album_name ?? '')} ${t.year ?? ''} ${fmtDuration(t.duration_secs)} ${esc(t.genre ?? '')}
'; @@ -702,22 +730,21 @@ async function loadLibAlbums() { 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 tot = document.getElementById('lib-total'); + if (tot) tot.textContent = `${data.total} albums`; const el = document.getElementById('content'); if (!data.items.length) { el.innerHTML = '
No albums found
'; } else { - let html = ``; + let html = `
AlbumArtistYearTracks
`; for (const a of data.items) { html += ` - + + `; } el.innerHTML = html + '
AlbumArtistYearTracksEdit
${esc(a.name)}${esc(a.name)} ${esc(a.artist_name)} ${a.year ?? ''} ${a.track_count}
'; @@ -733,9 +760,8 @@ async function loadLibArtists() { 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 tot = document.getElementById('lib-total'); + if (tot) tot.textContent = `${data.total} artists`; const el = document.getElementById('content'); if (!data.items.length) { @@ -817,6 +843,261 @@ async function editArtist(id, currentName) { loadArtists(); } +// --- Track edit modal --- +async function openTrackEdit(id) { + const t = await api(`/tracks/${id}`); + if (!t) return; + + editFeatured = [...(t.featured_artists || [])]; + + document.getElementById('modal').className = 'modal'; + document.getElementById('modal').innerHTML = ` +

Edit Track

+ ${t.album_id ? `
` : ''} + + + + +
+ +
+
+ + + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ Duration: ${fmtDuration(t.duration_secs)} · ${t.file_size ? (t.file_size/1048576).toFixed(1)+' MB' : ''} · ${esc(t.storage_path)} +
+ + + `; + renderFeatTags(); + openModal(); +} + +let teArtistTimer = null, teAlbumTimer = null; + +function onTeArtistSearch(q) { + clearTimeout(teArtistTimer); + document.getElementById('te-artist-id').value = ''; + if (q.length < 2) { document.getElementById('te-artist-dropdown').classList.remove('open'); return; } + teArtistTimer = setTimeout(async () => { + const results = await api(`/artists/search?q=${encodeURIComponent(q)}&limit=8`); + const dd = document.getElementById('te-artist-dropdown'); + if (!results || !results.length) { dd.classList.remove('open'); return; } + dd.innerHTML = results.map(a => `
${esc(a.name)}
`).join(''); + dd.classList.add('open'); + }, 200); +} + +function selectTeArtist(id, name) { + document.getElementById('te-artist-id').value = id; + document.getElementById('te-artist-name').value = name; + document.getElementById('te-artist-dropdown').classList.remove('open'); + // refresh album search with new artist + document.getElementById('te-album-name').value = ''; + document.getElementById('te-album-id').value = ''; +} + +function onTeArtistKey(e) { + if (e.key === 'Escape') document.getElementById('te-artist-dropdown').classList.remove('open'); +} + +function onTeAlbumSearch(q) { + clearTimeout(teAlbumTimer); + document.getElementById('te-album-id').value = ''; + const artistId = document.getElementById('te-artist-id').value; + if (q.length < 1) { document.getElementById('te-album-dropdown').classList.remove('open'); return; } + teAlbumTimer = setTimeout(async () => { + const params = new URLSearchParams({ q, ...(artistId ? { artist_id: artistId } : {}) }); + const results = await api(`/albums/search?${params}`); + const dd = document.getElementById('te-album-dropdown'); + if (!results || !results.length) { dd.classList.remove('open'); return; } + dd.innerHTML = results.map(a => `
${esc(a.name)}
`).join(''); + dd.classList.add('open'); + }, 200); +} + +function selectTeAlbum(id, name) { + document.getElementById('te-album-id').value = id; + document.getElementById('te-album-name').value = name; + document.getElementById('te-album-dropdown').classList.remove('open'); +} + +function onTeAlbumKey(e) { + if (e.key === 'Escape') document.getElementById('te-album-dropdown').classList.remove('open'); +} + +async function saveTrackEdit(id) { + const artistId = parseInt(document.getElementById('te-artist-id').value); + const albumIdVal = document.getElementById('te-album-id').value; + if (!artistId) { alert('Please select an artist from the dropdown'); return; } + const body = { + title: document.getElementById('te-title').value, + artist_id: artistId, + album_id: albumIdVal ? parseInt(albumIdVal) : null, + track_number: parseInt(document.getElementById('te-tracknum').value) || null, + genre: document.getElementById('te-genre').value || null, + featured_artists: editFeatured, + }; + await api(`/tracks/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + closeModal(); + if (currentTab === 'tracks') loadLibTracks(); +} + +// --- Album edit modal --- +async function openAlbumEdit(id) { + const d = await api(`/albums/${id}/full`); + if (!d) return; + + document.getElementById('modal').className = 'modal modal-wide'; + document.getElementById('modal').innerHTML = ` +

Edit Album

+
+ +
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+
+
+ + +
    + ${d.tracks.map((t, i) => ` +
  • + ${t.track_number ?? ''} + ${esc(t.title)} + ${fmtDuration(t.duration_secs)} + +
  • + `).join('')} +
+ + + `; + openModal(); +} + +// Drag and drop for album track list +let aeDragSrc = null; +function aeDragStart(e) { + aeDragSrc = e.currentTarget; + e.dataTransfer.effectAllowed = 'move'; + e.currentTarget.style.opacity = '0.4'; +} +function aeDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + const li = e.currentTarget; + const list = li.parentNode; + const rect = li.getBoundingClientRect(); + const after = e.clientY > rect.top + rect.height / 2; + if (after) list.insertBefore(aeDragSrc, li.nextSibling); + else list.insertBefore(aeDragSrc, li); +} +function aeDrop(e) { e.preventDefault(); } +function aeDragEnd() { + if (aeDragSrc) aeDragSrc.style.opacity = ''; + aeDragSrc = null; +} + +let aeArtistTimer = null; +function onAeArtistSearch(q) { + clearTimeout(aeArtistTimer); + document.getElementById('ae-artist-id').value = ''; + if (q.length < 2) { document.getElementById('ae-artist-dropdown').classList.remove('open'); return; } + aeArtistTimer = setTimeout(async () => { + const results = await api(`/artists/search?q=${encodeURIComponent(q)}&limit=8`); + const dd = document.getElementById('ae-artist-dropdown'); + if (!results || !results.length) { dd.classList.remove('open'); return; } + dd.innerHTML = results.map(a => `
${esc(a.name)}
`).join(''); + dd.classList.add('open'); + }, 200); +} +function selectAeArtist(id, name) { + document.getElementById('ae-artist-id').value = id; + document.getElementById('ae-artist-name').value = name; + document.getElementById('ae-artist-dropdown').classList.remove('open'); +} + +async function saveAlbumEdit(id) { + const artistId = parseInt(document.getElementById('ae-artist-id').value); + if (!artistId) { alert('Please select an artist from the dropdown'); return; } + const body = { + name: document.getElementById('ae-name').value, + year: parseInt(document.getElementById('ae-year').value) || null, + artist_id: artistId, + }; + await api(`/albums/${id}/edit`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + if (currentTab === 'albums') loadLibAlbums(); +} + +async function saveAlbumReorder(id) { + const items = document.querySelectorAll('#ae-track-list li'); + const orders = Array.from(items).map((li, i) => [parseInt(li.dataset.id), i + 1]); + await api(`/albums/${id}/reorder`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ orders }) }); + // update displayed numbers + items.forEach((li, i) => { li.querySelector('span').textContent = i + 1; }); +} + +async function openTrackEditFromAlbum(trackId, albumId) { + closeModal(); + await openTrackEdit(trackId); +} + // --- Helpers --- function openModal() { document.getElementById('modalOverlay').classList.add('visible'); } function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); } diff --git a/furumi-agent/src/web/api.rs b/furumi-agent/src/web/api.rs index f7ec1dc..197147d 100644 --- a/furumi-agent/src/web/api.rs +++ b/furumi-agent/src/web/api.rs @@ -480,6 +480,100 @@ pub async fn library_artists(State(state): State, Query(q): Query, Path(id): Path) -> impl IntoResponse { + match db::get_track_full(&state.pool, id).await { + Ok(Some(t)) => (StatusCode::OK, Json(serde_json::to_value(t).unwrap())).into_response(), + Ok(None) => error_response(StatusCode::NOT_FOUND, "not found"), + Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn update_track( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + match db::update_track_metadata(&state.pool, id, &body).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn get_album_full(State(state): State, Path(id): Path) -> impl IntoResponse { + match db::get_album_details(&state.pool, id).await { + Ok(Some(a)) => (StatusCode::OK, Json(serde_json::to_value(a).unwrap())).into_response(), + Ok(None) => error_response(StatusCode::NOT_FOUND, "not found"), + Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +#[derive(Deserialize)] +pub struct AlbumUpdateBody { + pub name: String, + pub year: Option, + pub artist_id: i64, +} + +pub async fn update_album_full( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> impl IntoResponse { + match db::update_album_full(&state.pool, id, &body.name, body.year, body.artist_id).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)>, +} + +pub async fn reorder_album_tracks( + State(state): State, + Path(_id): Path, + Json(body): Json, +) -> impl IntoResponse { + match db::reorder_tracks(&state.pool, &body.orders).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +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()), + }; + 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(), + } +} + +#[derive(Deserialize)] +pub struct AlbumSearchQuery { + #[serde(default)] + pub q: String, + pub artist_id: Option, +} + +pub async fn search_albums_for_artist(State(state): State, Query(q): Query) -> impl IntoResponse { + match db::search_albums_for_artist(&state.pool, &q.q, q.artist_id).await { + Ok(items) => (StatusCode::OK, Json(serde_json::to_value( + items.iter().map(|(id, name)| serde_json::json!({"id": id, "name": name})).collect::>() + ).unwrap())).into_response(), + 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 7b08388..f7a19c0 100644 --- a/furumi-agent/src/web/mod.rs +++ b/furumi-agent/src/web/mod.rs @@ -32,6 +32,12 @@ pub fn build_router(state: Arc) -> Router { .route("/artists", get(api::list_artists)) .route("/artists/:id", put(api::update_artist)) .route("/artists/:id/albums", get(api::list_albums)) + .route("/tracks/:id", get(api::get_track).put(api::update_track)) + .route("/albums/search", get(api::search_albums_for_artist)) + .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", put(api::update_album)) .route("/merges", get(api::list_merges).post(api::create_merge)) .route("/merges/:id", get(api::get_merge).put(api::update_merge))