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.
|
# 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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 <>🎵</>
|
||||||
setErrored(false)
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
if (errored) return <>🎵</>
|
|
||||||
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 <>🎵</>
|
if (!src || errored) return <>🎵</>
|
||||||
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} /> : <>🎵</>}
|
{hasAlbum ? <Cover slug={t.slug} /> : <>🎵</>}
|
||||||
</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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user