Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bc2b55ffd | |||
| ed918b9373 | |||
| 1ea5f66ea3 | |||
| 7bc7de44cf | |||
| befba57374 | |||
| a9a8ee81b8 | |||
| 1df10fb0b7 | |||
| 5a5c9967e1 | |||
| e920059125 | |||
| 48c473de56 | |||
| 1e75644abb | |||
| 2d7ac3d8ce | |||
| 70a947a8c1 | |||
| aea4aef4b2 |
Generated
+53
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -572,6 +578,15 @@ version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
@@ -969,6 +984,16 @@ version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1017,6 +1042,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"encoding_rs",
|
||||
"id3",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1750,6 +1776,17 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -2038,6 +2075,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -3429,6 +3476,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
|
||||
@@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
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"] }
|
||||
id3 = "1"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.50", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -19,9 +19,25 @@ pub struct RawMetadata {
|
||||
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).
|
||||
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 mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
@@ -66,6 +82,25 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
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) {
|
||||
for tag in tags {
|
||||
let value = fix_encoding(tag.value.to_string());
|
||||
|
||||
@@ -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;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #0a0c12;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.card {
|
||||
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);
|
||||
}
|
||||
/* ---------- loading spinner ---------- */
|
||||
|
||||
.subtitle {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #5a6475;
|
||||
}
|
||||
|
||||
.settings {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e6eaf2;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
.auth-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #1f2c45;
|
||||
border-top-color: #7c6af7;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 10px 0 0;
|
||||
color: #5a6475;
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
background: #2251ff;
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
.auth-loading .logo {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #7c6af7;
|
||||
}
|
||||
|
||||
.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;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: #edf1ff;
|
||||
color: #1e3fc4;
|
||||
margin-top: 10px;
|
||||
.auth-card .btn-login:hover {
|
||||
background: #6b58e8;
|
||||
}
|
||||
|
||||
.profile p {
|
||||
margin: 8px 0;
|
||||
.auth-card .error {
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #cc1e1e;
|
||||
}
|
||||
|
||||
@@ -9,35 +9,15 @@ type UserProfile = {
|
||||
email?: string
|
||||
}
|
||||
|
||||
const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth'
|
||||
|
||||
function App() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [user, setUser] = useState<UserProfile | 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(() => {
|
||||
if (runWithoutAuth) {
|
||||
setError(null)
|
||||
setUser({ sub: 'noauth', name: 'No Auth' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const loadMe = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/auth/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch('/auth/me', { credentials: 'include' })
|
||||
|
||||
if (response.status === 401) {
|
||||
setUser(null)
|
||||
@@ -52,12 +32,9 @@ function App() {
|
||||
const data = await response.json()
|
||||
setUser(data.user ?? null)
|
||||
|
||||
// Fetch OIDC access token for Rust API Bearer auth
|
||||
if (data.user) {
|
||||
try {
|
||||
const tokenRes = await fetch(`${apiBase}/auth/token`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
const tokenRes = await fetch('/auth/token', { credentials: 'include' })
|
||||
if (tokenRes.ok) {
|
||||
const tokenData = await tokenRes.json()
|
||||
if (tokenData.access_token) {
|
||||
@@ -65,7 +42,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token fetch failed — API calls will fall back to other auth methods
|
||||
// Token fetch failed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -76,84 +53,40 @@ function App() {
|
||||
}
|
||||
|
||||
void loadMe()
|
||||
}, [runWithoutAuth])
|
||||
}, [])
|
||||
|
||||
const loginUrl = `${apiBase}/auth/login`
|
||||
const logoutUrl = `${apiBase}/auth/logout`
|
||||
// Authenticated — render player immediately
|
||||
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 (
|
||||
<>
|
||||
{!loading && (user || runWithoutAuth) ? (
|
||||
<FurumiPlayer />
|
||||
) : (
|
||||
<main className="page">
|
||||
<section className="card">
|
||||
<h1>OIDC Login</h1>
|
||||
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
||||
<main className="auth-page">
|
||||
<section className="auth-card">
|
||||
<div className="logo">Furumi</div>
|
||||
<p className="subtitle">Sign in to continue</p>
|
||||
|
||||
<div className="settings">
|
||||
<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>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{loading && <p>Проверяю сессию...</p>}
|
||||
{error && <p className="error">Ошибка: {error}</p>}
|
||||
|
||||
{!loading && runWithoutAuth && (
|
||||
<p className="hint">
|
||||
Режим без авторизации включён. Для входа отключи настройку выше.
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
<a className="btn-login" href="/auth/login">
|
||||
Sign in with SSO
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import './furumi-player.css'
|
||||
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
|
||||
import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi'
|
||||
import { store, useAppDispatch, useAppSelector } from './store'
|
||||
import { fetchArtists } from './store/slices/artistsSlice'
|
||||
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
||||
@@ -80,15 +80,16 @@ export function FurumiPlayer() {
|
||||
return
|
||||
}
|
||||
document.title = `${nowPlayingTrack.title} — Furumi`
|
||||
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
|
||||
if ('mediaSession' in navigator) {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||
const meta = new window.MediaMetadata({
|
||||
title: nowPlayingTrack.title,
|
||||
artist: nowPlayingTrack.artist || '',
|
||||
album: '',
|
||||
artwork: [{ src: coverUrl, sizes: '512x512' }],
|
||||
})
|
||||
navigator.mediaSession.metadata = meta
|
||||
fetchCoverBlob(nowPlayingTrack.slug).then((url) => {
|
||||
if (url) meta.artwork = [{ src: url, sizes: '512x512' }]
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { API_ROOT } from '../furumiApi'
|
||||
import { useState } from 'react'
|
||||
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 src = useCoverUrl(slug)
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
if (!src || errored) return <>🎵</>
|
||||
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 (
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
<Cover src={coverUrl} />
|
||||
<Cover slug={track.slug} />
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { API_ROOT } from '../furumiApi'
|
||||
import { useCoverUrl } from '../hooks/useCoverUrl'
|
||||
|
||||
export type QueueItem = {
|
||||
slug: string
|
||||
@@ -32,13 +32,11 @@ function fmt(secs: number) {
|
||||
return `${m}:${pad(s % 60)}`
|
||||
}
|
||||
|
||||
function Cover({ src }: { src: string }) {
|
||||
function Cover({ slug }: { slug: string }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
const src = useCoverUrl(slug)
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
if (!src || errored) return <>🎵</>
|
||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
@@ -77,7 +75,7 @@ export function QueueList({
|
||||
if (!t) return null
|
||||
|
||||
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 isDragging = draggingPos === pos
|
||||
const isDragOver = dragOverPos === pos
|
||||
@@ -118,7 +116,7 @@ export function QueueList({
|
||||
>
|
||||
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||
<div className="qi-cover">
|
||||
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||
{hasAlbum ? <Cover slug={t.slug} /> : <>🎵</>}
|
||||
</div>
|
||||
<div className="qi-info">
|
||||
<div className="qi-title">{t.title}</div>
|
||||
|
||||
@@ -16,6 +16,38 @@ export function clearAuthToken() {
|
||||
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> {
|
||||
const res = await furumiApi.get<Artist[]>('/artists').catch(() => 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -24,7 +24,7 @@ const oidcConfig = {
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
|
||||
authorizationParams: {
|
||||
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) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
@@ -85,17 +85,27 @@ app.get('/auth/token', (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const accessToken = req.oidc.accessToken?.access_token;
|
||||
const expiresAt = req.oidc.accessToken?.expires_in;
|
||||
if (!accessToken) {
|
||||
let accessToken = req.oidc.accessToken;
|
||||
if (!accessToken?.access_token) {
|
||||
res.status(500).json({ error: 'no access token in session' });
|
||||
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({
|
||||
access_token: accessToken,
|
||||
access_token: accessToken.access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: expiresAt,
|
||||
expires_in: accessToken.expires_in,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user