3 Commits

Author SHA1 Message Date
ab
2129dc8007 Merge pull request 'feat: added express + vite app + oidc' (#1) from feature/node-app into DEV
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m18s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m25s
Publish Server Image / build-and-push-image (push) Successful in 2m56s
Reviewed-on: #1
2026-03-19 23:44:29 +00:00
cc3ef04cbe Fix phantom duplicate tracks created on Merged file ingestion
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m16s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m10s
Publish Server Image / build-and-push-image (push) Successful in 2m36s
When the mover returns MoveOutcome::Merged (destination already exists,
source deleted), approve_and_finalize was checking only file_hash to
detect duplicates. Since the second ingestion had a different hash
(different quality/mastering), it bypassed the check and created a
phantom track record pointing to an existing storage_path with the
wrong hash (of the now-deleted source file).

Added a second dedup check by storage_path: if a non-hidden track
already exists at that path, mark pending as 'merged' instead of
inserting a new track row. This prevents phantom entries for any
subsequent ingestion of a different-quality version of an already
stored file.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:37:33 +00:00
a730ab568c Improved admin UI
All checks were successful
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m6s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m7s
Publish Server Image / build-and-push-image (push) Successful in 2m13s
2026-03-19 15:28:25 +00:00
22 changed files with 447 additions and 696 deletions

View File

@@ -1,32 +0,0 @@
---
description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client)
globs: furumi-node-player/client/**/*.{ts,tsx}
alwaysApply: false
---
# REST API в furumi-node-player
**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход.
Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`.
## Правила
1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей.
2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта.
3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке).
## Пример
```typescript
// furumiApi.ts — добавлять сюда
export async function getSomething(id: string) {
const res = await furumiApi.get(`/something/${id}`).catch(() => null)
return res?.data ?? null
}
```
```typescript
// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую
const data = await getSomething(id)
```

View File

@@ -53,7 +53,6 @@ services:
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
FURUMI_PLAYER_STORAGE_DIR: "/storage" FURUMI_PLAYER_STORAGE_DIR: "/storage"
FURUMI_PLAYER_BIND: "0.0.0.0:8085" FURUMI_PLAYER_BIND: "0.0.0.0:8085"
FURUMI_PLAYER_API_KEY: "node-player-api-key"
volumes: volumes:
- ./storage:/storage - ./storage:/storage
restart: always restart: always

View File

@@ -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

View File

@@ -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);

View File

@@ -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)]

View File

@@ -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))

View File

@@ -1,2 +0,0 @@
VITE_API_BASE_URL=http://localhost:8085
VITE_API_KEY=

View File

@@ -8,7 +8,6 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },
@@ -1288,23 +1287,6 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1370,19 +1352,6 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1451,18 +1420,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1524,15 +1481,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1543,20 +1491,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.321", "version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
@@ -1564,51 +1498,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1906,42 +1795,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1957,15 +1810,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1976,43 +1820,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2039,18 +1846,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -2061,45 +1856,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -2569,36 +2325,6 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -2794,12 +2520,6 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -10,7 +10,6 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4"
}, },

View File

@@ -61,11 +61,12 @@ function App() {
const loginUrl = `${apiBase}/api/login` const loginUrl = `${apiBase}/api/login`
const logoutUrl = `${apiBase}/api/logout` const logoutUrl = `${apiBase}/api/logout`
const playerApiRoot = `${apiBase}/api`
return ( return (
<> <>
{!loading && (user || runWithoutAuth) ? ( {!loading && (user || runWithoutAuth) ? (
<FurumiPlayer /> <FurumiPlayer apiRoot={playerApiRoot} />
) : ( ) : (
<main className="page"> <main className="page">
<section className="card"> <section className="card">

View File

@@ -1,23 +1,20 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css' import './furumi-player.css'
import { import { createFurumiApiClient } from './furumiApi'
API_ROOT, import { SearchDropdown } from './components/SearchDropdown'
getArtists, import { Breadcrumbs } from './components/Breadcrumbs'
getArtistAlbums, import { LibraryList } from './components/LibraryList'
getAlbumTracks, import { QueueList, type QueueItem } from './components/QueueList'
getArtistTracks, import { NowPlaying } from './components/NowPlaying'
searchTracks,
getTrackInfo,
preloadStream,
} from './furumiApi'
import { fmt } from './utils'
import { Header } from './components/Header'
import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar'
import type { QueueItem } from './components/QueueList'
export function FurumiPlayer() { type FurumiPlayerProps = {
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>( apiRoot: string
}
type Crumb = { label: string; action?: () => void }
export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
const [breadcrumbs, setBreadcrumbs] = useState<Array<{ label: string; action?: () => void }>>(
[], [],
) )
const [libraryLoading, setLibraryLoading] = useState(false) const [libraryLoading, setLibraryLoading] = useState(false)
@@ -45,7 +42,6 @@ export function FurumiPlayer() {
const [queueOrderView, setQueueOrderView] = useState<number[]>([]) const [queueOrderView, setQueueOrderView] = useState<number[]>([])
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1) const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
const [queueScrollSignal, setQueueScrollSignal] = useState(0) const [queueScrollSignal, setQueueScrollSignal] = useState(0)
const [queue, setQueue] = useState<QueueItem[]>([])
const queueActionsRef = useRef<{ const queueActionsRef = useRef<{
playIndex: (i: number) => void playIndex: (i: number) => void
@@ -53,14 +49,12 @@ export function FurumiPlayer() {
moveQueueItem: (fromPos: number, toPos: number) => void moveQueueItem: (fromPos: number, toPos: number) => void
} | null>(null) } | null>(null)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => { useEffect(() => {
// --- Original player script adapted for React environment --- // --- Original player script adapted for React environment ---
const audioEl = audioRef.current const audio = document.getElementById('audioEl') as HTMLAudioElement
if (!audioEl) return if (!audio) return
const audio = audioEl
let queue: QueueItem[] = []
let queueIndex = -1 let queueIndex = -1
let shuffle = false let shuffle = false
let repeatAll = true let repeatAll = true
@@ -112,12 +106,16 @@ export function FurumiPlayer() {
nextTrack() nextTrack()
}) })
// --- API helper ---
const API = apiRoot
const api = createFurumiApiClient(API)
// --- Library navigation --- // --- Library navigation ---
async function showArtists() { async function showArtists() {
setBreadcrumb([{ label: 'Artists', action: showArtists }]) setBreadcrumb([{ label: 'Artists', action: showArtists }])
setLibraryLoading(true) setLibraryLoading(true)
setLibraryError(null) setLibraryError(null)
const artists = await getArtists() const artists = await api('/artists')
if (!artists) { if (!artists) {
setLibraryLoading(false) setLibraryLoading(false)
setLibraryError('Error') setLibraryError('Error')
@@ -143,7 +141,7 @@ export function FurumiPlayer() {
]) ])
setLibraryLoading(true) setLibraryLoading(true)
setLibraryError(null) setLibraryError(null)
const albums = await getArtistAlbums(artistSlug) const albums = await api('/artists/' + artistSlug + '/albums')
if (!albums) { if (!albums) {
setLibraryLoading(false) setLibraryLoading(false)
setLibraryError('Error') setLibraryError('Error')
@@ -192,7 +190,7 @@ export function FurumiPlayer() {
]) ])
setLibraryLoading(true) setLibraryLoading(true)
setLibraryError(null) setLibraryError(null)
const tracks = await getAlbumTracks(albumSlug) const tracks = await api('/albums/' + albumSlug)
if (!tracks) { if (!tracks) {
setLibraryLoading(false) setLibraryLoading(false)
setLibraryError('Error') setLibraryError('Error')
@@ -254,7 +252,7 @@ export function FurumiPlayer() {
if (playNow) playIndex(existing) if (playNow) playIndex(existing)
return return
} }
setQueue((q) => [...q, track]); queue.push(track)
updateQueueModel() updateQueueModel()
if (playNow || (queueIndex === -1 && queue.length === 1)) { if (playNow || (queueIndex === -1 && queue.length === 1)) {
playIndex(queue.length - 1) playIndex(queue.length - 1)
@@ -262,13 +260,19 @@ export function FurumiPlayer() {
} }
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
const tracks = await getAlbumTracks(albumSlug) const tracks = await api('/albums/' + albumSlug)
if (!tracks || !(tracks as any[]).length) return if (!tracks || !(tracks as any[]).length) return
const list = tracks as any[] const list = tracks as any[]
let firstIdx = queue.length let firstIdx = queue.length
list.forEach((t) => { list.forEach((t) => {
if (queue.find((q) => q.slug === t.slug)) return if (queue.find((q) => q.slug === t.slug)) return
setQueue((q) => [...q, t]) queue.push({
slug: t.slug,
title: t.title,
artist: t.artist_name,
album_slug: albumSlug,
duration: t.duration_secs,
})
}) })
updateQueueModel() updateQueueModel()
if (playFirst || queueIndex === -1) playIndex(firstIdx) if (playFirst || queueIndex === -1) playIndex(firstIdx)
@@ -276,7 +280,7 @@ export function FurumiPlayer() {
} }
async function playAllArtistTracks(artistSlug: string) { async function playAllArtistTracks(artistSlug: string) {
const tracks = await getArtistTracks(artistSlug) const tracks = await api('/artists/' + artistSlug + '/tracks')
if (!tracks || !(tracks as any[]).length) return if (!tracks || !(tracks as any[]).length) return
const list = tracks as any[] const list = tracks as any[]
clearQueue() clearQueue()
@@ -298,7 +302,7 @@ export function FurumiPlayer() {
if (i < 0 || i >= queue.length) return if (i < 0 || i >= queue.length) return
queueIndex = i queueIndex = i
const track = queue[i] const track = queue[i]
audio.src = `${API_ROOT}/stream/${track.slug}` audio.src = `${API}/stream/${track.slug}`
void audio.play().catch(() => {}) void audio.play().catch(() => {})
updateNowPlaying(track) updateNowPlaying(track)
updateQueueModel() updateQueueModel()
@@ -316,7 +320,7 @@ export function FurumiPlayer() {
document.title = `${track.title} — Furumi` document.title = `${track.title} — Furumi`
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` const coverUrl = `${API}/tracks/${track.slug}/cover`
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
navigator.mediaSession.metadata = new window.MediaMetadata({ navigator.mediaSession.metadata = new window.MediaMetadata({
@@ -351,7 +355,7 @@ export function FurumiPlayer() {
function updateQueueModel() { function updateQueueModel() {
const order = currentOrder() const order = currentOrder()
setQueueItemsView(queue) setQueueItemsView(queue.slice())
setQueueOrderView(order.slice()) setQueueOrderView(order.slice())
setQueuePlayingOrigIdxView(queueIndex) setQueuePlayingOrigIdxView(queueIndex)
} }
@@ -365,10 +369,7 @@ export function FurumiPlayer() {
} else if (queueIndex > idx) { } else if (queueIndex > idx) {
queueIndex-- queueIndex--
} }
queue.splice(idx, 1)
// queue.splice(idx, 1)
setQueue((q) => q.filter((_, i) => i !== idx));
if (shuffle) { if (shuffle) {
const si = shuffleOrder.indexOf(idx) const si = shuffleOrder.indexOf(idx)
if (si !== -1) shuffleOrder.splice(si, 1) if (si !== -1) shuffleOrder.splice(si, 1)
@@ -401,7 +402,7 @@ export function FurumiPlayer() {
} }
function clearQueue() { function clearQueue() {
setQueue([]); queue = []
queueIndex = -1 queueIndex = -1
shuffleOrder = [] shuffleOrder = []
audio.pause() audio.pause()
@@ -494,7 +495,7 @@ export function FurumiPlayer() {
return return
} }
searchTimer = window.setTimeout(async () => { searchTimer = window.setTimeout(async () => {
const results = await searchTracks(q) const results = await api('/search?q=' + encodeURIComponent(q))
if (!results || !(results as any[]).length) { if (!results || !(results as any[]).length) {
closeSearch() closeSearch()
return return
@@ -518,12 +519,27 @@ export function FurumiPlayer() {
{ slug, title: '', artist: '', album_slug: null, duration: null }, { slug, title: '', artist: '', album_slug: null, duration: null },
true, true,
) )
void preloadStream(slug) void api('/stream/' + slug).catch(() => null)
} }
} }
searchSelectRef.current = onSearchSelect searchSelectRef.current = onSearchSelect
// --- Helpers --- // --- Helpers ---
function fmt(secs: number) {
if (!secs || Number.isNaN(secs)) return '0:00'
const s = Math.floor(secs)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h > 0) {
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
}
return `${m}:${pad(s % 60)}`
}
function pad(n: number) {
return String(n).padStart(2, '0')
}
function showToast(msg: string) { function showToast(msg: string) {
const t = document.getElementById('toast') const t = document.getElementById('toast')
if (!t) return if (!t) return
@@ -609,7 +625,7 @@ export function FurumiPlayer() {
const url = new URL(window.location.href) const url = new URL(window.location.href)
const urlSlug = url.searchParams.get('t') const urlSlug = url.searchParams.get('t')
if (urlSlug) { if (urlSlug) {
const info = await getTrackInfo(urlSlug) const info = await api('/tracks/' + urlSlug)
if (info) { if (info) {
addTrackToQueue( addTrackToQueue(
{ {
@@ -631,38 +647,120 @@ export function FurumiPlayer() {
queueActionsRef.current = null queueActionsRef.current = null
audio.pause() audio.pause()
} }
}, []) }, [apiRoot])
return ( return (
<div className="furumi-root"> <div className="furumi-root">
<Header <header className="header">
searchOpen={searchOpen} <div className="header-logo">
searchResults={searchResults} <button className="btn-menu">&#9776;</button>
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
/> <circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className="header-version">v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={(type, slug) => searchSelectRef.current(type, slug)}
/>
</div>
</div>
</header>
<MainPanel <div className="main">
breadcrumbs={breadcrumbs} <div className="sidebar-overlay" id="sidebarOverlay" />
libraryLoading={libraryLoading} <aside className="sidebar" id="sidebar">
libraryError={libraryError} <div className="sidebar-header">Library</div>
libraryItems={libraryItems} <Breadcrumbs items={breadcrumbs} />
queueItemsView={queueItemsView} <div className="file-list" id="fileList">
queueOrderView={queueOrderView} <LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
queuePlayingOrigIdxView={queuePlayingOrigIdxView} </div>
queueScrollSignal={queueScrollSignal} </aside>
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
onQueueRemove={(origIdx) =>
queueActionsRef.current?.removeFromQueue(origIdx)
}
onQueueMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
}
/>
<PlayerBar track={nowPlayingTrack} /> <section className="queue-panel">
<div className="queue-header">
<span>Queue</span>
<div className="queue-actions">
<button className="queue-btn active" id="btnShuffle">
Shuffle
</button>
<button className="queue-btn active" id="btnRepeat">
Repeat
</button>
<button className="queue-btn" id="btnClearQueue">
Clear
</button>
</div>
</div>
<div className="queue-list" id="queueList">
<QueueList
apiRoot={apiRoot}
queue={queueItemsView}
order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal}
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
onRemove={(origIdx) =>
queueActionsRef.current?.removeFromQueue(origIdx)
}
onMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
}
/>
</div>
</section>
</div>
<div className="player-bar">
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</button>
</div>
<div className="progress-row">
<span className="time" id="timeElapsed">
0:00
</span>
<div className="progress-bar" id="progressBar">
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
</div>
<span className="time" id="timeDuration">
0:00
</span>
</div>
</div>
<div className="volume-row">
<span className="vol-icon" id="volIcon">
&#128266;
</span>
<input
type="range"
className="volume-slider"
id="volSlider"
min={0}
max={100}
defaultValue={80}
/>
</div>
</div>
<div className="toast" id="toast" /> <div className="toast" id="toast" />
<audio ref={audioRef} /> <audio id="audioEl" />
</div> </div>
) )
} }

View File

@@ -1,45 +0,0 @@
import { SearchDropdown } from './SearchDropdown'
type SearchResultItem = {
result_type: string
slug: string
name: string
detail?: string
}
type HeaderProps = {
searchOpen: boolean
searchResults: SearchResultItem[]
onSearchSelect: (type: string, slug: string) => void
}
export function Header({
searchOpen,
searchResults,
onSearchSelect,
}: HeaderProps) {
return (
<header className="header">
<div className="header-logo">
<button className="btn-menu">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className="header-version">v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={onSearchSelect}
/>
</div>
</div>
</header>
)
}

View File

@@ -1,86 +0,0 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { Breadcrumbs } from './Breadcrumbs'
import { LibraryList } from './LibraryList'
import { QueueList, type QueueItem } from './QueueList'
export type Crumb = { label: string; action?: () => void }
export type LibraryListItem = {
key: string
className: string
icon: string
name: string
detail?: string
nameClassName?: string
onClick: () => void
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
}
type MainPanelProps = {
breadcrumbs: Crumb[]
libraryLoading: boolean
libraryError: string | null
libraryItems: LibraryListItem[]
queueItemsView: QueueItem[]
queueOrderView: number[]
queuePlayingOrigIdxView: number
queueScrollSignal: number
onQueuePlay: (origIdx: number) => void
onQueueRemove: (origIdx: number) => void
onQueueMove: (fromPos: number, toPos: number) => void
}
export function MainPanel({
breadcrumbs,
libraryLoading,
libraryError,
libraryItems,
queueItemsView,
queueOrderView,
queuePlayingOrigIdxView,
queueScrollSignal,
onQueuePlay,
onQueueRemove,
onQueueMove,
}: MainPanelProps) {
return (
<div className="main">
<div className="sidebar-overlay" id="sidebarOverlay" />
<aside className="sidebar" id="sidebar">
<div className="sidebar-header">Library</div>
<Breadcrumbs items={breadcrumbs} />
<div className="file-list" id="fileList">
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
</div>
</aside>
<section className="queue-panel">
<div className="queue-header">
<span>Queue</span>
<div className="queue-actions">
<button className="queue-btn active" id="btnShuffle">
Shuffle
</button>
<button className="queue-btn active" id="btnRepeat">
Repeat
</button>
<button className="queue-btn" id="btnClearQueue">
Clear
</button>
</div>
</div>
<div className="queue-list" id="queueList">
<QueueList
queue={queueItemsView}
order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal}
onPlay={onQueuePlay}
onRemove={onQueueRemove}
onMove={onQueueMove}
/>
</div>
</section>
</div>
)
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { API_ROOT } from '../furumiApi'
import type { QueueItem } from './QueueList' import type { QueueItem } from './QueueList'
function Cover({ src }: { src: string }) { function Cover({ src }: { src: string }) {
@@ -13,7 +12,7 @@ function Cover({ src }: { src: string }) {
return <img src={src} alt="" onError={() => setErrored(true)} /> return <img src={src} alt="" onError={() => setErrored(true)} />
} }
export function NowPlaying({ track }: { track: QueueItem | null }) { export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
if (!track) { if (!track) {
return ( return (
<div className="np-info"> <div className="np-info">
@@ -32,7 +31,7 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
) )
} }
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
return ( return (
<div className="np-info"> <div className="np-info">

View File

@@ -1,47 +0,0 @@
import { NowPlaying } from './NowPlaying'
import type { QueueItem } from './QueueList'
export function PlayerBar({ track }: { track: QueueItem | null }) {
return (
<div className="player-bar">
<NowPlaying track={track} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</button>
</div>
<div className="progress-row">
<span className="time" id="timeElapsed">
0:00
</span>
<div className="progress-bar" id="progressBar">
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
</div>
<span className="time" id="timeDuration">
0:00
</span>
</div>
</div>
<div className="volume-row">
<span className="vol-icon" id="volIcon">
&#128266;
</span>
<input
type="range"
className="volume-slider"
id="volSlider"
min={0}
max={100}
defaultValue={80}
/>
</div>
</div>
)
}

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { API_ROOT } from '../furumiApi'
export type QueueItem = { export type QueueItem = {
slug: string slug: string
@@ -10,6 +9,7 @@ export type QueueItem = {
} }
type QueueListProps = { type QueueListProps = {
apiRoot: string
queue: QueueItem[] queue: QueueItem[]
order: number[] order: number[]
playingOrigIdx: number playingOrigIdx: number
@@ -43,6 +43,7 @@ function Cover({ src }: { src: string }) {
} }
export function QueueList({ export function QueueList({
apiRoot,
queue, queue,
order, order,
playingOrigIdx, playingOrigIdx,
@@ -77,7 +78,7 @@ export function QueueList({
if (!t) return null if (!t) return null
const isPlaying = origIdx === playingOrigIdx const isPlaying = origIdx === playingOrigIdx
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : '' const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
const dur = t.duration ? fmt(t.duration) : '' const dur = t.duration ? fmt(t.duration) : ''
const isDragging = draggingPos === pos const isDragging = draggingPos === pos
const isDragOver = dragOverPos === pos const isDragOver = dragOverPos === pos

View File

@@ -1,46 +1,12 @@
import axios from 'axios' export type FurumiApiClient = (path: string) => Promise<unknown | null>
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '' export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
export const API_ROOT = `${API_BASE}/api` const API = apiRoot
const apiKey = import.meta.env.VITE_API_KEY return async function api(path: string) {
const r = await fetch(API + path)
export const furumiApi = axios.create({ if (!r.ok) return null
baseURL: API_ROOT, return r.json()
headers: apiKey ? { 'x-api-key': apiKey } : {}, }
})
export async function getArtists() {
const res = await furumiApi.get('/artists').catch(() => null)
return res?.data ?? null
}
export async function getArtistAlbums(artistSlug: string) {
const res = await furumiApi.get(`/artists/${artistSlug}/albums`).catch(() => null)
return res?.data ?? null
}
export async function getAlbumTracks(albumSlug: string) {
const res = await furumiApi.get(`/albums/${albumSlug}`).catch(() => null)
return res?.data ?? null
}
export async function getArtistTracks(artistSlug: string) {
const res = await furumiApi.get(`/artists/${artistSlug}/tracks`).catch(() => null)
return res?.data ?? null
}
export async function searchTracks(query: string) {
const res = await furumiApi.get(`/search?q=${encodeURIComponent(query)}`).catch(() => null)
return res?.data ?? null
}
export async function getTrackInfo(trackSlug: string) {
const res = await furumiApi.get(`/tracks/${trackSlug}`).catch(() => null)
return res?.data ?? null
}
export async function preloadStream(trackSlug: string) {
await furumiApi.get(`/stream/${trackSlug}`).catch(() => null)
} }

View File

@@ -1,14 +0,0 @@
function pad(n: number) {
return String(n).padStart(2, '0')
}
export function fmt(secs: number) {
if (!secs || Number.isNaN(secs)) return '0:00'
const s = Math.floor(secs)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h > 0) {
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
}
return `${m}:${pad(s % 60)}`
}

View File

@@ -25,4 +25,3 @@ base64 = "0.22"
rand = "0.8" rand = "0.8"
urlencoding = "2.1.3" urlencoding = "2.1.3"
rustls = { version = "0.23", features = ["ring"] } rustls = { version = "0.23", features = ["ring"] }
tower-http = { version = "0.6", features = ["cors"] }

View File

@@ -39,10 +39,6 @@ struct Args {
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided. /// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
oidc_session_secret: Option<String>, oidc_session_secret: Option<String>,
/// API key for x-api-key header auth (alternative to OIDC session)
#[arg(long, env = "FURUMI_PLAYER_API_KEY")]
api_key: Option<String>,
} }
#[tokio::main] #[tokio::main]
@@ -94,15 +90,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1); std::process::exit(1);
}); });
if args.api_key.is_some() {
tracing::info!("x-api-key auth: enabled");
}
let state = Arc::new(web::AppState { let state = Arc::new(web::AppState {
pool, pool,
storage_dir: Arc::new(args.storage_dir), storage_dir: Arc::new(args.storage_dir),
oidc: oidc_state, oidc: oidc_state,
api_key: args.api_key,
}); });
tracing::info!("Web player: http://{}", bind_addr); tracing::info!("Web player: http://{}", bind_addr);

View File

@@ -5,8 +5,6 @@ use axum::{
middleware::Next, middleware::Next,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
const X_API_KEY: &str = "x-api-key";
use openidconnect::{ use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType}, core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client, reqwest::async_http_client,
@@ -94,51 +92,37 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
} }
} }
/// Auth middleware: requires valid SSO session cookie or x-api-key header. /// Auth middleware: requires valid SSO session cookie.
pub async fn require_auth( pub async fn require_auth(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
req: Request, req: Request,
next: Next, next: Next,
) -> Response { ) -> Response {
// 1. Check x-api-key header (if configured) let oidc = match &state.oidc {
if let Some(ref expected) = state.api_key { Some(o) => o,
if let Some(val) = req None => return next.run(req).await, // No OIDC configured = no auth
.headers() };
.get(X_API_KEY)
.and_then(|v| v.to_str().ok()) let cookies = req
{ .headers()
if val == expected { .get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await; return next.run(req).await;
} }
} }
} }
// 2. Check SSO session cookie (if OIDC configured)
if let Some(ref oidc) = state.oidc {
let cookies = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await;
}
}
}
}
let uri = req.uri().to_string(); let uri = req.uri().to_string();
if uri.starts_with("/api/") { if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response() (StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else if state.oidc.is_some() {
Redirect::to("/login").into_response()
} else { } else {
// Only API key configured — no web login available Redirect::to("/login").into_response()
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} }
} }

View File

@@ -3,12 +3,9 @@ pub mod auth;
use std::sync::Arc; use std::sync::Arc;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
use axum::{Router, routing::get, middleware}; use axum::{Router, routing::get, middleware};
use axum::http::{header, Method};
use sqlx::PgPool; use sqlx::PgPool;
use tower_http::cors::{Any, CorsLayer};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -16,7 +13,6 @@ pub struct AppState {
#[allow(dead_code)] #[allow(dead_code)]
pub storage_dir: Arc<PathBuf>, pub storage_dir: Arc<PathBuf>,
pub oidc: Option<Arc<auth::OidcState>>, pub oidc: Option<Arc<auth::OidcState>>,
pub api_key: Option<String>,
} }
pub fn build_router(state: Arc<AppState>) -> Router { pub fn build_router(state: Arc<AppState>) -> Router {
@@ -36,28 +32,21 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/", get(player_html)) .route("/", get(player_html))
.nest("/api", library); .nest("/api", library);
let requires_auth = state.oidc.is_some(); let has_oidc = state.oidc.is_some();
let app = if requires_auth { let app = if has_oidc {
authed authed
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
} else { } else {
authed authed
}; };
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")])
.max_age(Duration::from_secs(600));
Router::new() Router::new()
.route("/login", get(auth::login_page)) .route("/login", get(auth::login_page))
.route("/logout", get(auth::logout)) .route("/logout", get(auth::logout))
.route("/auth/login", get(auth::oidc_login)) .route("/auth/login", get(auth::oidc_login))
.route("/auth/callback", get(auth::oidc_callback)) .route("/auth/callback", get(auth::oidc_callback))
.merge(app) .merge(app)
.layer(cors)
.with_state(state) .with_state(state)
} }