Compare commits
4 Commits
feature/no
...
3f2013e9d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f2013e9d5 | |||
| cc3ef04cbe | |||
| 7ede23ff94 | |||
| a730ab568c |
@@ -334,18 +334,31 @@ pub async fn approve_and_finalize(
|
|||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Check if track already exists (e.g. previously approved but pending not cleaned up)
|
// Check if track already exists by file_hash (re-approval of same file)
|
||||||
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
||||||
.bind(&pt.file_hash)
|
.bind(&pt.file_hash)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some((track_id,)) = existing {
|
if let Some((track_id,)) = existing {
|
||||||
// Already finalized — just mark pending as approved
|
|
||||||
update_pending_status(pool, pending_id, "approved", None).await?;
|
update_pending_status(pool, pending_id, "approved", None).await?;
|
||||||
return Ok(track_id);
|
return Ok(track_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if track already exists by storage_path (Merged: different quality file landed
|
||||||
|
// at the same destination, source was deleted — don't create a phantom duplicate)
|
||||||
|
let existing_path: Option<(i64,)> = sqlx::query_as(
|
||||||
|
"SELECT id FROM tracks WHERE storage_path = $1 AND NOT hidden"
|
||||||
|
)
|
||||||
|
.bind(storage_path)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some((track_id,)) = existing_path {
|
||||||
|
update_pending_status(pool, pending_id, "merged", None).await?;
|
||||||
|
return Ok(track_id);
|
||||||
|
}
|
||||||
|
|
||||||
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
||||||
let artist_id = upsert_artist(pool, artist_name).await?;
|
let artist_id = upsert_artist(pool, artist_name).await?;
|
||||||
|
|
||||||
@@ -837,6 +850,12 @@ pub struct AlbumTrackRow {
|
|||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Option<AlbumDetails>, sqlx::Error> {
|
pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> {
|
||||||
let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as(
|
let row: Option<(i64, String, Option<i32>, 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"
|
"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 +892,14 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result<Option<(Str
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the storage_path of the first track in an album (for embedded cover fallback).
|
||||||
|
pub async fn get_album_first_track_path(pool: &PgPool, album_id: i64) -> Result<Option<String>, 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<Option<Artist>, sqlx::Error> {
|
pub async fn get_artist_by_id(pool: &PgPool, id: i64) -> Result<Option<Artist>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
|
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
|
||||||
.bind(id).fetch_optional(pool).await
|
.bind(id).fetch_optional(pool).await
|
||||||
|
|||||||
@@ -295,13 +295,14 @@ function renderFilterBar(s) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(tab, btn) {
|
function showTab(tab, btn, noHash) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
const pag = document.getElementById('lib-pagination');
|
const pag = document.getElementById('lib-pagination');
|
||||||
if (pag) pag.style.display = 'none';
|
if (pag) pag.style.display = 'none';
|
||||||
|
if (!noHash) location.hash = tab;
|
||||||
if (tab === 'queue') { loadQueue(); loadStats(); }
|
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 === '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 === '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 ---
|
// --- Queue ---
|
||||||
async function loadQueue(status, keepSelection) {
|
async function loadQueue(status, keepSelection) {
|
||||||
currentFilter = status;
|
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 qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
|
||||||
const raw = await api(`/queue${qs}`) || [];
|
const raw = await api(`/queue${qs}`) || [];
|
||||||
const hasMore = raw.length > queuePageSize;
|
const hasMore = raw.length > queuePageSize;
|
||||||
@@ -359,6 +364,9 @@ function renderQueue(hasMore) {
|
|||||||
const artist = it.norm_artist || it.raw_artist || '-';
|
const artist = it.norm_artist || it.raw_artist || '-';
|
||||||
const title = it.norm_title || it.raw_title || '-';
|
const title = it.norm_title || it.raw_title || '-';
|
||||||
const album = it.norm_album || it.raw_album || '-';
|
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 year = it.norm_year || it.raw_year || '';
|
||||||
const tnum = it.norm_track_number || it.raw_track_number || '';
|
const tnum = it.norm_track_number || it.raw_track_number || '';
|
||||||
const canApprove = it.status === 'review';
|
const canApprove = it.status === 'review';
|
||||||
@@ -368,7 +376,7 @@ function renderQueue(hasMore) {
|
|||||||
<td><span class="status status-${it.status}">${it.status}</span></td>
|
<td><span class="status status-${it.status}">${it.status}</span></td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}"><span style="display:inline-flex;align-items:center;gap:4px">${albumCoverUrl?`<img src="${albumCoverUrl}" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">`:''}${esc(album)}</span></td>
|
||||||
<td>${year}</td>
|
<td>${year}</td>
|
||||||
<td>${tnum}</td>
|
<td>${tnum}</td>
|
||||||
<td>${conf}</td>
|
<td>${conf}</td>
|
||||||
@@ -1163,6 +1171,37 @@ function openTrackEditForArtist(trackId, artistId) {
|
|||||||
openTrackEdit(trackId, () => openArtistForm(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 ---
|
// --- Helpers ---
|
||||||
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
||||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
||||||
@@ -1495,7 +1534,20 @@ async function openArtistForm(id) {
|
|||||||
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
|
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
|
||||||
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
|
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
body.innerHTML = tracks;
|
const albumMeta = `<div style="padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg-panel)">
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
||||||
|
<input id="alb-name-${alb.id}" value="${esc(alb.name)}" placeholder="Album name"
|
||||||
|
style="flex:2;min-width:140px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
||||||
|
<input id="alb-year-${alb.id}" type="number" value="${alb.year??''}" placeholder="Year"
|
||||||
|
style="width:70px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
||||||
|
<button class="btn btn-primary" style="font-size:10px;padding:3px 10px" onclick="saveAlbumMeta(${alb.id},${id})">Save</button>
|
||||||
|
<span style="color:var(--border);user-select:none">|</span>
|
||||||
|
<input id="alb-genre-${alb.id}" placeholder="Apply genre to all tracks…"
|
||||||
|
style="flex:1;min-width:130px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
||||||
|
<button class="btn btn-edit" style="font-size:10px;padding:3px 10px" onclick="applyAlbumGenre(${alb.id})">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
body.innerHTML = albumMeta + tracks;
|
||||||
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
|
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,9 +1638,82 @@ async function removeAppearance(artistId, trackId, btn) {
|
|||||||
btn.closest('.appearance-row').remove();
|
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 ---
|
// --- 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();
|
loadStats();
|
||||||
loadQueue();
|
if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
|
||||||
setInterval(loadStats, 5000);
|
setInterval(loadStats, 5000);
|
||||||
// Auto-refresh queue when on queue tab
|
// Auto-refresh queue when on queue tab
|
||||||
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
||||||
|
|||||||
@@ -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<S>,
|
||||||
|
Path(id): Path<i64>,
|
||||||
|
Json(body): Json<SetGenreBody>,
|
||||||
|
) -> 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)]
|
#[derive(Deserialize)]
|
||||||
pub struct ReorderBody {
|
pub struct ReorderBody {
|
||||||
pub orders: Vec<(i64, i32)>,
|
pub orders: Vec<(i64, i32)>,
|
||||||
@@ -544,19 +558,82 @@ pub async fn reorder_album_tracks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
/// Cover by artist+album name — used for queue items that may not have an album_id yet.
|
||||||
let cover = match db::get_album_cover(&state.pool, id).await {
|
#[derive(Deserialize)]
|
||||||
Ok(Some(c)) => c,
|
pub struct CoverByNameQuery {
|
||||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
#[serde(default)] pub artist: String,
|
||||||
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
#[serde(default)] pub name: String,
|
||||||
|
}
|
||||||
|
pub async fn album_cover_by_name(State(state): State<S>, Query(q): Query<CoverByNameQuery>) -> 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 {
|
album_cover_by_id(&state, album_id).await
|
||||||
Ok(bytes) => (
|
}
|
||||||
[(axum::http::header::CONTENT_TYPE, cover.1)],
|
|
||||||
bytes,
|
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
||||||
).into_response(),
|
album_cover_by_id(&state, id).await
|
||||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
}
|
||||||
|
|
||||||
|
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<u8>, 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)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
||||||
.route("/tracks/:id/hidden", put(api::set_track_hidden))
|
.route("/tracks/:id/hidden", put(api::set_track_hidden))
|
||||||
.route("/albums/search", get(api::search_albums_for_artist))
|
.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/cover", get(api::album_cover))
|
||||||
.route("/albums/:id/full", get(api::get_album_full))
|
.route("/albums/:id/full", get(api::get_album_full))
|
||||||
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
||||||
.route("/albums/:id/edit", put(api::update_album_full))
|
.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/hidden", put(api::set_album_hidden))
|
||||||
.route("/albums/:id/release_type", put(api::set_album_release_type))
|
.route("/albums/:id/release_type", put(api::set_album_release_type))
|
||||||
.route("/albums/:id", put(api::update_album))
|
.route("/albums/:id", put(api::update_album))
|
||||||
|
|||||||
Reference in New Issue
Block a user