Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bafa3e0ec0 | |||
| df8d8f0d73 | |||
| 74d7b10386 | |||
| 9467737a7c | |||
| 2edc293ee0 | |||
| 5c7f940b7e | |||
| 4a39d44211 | |||
| d3aba1152c | |||
| c11b71a0ef | |||
| ea2fc53faf | |||
| d6dd046fad | |||
| 3fa79423bd | |||
| 6b1aa6b5d5 | |||
| 4fdd56dae4 | |||
| 5bc2b55ffd | |||
| ed918b9373 | |||
| 1ea5f66ea3 | |||
| 7bc7de44cf | |||
| befba57374 | |||
| a9a8ee81b8 | |||
| 1df10fb0b7 | |||
| 5a5c9967e1 | |||
| e920059125 | |||
| 48c473de56 | |||
| 1e75644abb | |||
| 2d7ac3d8ce | |||
| 70a947a8c1 | |||
| aea4aef4b2 |
@@ -2,3 +2,4 @@
|
|||||||
/docker/inbox
|
/docker/inbox
|
||||||
/docker/storage
|
/docker/storage
|
||||||
.env
|
.env
|
||||||
|
.DS_Store
|
||||||
|
|||||||
Generated
+54
@@ -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",
|
||||||
@@ -1150,6 +1176,7 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"hmac",
|
"hmac",
|
||||||
"jsonwebtoken 9.3.1",
|
"jsonwebtoken 9.3.1",
|
||||||
@@ -1750,6 +1777,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 +2076,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 +3477,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"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
display_name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE play_events (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
track_id BIGINT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
played_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_play_events_user_id ON play_events(user_id);
|
||||||
|
CREATE INDEX idx_play_events_track_id ON play_events(track_id);
|
||||||
|
CREATE INDEX idx_play_events_user_track ON play_events(user_id, track_id);
|
||||||
|
CREATE INDEX idx_play_events_played_at ON play_events(played_at DESC);
|
||||||
@@ -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 +1,2 @@
|
|||||||
VITE_FURUMI_API_URL=http://localhost:8085
|
# Leave empty — vite proxy handles /api in dev, same-origin in production
|
||||||
|
VITE_FURUMI_API_URL=
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
const DB_NAME = 'furumi-sw'
|
||||||
|
const STORE = 'auth'
|
||||||
|
const KEY = 'bearer'
|
||||||
|
|
||||||
|
function openDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, 1)
|
||||||
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE)
|
||||||
|
req.onsuccess = () => resolve(req.result)
|
||||||
|
req.onerror = () => reject(req.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getToken() {
|
||||||
|
try {
|
||||||
|
const db = await openDB()
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const tx = db.transaction(STORE, 'readonly')
|
||||||
|
const req = tx.objectStore(STORE).get(KEY)
|
||||||
|
req.onsuccess = () => resolve(req.result || null)
|
||||||
|
req.onerror = () => resolve(null)
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (e) => {
|
||||||
|
const url = new URL(e.request.url)
|
||||||
|
if (url.origin !== self.location.origin || !url.pathname.startsWith('/api/')) return
|
||||||
|
|
||||||
|
e.respondWith(
|
||||||
|
(async () => {
|
||||||
|
const token = await getToken()
|
||||||
|
if (!token) return fetch(e.request)
|
||||||
|
|
||||||
|
const headers = new Headers(e.request.headers)
|
||||||
|
headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
return fetch(new Request(e.request, { headers }))
|
||||||
|
})()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('install', () => self.skipWaiting())
|
||||||
|
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
|
||||||
@@ -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,33 +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',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -52,12 +34,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 +44,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 +55,42 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadMe()
|
void loadMe()
|
||||||
}, [runWithoutAuth])
|
}, [])
|
||||||
|
|
||||||
const loginUrl = `${apiBase}/auth/login`
|
const loginUrl = `/api/login`
|
||||||
const logoutUrl = `${apiBase}/auth/logout`
|
const logoutUrl = `/api/logout`
|
||||||
|
// Authenticated — render player immediately
|
||||||
|
if (!loading && user) {
|
||||||
|
return <FurumiPlayer user={user} />
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 { furumiApi, searchTracks, recordPlay } 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'
|
||||||
@@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel'
|
|||||||
import { PlayerBar } from './components/PlayerBar'
|
import { PlayerBar } from './components/PlayerBar'
|
||||||
import type { Track } from './types'
|
import type { Track } from './types'
|
||||||
|
|
||||||
export function FurumiPlayer() {
|
export type UserProfile = {
|
||||||
|
sub: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FurumiPlayer({ user }: { user: UserProfile }) {
|
||||||
const dispatch = useAppDispatch()
|
const dispatch = useAppDispatch()
|
||||||
const artistsLoading = useAppSelector((s) => s.artists.loading)
|
const artistsLoading = useAppSelector((s) => s.artists.loading)
|
||||||
const artistsError = useAppSelector((s) => s.artists.error)
|
const artistsError = useAppSelector((s) => s.artists.error)
|
||||||
@@ -80,16 +86,19 @@ 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
|
||||||
|
furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' })
|
||||||
|
.then((res) => {
|
||||||
|
meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }]
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -292,6 +301,7 @@ export function FurumiPlayer() {
|
|||||||
dispatch(playAtIndex(i))
|
dispatch(playAtIndex(i))
|
||||||
const track = store.getState().queue.items[i]
|
const track = store.getState().queue.items[i]
|
||||||
void playback.loadStreamForTrack(track.slug)
|
void playback.loadStreamForTrack(track.slug)
|
||||||
|
void recordPlay(track.slug)
|
||||||
if (window.history && window.history.replaceState) {
|
if (window.history && window.history.replaceState) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('t', track.slug)
|
url.searchParams.set('t', track.slug)
|
||||||
@@ -384,7 +394,7 @@ 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 playback.loadStreamForTrack(slug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
@@ -512,6 +522,8 @@ export function FurumiPlayer() {
|
|||||||
searchOpen={searchOpen}
|
searchOpen={searchOpen}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
|
onPlayTrack={(slug) => searchSelectRef.current('track', slug)}
|
||||||
|
user={user}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MainPanel
|
<MainPanel
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { preloadStream } from './furumiApi'
|
import { furumiApi } from './furumiApi'
|
||||||
import { fmt } from './utils'
|
import { fmt } from './utils'
|
||||||
|
|
||||||
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
const MAX_PLAYBACK_ERROR_SKIPS = 5
|
||||||
@@ -109,9 +109,13 @@ export function attachAudioPlayback(
|
|||||||
volSlider?.addEventListener('input', onVolInput)
|
volSlider?.addEventListener('input', onVolInput)
|
||||||
|
|
||||||
async function loadStreamForTrack(slug: string) {
|
async function loadStreamForTrack(slug: string) {
|
||||||
const response = await preloadStream(slug)
|
try {
|
||||||
audio.src = URL.createObjectURL(response?.data)
|
const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' })
|
||||||
await audio.play().catch(() => { })
|
audio.src = URL.createObjectURL(res.data)
|
||||||
|
await audio.play().catch(() => { })
|
||||||
|
} catch {
|
||||||
|
// stream failed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pauseAndClearSource() {
|
function pauseAndClearSource() {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { furumiApi } from '../furumiApi'
|
||||||
|
|
||||||
|
export function AuthImg({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!src) return
|
||||||
|
let revoked = false
|
||||||
|
furumiApi.get(src, { responseType: 'blob' })
|
||||||
|
.then((res) => {
|
||||||
|
if (!revoked) setBlobUrl(URL.createObjectURL(res.data))
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return () => {
|
||||||
|
revoked = true
|
||||||
|
if (blobUrl) URL.revokeObjectURL(blobUrl)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [src])
|
||||||
|
|
||||||
|
if (!blobUrl) return null
|
||||||
|
return <img src={blobUrl} alt={alt ?? ''} {...props} />
|
||||||
|
}
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { API_ROOT } from '../furumiApi'
|
import { AuthImg } from './AuthImg'
|
||||||
import type { QueueItem } from './QueueList'
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
function Cover({ src }: { src: string }) {
|
function Cover({ slug }: { slug: string }) {
|
||||||
const [errored, setErrored] = useState(false)
|
const [errored, setErrored] = useState(false)
|
||||||
|
const src = `/tracks/${slug}/cover`
|
||||||
useEffect(() => {
|
|
||||||
setErrored(false)
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
if (errored) return <>🎵</>
|
if (errored) return <>🎵</>
|
||||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
return <AuthImg src={src} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||||
@@ -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">
|
||||||
@@ -50,4 +45,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { API_ROOT } from '../furumiApi'
|
import { AuthImg } from './AuthImg'
|
||||||
|
|
||||||
export type QueueItem = {
|
export type QueueItem = {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -32,14 +32,10 @@ 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(() => {
|
|
||||||
setErrored(false)
|
|
||||||
}, [src])
|
|
||||||
|
|
||||||
if (errored) return <>🎵</>
|
if (errored) return <>🎵</>
|
||||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueueList({
|
export function QueueList({
|
||||||
@@ -77,7 +73,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 +114,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>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { SearchDropdown } from '../SearchDropdown'
|
import { SearchDropdown } from '../SearchDropdown'
|
||||||
|
import { RecentPlays } from './RecentPlays'
|
||||||
import styles from './header.module.css'
|
import styles from './header.module.css'
|
||||||
|
|
||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
@@ -8,39 +10,100 @@ type SearchResultItem = {
|
|||||||
detail?: string
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserInfo = {
|
||||||
|
sub: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
searchOpen: boolean
|
searchOpen: boolean
|
||||||
searchResults: SearchResultItem[]
|
searchResults: SearchResultItem[]
|
||||||
onSearchSelect: (type: string, slug: string) => void
|
onSearchSelect: (type: string, slug: string) => void
|
||||||
|
onPlayTrack: (slug: string) => void
|
||||||
|
user: UserInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const initials = (user.name ?? user.sub)
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.userMenu} ref={ref}>
|
||||||
|
<button className={styles.userAvatar} onClick={() => setOpen(!open)} title={user.name ?? user.sub}>
|
||||||
|
{initials}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className={styles.userDropdown}>
|
||||||
|
<div className={styles.userInfo}>
|
||||||
|
<div className={styles.userName}>{user.name ?? user.sub}</div>
|
||||||
|
{user.email && <div className={styles.userEmail}>{user.email}</div>}
|
||||||
|
</div>
|
||||||
|
<button className={styles.userAction} onClick={() => { setOpen(false); onShowRecent() }}>
|
||||||
|
Recent plays
|
||||||
|
</button>
|
||||||
|
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
searchOpen,
|
searchOpen,
|
||||||
searchResults,
|
searchResults,
|
||||||
onSearchSelect,
|
onSearchSelect,
|
||||||
|
onPlayTrack,
|
||||||
|
user,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
|
const [showRecent, setShowRecent] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<>
|
||||||
<div className={styles.headerLogo}>
|
<header className={styles.header}>
|
||||||
<button className="btn-menu">☰</button>
|
<div className={styles.headerLogo}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<button className="btn-menu">☰</button>
|
||||||
<circle cx="9" cy="18" r="3" />
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<circle cx="18" cy="15" r="3" />
|
<circle cx="9" cy="18" r="3" />
|
||||||
<path d="M12 18V6l9-3v3" />
|
<circle cx="18" cy="15" r="3" />
|
||||||
</svg>
|
<path d="M12 18V6l9-3v3" />
|
||||||
Furumi
|
</svg>
|
||||||
<span className={styles.headerVersion}>v</span>
|
Furumi
|
||||||
</div>
|
<span className={styles.headerVersion}>v</span>
|
||||||
<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>
|
||||||
</div>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
</header>
|
<div className="search-wrap">
|
||||||
|
<input id="searchInput" placeholder="Search..." />
|
||||||
|
<SearchDropdown
|
||||||
|
isOpen={searchOpen}
|
||||||
|
results={searchResults}
|
||||||
|
onSelect={onSearchSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<UserMenu user={user} onShowRecent={() => setShowRecent(true)} />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{showRecent && (
|
||||||
|
<RecentPlays
|
||||||
|
onClose={() => setShowRecent(false)}
|
||||||
|
onPlay={onPlayTrack}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { getRecentPlays, type RecentPlay } from '../../furumiApi'
|
||||||
|
import styles from './header.module.css'
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 1) return 'just now'
|
||||||
|
if (mins < 60) return `${mins}m ago`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `${hrs}h ago`
|
||||||
|
const days = Math.floor(hrs / 24)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentPlays({ onClose, onPlay }: { onClose: () => void; onPlay: (slug: string) => void }) {
|
||||||
|
const [plays, setPlays] = useState<RecentPlay[] | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getRecentPlays().then((data) => {
|
||||||
|
setPlays(data)
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.recentOverlay} onClick={onClose}>
|
||||||
|
<div className={styles.recentPanel} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={styles.recentHeader}>
|
||||||
|
<h2>Recent plays</h2>
|
||||||
|
<button className={styles.recentClose} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.recentList}>
|
||||||
|
{loading && <p className={styles.recentEmpty}>Loading...</p>}
|
||||||
|
{!loading && (!plays || plays.length === 0) && (
|
||||||
|
<p className={styles.recentEmpty}>No play history yet</p>
|
||||||
|
)}
|
||||||
|
{plays?.map((p, i) => (
|
||||||
|
<div
|
||||||
|
key={`${p.track_slug}-${i}`}
|
||||||
|
className={styles.recentItem}
|
||||||
|
onClick={() => { onPlay(p.track_slug); onClose() }}
|
||||||
|
>
|
||||||
|
<div className={styles.recentTrack}>
|
||||||
|
<div className={styles.recentTitle}>{p.track_title}</div>
|
||||||
|
<div className={styles.recentArtist}>{p.artist_name}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.recentTime}>{timeAgo(p.played_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,4 +32,203 @@
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User menu */
|
||||||
|
|
||||||
|
.userMenu {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAvatar:hover {
|
||||||
|
background: var(--accent-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userDropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 200px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.userInfo {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userName {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userEmail {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userLogout {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--danger);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userLogout:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAction {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userAction:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recent plays overlay */
|
||||||
|
|
||||||
|
.recentOverlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 200;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
animation: fadeIn 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentPanel {
|
||||||
|
width: min(480px, 90vw);
|
||||||
|
max-height: 70vh;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentHeader h2 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentClose {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentClose:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentList {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentItem:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentTrack {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentTitle {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentArtist {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentTime {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recentEmpty {
|
||||||
|
padding: 32px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
@@ -8,14 +8,57 @@ export const furumiApi = axios.create({
|
|||||||
baseURL: API_ROOT,
|
baseURL: API_ROOT,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function sendTokenToSW(token: string) {
|
||||||
|
try {
|
||||||
|
const req = indexedDB.open('furumi-sw', 1)
|
||||||
|
req.onupgradeneeded = () => req.result.createObjectStore('auth')
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const tx = req.result.transaction('auth', 'readwrite')
|
||||||
|
tx.objectStore('auth').put(token, 'bearer')
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
export function setAuthToken(token: string) {
|
export function setAuthToken(token: string) {
|
||||||
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||||
|
sendTokenToSW(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuthToken() {
|
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
|
||||||
|
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
|
||||||
@@ -48,7 +91,19 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
|
|||||||
return res?.data ?? null
|
return res?.data ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function preloadStream(trackSlug: string) {
|
export type RecentPlay = {
|
||||||
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
|
track_slug: string
|
||||||
|
track_title: string
|
||||||
|
artist_name: string
|
||||||
|
album_slug: string | null
|
||||||
|
played_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRecentPlays(): Promise<RecentPlay[] | null> {
|
||||||
|
const res = await furumiApi.get<RecentPlay[]>('/me/recent').catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordPlay(trackSlug: string): Promise<void> {
|
||||||
|
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { store } from './store'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8085',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ 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',
|
||||||
},
|
},
|
||||||
|
routes: {
|
||||||
|
callback: process.env.OIDC_REDIRECT_URL
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
|
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
|
||||||
@@ -74,7 +77,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 +88,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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ axum = { version = "0.7", features = ["tokio", "macros"] }
|
|||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||||
tokio = { version = "1.50", features = ["full"] }
|
tokio = { version = "1.50", features = ["full"] }
|
||||||
tower = { version = "0.4", features = ["util"] }
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
|||||||
@@ -82,6 +82,82 @@ pub struct SearchResult {
|
|||||||
pub detail: Option<String>, // artist name for albums/tracks
|
pub detail: Option<String>, // artist name for albums/tracks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- User management ---
|
||||||
|
|
||||||
|
pub async fn upsert_user(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: &str,
|
||||||
|
username: &str,
|
||||||
|
display_name: Option<&str>,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"INSERT INTO users (id, username, display_name, email, last_seen_at)
|
||||||
|
VALUES ($1, $2, $3, $4, NOW())
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
last_seen_at = NOW()"#
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(username)
|
||||||
|
.bind(display_name)
|
||||||
|
.bind(email)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_play_event(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: &str,
|
||||||
|
track_slug: &str,
|
||||||
|
) -> Result<bool, sqlx::Error> {
|
||||||
|
let result = sqlx::query(
|
||||||
|
r#"INSERT INTO play_events (user_id, track_id, played_at)
|
||||||
|
SELECT $1, t.id, NOW()
|
||||||
|
FROM tracks t WHERE t.slug = $2"#
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(track_slug)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||||
|
pub struct RecentPlay {
|
||||||
|
pub track_slug: String,
|
||||||
|
pub track_title: String,
|
||||||
|
pub artist_name: String,
|
||||||
|
pub album_slug: Option<String>,
|
||||||
|
pub played_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn recent_plays(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: &str,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<RecentPlay>, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, RecentPlay>(
|
||||||
|
r#"SELECT t.slug AS track_slug, t.title AS track_title,
|
||||||
|
ar.name AS artist_name, al.slug AS album_slug,
|
||||||
|
pe.played_at
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON pe.track_id = t.id
|
||||||
|
JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
ORDER BY pe.played_at DESC
|
||||||
|
LIMIT $2"#
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
|
use axum::Extension;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
|
use super::auth::AuthUser;
|
||||||
|
|
||||||
type S = Arc<AppState>;
|
type S = Arc<AppState>;
|
||||||
|
|
||||||
@@ -291,6 +293,30 @@ pub async fn search(State(state): State<S>, Query(q): Query<SearchQuery>) -> imp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Play tracking ---
|
||||||
|
|
||||||
|
pub async fn recent_plays(
|
||||||
|
State(state): State<S>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match db::recent_plays(&state.pool, &user.id, 50).await {
|
||||||
|
Ok(plays) => (StatusCode::OK, Json(serde_json::to_value(plays).unwrap())).into_response(),
|
||||||
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn record_play(
|
||||||
|
State(state): State<S>,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
Extension(user): Extension<AuthUser>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
match db::record_play_event(&state.pool, &user.id, &slug).await {
|
||||||
|
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Ok(false) => error_json(StatusCode::NOT_FOUND, "track not found"),
|
||||||
|
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|
||||||
fn error_json(status: StatusCode, message: &str) -> Response {
|
fn error_json(status: StatusCode, message: &str) -> Response {
|
||||||
|
|||||||
@@ -109,12 +109,23 @@ impl OidcState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthUser {
|
||||||
|
pub id: String,
|
||||||
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct BearerClaims {
|
struct BearerClaims {
|
||||||
sub: String,
|
sub: String,
|
||||||
|
preferred_username: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String> {
|
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<AuthUser> {
|
||||||
let header = decode_header(token).ok()?;
|
let header = decode_header(token).ok()?;
|
||||||
let kid = header.kid.as_ref()?;
|
let kid = header.kid.as_ref()?;
|
||||||
|
|
||||||
@@ -134,7 +145,13 @@ async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String>
|
|||||||
validation.validate_aud = false;
|
validation.validate_aud = false;
|
||||||
|
|
||||||
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
|
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
|
||||||
Some(data.claims.sub)
|
let c = data.claims;
|
||||||
|
Some(AuthUser {
|
||||||
|
id: c.sub.clone(),
|
||||||
|
username: c.preferred_username.unwrap_or(c.sub),
|
||||||
|
display_name: c.name,
|
||||||
|
email: c.email,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
|
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
|
||||||
@@ -164,11 +181,14 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Auth middleware: requires valid Bearer JWT or SSO session cookie.
|
/// Auth middleware: requires valid Bearer JWT or SSO session cookie.
|
||||||
|
/// Inserts AuthUser into request extensions and upserts user in DB.
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
let mut auth_user: Option<AuthUser> = None;
|
||||||
|
|
||||||
// 1. Check Bearer token — JWT from OIDC provider
|
// 1. Check Bearer token — JWT from OIDC provider
|
||||||
if let Some(ref oidc) = state.oidc {
|
if let Some(ref oidc) = state.oidc {
|
||||||
if let Some(token) = req
|
if let Some(token) = req
|
||||||
@@ -177,32 +197,54 @@ pub async fn require_auth(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
{
|
{
|
||||||
if let Some(user_id) = validate_bearer_token(oidc, token).await {
|
auth_user = validate_bearer_token(oidc, token).await;
|
||||||
tracing::debug!("Bearer auth OK for user: {}", user_id);
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check SSO session cookie (if OIDC configured)
|
// 2. Check SSO session cookie (if OIDC configured)
|
||||||
if let Some(ref oidc) = state.oidc {
|
if auth_user.is_none() {
|
||||||
let cookies = req
|
if let Some(ref oidc) = state.oidc {
|
||||||
.headers()
|
let cookies = req
|
||||||
.get(header::COOKIE)
|
.headers()
|
||||||
.and_then(|v| v.to_str().ok())
|
.get(header::COOKIE)
|
||||||
.unwrap_or("");
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
for c in cookies.split(';') {
|
for c in cookies.split(';') {
|
||||||
let c = c.trim();
|
let c = c.trim();
|
||||||
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||||
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
|
if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) {
|
||||||
return next.run(req).await;
|
auth_user = Some(AuthUser {
|
||||||
|
id: user_id.clone(),
|
||||||
|
username: user_id,
|
||||||
|
display_name: None,
|
||||||
|
email: None,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
match auth_user {
|
||||||
|
Some(user) => {
|
||||||
|
tracing::debug!("Auth OK for user: {}", user.username);
|
||||||
|
// Upsert user in background
|
||||||
|
let pool = state.pool.clone();
|
||||||
|
let u = user.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = crate::db::upsert_user(
|
||||||
|
&pool, &u.id, &u.username, u.display_name.as_deref(), u.email.as_deref(),
|
||||||
|
).await {
|
||||||
|
tracing::warn!("Failed to upsert user: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.extensions_mut().insert(user);
|
||||||
|
next.run(req).await
|
||||||
|
}
|
||||||
|
None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::{Router, routing::get, middleware};
|
use axum::{Router, routing::{get, post}, middleware};
|
||||||
use axum::http::{header, Method};
|
use axum::http::{header, Method};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
@@ -29,7 +29,9 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/tracks/:slug", get(api::get_track_detail))
|
.route("/tracks/:slug", get(api::get_track_detail))
|
||||||
.route("/tracks/:slug/cover", get(api::track_cover))
|
.route("/tracks/:slug/cover", get(api::track_cover))
|
||||||
.route("/stream/:slug", get(api::stream_track))
|
.route("/stream/:slug", get(api::stream_track))
|
||||||
.route("/search", get(api::search));
|
.route("/search", get(api::search))
|
||||||
|
.route("/tracks/:slug/play", post(api::record_play))
|
||||||
|
.route("/me/recent", get(api::recent_plays));
|
||||||
|
|
||||||
let api = Router::new()
|
let api = Router::new()
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
@@ -44,7 +46,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.allow_origin(Any)
|
||||||
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
|
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD])
|
||||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
|
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||||
.max_age(Duration::from_secs(600));
|
.max_age(Duration::from_secs(600));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user