14 Commits

Author SHA1 Message Date
Ultradesu 5bc2b55ffd feat(node-player): remove run-without-auth option from login page
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m28s
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 38s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m56s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:32:19 +01:00
Ultradesu ed918b9373 feat(node-player): redesign auth page with loading state
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m31s
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 43s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 2m22s
- Show spinner while checking session (no login form flash on refresh)
- Translate UI to English
- Match player's dark theme (colors, fonts, card style)
- Render login form only when authentication is actually needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:27:22 +01:00
Ultradesu 1ea5f66ea3 fix(node-player): add offline_access scope and server-side token refresh
Publish Metadata Agent Image (dev) / build-and-push-image (push) Has been cancelled
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 36s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m50s
- Add offline_access to OIDC scope so Authentik issues a refresh token
- /auth/token now checks if access token is expired and refreshes it
  server-side before returning to the client

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 16:01:16 +01:00
Ultradesu 7bc7de44cf fix(node-player): restore useEffect import in QueueList
Publish Metadata Agent Image (dev) / build-and-push-image (push) Has been cancelled
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 38s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m48s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:44:28 +01:00
Ultradesu befba57374 fix(node-player): auto-refresh expired JWT tokens on 401
Publish Metadata Agent Image (dev) / build-and-push-image (push) Has been cancelled
Publish Node Player Image (dev) / build-and-push-image (push) Failing after 28s
Publish Web Player Image (dev) / build-and-push-image (push) Has been cancelled
Adds an axios response interceptor that catches 401 errors, fetches a
fresh access token from /auth/token, and retries the original request.
Concurrent refresh attempts are deduplicated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:41:47 +01:00
Ultradesu a9a8ee81b8 fix(node-player): load cover art via axios with Bearer token
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m22s
Publish Node Player Image (dev) / build-and-push-image (push) Failing after 29s
Publish Web Player Image (dev) / build-and-push-image (push) Has been cancelled
Cover images were loaded via <img src> which doesn't include the
Authorization header, resulting in 401 from the Rust API. Now covers
are fetched through axios as blobs and displayed via object URLs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:39:52 +01:00
ab 1df10fb0b7 Merge pull request 'fix(node-player): use Express 5 catch-all route syntax' (#10) from feature/JWT-OIDC-SSO into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Has been cancelled
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 37s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m48s
Reviewed-on: #10
2026-04-08 14:21:54 +00:00
ab 5a5c9967e1 Merge pull request 'fix(node-player): use expires_in instead of expires_at on AccessToken type' (#9) from feature/JWT-OIDC-SSO into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 3m38s
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 36s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m50s
Reviewed-on: #9
2026-04-08 14:11:17 +00:00
ab e920059125 Merge pull request 'feature/JWT-OIDC-SSO' (#8) from feature/JWT-OIDC-SSO into DEV
Publish Web Player Image (dev) / build-and-push-image (push) Has been cancelled
Publish Node Player Image (dev) / build-and-push-image (push) Has been cancelled
Publish Metadata Agent Image (dev) / build-and-push-image (push) Has been cancelled
Reviewed-on: #8
2026-04-08 13:53:36 +00:00
ab 48c473de56 fix(agent): increase max_tokens for merge requests to avoid truncated responses
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 3m43s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 4m20s
normalize: 512 tokens (sufficient for single track metadata)
merge: 4096 tokens (needed for artists with many albums)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:52:23 +01:00
ab 1e75644abb feat(agent): switch LLM client from Ollama to OpenAI-compatible API (LM Studio support)
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 4m7s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 3m57s
- Replace /api/chat with /v1/chat/completions endpoint
- Use json_schema response_format (LM Studio does not support json_object)
- Make schema parameter optional in call_ollama to support different schemas per use case
- Add dedicated normalize schema (normalized_metadata) with release_kind field
  instead of release_type to avoid model repetition loops
- Add dedicated merge schema (artist_merge) so model no longer confuses
  normalize and merge response structures
- Add retry with frequency_penalty=1.5 on parse failure to suppress repetition
- Add id3 crate as fallback metadata reader for MP3 files with large embedded
  cover art that exceed Symphonia probe limit of 1MB

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:34:39 +01:00
ab 2d7ac3d8ce Fixed openai api endpoint
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 4m0s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 4m23s
2026-04-07 19:52:03 +01:00
ab 70a947a8c1 Fixed openai api endpoint
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 3m38s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 3m49s
2026-04-07 19:32:17 +01:00
XakPlant aea4aef4b2 Merge pull request 'feature/node-app' (#7) from feature/node-app into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m8s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m7s
Reviewed-on: #7
2026-03-23 14:00:08 +00:00
11 changed files with 302 additions and 179 deletions
Generated
+53
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@@ -572,6 +578,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.15" version = "0.5.15"
@@ -969,6 +984,16 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]] [[package]]
name = "flume" name = "flume"
version = "0.11.1" version = "0.11.1"
@@ -1017,6 +1042,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"encoding_rs", "encoding_rs",
"id3",
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
"serde_json", "serde_json",
@@ -1750,6 +1776,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "id3"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
dependencies = [
"bitflags 2.11.0",
"byteorder",
"flate2",
]
[[package]] [[package]]
name = "ident_case" name = "ident_case"
version = "1.0.1" version = "1.0.1"
@@ -2038,6 +2075,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.1.1" version = "1.1.1"
@@ -3429,6 +3476,12 @@ dependencies = [
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]] [[package]]
name = "simple_asn1" name = "simple_asn1"
version = "0.6.4" version = "0.6.4"
+1
View File
@@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] } symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
id3 = "1"
thiserror = "2.0" thiserror = "2.0"
tokio = { version = "1.50", features = ["full"] } tokio = { version = "1.50", features = ["full"] }
tracing = "0.1" tracing = "0.1"
+36 -1
View File
@@ -19,9 +19,25 @@ pub struct RawMetadata {
pub duration_secs: Option<f64>, pub duration_secs: Option<f64>,
} }
/// Extract metadata from an audio file using Symphonia. /// Extract metadata from an audio file.
/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file
/// (e.g., ID3 tag with large embedded cover art exceeds Symphonia's 1 MB probe limit).
/// Must be called from a blocking context (spawn_blocking). /// Must be called from a blocking context (spawn_blocking).
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> { pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
match extract_via_symphonia(path) {
Ok(meta) => return Ok(meta),
Err(e) => {
let is_mp3 = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("mp3")).unwrap_or(false);
if is_mp3 {
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
return extract_mp3_via_id3(path);
}
return Err(e);
}
}
}
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
let file = std::fs::File::open(path)?; let file = std::fs::File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default()); let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -66,6 +82,25 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
Ok(meta) Ok(meta)
} }
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
use id3::TagLike;
let tag = id3::Tag::read_from_path(path)
.map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
let mut meta = RawMetadata::default();
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
meta.track_number = tag.track();
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
// duration_secs remains None — acceptable for large-cover files
Ok(meta)
}
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) { fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
for tag in tags { for tag in tags {
let value = fix_encoding(tag.value.to_string()); let value = fix_encoding(tag.value.to_string());
+79 -48
View File
@@ -1,71 +1,102 @@
.page { @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
.auth-page {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
place-items: center; place-items: center;
padding: 24px; padding: 24px;
background: #0a0c12;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
} }
.card { /* ---------- loading spinner ---------- */
width: min(520px, 100%);
border: 1px solid #d8dde6;
border-radius: 14px;
padding: 24px;
background-color: #ffffff;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
}
.subtitle { .auth-loading {
margin-top: 0;
margin-bottom: 20px;
color: #5a6475;
}
.settings {
margin-bottom: 16px;
padding: 12px;
border: 1px solid #e6eaf2;
border-radius: 10px;
background: #f8fafc;
}
.toggle {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 10px; gap: 20px;
color: #0f172a;
font-weight: 600;
} }
.toggle input { .spinner {
width: 18px; width: 36px;
height: 18px; height: 36px;
border: 3px solid #1f2c45;
border-top-color: #7c6af7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
.hint { @keyframes spin {
margin: 10px 0 0; to { transform: rotate(360deg); }
color: #5a6475;
} }
.btn { .auth-loading .logo {
display: inline-block; font-size: 1.6rem;
text-decoration: none; font-weight: 700;
background: #2251ff; color: #7c6af7;
color: #ffffff; }
padding: 10px 16px;
.auth-loading p {
color: #64748b;
font-size: 0.85rem;
}
/* ---------- login card ---------- */
.auth-card {
width: min(380px, 100%);
background: #111520;
border: 1px solid #1f2c45;
border-radius: 16px;
padding: 2.5rem 2rem;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-card .logo {
font-size: 1.8rem;
font-weight: 700;
color: #7c6af7;
margin-bottom: 4px;
}
.auth-card .subtitle {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 2rem;
}
.auth-card .btn-login {
display: block;
width: 100%;
padding: 0.75rem;
text-align: center;
background: #7c6af7;
border: none;
border-radius: 8px; border-radius: 8px;
color: #fff;
font-size: 0.95rem;
font-weight: 600; font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background 0.2s;
} }
.btn.ghost { .auth-card .btn-login:hover {
background: #edf1ff; background: #6b58e8;
color: #1e3fc4;
margin-top: 10px;
} }
.profile p { .auth-card .error {
margin: 8px 0; color: #f87171;
font-size: 0.85rem;
margin-bottom: 1rem;
} }
.error {
color: #cc1e1e;
}
+32 -99
View File
@@ -9,35 +9,15 @@ type UserProfile = {
email?: string email?: string
} }
const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth'
function App() { function App() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [user, setUser] = useState<UserProfile | null>(null) const [user, setUser] = useState<UserProfile | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [runWithoutAuth, setRunWithoutAuth] = useState(() => {
try {
return window.localStorage.getItem(NO_AUTH_STORAGE_KEY) === '1'
} catch {
return false
}
})
const apiBase = ''
useEffect(() => { useEffect(() => {
if (runWithoutAuth) {
setError(null)
setUser({ sub: 'noauth', name: 'No Auth' })
setLoading(false)
return
}
const loadMe = async () => { const loadMe = async () => {
try { try {
const response = await fetch(`${apiBase}/auth/me`, { const response = await fetch('/auth/me', { credentials: 'include' })
credentials: 'include',
})
if (response.status === 401) { if (response.status === 401) {
setUser(null) setUser(null)
@@ -52,12 +32,9 @@ function App() {
const data = await response.json() const data = await response.json()
setUser(data.user ?? null) setUser(data.user ?? null)
// Fetch OIDC access token for Rust API Bearer auth
if (data.user) { if (data.user) {
try { try {
const tokenRes = await fetch(`${apiBase}/auth/token`, { const tokenRes = await fetch('/auth/token', { credentials: 'include' })
credentials: 'include',
})
if (tokenRes.ok) { if (tokenRes.ok) {
const tokenData = await tokenRes.json() const tokenData = await tokenRes.json()
if (tokenData.access_token) { if (tokenData.access_token) {
@@ -65,7 +42,7 @@ function App() {
} }
} }
} catch { } catch {
// Token fetch failed — API calls will fall back to other auth methods // Token fetch failed
} }
} }
} catch (err) { } catch (err) {
@@ -76,84 +53,40 @@ function App() {
} }
void loadMe() void loadMe()
}, [runWithoutAuth]) }, [])
const loginUrl = `${apiBase}/auth/login` // Authenticated — render player immediately
const logoutUrl = `${apiBase}/auth/logout` if (!loading && user) {
return <FurumiPlayer />
}
// Loading — show spinner (no login form flash)
if (loading) {
return (
<main className="auth-page">
<div className="auth-loading">
<div className="logo">Furumi</div>
<div className="spinner" />
<p>Loading...</p>
</div>
</main>
)
}
// Not authenticated — show login
return ( return (
<> <main className="auth-page">
{!loading && (user || runWithoutAuth) ? ( <section className="auth-card">
<FurumiPlayer /> <div className="logo">Furumi</div>
) : ( <p className="subtitle">Sign in to continue</p>
<main className="page">
<section className="card">
<h1>OIDC Login</h1>
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
<div className="settings"> {error && <p className="error">{error}</p>}
<label className="toggle">
<input
type="checkbox"
checked={runWithoutAuth}
onChange={(e) => {
const next = e.target.checked
setRunWithoutAuth(next)
try {
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
// ignore
}
setLoading(true)
setUser(null)
}}
/>
<span>Запускать без авторизации</span>
</label>
</div>
{loading && <p>Проверяю сессию...</p>} <a className="btn-login" href="/auth/login">
{error && <p className="error">Ошибка: {error}</p>} Sign in with SSO
</a>
{!loading && runWithoutAuth && ( </section>
<p className="hint"> </main>
Режим без авторизации включён. Для входа отключи настройку выше.
</p>
)}
{!loading && !user && (
<a className="btn" href={loginUrl}>
Войти через OIDC
</a>
)}
{!loading && user && (
<div className="profile">
<p>
<strong>ID:</strong> {user.sub}
</p>
{user.name && (
<p>
<strong>Имя:</strong> {user.name}
</p>
)}
{user.email && (
<p>
<strong>Email:</strong> {user.email}
</p>
)}
{!runWithoutAuth && (
<a className="btn ghost" href={logoutUrl}>
Выйти
</a>
)}
</div>
)}
</section>
</main>
)}
</>
) )
} }
@@ -1,6 +1,6 @@
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 { API_ROOT, searchTracks, preloadStream } from './furumiApi' import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi'
import { store, useAppDispatch, useAppSelector } from './store' import { store, useAppDispatch, useAppSelector } from './store'
import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtists } from './store/slices/artistsSlice'
import { fetchArtistAlbums } from './store/slices/albumsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice'
@@ -80,15 +80,16 @@ export function FurumiPlayer() {
return return
} }
document.title = `${nowPlayingTrack.title} — Furumi` document.title = `${nowPlayingTrack.title} — Furumi`
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
try { try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment const meta = new window.MediaMetadata({
navigator.mediaSession.metadata = new window.MediaMetadata({
title: nowPlayingTrack.title, title: nowPlayingTrack.title,
artist: nowPlayingTrack.artist || '', artist: nowPlayingTrack.artist || '',
album: '', album: '',
artwork: [{ src: coverUrl, sizes: '512x512' }], })
navigator.mediaSession.metadata = meta
fetchCoverBlob(nowPlayingTrack.slug).then((url) => {
if (url) meta.artwork = [{ src: url, sizes: '512x512' }]
}) })
} catch { } catch {
// ignore // ignore
@@ -1,15 +1,12 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { API_ROOT } from '../furumiApi'
import type { QueueItem } from './QueueList' import type { QueueItem } from './QueueList'
import { useCoverUrl } from '../hooks/useCoverUrl'
function Cover({ src }: { src: string }) { function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false) const [errored, setErrored] = useState(false)
const src = useCoverUrl(slug)
useEffect(() => { if (!src || errored) return <>&#127925;</>
setErrored(false)
}, [src])
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} /> return <img src={src} alt="" onError={() => setErrored(true)} />
} }
@@ -32,12 +29,10 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
) )
} }
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
return ( return (
<div className="np-info"> <div className="np-info">
<div className="np-cover" id="npCover"> <div className="np-cover" id="npCover">
<Cover src={coverUrl} /> <Cover slug={track.slug} />
</div> </div>
<div className="np-text"> <div className="np-text">
<div className="np-title" id="npTitle"> <div className="np-title" id="npTitle">
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { API_ROOT } from '../furumiApi' import { useCoverUrl } from '../hooks/useCoverUrl'
export type QueueItem = { export type QueueItem = {
slug: string slug: string
@@ -32,13 +32,11 @@ function fmt(secs: number) {
return `${m}:${pad(s % 60)}` return `${m}:${pad(s % 60)}`
} }
function Cover({ src }: { src: string }) { function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false) const [errored, setErrored] = useState(false)
useEffect(() => { const src = useCoverUrl(slug)
setErrored(false)
}, [src])
if (errored) return <>&#127925;</> if (!src || errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} /> return <img src={src} alt="" onError={() => setErrored(true)} />
} }
@@ -77,7 +75,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 hasAlbum = !!t.album_slug
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
@@ -118,7 +116,7 @@ export function QueueList({
> >
<span className="qi-index">{isPlaying ? '' : pos + 1}</span> <span className="qi-index">{isPlaying ? '' : pos + 1}</span>
<div className="qi-cover"> <div className="qi-cover">
{coverSrc ? <Cover src={coverSrc} /> : <>&#127925;</>} {hasAlbum ? <Cover slug={t.slug} /> : <>&#127925;</>}
</div> </div>
<div className="qi-info"> <div className="qi-info">
<div className="qi-title">{t.title}</div> <div className="qi-title">{t.title}</div>
@@ -16,6 +16,38 @@ export function clearAuthToken() {
delete furumiApi.defaults.headers.common['Authorization'] delete furumiApi.defaults.headers.common['Authorization']
} }
async function refreshToken(): Promise<boolean> {
try {
const res = await fetch('/auth/token', { credentials: 'include' })
if (!res.ok) return false
const data = await res.json()
if (data.access_token) {
setAuthToken(data.access_token)
return true
}
} catch { /* ignore */ }
return false
}
let refreshPromise: Promise<boolean> | null = null
furumiApi.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retried) {
original._retried = true
// Deduplicate concurrent refresh attempts
if (!refreshPromise) {
refreshPromise = refreshToken().finally(() => { refreshPromise = null })
}
const ok = await refreshPromise
if (ok) return furumiApi(original)
}
return Promise.reject(error)
},
)
export async function getArtists(): Promise<Artist[] | null> { export async function getArtists(): Promise<Artist[] | null> {
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null) const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
return res?.data ?? null return res?.data ?? null
@@ -52,3 +84,9 @@ export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
} }
export async function fetchCoverBlob(trackSlug: string): Promise<string | null> {
const res = await furumiApi.get(`/tracks/${trackSlug}/cover`, { responseType: 'blob' }).catch(() => null)
if (!res?.data) return null
return URL.createObjectURL(res.data)
}
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
import { fetchCoverBlob } from '../furumiApi'
export function useCoverUrl(trackSlug: string | undefined): string | null {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
if (!trackSlug) {
setUrl(null)
return
}
let revoke: string | null = null
fetchCoverBlob(trackSlug).then((blobUrl) => {
if (blobUrl) {
revoke = blobUrl
setUrl(blobUrl)
}
})
return () => {
if (revoke) URL.revokeObjectURL(revoke)
}
}, [trackSlug])
return url
}
+17 -7
View File
@@ -24,7 +24,7 @@ const oidcConfig = {
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
authorizationParams: { authorizationParams: {
response_type: 'code', response_type: 'code',
scope: process.env.OIDC_SCOPE ?? 'openid profile email', scope: process.env.OIDC_SCOPE ?? 'openid profile email offline_access',
}, },
}; };
@@ -74,7 +74,7 @@ app.get('/auth/me', (req, res) => {
}); });
}); });
app.get('/auth/token', (req, res) => { app.get('/auth/token', async (req, res) => {
if (disableAuth) { if (disableAuth) {
res.status(204).end(); res.status(204).end();
return; return;
@@ -85,17 +85,27 @@ app.get('/auth/token', (req, res) => {
return; return;
} }
const accessToken = req.oidc.accessToken?.access_token; let accessToken = req.oidc.accessToken;
const expiresAt = req.oidc.accessToken?.expires_in; if (!accessToken?.access_token) {
if (!accessToken) {
res.status(500).json({ error: 'no access token in session' }); res.status(500).json({ error: 'no access token in session' });
return; return;
} }
// Refresh if expired
if (accessToken.isExpired()) {
try {
accessToken = await accessToken.refresh();
} catch (e) {
console.error('Token refresh failed:', e);
res.status(401).json({ error: 'token refresh failed' });
return;
}
}
res.json({ res.json({
access_token: accessToken, access_token: accessToken.access_token,
token_type: 'Bearer', token_type: 'Bearer',
expires_in: expiresAt, expires_in: accessToken.expires_in,
}); });
}); });