Added merge
All checks were successful
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m9s
Publish Web Player Image / build-and-push-image (push) Successful in 1m12s
Publish Server Image / build-and-push-image (push) Successful in 2m18s

This commit is contained in:
2026-03-19 02:36:27 +00:00
parent e1210e6e20
commit a7af27d064
4 changed files with 565 additions and 29 deletions

View File

@@ -588,6 +588,7 @@ pub struct TrackRow {
pub id: i64,
pub title: String,
pub artist_name: String,
pub album_id: Option<i64>,
pub album_name: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
@@ -618,7 +619,7 @@ pub async fn search_tracks(
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,
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<i64, sqlx::Erro
Ok(n)
}
// --- Track full details ---
#[derive(Debug, Serialize)]
pub struct TrackFull {
pub id: i64,
pub title: String,
pub artist_id: i64,
pub artist_name: String,
pub album_id: Option<i64>,
pub album_name: Option<String>,
pub track_number: Option<i32>,
pub duration_secs: Option<f64>,
pub genre: Option<String>,
pub file_hash: String,
pub file_size: i64,
pub storage_path: String,
pub featured_artists: Vec<String>,
}
pub async fn get_track_full(pool: &PgPool, id: i64) -> Result<Option<TrackFull>, sqlx::Error> {
#[derive(sqlx::FromRow)]
struct Row {
id: i64, title: String, artist_id: i64, artist_name: String,
album_id: Option<i64>, album_name: Option<String>,
track_number: Option<i32>, duration_secs: Option<f64>,
genre: Option<String>, file_hash: String, file_size: i64, storage_path: String,
}
let row: Option<Row> = 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<i64>,
pub track_number: Option<i32>,
pub genre: Option<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
}
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<i32>,
pub artist_id: i64,
pub artist_name: String,
pub tracks: Vec<AlbumTrackRow>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AlbumTrackRow {
pub id: i64,
pub title: String,
pub track_number: Option<i32>,
pub duration_secs: Option<f64>,
pub artist_name: String,
pub genre: Option<String>,
}
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(
"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<AlbumTrackRow> = 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<i32>, 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<Option<(String, String)>, 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<i64>) -> Result<Vec<(i64, String)>, 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)]

View File

@@ -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) {
<button class="filter-btn ${f==='merged'?'active':''}" onclick="loadQueue('merged')">Merged<span class="count">${s.merged_count}</span></button>
<button class="filter-btn ${f==='approved'?'active':''}" onclick="loadQueue('approved')">Approved</button>
<button class="filter-btn ${f==='rejected'?'active':''}" onclick="loadQueue('rejected')">Rejected</button>
<select style="margin-left:auto;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:3px 6px;color:var(--text);font-size:11px;font-family:inherit" onchange="setQueuePageSize(parseInt(this.value))">
<option value="50" ${queuePageSize===50?'selected':''}>50</option>
<option value="100" ${queuePageSize===100?'selected':''}>100</option>
<option value="200" ${queuePageSize===200?'selected':''}>200</option>
</select>
`;
}
@@ -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 = '<div class="empty">No items in queue</div>'; return; }
@@ -358,7 +371,11 @@ function renderQueue() {
}
html += '</table>';
el.innerHTML = html;
let pagHtml = '<div class="pagination">';
if (queueOffset > 0) pagHtml += `<button onclick="queueGo(${queueOffset - queuePageSize})"> Prev</button>`;
if (hasMore) pagHtml += `<button onclick="queueGo(${queueOffset + queuePageSize})">Next </button>`;
pagHtml += '</div>';
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 += `<span class="total-label">${totalLabel}</span>`;
html += `<span class="total-label"><span id="lib-total">${totalLabel}</span></span>`;
html += `<select id="lib-page-size" onchange="setLibPageSize(parseInt(this.value))" style="margin-left:8px;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:4px 6px;color:var(--text);font-size:11px;font-family:inherit">
<option value="50" ${LIB_LIMIT===50?'selected':''}>50</option>
<option value="100" ${LIB_LIMIT===100?'selected':''}>100</option>
<option value="200" ${LIB_LIMIT===200?'selected':''}>200</option>
</select>`;
bar.innerHTML = `<div class="search-bar" style="padding:0;border:none;width:100%">${html}</div>`;
}
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 = '<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>`;
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><th style="width:50px">Edit</th></tr>`;
for (const t of data.items) {
html += `<tr>
<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><span style="display:inline-flex;align-items:center;gap:5px">${t.album_id ? `<img src="${API}/albums/${t.album_id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">` : ''}<span>${esc(t.album_name ?? '')}</span></span></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>
<td><button class="btn btn-edit" onclick="openTrackEdit(${t.id})">Edit</button></td>
</tr>`;
}
el.innerHTML = html + '</table>';
@@ -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 = '<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>`;
let html = `<table><tr><th>Album</th><th>Artist</th><th style="width:50px">Year</th><th style="width:60px">Tracks</th><th style="width:50px">Edit</th></tr>`;
for (const a of data.items) {
html += `<tr>
<td>${esc(a.name)}</td>
<td><span style="display:inline-flex;align-items:center;gap:5px"><img src="${API}/albums/${a.id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'"><span>${esc(a.name)}</span></span></td>
<td>${esc(a.artist_name)}</td>
<td>${a.year ?? ''}</td>
<td style="color:var(--text-muted)">${a.track_count}</td>
<td><button class="btn btn-edit" onclick="openAlbumEdit(${a.id})">Edit</button></td>
</tr>`;
}
el.innerHTML = html + '</table>';
@@ -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 = `
<h2>Edit Track</h2>
${t.album_id ? `<div style="float:right;margin-left:12px"><img src="${API}/albums/${t.album_id}/cover" width="80" height="80" style="border-radius:6px;object-fit:cover" onerror="this.style.display='none'"></div>` : ''}
<label>Title</label>
<input id="te-title" value="${esc(t.title)}">
<label>Artist</label>
<div class="artist-search-wrap" style="margin-top:3px">
<input id="te-artist-name" value="${esc(t.artist_name)}" autocomplete="off"
oninput="onTeArtistSearch(this.value)" onkeydown="onTeArtistKey(event)"
placeholder="Search artist…">
<div class="artist-dropdown" id="te-artist-dropdown"></div>
</div>
<input type="hidden" id="te-artist-id" value="${t.artist_id}">
<label>Album</label>
<div class="artist-search-wrap" style="margin-top:3px">
<input id="te-album-name" value="${esc(t.album_name || '')}" autocomplete="off"
oninput="onTeAlbumSearch(this.value)" onkeydown="onTeAlbumKey(event)"
placeholder="Search album…">
<div class="artist-dropdown" id="te-album-dropdown"></div>
</div>
<input type="hidden" id="te-album-id" value="${t.album_id || ''}">
<div class="detail-row">
<div class="field">
<label>Track #</label>
<input id="te-tracknum" type="number" value="${t.track_number ?? ''}">
</div>
<div class="field">
<label>Genre</label>
<input id="te-genre" value="${esc(t.genre || '')}">
</div>
</div>
<label>Featured Artists</label>
<div class="feat-tags" id="feat-tags"></div>
<div class="artist-search-wrap">
<input id="feat-search" placeholder="Search artist to add…" autocomplete="off"
oninput="onFeatSearch(this.value)" onkeydown="onFeatKey(event)">
<div class="artist-dropdown" id="feat-dropdown"></div>
</div>
<div class="raw-value" style="margin-top:8px;clear:both">
Duration: ${fmtDuration(t.duration_secs)} · ${t.file_size ? (t.file_size/1048576).toFixed(1)+' MB' : ''} · ${esc(t.storage_path)}
</div>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveTrackEdit(${t.id})">Save</button>
</div>
`;
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 => `<div class="artist-option" onclick="selectTeArtist(${a.id},'${esc(a.name)}')">${esc(a.name)}</div>`).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 => `<div class="artist-option" onclick="selectTeAlbum(${a.id},'${esc(a.name)}')">${esc(a.name)}</div>`).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 = `
<h2>Edit Album</h2>
<div style="display:flex;gap:16px;align-items:flex-start">
<img src="${API}/albums/${id}/cover" width="80" height="80" style="border-radius:6px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">
<div style="flex:1">
<label>Album name</label>
<input id="ae-name" value="${esc(d.name)}">
<div class="detail-row">
<div class="field">
<label>Year</label>
<input id="ae-year" type="number" value="${d.year ?? ''}">
</div>
<div class="field">
<label>Artist</label>
<div class="artist-search-wrap" style="margin-top:3px">
<input id="ae-artist-name" value="${esc(d.artist_name)}" autocomplete="off"
oninput="onAeArtistSearch(this.value)" onkeydown="if(event.key==='Escape')document.getElementById('ae-artist-dropdown').classList.remove('open')" placeholder="Search artist…">
<div class="artist-dropdown" id="ae-artist-dropdown"></div>
</div>
<input type="hidden" id="ae-artist-id" value="${d.artist_id}">
</div>
</div>
</div>
</div>
<div class="section-label">Tracks <span style="font-weight:400;color:var(--text-dim)">(drag to reorder)</span></div>
<ul id="ae-track-list" style="list-style:none;margin:0;padding:0;max-height:350px;overflow-y:auto;border:1px solid var(--border);border-radius:5px">
${d.tracks.map((t, i) => `
<li data-id="${t.id}" data-idx="${i}" draggable="true"
style="display:flex;align-items:center;gap:8px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--bg-card);cursor:grab;font-size:12px"
ondragstart="aeDragStart(event)" ondragover="aeDragOver(event)" ondrop="aeDrop(event)" ondragend="aeDragEnd()">
<span style="color:var(--text-muted);width:24px;text-align:right;flex-shrink:0">${t.track_number ?? ''}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.title)}</span>
<span style="color:var(--text-muted);font-size:11px;flex-shrink:0">${fmtDuration(t.duration_secs)}</span>
<button class="btn btn-edit" style="flex-shrink:0" onclick="openTrackEditFromAlbum(${t.id},${id})">Edit</button>
</li>
`).join('')}
</ul>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button>
<button class="btn btn-edit" onclick="saveAlbumReorder(${id})">Save Order</button>
<button class="btn btn-primary" onclick="saveAlbumEdit(${id})">Save Album</button>
</div>
`;
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 => `<div class="artist-option" onclick="selectAeArtist(${a.id},'${esc(a.name)}')">${esc(a.name)}</div>`).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'); }

View File

@@ -480,6 +480,100 @@ pub async fn library_artists(State(state): State<S>, Query(q): Query<LibraryQuer
}
}
// --- Track / Album detail & edit ---
pub async fn get_track(State(state): State<S>, Path(id): Path<i64>) -> 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<S>,
Path(id): Path<i64>,
Json(body): Json<db::TrackUpdateFields>,
) -> 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<S>, Path(id): Path<i64>) -> 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<i32>,
pub artist_id: i64,
}
pub async fn update_album_full(
State(state): State<S>,
Path(id): Path<i64>,
Json(body): Json<AlbumUpdateBody>,
) -> 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<S>,
Path(_id): Path<i64>,
Json(body): Json<ReorderBody>,
) -> 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<S>, Path(id): Path<i64>) -> 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<i64>,
}
pub async fn search_albums_for_artist(State(state): State<S>, Query(q): Query<AlbumSearchQuery>) -> 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::<Vec<_>>()
).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 {

View File

@@ -32,6 +32,12 @@ pub fn build_router(state: Arc<AppState>) -> 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))