Added merge
This commit is contained in:
@@ -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 ===================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user