25 Commits

Author SHA1 Message Date
ab 9467737a7c Merge pull request 'fix(node-player): remove unused API_ROOT import from NowPlaying' (#13) from feature/USERS into DEV
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 2m12s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m49s
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m24s
Reviewed-on: #13
2026-04-08 16:55:19 +00:00
Ultradesu 2edc293ee0 fix(node-player): remove unused API_ROOT import from NowPlaying
Publish Web Player Image / build-and-push-image (push) Has been cancelled
Publish Node Player Image / build-and-push-image (push) Has been cancelled
Publish Metadata Agent Image / build-and-push-image (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:54:53 +01:00
ab 5c7f940b7e Merge pull request 'feature/USERS' (#12) from feature/USERS into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 44s
Publish Node Player Image (dev) / build-and-push-image (push) Failing after 27s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m25s
Reviewed-on: #12
2026-04-08 16:51:24 +00:00
Ultradesu 4a39d44211 fix(node-player): use AuthImg component for cover art with Bearer auth
Publish Web Player Image / build-and-push-image (push) Has been cancelled
Publish Node Player Image / build-and-push-image (push) Has been cancelled
Publish Metadata Agent Image / build-and-push-image (push) Has been cancelled
SW doesn't reliably intercept <img> requests (no-cors mode). Use a
thin AuthImg component that loads images via axios (which has the
Bearer token) and displays them as blob URLs. Audio streaming still
works via SW.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:50:19 +01:00
Ultradesu d3aba1152c fix(node-player): use blob for audio stream, keep SW for cover art
<audio> elements with Sec-Fetch-Mode: no-cors are unreliable with
Service Workers across browsers. Revert stream to blob download via
axios (Bearer token works). SW remains for cover art in <img> tags.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:45:54 +01:00
Ultradesu c11b71a0ef fix(node-player): use IndexedDB for SW token instead of postMessage
postMessage is unreliable on first load — SW may not be active yet.
IndexedDB is shared between page and SW, so the token is always
available regardless of SW lifecycle timing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:43:33 +01:00
Ultradesu ea2fc53faf fix(node-player): proxy /api through vite dev server for same-origin SW
Service Worker only intercepts same-origin requests. In dev mode, API
calls went directly to localhost:8085 (cross-origin), bypassing the SW.
Now vite proxies /api to Rust API, keeping everything same-origin so
the SW can inject Bearer tokens for audio/image requests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:40:47 +01:00
Ultradesu d6dd046fad feat(node-player): use Service Worker for auth, enable streaming playback
Add a Service Worker that intercepts /api/* requests and injects the
Bearer token. This allows <audio> and <img> elements to use direct
URLs instead of downloading entire files as blobs first.

- Audio now streams progressively (no full download before playback)
- Cover art loads via regular <img src> (SW adds auth header)
- Remove blob-based preloadStream, fetchCoverBlob, useCoverUrl hook
- Register SW in main.tsx, token synced via postMessage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:30:17 +01:00
ab 3fa79423bd Merge pull request 'feature/USERS' (#11) from feature/USERS into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m28s
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 37s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m53s
Reviewed-on: #11
2026-04-08 16:23:15 +00:00
Ultradesu 6b1aa6b5d5 feat: add recent plays history modal
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m23s
Publish Node Player Image / build-and-push-image (push) Successful in 37s
Publish Web Player Image / build-and-push-image (push) Successful in 1m49s
- GET /api/me/recent endpoint returning last 50 play events with track
  and artist info
- RecentPlays modal component with time-ago display
- "Recent plays" button in user dropdown menu
- Clicking a track in history starts playback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:08:54 +01:00
Ultradesu 4fdd56dae4 feat: add user support with play event tracking
Backend (Rust API):
- Add users and play_events tables (migration 0005)
- Extract full user identity from JWT (sub, username, email, name)
  and pass AuthUser via request extensions to all handlers
- Auto-upsert user in background on every authenticated request
- POST /api/tracks/:slug/play endpoint to record play events
- Allow POST method in CORS

Frontend (Node player):
- Call recordPlay() when a track starts playing
- Add user profile avatar with dropdown menu (name, email, sign out)
- Pass user info from App through FurumiPlayer to Header

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:34:39 +01:00
ab 2d7ac3d8ce Fixed openai api endpoint
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 4m0s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 4m23s
2026-04-07 19:52:03 +01:00
ab 70a947a8c1 Fixed openai api endpoint
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 3m38s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 3m49s
2026-04-07 19:32:17 +01:00
XakPlant aea4aef4b2 Merge pull request 'feature/node-app' (#7) from feature/node-app into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m8s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m7s
Reviewed-on: #7
2026-03-23 14:00:08 +00:00
26 changed files with 930 additions and 233 deletions
+1
View File
@@ -2,3 +2,4 @@
/docker/inbox
/docker/storage
.env
.DS_Store
Generated
+54
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -572,6 +578,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
@@ -969,6 +984,16 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
@@ -1017,6 +1042,7 @@ dependencies = [
"chrono",
"clap",
"encoding_rs",
"id3",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -1150,6 +1176,7 @@ dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"chrono",
"clap",
"hmac",
"jsonwebtoken 9.3.1",
@@ -1750,6 +1777,17 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "id3"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
dependencies = [
"bitflags 2.11.0",
"byteorder",
"flate2",
]
[[package]]
name = "ident_case"
version = "1.0.1"
@@ -2038,6 +2076,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -3429,6 +3477,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simple_asn1"
version = "0.6.4"
+1
View File
@@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
id3 = "1"
thiserror = "2.0"
tokio = { version = "1.50", features = ["full"] }
tracing = "0.1"
@@ -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);
+36 -1
View File
@@ -19,9 +19,25 @@ pub struct RawMetadata {
pub duration_secs: Option<f64>,
}
/// Extract metadata from an audio file using Symphonia.
/// Extract metadata from an audio file.
/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file
/// (e.g., ID3 tag with large embedded cover art exceeds Symphonia's 1 MB probe limit).
/// Must be called from a blocking context (spawn_blocking).
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
match extract_via_symphonia(path) {
Ok(meta) => return Ok(meta),
Err(e) => {
let is_mp3 = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("mp3")).unwrap_or(false);
if is_mp3 {
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
return extract_mp3_via_id3(path);
}
return Err(e);
}
}
}
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
let file = std::fs::File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -66,6 +82,25 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
Ok(meta)
}
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
use id3::TagLike;
let tag = id3::Tag::read_from_path(path)
.map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
let mut meta = RawMetadata::default();
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
meta.track_number = tag.track();
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
// duration_secs remains None — acceptable for large-cover files
Ok(meta)
}
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
for tag in tags {
let value = fix_encoding(tag.value.to_string());
+2 -1
View File
@@ -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=
+45
View File
@@ -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()))
+79 -48
View File
@@ -1,71 +1,102 @@
.page {
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
.auth-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #0a0c12;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
}
.card {
width: min(520px, 100%);
border: 1px solid #d8dde6;
border-radius: 14px;
padding: 24px;
background-color: #ffffff;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
}
/* ---------- loading spinner ---------- */
.subtitle {
margin-top: 0;
margin-bottom: 20px;
color: #5a6475;
}
.settings {
margin-bottom: 16px;
padding: 12px;
border: 1px solid #e6eaf2;
border-radius: 10px;
background: #f8fafc;
}
.toggle {
.auth-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: #0f172a;
font-weight: 600;
gap: 20px;
}
.toggle input {
width: 18px;
height: 18px;
.spinner {
width: 36px;
height: 36px;
border: 3px solid #1f2c45;
border-top-color: #7c6af7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.hint {
margin: 10px 0 0;
color: #5a6475;
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn {
display: inline-block;
text-decoration: none;
background: #2251ff;
color: #ffffff;
padding: 10px 16px;
.auth-loading .logo {
font-size: 1.6rem;
font-weight: 700;
color: #7c6af7;
}
.auth-loading p {
color: #64748b;
font-size: 0.85rem;
}
/* ---------- login card ---------- */
.auth-card {
width: min(380px, 100%);
background: #111520;
border: 1px solid #1f2c45;
border-radius: 16px;
padding: 2.5rem 2rem;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-card .logo {
font-size: 1.8rem;
font-weight: 700;
color: #7c6af7;
margin-bottom: 4px;
}
.auth-card .subtitle {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 2rem;
}
.auth-card .btn-login {
display: block;
width: 100%;
padding: 0.75rem;
text-align: center;
background: #7c6af7;
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background 0.2s;
}
.btn.ghost {
background: #edf1ff;
color: #1e3fc4;
margin-top: 10px;
.auth-card .btn-login:hover {
background: #6b58e8;
}
.profile p {
margin: 8px 0;
.auth-card .error {
color: #f87171;
font-size: 0.85rem;
margin-bottom: 1rem;
}
.error {
color: #cc1e1e;
}
+28 -95
View File
@@ -9,35 +9,15 @@ type UserProfile = {
email?: string
}
const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth'
function App() {
const [loading, setLoading] = useState(true)
const [user, setUser] = useState<UserProfile | null>(null)
const [error, setError] = useState<string | null>(null)
const [runWithoutAuth, setRunWithoutAuth] = useState(() => {
try {
return window.localStorage.getItem(NO_AUTH_STORAGE_KEY) === '1'
} catch {
return false
}
})
const apiBase = ''
useEffect(() => {
if (runWithoutAuth) {
setError(null)
setUser({ sub: 'noauth', name: 'No Auth' })
setLoading(false)
return
}
const loadMe = async () => {
try {
const response = await fetch(`${apiBase}/auth/me`, {
credentials: 'include',
})
const response = await fetch('/auth/me', { credentials: 'include' })
if (response.status === 401) {
setUser(null)
@@ -52,12 +32,9 @@ function App() {
const data = await response.json()
setUser(data.user ?? null)
// Fetch OIDC access token for Rust API Bearer auth
if (data.user) {
try {
const tokenRes = await fetch(`${apiBase}/auth/token`, {
credentials: 'include',
})
const tokenRes = await fetch('/auth/token', { credentials: 'include' })
if (tokenRes.ok) {
const tokenData = await tokenRes.json()
if (tokenData.access_token) {
@@ -65,7 +42,7 @@ function App() {
}
}
} catch {
// Token fetch failed — API calls will fall back to other auth methods
// Token fetch failed
}
}
} catch (err) {
@@ -76,84 +53,40 @@ function App() {
}
void loadMe()
}, [runWithoutAuth])
}, [])
const loginUrl = `${apiBase}/auth/login`
const logoutUrl = `${apiBase}/auth/logout`
return (
<>
{!loading && (user || runWithoutAuth) ? (
<FurumiPlayer />
) : (
<main className="page">
<section className="card">
<h1>OIDC Login</h1>
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
<div className="settings">
<label className="toggle">
<input
type="checkbox"
checked={runWithoutAuth}
onChange={(e) => {
const next = e.target.checked
setRunWithoutAuth(next)
try {
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
// ignore
// Authenticated — render player immediately
if (!loading && user) {
return <FurumiPlayer user={user} />
}
setLoading(true)
setUser(null)
}}
/>
<span>Запускать без авторизации</span>
</label>
// 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>
)
}
{loading && <p>Проверяю сессию...</p>}
{error && <p className="error">Ошибка: {error}</p>}
// Not authenticated — show login
return (
<main className="auth-page">
<section className="auth-card">
<div className="logo">Furumi</div>
<p className="subtitle">Sign in to continue</p>
{!loading && runWithoutAuth && (
<p className="hint">
Режим без авторизации включён. Для входа отключи настройку выше.
</p>
)}
{error && <p className="error">{error}</p>}
{!loading && !user && (
<a className="btn" href={loginUrl}>
Войти через OIDC
<a className="btn-login" href="/auth/login">
Sign in with SSO
</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>
)}
</>
)
}
+19 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css'
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
import { furumiApi, searchTracks, recordPlay } from './furumiApi'
import { store, useAppDispatch, useAppSelector } from './store'
import { fetchArtists } from './store/slices/artistsSlice'
import { fetchArtistAlbums } from './store/slices/albumsSlice'
@@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar'
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 artistsLoading = useAppSelector((s) => s.artists.loading)
const artistsError = useAppSelector((s) => s.artists.error)
@@ -80,16 +86,19 @@ export function FurumiPlayer() {
return
}
document.title = `${nowPlayingTrack.title} — Furumi`
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
if ('mediaSession' in navigator) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
navigator.mediaSession.metadata = new window.MediaMetadata({
const meta = new window.MediaMetadata({
title: nowPlayingTrack.title,
artist: nowPlayingTrack.artist || '',
album: '',
artwork: [{ src: coverUrl, sizes: '512x512' }],
})
navigator.mediaSession.metadata = meta
furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' })
.then((res) => {
meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }]
})
.catch(() => {})
} catch {
// ignore
}
@@ -292,6 +301,7 @@ export function FurumiPlayer() {
dispatch(playAtIndex(i))
const track = store.getState().queue.items[i]
void playback.loadStreamForTrack(track.slug)
void recordPlay(track.slug)
if (window.history && window.history.replaceState) {
const url = new URL(window.location.href)
url.searchParams.set('t', track.slug)
@@ -384,7 +394,7 @@ export function FurumiPlayer() {
{ slug, title: '', artist: '', album_slug: null, duration: null },
true,
)
void preloadStream(slug)
void playback.loadStreamForTrack(slug)
}
}
searchSelectRef.current = onSearchSelect
@@ -512,6 +522,8 @@ export function FurumiPlayer() {
searchOpen={searchOpen}
searchResults={searchResults}
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
onPlayTrack={(slug) => searchSelectRef.current('track', slug)}
user={user}
/>
<MainPanel
@@ -1,4 +1,4 @@
import { preloadStream } from './furumiApi'
import { furumiApi } from './furumiApi'
import { fmt } from './utils'
const MAX_PLAYBACK_ERROR_SKIPS = 5
@@ -109,9 +109,13 @@ export function attachAudioPlayback(
volSlider?.addEventListener('input', onVolInput)
async function loadStreamForTrack(slug: string) {
const response = await preloadStream(slug)
audio.src = URL.createObjectURL(response?.data)
try {
const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' })
audio.src = URL.createObjectURL(res.data)
await audio.play().catch(() => { })
} catch {
// stream failed
}
}
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 { API_ROOT } from '../furumiApi'
import { useState } from 'react'
import { AuthImg } from './AuthImg'
import type { QueueItem } from './QueueList'
function Cover({ src }: { src: string }) {
function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
const src = `/tracks/${slug}/cover`
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
return <AuthImg src={src} alt="" onError={() => setErrored(true)} />
}
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 (
<div className="np-info">
<div className="np-cover" id="npCover">
<Cover src={coverUrl} />
<Cover slug={track.slug} />
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
@@ -50,4 +45,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
</div>
)
}
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { API_ROOT } from '../furumiApi'
import { AuthImg } from './AuthImg'
export type QueueItem = {
slug: string
@@ -32,14 +32,10 @@ function fmt(secs: number) {
return `${m}:${pad(s % 60)}`
}
function Cover({ src }: { src: string }) {
function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
}
export function QueueList({
@@ -77,7 +73,7 @@ export function QueueList({
if (!t) return null
const isPlaying = origIdx === playingOrigIdx
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
const hasAlbum = !!t.album_slug
const dur = t.duration ? fmt(t.duration) : ''
const isDragging = draggingPos === pos
const isDragOver = dragOverPos === pos
@@ -118,7 +114,7 @@ export function QueueList({
>
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
<div className="qi-cover">
{coverSrc ? <Cover src={coverSrc} /> : <>&#127925;</>}
{hasAlbum ? <Cover slug={t.slug} /> : <>&#127925;</>}
</div>
<div className="qi-info">
<div className="qi-title">{t.title}</div>
@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { SearchDropdown } from '../SearchDropdown'
import { RecentPlays } from './RecentPlays'
import styles from './header.module.css'
type SearchResultItem = {
@@ -8,18 +10,71 @@ type SearchResultItem = {
detail?: string
}
type UserInfo = {
sub: string
name?: string
email?: string
}
type HeaderProps = {
searchOpen: boolean
searchResults: SearchResultItem[]
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({
searchOpen,
searchResults,
onSearchSelect,
onPlayTrack,
user,
}: HeaderProps) {
const [showRecent, setShowRecent] = useState(false)
return (
<>
<header className={styles.header}>
<div className={styles.headerLogo}>
<button className="btn-menu">&#9776;</button>
@@ -40,7 +95,15 @@ export function Header({
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}>&#10005;</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>
)
}
@@ -33,3 +33,202 @@
font-weight: 500;
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;
}
+57 -2
View File
@@ -8,14 +8,57 @@ export const furumiApi = axios.create({
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) {
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
sendTokenToSW(token)
}
export function clearAuthToken() {
delete furumiApi.defaults.headers.common['Authorization']
}
async function refreshToken(): Promise<boolean> {
try {
const res = await fetch('/auth/token', { credentials: 'include' })
if (!res.ok) return false
const data = await res.json()
if (data.access_token) {
setAuthToken(data.access_token)
return true
}
} catch { /* ignore */ }
return false
}
let refreshPromise: Promise<boolean> | null = null
furumiApi.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config
if (error.response?.status === 401 && !original._retried) {
original._retried = true
if (!refreshPromise) {
refreshPromise = refreshToken().finally(() => { refreshPromise = null })
}
const ok = await refreshPromise
if (ok) return furumiApi(original)
}
return Promise.reject(error)
},
)
export async function getArtists(): Promise<Artist[] | null> {
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
return res?.data ?? null
@@ -48,7 +91,19 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
return res?.data ?? null
}
export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
export type RecentPlay = {
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)
}
+4
View File
@@ -5,6 +5,10 @@ import { store } from './store'
import './index.css'
import App from './App.tsx'
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
+4
View File
@@ -14,6 +14,10 @@ export default defineConfig({
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8085',
changeOrigin: true,
},
},
},
})
+17 -7
View File
@@ -24,7 +24,7 @@ const oidcConfig = {
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
authorizationParams: {
response_type: 'code',
scope: process.env.OIDC_SCOPE ?? 'openid profile email',
scope: process.env.OIDC_SCOPE ?? 'openid profile email offline_access',
},
};
@@ -74,7 +74,7 @@ app.get('/auth/me', (req, res) => {
});
});
app.get('/auth/token', (req, res) => {
app.get('/auth/token', async (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
@@ -85,17 +85,27 @@ app.get('/auth/token', (req, res) => {
return;
}
const accessToken = req.oidc.accessToken?.access_token;
const expiresAt = req.oidc.accessToken?.expires_in;
if (!accessToken) {
let accessToken = req.oidc.accessToken;
if (!accessToken?.access_token) {
res.status(500).json({ error: 'no access token in session' });
return;
}
// Refresh if expired
if (accessToken.isExpired()) {
try {
accessToken = await accessToken.refresh();
} catch (e) {
console.error('Token refresh failed:', e);
res.status(401).json({ error: 'token refresh failed' });
return;
}
}
res.json({
access_token: accessToken,
access_token: accessToken.access_token,
token_type: 'Bearer',
expires_in: expiresAt,
expires_in: accessToken.expires_in,
});
});
+1
View File
@@ -9,6 +9,7 @@ axum = { version = "0.7", features = ["tokio", "macros"] }
clap = { version = "4.5", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
tokio = { version = "1.50", features = ["full"] }
tower = { version = "0.4", features = ["util"] }
+76
View File
@@ -82,6 +82,82 @@ pub struct SearchResult {
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 ---
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
+26
View File
@@ -9,8 +9,10 @@ use axum::{
use serde::Deserialize;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
use axum::Extension;
use crate::db;
use super::AppState;
use super::auth::AuthUser;
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 ---
fn error_json(status: StatusCode, message: &str) -> Response {
+52 -10
View File
@@ -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)]
struct BearerClaims {
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 kid = header.kid.as_ref()?;
@@ -134,7 +145,13 @@ async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String>
validation.validate_aud = false;
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 {
@@ -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.
/// Inserts AuthUser into request extensions and upserts user in DB.
pub async fn require_auth(
State(state): State<Arc<AppState>>,
req: Request,
mut req: Request,
next: Next,
) -> Response {
let mut auth_user: Option<AuthUser> = None;
// 1. Check Bearer token — JWT from OIDC provider
if let Some(ref oidc) = state.oidc {
if let Some(token) = req
@@ -177,14 +197,12 @@ pub async fn require_auth(
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
if let Some(user_id) = validate_bearer_token(oidc, token).await {
tracing::debug!("Bearer auth OK for user: {}", user_id);
return next.run(req).await;
}
auth_user = validate_bearer_token(oidc, token).await;
}
}
// 2. Check SSO session cookie (if OIDC configured)
if auth_user.is_none() {
if let Some(ref oidc) = state.oidc {
let cookies = req
.headers()
@@ -195,14 +213,38 @@ pub async fn require_auth(
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await;
if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) {
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)]
+5 -3
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use std::path::PathBuf;
use std::time::Duration;
use axum::{Router, routing::get, middleware};
use axum::{Router, routing::{get, post}, middleware};
use axum::http::{header, Method};
use sqlx::PgPool;
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/cover", get(api::track_cover))
.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()
.nest("/api", library);
@@ -44,7 +46,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
let cors = CorsLayer::new()
.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])
.max_age(Duration::from_secs(600));