From 70a947a8c161431cdc66a0ef015d957f3c60349a Mon Sep 17 00:00:00 2001 From: AB-UK Date: Tue, 7 Apr 2026 19:32:17 +0100 Subject: [PATCH 01/21] Fixed openai api endpoint --- Cargo.lock | 54 ++++++++++++++++++++++++ furumi-agent/Cargo.toml | 1 + furumi-agent/src/ingest/metadata.rs | 37 ++++++++++++++++- furumi-agent/src/ingest/normalize.rs | 61 +++++++++++++++++----------- 4 files changed, 128 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9cb8bb9..d733eb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -1165,6 +1191,7 @@ dependencies = [ "tokio", "tokio-util", "tower 0.4.13", + "tower-http", "tracing", "tracing-subscriber", "urlencoding", @@ -1748,6 +1775,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" @@ -2021,6 +2059,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" @@ -3412,6 +3460,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" diff --git a/furumi-agent/Cargo.toml b/furumi-agent/Cargo.toml index 1163d95..f4c97ea 100644 --- a/furumi-agent/Cargo.toml +++ b/furumi-agent/Cargo.toml @@ -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" diff --git a/furumi-agent/src/ingest/metadata.rs b/furumi-agent/src/ingest/metadata.rs index 99a3371..8331181 100644 --- a/furumi-agent/src/ingest/metadata.rs +++ b/furumi-agent/src/ingest/metadata.rs @@ -19,9 +19,25 @@ pub struct RawMetadata { pub duration_secs: Option, } -/// 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 { + 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 { 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 { Ok(meta) } +/// Read MP3 tags via the `id3` crate. Duration is not available this way. +fn extract_mp3_via_id3(path: &Path) -> anyhow::Result { + 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()); diff --git a/furumi-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index bd8020c..1de02a1 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -113,32 +113,38 @@ fn build_user_message( } #[derive(Serialize)] -struct OllamaRequest { +struct ChatRequest { model: String, - messages: Vec, - format: String, + messages: Vec, + response_format: ChatResponseFormat, stream: bool, - options: OllamaOptions, + temperature: f64, } #[derive(Serialize)] -struct OllamaMessage { +struct ChatMessage { role: String, content: String, } #[derive(Serialize)] -struct OllamaOptions { - temperature: f64, +struct ChatResponseFormat { + #[serde(rename = "type")] + kind: String, } #[derive(Deserialize)] -struct OllamaResponse { - message: OllamaResponseMessage, +struct ChatResponse { + choices: Vec, } #[derive(Deserialize)] -struct OllamaResponseMessage { +struct ChatChoice { + message: ChatResponseMessage, +} + +#[derive(Deserialize)] +struct ChatResponseMessage { content: String, } @@ -153,25 +159,25 @@ pub async fn call_ollama( .timeout(std::time::Duration::from_secs(120)) .build()?; - let request = OllamaRequest { + let request = ChatRequest { model: model.to_owned(), messages: vec![ - OllamaMessage { + ChatMessage { role: "system".to_owned(), content: system_prompt.to_owned(), }, - OllamaMessage { + ChatMessage { role: "user".to_owned(), content: user_message.to_owned(), }, ], - format: "json".to_owned(), + response_format: ChatResponseFormat { kind: "json_object".to_owned() }, stream: false, - options: OllamaOptions { temperature: 0.1 }, + temperature: 0.1, }; - let url = format!("{}/api/chat", base_url.trim_end_matches('/')); - tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API..."); + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API..."); let start = std::time::Instant::now(); let mut req = client.post(&url).json(&request); @@ -184,18 +190,25 @@ pub async fn call_ollama( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error"); - anyhow::bail!("Ollama returned {}: {}", status, body); + tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error"); + anyhow::bail!("LLM returned {}: {}", status, body); } - let ollama_resp: OllamaResponse = resp.json().await?; + let chat_resp: ChatResponse = resp.json().await?; + let content = chat_resp + .choices + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))? + .message + .content; tracing::info!( elapsed_ms = elapsed.as_millis() as u64, - response_len = ollama_resp.message.content.len(), - "Ollama response received" + response_len = content.len(), + "LLM response received" ); - tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output"); - Ok(ollama_resp.message.content) + tracing::debug!(raw_response = %content, "LLM raw output"); + Ok(content) } /// Parse the LLM JSON response into NormalizedFields. From 2d7ac3d8cefb719173a9be5f9a2a17440ede96a4 Mon Sep 17 00:00:00 2001 From: AB-UK Date: Tue, 7 Apr 2026 19:52:03 +0100 Subject: [PATCH 02/21] Fixed openai api endpoint --- Cargo.lock | 1 - furumi-agent/src/ingest/normalize.rs | 33 +++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d733eb8..1a2b982 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1191,7 +1191,6 @@ dependencies = [ "tokio", "tokio-util", "tower 0.4.13", - "tower-http", "tracing", "tracing-subscriber", "urlencoding", diff --git a/furumi-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index 1de02a1..541fb6f 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -131,6 +131,14 @@ struct ChatMessage { struct ChatResponseFormat { #[serde(rename = "type")] kind: String, + json_schema: JsonSchemaWrapper, +} + +#[derive(Serialize)] +struct JsonSchemaWrapper { + name: String, + strict: bool, + schema: serde_json::Value, } #[derive(Deserialize)] @@ -171,7 +179,30 @@ pub async fn call_ollama( content: user_message.to_owned(), }, ], - response_format: ChatResponseFormat { kind: "json_object".to_owned() }, + response_format: ChatResponseFormat { + kind: "json_schema".to_owned(), + json_schema: JsonSchemaWrapper { + name: "normalized_metadata".to_owned(), + strict: true, + schema: serde_json::json!({ + "type": "object", + "properties": { + "artist": { "type": ["string", "null"] }, + "album": { "type": ["string", "null"] }, + "title": { "type": ["string", "null"] }, + "year": { "type": ["integer", "null"] }, + "track_number": { "type": ["integer", "null"] }, + "genre": { "type": ["string", "null"] }, + "featured_artists": { "type": "array", "items": { "type": "string" } }, + "release_type": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"] }, + "notes": { "type": ["string", "null"] } + }, + "required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_type", "confidence", "notes"], + "additionalProperties": false + }), + }, + }, stream: false, temperature: 0.1, }; From 1e75644abb1db57beb97c6a97e0a023706ced498 Mon Sep 17 00:00:00 2001 From: AB-UK Date: Tue, 7 Apr 2026 22:34:39 +0100 Subject: [PATCH 03/21] feat(agent): switch LLM client from Ollama to OpenAI-compatible API (LM Studio support) - 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) --- Cargo.lock | 1 + furumi-agent/src/ingest/normalize.rs | 81 +++++++++++++++++++--------- furumi-agent/src/merge.rs | 26 +++++++++ 3 files changed, 82 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a2b982..d733eb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1191,6 +1191,7 @@ dependencies = [ "tokio", "tokio-util", "tower 0.4.13", + "tower-http", "tracing", "tracing-subscriber", "urlencoding", diff --git a/furumi-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index 541fb6f..087bab1 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -25,16 +25,35 @@ pub async fn normalize( ) -> anyhow::Result { let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx); + let schema = normalize_schema(); let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.system_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + Some(("normalized_metadata", schema.clone())), ) .await?; - parse_response(&response) + match parse_response(&response) { + Ok(fields) => Ok(fields), + Err(e) => { + tracing::warn!(error = %e, "LLM parse failed, retrying with higher frequency_penalty"); + let response2 = call_ollama( + &state.config.ollama_url, + &state.config.ollama_model, + &state.system_prompt, + &user_message, + state.config.ollama_auth.as_deref(), + 1.5, + Some(("normalized_metadata", schema)), + ) + .await?; + parse_response(&response2) + } + } } fn build_user_message( @@ -116,9 +135,12 @@ fn build_user_message( struct ChatRequest { model: String, messages: Vec, - response_format: ChatResponseFormat, + #[serde(skip_serializing_if = "Option::is_none")] + response_format: Option, stream: bool, temperature: f64, + max_tokens: u32, + frequency_penalty: f64, } #[derive(Serialize)] @@ -162,11 +184,18 @@ pub async fn call_ollama( system_prompt: &str, user_message: &str, auth: Option<&str>, + frequency_penalty: f64, + schema: Option<(&str, serde_json::Value)>, ) -> anyhow::Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(120)) .build()?; + let response_format = schema.map(|(name, schema)| ChatResponseFormat { + kind: "json_schema".to_owned(), + json_schema: JsonSchemaWrapper { name: name.to_owned(), strict: true, schema }, + }); + let request = ChatRequest { model: model.to_owned(), messages: vec![ @@ -179,32 +208,11 @@ pub async fn call_ollama( content: user_message.to_owned(), }, ], - response_format: ChatResponseFormat { - kind: "json_schema".to_owned(), - json_schema: JsonSchemaWrapper { - name: "normalized_metadata".to_owned(), - strict: true, - schema: serde_json::json!({ - "type": "object", - "properties": { - "artist": { "type": ["string", "null"] }, - "album": { "type": ["string", "null"] }, - "title": { "type": ["string", "null"] }, - "year": { "type": ["integer", "null"] }, - "track_number": { "type": ["integer", "null"] }, - "genre": { "type": ["string", "null"] }, - "featured_artists": { "type": "array", "items": { "type": "string" } }, - "release_type": { "type": ["string", "null"] }, - "confidence": { "type": ["number", "null"] }, - "notes": { "type": ["string", "null"] } - }, - "required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_type", "confidence", "notes"], - "additionalProperties": false - }), - }, - }, + response_format, stream: false, temperature: 0.1, + max_tokens: 512, + frequency_penalty, }; let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); @@ -242,6 +250,26 @@ pub async fn call_ollama( Ok(content) } +fn normalize_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "artist": { "type": ["string", "null"] }, + "album": { "type": ["string", "null"] }, + "title": { "type": ["string", "null"] }, + "year": { "type": ["integer", "null"] }, + "track_number": { "type": ["integer", "null"] }, + "genre": { "type": ["string", "null"] }, + "featured_artists": { "type": "array", "items": { "type": "string" } }, + "release_kind": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"] }, + "notes": { "type": ["string", "null"] } + }, + "required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_kind", "confidence", "notes"], + "additionalProperties": false + }) +} + /// Parse the LLM JSON response into NormalizedFields. /// Handles both clean JSON and JSON wrapped in markdown code fences. fn parse_response(response: &str) -> anyhow::Result { @@ -266,6 +294,7 @@ fn parse_response(response: &str) -> anyhow::Result { genre: Option, #[serde(default)] featured_artists: Vec, + #[serde(rename = "release_kind")] release_type: Option, confidence: Option, notes: Option, diff --git a/furumi-agent/src/merge.rs b/furumi-agent/src/merge.rs index 25f6e5b..3ab95a7 100644 --- a/furumi-agent/src/merge.rs +++ b/furumi-agent/src/merge.rs @@ -35,12 +35,38 @@ pub async fn propose_merge(state: &Arc, merge_id: Uuid) -> anyhow::Res let user_message = build_merge_message(&artists_data); + let schema = serde_json::json!({ + "type": "object", + "properties": { + "canonical_artist_name": { "type": "string" }, + "winner_artist_id": { "type": "integer" }, + "album_mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_album_id": { "type": "integer" }, + "canonical_name": { "type": "string" }, + "merge_into_album_id": { "type": ["integer", "null"] } + }, + "required": ["source_album_id", "canonical_name", "merge_into_album_id"], + "additionalProperties": false + } + }, + "notes": { "type": "string" } + }, + "required": ["canonical_artist_name", "winner_artist_id", "album_mappings", "notes"], + "additionalProperties": false + }); + let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.merge_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + Some(("artist_merge", schema)), ).await?; let proposal = parse_merge_response(&response)?; From 48c473de5678dc4d508dc754e0d557b9b3cc96f7 Mon Sep 17 00:00:00 2001 From: AB-UK Date: Tue, 7 Apr 2026 22:52:23 +0100 Subject: [PATCH 04/21] fix(agent): increase max_tokens for merge requests to avoid truncated responses 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) --- furumi-agent/src/ingest/normalize.rs | 5 ++++- furumi-agent/src/merge.rs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/furumi-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index 087bab1..cc19bce 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -33,6 +33,7 @@ pub async fn normalize( &user_message, state.config.ollama_auth.as_deref(), 0.5, + 512, Some(("normalized_metadata", schema.clone())), ) .await?; @@ -48,6 +49,7 @@ pub async fn normalize( &user_message, state.config.ollama_auth.as_deref(), 1.5, + 512, Some(("normalized_metadata", schema)), ) .await?; @@ -185,6 +187,7 @@ pub async fn call_ollama( user_message: &str, auth: Option<&str>, frequency_penalty: f64, + max_tokens: u32, schema: Option<(&str, serde_json::Value)>, ) -> anyhow::Result { let client = reqwest::Client::builder() @@ -211,7 +214,7 @@ pub async fn call_ollama( response_format, stream: false, temperature: 0.1, - max_tokens: 512, + max_tokens, frequency_penalty, }; diff --git a/furumi-agent/src/merge.rs b/furumi-agent/src/merge.rs index 3ab95a7..8c7f83c 100644 --- a/furumi-agent/src/merge.rs +++ b/furumi-agent/src/merge.rs @@ -66,6 +66,7 @@ pub async fn propose_merge(state: &Arc, merge_id: Uuid) -> anyhow::Res &user_message, state.config.ollama_auth.as_deref(), 0.5, + 4096, Some(("artist_merge", schema)), ).await?; From e99cacae8b4be31a85ed941611780fb48ef27651 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 14:51:52 +0100 Subject: [PATCH 05/21] feat(auth): replace cookie/api-key auth with JWT Bearer tokens, separate UI from API - Add JWT Bearer token validation to Rust API via OIDC provider JWKS with automatic key rotation and 1-hour cache - Remove x-api-key auth support and built-in web UI from furumi-web-player, leaving it as a pure API server - Add /auth/token endpoint to Node player server to expose OIDC access tokens to the frontend - Move Node player auth endpoints from /api/* to /auth/* to avoid path conflicts with Rust API - Add static file serving to Node Express server for production single-container deployment - Fix SameSite=Strict cookie issue breaking OIDC redirect flow (use Lax) - Add Dockerfile.node-player with multi-stage Node.js build - Add CI workflows for node-player Docker image (dev + release) - Optimize Rust Dockerfiles with dependency caching layer - Update docker-compose with OIDC env vars and OLLAMA_MODEL support - Cherry-pick agent LLM client fixes from DEV branch Co-Authored-By: Claude Opus 4.6 (1M context) --- .../docker-publish-node-player-dev.yml | 40 +++++ .../workflows/docker-publish-node-player.yml | 57 ++++++ .gitignore | 4 +- Cargo.lock | 19 +- docker/Dockerfile.agent | 27 +++ docker/Dockerfile.node-player | 38 ++++ docker/Dockerfile.web-player | 27 +++ docker/docker-compose.yml | 16 +- furumi-agent/prompts/normalize.txt | 2 +- furumi-agent/src/ingest/normalize.rs | 126 ++++++++++--- furumi-agent/src/merge.rs | 27 +++ furumi-node-player/client/.env.example | 3 +- furumi-node-player/client/src/App.tsx | 31 +++- furumi-node-player/client/src/furumiApi.ts | 15 +- furumi-node-player/client/vite.config.ts | 6 +- furumi-node-player/server/src/index.ts | 42 ++++- furumi-web-player/Cargo.toml | 3 +- furumi-web-player/src/main.rs | 8 - furumi-web-player/src/web/auth.rs | 166 +++++++++--------- furumi-web-player/src/web/mod.rs | 19 +- 20 files changed, 515 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/docker-publish-node-player-dev.yml create mode 100644 .github/workflows/docker-publish-node-player.yml create mode 100644 docker/Dockerfile.node-player diff --git a/.github/workflows/docker-publish-node-player-dev.yml b/.github/workflows/docker-publish-node-player-dev.yml new file mode 100644 index 0000000..d0ec036 --- /dev/null +++ b/.github/workflows/docker-publish-node-player-dev.yml @@ -0,0 +1,40 @@ +name: Publish Node Player Image (dev) + +on: + push: + branches: + - DEV + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.node-player + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-publish-node-player.yml b/.github/workflows/docker-publish-node-player.yml new file mode 100644 index 0000000..41d2693 --- /dev/null +++ b/.github/workflows/docker-publish-node-player.yml @@ -0,0 +1,57 @@ +name: Publish Node Player Image + +on: + push: + branches: + - '**' + - '!DEV' + tags: + - 'v*.*.*' + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine version and tags + id: info + run: | + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)" + + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + TAG="${{ github.ref_name }}" + VERSION="${TAG#v}" + echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.node-player + push: true + tags: ${{ steps.info.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index eeb9a57..2f3c66b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /target -/inbox -/storage +/docker/inbox +/docker/storage .env diff --git a/Cargo.lock b/Cargo.lock index 9cb8bb9..87c6251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,7 +1114,7 @@ dependencies = [ "futures-core", "futures-util", "hmac", - "jsonwebtoken", + "jsonwebtoken 10.3.0", "libc", "mime_guess", "ogg", @@ -1152,6 +1152,7 @@ dependencies = [ "base64 0.22.1", "clap", "hmac", + "jsonwebtoken 9.3.1", "mime_guess", "openidconnect", "rand 0.8.5", @@ -1165,6 +1166,7 @@ dependencies = [ "tokio", "tokio-util", "tower 0.4.13", + "tower-http", "tracing", "tracing-subscriber", "urlencoding", @@ -1864,6 +1866,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index d0c2da4..ed676b9 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app + +# 1. Copy workspace manifests and lock file (changes rarely → cached layer) +COPY Cargo.toml Cargo.lock ./ +COPY furumi-common/Cargo.toml furumi-common/Cargo.toml +COPY furumi-server/Cargo.toml furumi-server/Cargo.toml +COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml +COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml +COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml +COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml +COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml + +# 2. Create dummy sources so cargo can resolve and build dependencies +RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \ + && mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \ + && mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \ + && mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \ + && mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \ + && mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \ + && mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs + +# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change) +RUN cargo build --release --bin furumi-agent 2>/dev/null || true + +# 4. Copy real source code COPY . . +# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps) +RUN touch furumi-common/src/lib.rs furumi-agent/src/main.rs + ARG FURUMI_VERSION=dev RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent diff --git a/docker/Dockerfile.node-player b/docker/Dockerfile.node-player new file mode 100644 index 0000000..0ad2d3d --- /dev/null +++ b/docker/Dockerfile.node-player @@ -0,0 +1,38 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +# 1. Install server dependencies (cached layer) +COPY furumi-node-player/server/package.json furumi-node-player/server/package-lock.json ./server/ +RUN cd server && npm ci + +# 2. Install client dependencies (cached layer) +COPY furumi-node-player/client/package.json furumi-node-player/client/package-lock.json ./client/ +RUN cd client && npm ci + +# 3. Build server +COPY furumi-node-player/server/ ./server/ +RUN cd server && npm run build + +# 4. Build client (VITE_FURUMI_API_URL empty = relative /api on same origin) +COPY furumi-node-player/client/ ./client/ +RUN cd client && npm run build + +FROM node:22-alpine + +WORKDIR /app + +# Server runtime +COPY --from=build /app/server/dist ./server/dist +COPY --from=build /app/server/node_modules ./server/node_modules +COPY --from=build /app/server/package.json ./server/ + +# Client static files +COPY --from=build /app/client/dist ./client/dist + +ENV NODE_ENV=production +ENV PORT=3001 + +EXPOSE 3001 + +CMD ["node", "server/dist/index.js"] diff --git a/docker/Dockerfile.web-player b/docker/Dockerfile.web-player index ee6902a..1ae113c 100644 --- a/docker/Dockerfile.web-player +++ b/docker/Dockerfile.web-player @@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app + +# 1. Copy workspace manifests and lock file (changes rarely → cached layer) +COPY Cargo.toml Cargo.lock ./ +COPY furumi-common/Cargo.toml furumi-common/Cargo.toml +COPY furumi-server/Cargo.toml furumi-server/Cargo.toml +COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml +COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml +COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml +COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml +COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml + +# 2. Create dummy sources so cargo can resolve and build dependencies +RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \ + && mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \ + && mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \ + && mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \ + && mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \ + && mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \ + && mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs + +# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change) +RUN cargo build --release --bin furumi-web-player 2>/dev/null || true + +# 4. Copy real source code COPY . . +# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps) +RUN touch furumi-common/src/lib.rs furumi-web-player/src/main.rs + ARG FURUMI_VERSION=dev RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 100a9a8..438546d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,8 +16,8 @@ services: agent: build: - context: . - dockerfile: Dockerfile.agent + context: .. + dockerfile: docker/Dockerfile.agent container_name: furumi-agent depends_on: db: @@ -25,10 +25,12 @@ services: ports: - "8090:8090" environment: + RUST_LOG: info FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_AGENT_INBOX_DIR: "/inbox" FURUMI_AGENT_STORAGE_DIR: "/storage" FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}" + FURUMI_AGENT_OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}" FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}" FURUMI_PLAYER_BIND: "0.0.0.0:8090" FURUMI_AGENT_POLL_INTERVAL_SECS: 5 @@ -41,8 +43,8 @@ services: web-player: build: - context: . - dockerfile: Dockerfile.web-player + context: .. + dockerfile: docker/Dockerfile.web-player container_name: furumi-web-player depends_on: db: @@ -53,7 +55,11 @@ services: FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_PLAYER_STORAGE_DIR: "/storage" FURUMI_PLAYER_BIND: "0.0.0.0:8085" - FURUMI_PLAYER_API_KEY: "node-player-api-key" + FURUMI_PLAYER_OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}" + FURUMI_PLAYER_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}" + FURUMI_PLAYER_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}" + FURUMI_PLAYER_OIDC_REDIRECT_URL: "${OIDC_REDIRECT_URL}" + FURUMI_PLAYER_OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}" volumes: - ./storage:/storage restart: always diff --git a/furumi-agent/prompts/normalize.txt b/furumi-agent/prompts/normalize.txt index fe8577d..d049bc3 100644 --- a/furumi-agent/prompts/normalize.txt +++ b/furumi-agent/prompts/normalize.txt @@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada 10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations. -11. **Confidence**: Rate your confidence from 0.0 to 1.0. +11. **Confidence**: MUST be a decimal number between 0.0 and 1.0 (e.g., 0.95, 0.7, 0.3). NEVER use words like "high", "medium", "low" — only a numeric float value. - 1.0: All fields are clear and unambiguous. - 0.8+: Minor inferences made (e.g., year from path), but high certainty. - 0.5-0.8: Some guesswork involved, human review recommended. diff --git a/furumi-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index bd8020c..cc19bce 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -25,16 +25,37 @@ pub async fn normalize( ) -> anyhow::Result { let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx); + let schema = normalize_schema(); let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.system_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + 512, + Some(("normalized_metadata", schema.clone())), ) .await?; - parse_response(&response) + match parse_response(&response) { + Ok(fields) => Ok(fields), + Err(e) => { + tracing::warn!(error = %e, "LLM parse failed, retrying with higher frequency_penalty"); + let response2 = call_ollama( + &state.config.ollama_url, + &state.config.ollama_model, + &state.system_prompt, + &user_message, + state.config.ollama_auth.as_deref(), + 1.5, + 512, + Some(("normalized_metadata", schema)), + ) + .await?; + parse_response(&response2) + } + } } fn build_user_message( @@ -113,32 +134,49 @@ fn build_user_message( } #[derive(Serialize)] -struct OllamaRequest { +struct ChatRequest { model: String, - messages: Vec, - format: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + response_format: Option, stream: bool, - options: OllamaOptions, + temperature: f64, + max_tokens: u32, + frequency_penalty: f64, } #[derive(Serialize)] -struct OllamaMessage { +struct ChatMessage { role: String, content: String, } #[derive(Serialize)] -struct OllamaOptions { - temperature: f64, +struct ChatResponseFormat { + #[serde(rename = "type")] + kind: String, + json_schema: JsonSchemaWrapper, +} + +#[derive(Serialize)] +struct JsonSchemaWrapper { + name: String, + strict: bool, + schema: serde_json::Value, } #[derive(Deserialize)] -struct OllamaResponse { - message: OllamaResponseMessage, +struct ChatResponse { + choices: Vec, } #[derive(Deserialize)] -struct OllamaResponseMessage { +struct ChatChoice { + message: ChatResponseMessage, +} + +#[derive(Deserialize)] +struct ChatResponseMessage { content: String, } @@ -148,30 +186,40 @@ pub async fn call_ollama( system_prompt: &str, user_message: &str, auth: Option<&str>, + frequency_penalty: f64, + max_tokens: u32, + schema: Option<(&str, serde_json::Value)>, ) -> anyhow::Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(120)) .build()?; - let request = OllamaRequest { + let response_format = schema.map(|(name, schema)| ChatResponseFormat { + kind: "json_schema".to_owned(), + json_schema: JsonSchemaWrapper { name: name.to_owned(), strict: true, schema }, + }); + + let request = ChatRequest { model: model.to_owned(), messages: vec![ - OllamaMessage { + ChatMessage { role: "system".to_owned(), content: system_prompt.to_owned(), }, - OllamaMessage { + ChatMessage { role: "user".to_owned(), content: user_message.to_owned(), }, ], - format: "json".to_owned(), + response_format, stream: false, - options: OllamaOptions { temperature: 0.1 }, + temperature: 0.1, + max_tokens, + frequency_penalty, }; - let url = format!("{}/api/chat", base_url.trim_end_matches('/')); - tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API..."); + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API..."); let start = std::time::Instant::now(); let mut req = client.post(&url).json(&request); @@ -184,18 +232,45 @@ pub async fn call_ollama( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error"); - anyhow::bail!("Ollama returned {}: {}", status, body); + tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error"); + anyhow::bail!("LLM returned {}: {}", status, body); } - let ollama_resp: OllamaResponse = resp.json().await?; + let chat_resp: ChatResponse = resp.json().await?; + let content = chat_resp + .choices + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))? + .message + .content; tracing::info!( elapsed_ms = elapsed.as_millis() as u64, - response_len = ollama_resp.message.content.len(), - "Ollama response received" + response_len = content.len(), + "LLM response received" ); - tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output"); - Ok(ollama_resp.message.content) + tracing::debug!(raw_response = %content, "LLM raw output"); + Ok(content) +} + +fn normalize_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "artist": { "type": ["string", "null"] }, + "album": { "type": ["string", "null"] }, + "title": { "type": ["string", "null"] }, + "year": { "type": ["integer", "null"] }, + "track_number": { "type": ["integer", "null"] }, + "genre": { "type": ["string", "null"] }, + "featured_artists": { "type": "array", "items": { "type": "string" } }, + "release_kind": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"] }, + "notes": { "type": ["string", "null"] } + }, + "required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_kind", "confidence", "notes"], + "additionalProperties": false + }) } /// Parse the LLM JSON response into NormalizedFields. @@ -222,6 +297,7 @@ fn parse_response(response: &str) -> anyhow::Result { genre: Option, #[serde(default)] featured_artists: Vec, + #[serde(rename = "release_kind")] release_type: Option, confidence: Option, notes: Option, diff --git a/furumi-agent/src/merge.rs b/furumi-agent/src/merge.rs index 25f6e5b..8c7f83c 100644 --- a/furumi-agent/src/merge.rs +++ b/furumi-agent/src/merge.rs @@ -35,12 +35,39 @@ pub async fn propose_merge(state: &Arc, merge_id: Uuid) -> anyhow::Res let user_message = build_merge_message(&artists_data); + let schema = serde_json::json!({ + "type": "object", + "properties": { + "canonical_artist_name": { "type": "string" }, + "winner_artist_id": { "type": "integer" }, + "album_mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_album_id": { "type": "integer" }, + "canonical_name": { "type": "string" }, + "merge_into_album_id": { "type": ["integer", "null"] } + }, + "required": ["source_album_id", "canonical_name", "merge_into_album_id"], + "additionalProperties": false + } + }, + "notes": { "type": "string" } + }, + "required": ["canonical_artist_name", "winner_artist_id", "album_mappings", "notes"], + "additionalProperties": false + }); + let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.merge_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + 4096, + Some(("artist_merge", schema)), ).await?; let proposal = parse_merge_response(&response)?; diff --git a/furumi-node-player/client/.env.example b/furumi-node-player/client/.env.example index 2312cf7..eeec224 100644 --- a/furumi-node-player/client/.env.example +++ b/furumi-node-player/client/.env.example @@ -1,2 +1 @@ -VITE_API_BASE_URL=http://localhost:8085 -VITE_API_KEY= \ No newline at end of file +VITE_FURUMI_API_URL=http://localhost:8085 diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index 51a08a4..fed1fdb 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { FurumiPlayer } from './FurumiPlayer' +import { setAuthToken, clearAuthToken } from './furumiApi' import './App.css' type UserProfile = { @@ -22,7 +23,7 @@ function App() { } }) - const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', []) + const apiBase = '' useEffect(() => { if (runWithoutAuth) { @@ -34,12 +35,13 @@ function App() { const loadMe = async () => { try { - const response = await fetch(`${apiBase}/api/me`, { + const response = await fetch(`${apiBase}/auth/me`, { credentials: 'include', }) if (response.status === 401) { setUser(null) + clearAuthToken() return } @@ -49,6 +51,23 @@ 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', + }) + if (tokenRes.ok) { + const tokenData = await tokenRes.json() + if (tokenData.access_token) { + setAuthToken(tokenData.access_token) + } + } + } catch { + // Token fetch failed — API calls will fall back to other auth methods + } + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load session') } finally { @@ -57,10 +76,10 @@ function App() { } void loadMe() - }, [apiBase, runWithoutAuth]) + }, [runWithoutAuth]) - const loginUrl = `${apiBase}/api/login` - const logoutUrl = `${apiBase}/api/logout` + const loginUrl = `${apiBase}/auth/login` + const logoutUrl = `${apiBase}/auth/logout` return ( <> diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index bdf4114..176166a 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -1,16 +1,21 @@ import axios from 'axios' import type { Album, Artist, SearchResult, Track, TrackDetail } from './types' -const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '' -export const API_ROOT = `${API_BASE}/api` - -const API_KEY = import.meta.env.VITE_API_KEY +const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? '' +export const API_ROOT = `${FURUMI_API_BASE}/api` export const furumiApi = axios.create({ baseURL: API_ROOT, - headers: API_KEY ? { 'x-api-key': API_KEY } : {}, }) +export function setAuthToken(token: string) { + furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}` +} + +export function clearAuthToken() { + delete furumiApi.defaults.headers.common['Authorization'] +} + export async function getArtists(): Promise { const res = await furumiApi.get('/artists').catch(() => null) return res?.data ?? null diff --git a/furumi-node-player/client/vite.config.ts b/furumi-node-player/client/vite.config.ts index ab8ee06..58fa2c9 100644 --- a/furumi-node-player/client/vite.config.ts +++ b/furumi-node-player/client/vite.config.ts @@ -6,7 +6,11 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': { + '/auth': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/callback': { target: 'http://localhost:3001', changeOrigin: true, }, diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts index 59622ad..93f7f29 100644 --- a/furumi-node-player/server/src/index.ts +++ b/furumi-node-player/server/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; +import path from 'path'; import cors from 'cors'; import express from 'express'; import { auth } from 'express-openid-connect'; @@ -28,7 +29,6 @@ const oidcConfig = { }; if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) { - // Keep a clear startup failure if OIDC is not configured. throw new Error( 'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)', ); @@ -46,11 +46,11 @@ if (!disableAuth) { app.use(auth(oidcConfig)); } -app.get('/api/health', (_req, res) => { +app.get('/auth/health', (_req, res) => { res.json({ ok: true }); }); -app.get('/api/me', (req, res) => { +app.get('/auth/me', (req, res) => { if (disableAuth) { res.json({ authenticated: false, @@ -74,7 +74,32 @@ app.get('/api/me', (req, res) => { }); }); -app.get('/api/login', (req, res) => { +app.get('/auth/token', (req, res) => { + if (disableAuth) { + res.status(204).end(); + return; + } + + if (!req.oidc.isAuthenticated()) { + res.status(401).json({ authenticated: false }); + return; + } + + const accessToken = req.oidc.accessToken?.access_token; + const expiresAt = req.oidc.accessToken?.expires_at; + if (!accessToken) { + res.status(500).json({ error: 'no access token in session' }); + return; + } + + res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_at: expiresAt, + }); +}); + +app.get('/auth/login', (req, res) => { if (disableAuth) { res.status(204).end(); return; @@ -85,7 +110,7 @@ app.get('/api/login', (req, res) => { }); }); -app.get('/api/logout', (req, res) => { +app.get('/auth/logout', (req, res) => { if (disableAuth) { res.status(204).end(); return; @@ -96,6 +121,13 @@ app.get('/api/logout', (req, res) => { }); }); +// Production: serve Vite-built client as static files +const clientDist = path.resolve(import.meta.dirname, '../../client/dist'); +app.use(express.static(clientDist)); +app.get('*', (_req, res) => { + res.sendFile(path.join(clientDist, 'index.html')); +}); + app.listen(port, () => { console.log( `${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`, diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 822300c..6638f38 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -18,7 +18,8 @@ mime_guess = "2.0" symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] } tokio-util = { version = "0.7", features = ["io"] } openidconnect = "3.4" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +jsonwebtoken = "9" sha2 = "0.10" hmac = "0.12" base64 = "0.22" diff --git a/furumi-web-player/src/main.rs b/furumi-web-player/src/main.rs index b8a8592..f95c39b 100644 --- a/furumi-web-player/src/main.rs +++ b/furumi-web-player/src/main.rs @@ -40,9 +40,6 @@ struct Args { #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] oidc_session_secret: Option, - /// API key for x-api-key header auth (alternative to OIDC session) - #[arg(long, env = "FURUMI_PLAYER_API_KEY")] - api_key: Option, } #[tokio::main] @@ -94,15 +91,10 @@ async fn main() -> Result<(), Box> { std::process::exit(1); }); - if args.api_key.is_some() { - tracing::info!("x-api-key auth: enabled"); - } - let state = Arc::new(web::AppState { pool, storage_dir: Arc::new(args.storage_dir), oidc: oidc_state, - api_key: args.api_key, }); tracing::info!("Web player: http://{}", bind_addr); diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index 33f8184..98574f7 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -3,10 +3,9 @@ use axum::{ extract::{Request, State}, http::{header, HeaderMap, StatusCode}, middleware::Next, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect, Response}, }; -const X_API_KEY: &str = "x-api-key"; use openidconnect::{ core::{CoreClient, CoreProviderMetadata, CoreResponseType}, reqwest::async_http_client, @@ -18,17 +17,26 @@ use serde::Deserialize; use base64::Engine; use hmac::{Hmac, Mac}; +use jsonwebtoken::{decode, decode_header, DecodingKey, Validation as JwtValidation}; +use jsonwebtoken::jwk::JwkSet; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; use super::AppState; use std::sync::Arc; const SESSION_COOKIE: &str = "furumi_session"; +const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600); type HmacSha256 = Hmac; pub struct OidcState { pub client: CoreClient, pub session_secret: Vec, + jwks_uri: String, + issuer_url: String, + jwks_cache: RwLock>, + http_client: reqwest::Client, } pub async fn oidc_init( @@ -44,6 +52,9 @@ pub async fn oidc_init( ) .await?; + let jwks_uri = provider_metadata.jwks_uri().to_string(); + let issuer_url = provider_metadata.issuer().to_string(); + let client = CoreClient::from_provider_metadata( provider_metadata, ClientId::new(client_id), @@ -62,12 +73,70 @@ pub async fn oidc_init( b }; + let http_client = reqwest::Client::new(); + + tracing::info!("JWKS URI: {}", jwks_uri); + Ok(OidcState { client, session_secret, + jwks_uri, + issuer_url, + jwks_cache: RwLock::new(None), + http_client, }) } +impl OidcState { + async fn get_jwks(&self) -> anyhow::Result { + { + let cache = self.jwks_cache.read().await; + if let Some((ref jwks, fetched_at)) = *cache { + if fetched_at.elapsed() < JWKS_CACHE_TTL { + return Ok(jwks.clone()); + } + } + } + self.refresh_jwks().await + } + + async fn refresh_jwks(&self) -> anyhow::Result { + tracing::debug!("Fetching JWKS from {}", self.jwks_uri); + let jwks: JwkSet = self.http_client.get(&self.jwks_uri).send().await?.json().await?; + let mut cache = self.jwks_cache.write().await; + *cache = Some((jwks.clone(), Instant::now())); + Ok(jwks) + } +} + +#[derive(Debug, serde::Deserialize)] +struct BearerClaims { + sub: String, +} + +async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { + let header = decode_header(token).ok()?; + let kid = header.kid.as_ref()?; + + let mut jwks = oidc.get_jwks().await.ok()?; + let mut jwk = jwks.find(kid); + + // Handle key rotation: refresh JWKS if kid not found + if jwk.is_none() { + jwks = oidc.refresh_jwks().await.ok()?; + jwk = jwks.find(kid); + } + + let key = DecodingKey::from_jwk(jwk?).ok()?; + + let mut validation = JwtValidation::new(header.alg); + validation.set_issuer(&[&oidc.issuer_url]); + validation.validate_aud = false; + + let data = decode::(token, &key, &validation).ok()?; + Some(data.claims.sub) +} + fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String { let mut mac = HmacSha256::new_from_slice(secret).unwrap(); mac.update(user_id.as_bytes()); @@ -94,20 +163,22 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option { } } -/// Auth middleware: requires valid SSO session cookie or x-api-key header. +/// Auth middleware: requires valid Bearer JWT or SSO session cookie. pub async fn require_auth( State(state): State>, req: Request, next: Next, ) -> Response { - // 1. Check x-api-key header (if configured) - if let Some(ref expected) = state.api_key { - if let Some(val) = req + // 1. Check Bearer token — JWT from OIDC provider + if let Some(ref oidc) = state.oidc { + if let Some(token) = req .headers() - .get(X_API_KEY) + .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) { - if val == expected { + 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; } } @@ -131,36 +202,7 @@ pub async fn require_auth( } } - let uri = req.uri().to_string(); - if uri.starts_with("/api/") { - (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } else if state.oidc.is_some() { - Redirect::to("/login").into_response() - } else { - // Only API key configured — no web login available - (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } -} - -/// GET /login — show SSO login page. -pub async fn login_page(State(state): State>) -> impl IntoResponse { - if state.oidc.is_none() { - return Redirect::to("/").into_response(); - } - - Html(LOGIN_HTML).into_response() -} - -/// GET /logout — clear session cookie. -pub async fn logout() -> impl IntoResponse { - let cookie = format!( - "{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", - SESSION_COOKIE - ); - let mut headers = HeaderMap::new(); - headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); - headers.insert(header::LOCATION, "/login".parse().unwrap()); - (StatusCode::FOUND, headers, Body::empty()).into_response() + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() } #[derive(Deserialize)] @@ -335,9 +377,9 @@ pub async fn oidc_callback( .unwrap_or(false); let session_attrs = if is_https { - "SameSite=Strict; Secure" + "SameSite=Lax; Secure" } else { - "SameSite=Strict" + "SameSite=Lax" }; let session_cookie = format!( @@ -354,47 +396,3 @@ pub async fn oidc_callback( (StatusCode::FOUND, headers, Body::empty()).into_response() } - -const LOGIN_HTML: &str = r#" - - - - -Furumi Player — Login - - - -
- -
Sign in to continue
- SSO Login -
- -"#; diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index 355b4bb..c8d95b9 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -16,7 +16,6 @@ pub struct AppState { #[allow(dead_code)] pub storage_dir: Arc, pub oidc: Option>, - pub api_key: Option, } pub fn build_router(state: Arc) -> Router { @@ -32,37 +31,27 @@ pub fn build_router(state: Arc) -> Router { .route("/stream/:slug", get(api::stream_track)) .route("/search", get(api::search)); - let authed = Router::new() - .route("/", get(player_html)) + let api = Router::new() .nest("/api", library); let requires_auth = state.oidc.is_some(); let app = if requires_auth { - authed - .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) + api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) } else { - authed + api }; let cors = CorsLayer::new() .allow_origin(Any) .allow_methods([Method::GET, Method::OPTIONS, Method::HEAD]) - .allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")]) + .allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION]) .max_age(Duration::from_secs(600)); Router::new() - .route("/login", get(auth::login_page)) - .route("/logout", get(auth::logout)) .route("/auth/login", get(auth::oidc_login)) .route("/auth/callback", get(auth::oidc_callback)) .merge(app) .layer(cors) .with_state(state) } - -async fn player_html() -> axum::response::Html { - let html = include_str!("player.html") - .replace("", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))); - axum::response::Html(html) -} From f3392eff9fb799cd4ff4926d6958044ed5516293 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 15:01:30 +0100 Subject: [PATCH 06/21] fix(node-player): use expires_in instead of expires_at on AccessToken type Co-Authored-By: Claude Opus 4.6 (1M context) --- furumi-node-player/server/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts index 93f7f29..5a1e1b0 100644 --- a/furumi-node-player/server/src/index.ts +++ b/furumi-node-player/server/src/index.ts @@ -86,7 +86,7 @@ app.get('/auth/token', (req, res) => { } const accessToken = req.oidc.accessToken?.access_token; - const expiresAt = req.oidc.accessToken?.expires_at; + const expiresAt = req.oidc.accessToken?.expires_in; if (!accessToken) { res.status(500).json({ error: 'no access token in session' }); return; @@ -95,7 +95,7 @@ app.get('/auth/token', (req, res) => { res.json({ access_token: accessToken, token_type: 'Bearer', - expires_at: expiresAt, + expires_in: expiresAt, }); }); From b1f75b3ee2f6a8b89d1b59b6f548176110593807 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 15:20:02 +0100 Subject: [PATCH 07/21] fix(node-player): use Express 5 catch-all route syntax Co-Authored-By: Claude Opus 4.6 (1M context) --- furumi-node-player/server/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts index 5a1e1b0..b72a382 100644 --- a/furumi-node-player/server/src/index.ts +++ b/furumi-node-player/server/src/index.ts @@ -124,7 +124,7 @@ app.get('/auth/logout', (req, res) => { // Production: serve Vite-built client as static files const clientDist = path.resolve(import.meta.dirname, '../../client/dist'); app.use(express.static(clientDist)); -app.get('*', (_req, res) => { +app.get('/{*path}', (_req, res) => { res.sendFile(path.join(clientDist, 'index.html')); }); From a9a8ee81b81cb60697aff95e0d04af8c16a03451 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 15:39:14 +0100 Subject: [PATCH 08/21] fix(node-player): load cover art via axios with Bearer token Cover images were loaded via 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) --- .../client/src/FurumiPlayer.tsx | 11 ++++---- .../client/src/components/NowPlaying.tsx | 17 ++++------- .../client/src/components/QueueList.tsx | 16 +++++------ furumi-node-player/client/src/furumiApi.ts | 6 ++++ .../client/src/hooks/useCoverUrl.ts | 28 +++++++++++++++++++ 5 files changed, 53 insertions(+), 25 deletions(-) create mode 100644 furumi-node-player/client/src/hooks/useCoverUrl.ts diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index b17985c..64d3410 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { API_ROOT, searchTracks, preloadStream } from './furumiApi' +import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi' import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' @@ -80,15 +80,16 @@ export function FurumiPlayer() { return } document.title = `${nowPlayingTrack.title} — Furumi` - const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover` if ('mediaSession' in navigator) { try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - navigator.mediaSession.metadata = new window.MediaMetadata({ + const meta = new window.MediaMetadata({ title: nowPlayingTrack.title, artist: nowPlayingTrack.artist || '', album: '', - artwork: [{ src: coverUrl, sizes: '512x512' }], + }) + navigator.mediaSession.metadata = meta + fetchCoverBlob(nowPlayingTrack.slug).then((url) => { + if (url) meta.artwork = [{ src: url, sizes: '512x512' }] }) } catch { // ignore diff --git a/furumi-node-player/client/src/components/NowPlaying.tsx b/furumi-node-player/client/src/components/NowPlaying.tsx index c3275bc..25e4793 100644 --- a/furumi-node-player/client/src/components/NowPlaying.tsx +++ b/furumi-node-player/client/src/components/NowPlaying.tsx @@ -1,15 +1,12 @@ -import { useEffect, useState } from 'react' -import { API_ROOT } from '../furumiApi' +import { useState } from 'react' import type { QueueItem } from './QueueList' +import { useCoverUrl } from '../hooks/useCoverUrl' -function Cover({ src }: { src: string }) { +function Cover({ slug }: { slug: string }) { const [errored, setErrored] = useState(false) + const src = useCoverUrl(slug) - useEffect(() => { - setErrored(false) - }, [src]) - - if (errored) return <>🎵 + if (!src || errored) return <>🎵 return setErrored(true)} /> } @@ -32,12 +29,10 @@ export function NowPlaying({ track }: { track: QueueItem | null }) { ) } - const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` - return (
- +
diff --git a/furumi-node-player/client/src/components/QueueList.tsx b/furumi-node-player/client/src/components/QueueList.tsx index c371f09..fe53bdf 100644 --- a/furumi-node-player/client/src/components/QueueList.tsx +++ b/furumi-node-player/client/src/components/QueueList.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import { API_ROOT } from '../furumiApi' +import { useRef, useState } from 'react' +import { useCoverUrl } from '../hooks/useCoverUrl' export type QueueItem = { slug: string @@ -32,13 +32,11 @@ function fmt(secs: number) { return `${m}:${pad(s % 60)}` } -function Cover({ src }: { src: string }) { +function Cover({ slug }: { slug: string }) { const [errored, setErrored] = useState(false) - useEffect(() => { - setErrored(false) - }, [src]) + const src = useCoverUrl(slug) - if (errored) return <>🎵 + if (!src || errored) return <>🎵 return setErrored(true)} /> } @@ -77,7 +75,7 @@ export function QueueList({ if (!t) return null const isPlaying = origIdx === playingOrigIdx - const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : '' + const hasAlbum = !!t.album_slug const dur = t.duration ? fmt(t.duration) : '' const isDragging = draggingPos === pos const isDragOver = dragOverPos === pos @@ -118,7 +116,7 @@ export function QueueList({ > {isPlaying ? '' : pos + 1}
- {coverSrc ? : <>🎵} + {hasAlbum ? : <>🎵}
{t.title}
diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index 176166a..fa492fb 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -52,3 +52,9 @@ export async function preloadStream(trackSlug: string) { return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) } +export async function fetchCoverBlob(trackSlug: string): Promise { + const res = await furumiApi.get(`/tracks/${trackSlug}/cover`, { responseType: 'blob' }).catch(() => null) + if (!res?.data) return null + return URL.createObjectURL(res.data) +} + diff --git a/furumi-node-player/client/src/hooks/useCoverUrl.ts b/furumi-node-player/client/src/hooks/useCoverUrl.ts new file mode 100644 index 0000000..1b53be0 --- /dev/null +++ b/furumi-node-player/client/src/hooks/useCoverUrl.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react' +import { fetchCoverBlob } from '../furumiApi' + +export function useCoverUrl(trackSlug: string | undefined): string | null { + const [url, setUrl] = useState(null) + + useEffect(() => { + if (!trackSlug) { + setUrl(null) + return + } + + let revoke: string | null = null + + fetchCoverBlob(trackSlug).then((blobUrl) => { + if (blobUrl) { + revoke = blobUrl + setUrl(blobUrl) + } + }) + + return () => { + if (revoke) URL.revokeObjectURL(revoke) + } + }, [trackSlug]) + + return url +} From befba57374a63a3dab7283512e948bb7281bdf7f Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 15:41:47 +0100 Subject: [PATCH 09/21] fix(node-player): auto-refresh expired JWT tokens on 401 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) --- furumi-node-player/client/src/furumiApi.ts | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index fa492fb..fa447c7 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -16,6 +16,38 @@ export function clearAuthToken() { delete furumiApi.defaults.headers.common['Authorization'] } +async function refreshToken(): Promise { + 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 | null = null + +furumiApi.interceptors.response.use( + (response) => response, + async (error) => { + const original = error.config + if (error.response?.status === 401 && !original._retried) { + original._retried = true + // Deduplicate concurrent refresh attempts + if (!refreshPromise) { + refreshPromise = refreshToken().finally(() => { refreshPromise = null }) + } + const ok = await refreshPromise + if (ok) return furumiApi(original) + } + return Promise.reject(error) + }, +) + export async function getArtists(): Promise { const res = await furumiApi.get('/artists').catch(() => null) return res?.data ?? null From 7bc7de44cfdae1986bce840b799334bf32561803 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 15:44:28 +0100 Subject: [PATCH 10/21] fix(node-player): restore useEffect import in QueueList Co-Authored-By: Claude Opus 4.6 (1M context) --- furumi-node-player/client/src/components/QueueList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/furumi-node-player/client/src/components/QueueList.tsx b/furumi-node-player/client/src/components/QueueList.tsx index fe53bdf..3cb7428 100644 --- a/furumi-node-player/client/src/components/QueueList.tsx +++ b/furumi-node-player/client/src/components/QueueList.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useCoverUrl } from '../hooks/useCoverUrl' export type QueueItem = { From 1ea5f66ea3f436a1a813d3dd891acddb61088899 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 16:01:16 +0100 Subject: [PATCH 11/21] fix(node-player): add offline_access scope and server-side token refresh - 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) --- furumi-node-player/server/src/index.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts index b72a382..dabe8b2 100644 --- a/furumi-node-player/server/src/index.ts +++ b/furumi-node-player/server/src/index.ts @@ -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, }); }); From ed918b93732ece00a0c297a1df0594e237e2df9a Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 16:27:22 +0100 Subject: [PATCH 12/21] feat(node-player): redesign auth page with loading state - 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) --- furumi-node-player/client/src/App.css | 160 +++++++++++++++++--------- furumi-node-player/client/src/App.tsx | 133 +++++++++------------ 2 files changed, 158 insertions(+), 135 deletions(-) diff --git a/furumi-node-player/client/src/App.css b/furumi-node-player/client/src/App.css index 1334e57..ca9687d 100644 --- a/furumi-node-player/client/src/App.css +++ b/furumi-node-player/client/src/App.css @@ -1,71 +1,123 @@ -.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 ---------- */ + +.auth-loading { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; } -.subtitle { - margin-top: 0; - margin-bottom: 20px; - color: #5a6475; +.spinner { + width: 36px; + height: 36px; + border: 3px solid #1f2c45; + border-top-color: #7c6af7; + border-radius: 50%; + animation: spin 0.8s linear infinite; } -.settings { - margin-bottom: 16px; - padding: 12px; - border: 1px solid #e6eaf2; - border-radius: 10px; - background: #f8fafc; +@keyframes spin { + to { transform: rotate(360deg); } } -.toggle { +.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; +} + +.auth-card .btn-login:hover { + background: #6b58e8; +} + +.auth-card .error { + color: #f87171; + font-size: 0.85rem; + margin-bottom: 1rem; +} + +.auth-card .settings { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid #1f2c45; +} + +.auth-card .toggle { display: flex; align-items: center; - gap: 10px; - color: #0f172a; - font-weight: 600; + justify-content: center; + gap: 8px; + color: #64748b; + font-size: 0.8rem; + cursor: pointer; } -.toggle input { - width: 18px; - height: 18px; -} - -.hint { - margin: 10px 0 0; - color: #5a6475; -} - -.btn { - display: inline-block; - text-decoration: none; - background: #2251ff; - color: #ffffff; - padding: 10px 16px; - border-radius: 8px; - font-weight: 600; -} - -.btn.ghost { - background: #edf1ff; - color: #1e3fc4; - margin-top: 10px; -} - -.profile p { - margin: 8px 0; -} - -.error { - color: #cc1e1e; +.auth-card .toggle input { + width: 14px; + height: 14px; + accent-color: #7c6af7; } diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index fed1fdb..16e0d5b 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -23,8 +23,6 @@ function App() { } }) - const apiBase = '' - useEffect(() => { if (runWithoutAuth) { setError(null) @@ -35,9 +33,7 @@ function App() { 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 +48,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 +58,7 @@ function App() { } } } catch { - // Token fetch failed — API calls will fall back to other auth methods + // Token fetch failed } } } catch (err) { @@ -78,82 +71,60 @@ function App() { void loadMe() }, [runWithoutAuth]) - const loginUrl = `${apiBase}/auth/login` - const logoutUrl = `${apiBase}/auth/logout` + // Authenticated — render player immediately + if (!loading && (user || runWithoutAuth)) { + return + } + // Loading — show spinner (no login form flash) + if (loading) { + return ( +
+
+
Furumi
+
+

Loading...

+
+
+ ) + } + + // Not authenticated — show login return ( - <> - {!loading && (user || runWithoutAuth) ? ( - - ) : ( -
-
-

OIDC Login

-

Авторизация обрабатывается на Express сервере.

+
+
+
Furumi
+

Sign in to continue

-
- -
+ {error &&

{error}

} - {loading &&

Проверяю сессию...

} - {error &&

Ошибка: {error}

} + + Sign in with SSO + - {!loading && runWithoutAuth && ( -

- Режим без авторизации включён. Для входа отключи настройку выше. -

- )} - - {!loading && !user && ( - - Войти через OIDC - - )} - - {!loading && user && ( -
-

- ID: {user.sub} -

- {user.name && ( -

- Имя: {user.name} -

- )} - {user.email && ( -

- Email: {user.email} -

- )} - {!runWithoutAuth && ( - - Выйти - - )} -
- )} -
-
- )} - +
+ +
+
+
) } From 5bc2b55ffd9d1b476b76cfd5f87d23d8cd9ef9d0 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 16:32:19 +0100 Subject: [PATCH 13/21] feat(node-player): remove run-without-auth option from login page Co-Authored-By: Claude Opus 4.6 (1M context) --- furumi-node-player/client/src/App.css | 21 -------------- furumi-node-player/client/src/App.tsx | 42 ++------------------------- 2 files changed, 2 insertions(+), 61 deletions(-) diff --git a/furumi-node-player/client/src/App.css b/furumi-node-player/client/src/App.css index ca9687d..8523c77 100644 --- a/furumi-node-player/client/src/App.css +++ b/furumi-node-player/client/src/App.css @@ -100,24 +100,3 @@ margin-bottom: 1rem; } -.auth-card .settings { - margin-top: 1.5rem; - padding-top: 1rem; - border-top: 1px solid #1f2c45; -} - -.auth-card .toggle { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: #64748b; - font-size: 0.8rem; - cursor: pointer; -} - -.auth-card .toggle input { - width: 14px; - height: 14px; - accent-color: #7c6af7; -} diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index 16e0d5b..82ac92a 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -9,28 +9,12 @@ type UserProfile = { email?: string } -const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth' - function App() { const [loading, setLoading] = useState(true) const [user, setUser] = useState(null) const [error, setError] = useState(null) - const [runWithoutAuth, setRunWithoutAuth] = useState(() => { - try { - return window.localStorage.getItem(NO_AUTH_STORAGE_KEY) === '1' - } catch { - return false - } - }) useEffect(() => { - if (runWithoutAuth) { - setError(null) - setUser({ sub: 'noauth', name: 'No Auth' }) - setLoading(false) - return - } - const loadMe = async () => { try { const response = await fetch('/auth/me', { credentials: 'include' }) @@ -69,10 +53,10 @@ function App() { } void loadMe() - }, [runWithoutAuth]) + }, []) // Authenticated — render player immediately - if (!loading && (user || runWithoutAuth)) { + if (!loading && user) { return } @@ -101,28 +85,6 @@ function App() { Sign in with SSO - -
- -
) From 4fdd56dae414cb60106b91cb39acd953ae7291ec Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 16:51:53 +0100 Subject: [PATCH 14/21] 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) --- .../migrations/0005_users_and_play_events.sql | 20 +++++ furumi-node-player/client/src/App.tsx | 2 +- .../client/src/FurumiPlayer.tsx | 12 ++- .../client/src/components/header/Header.tsx | 47 +++++++++++ .../src/components/header/header.module.css | 76 ++++++++++++++++++ furumi-node-player/client/src/furumiApi.ts | 4 + furumi-web-player/src/db.rs | 44 ++++++++++ furumi-web-player/src/web/api.rs | 16 ++++ furumi-web-player/src/web/auth.rs | 80 ++++++++++++++----- furumi-web-player/src/web/mod.rs | 7 +- 10 files changed, 283 insertions(+), 25 deletions(-) create mode 100644 furumi-agent/migrations/0005_users_and_play_events.sql diff --git a/furumi-agent/migrations/0005_users_and_play_events.sql b/furumi-agent/migrations/0005_users_and_play_events.sql new file mode 100644 index 0000000..cae6b92 --- /dev/null +++ b/furumi-agent/migrations/0005_users_and_play_events.sql @@ -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); diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index 82ac92a..34d51a3 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -57,7 +57,7 @@ function App() { // Authenticated — render player immediately if (!loading && user) { - return + return } // Loading — show spinner (no login form flash) diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 64d3410..c2572e5 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi' +import { searchTracks, preloadStream, fetchCoverBlob, 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) @@ -293,6 +299,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) @@ -513,6 +520,7 @@ export function FurumiPlayer() { searchOpen={searchOpen} searchResults={searchResults} onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} + user={user} /> void + user: UserInfo +} + +function UserMenu({ user }: { user: UserInfo }) { + const [open, setOpen] = useState(false) + const ref = useRef(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 ( +
+ + {open && ( +
+
+
{user.name ?? user.sub}
+ {user.email &&
{user.email}
} +
+ Sign out +
+ )} +
+ ) } export function Header({ searchOpen, searchResults, onSearchSelect, + user, }: HeaderProps) { return (
@@ -40,6 +86,7 @@ export function Header({ onSelect={onSearchSelect} />
+
) diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css index 3777ab4..02af660 100644 --- a/furumi-node-player/client/src/components/header/header.module.css +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -32,4 +32,80 @@ margin-left: 0.25rem; 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); } \ No newline at end of file diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index fa447c7..eb9aa4e 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -80,6 +80,10 @@ export async function getTrackInfo(trackSlug: string): Promise { + await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) +} + export async function preloadStream(trackSlug: string) { return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) } diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs index f33fac6..0fad353 100644 --- a/furumi-web-player/src/db.rs +++ b/furumi-web-player/src/db.rs @@ -82,6 +82,50 @@ pub struct SearchResult { pub detail: Option, // 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 { + 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) +} + // --- Queries --- pub async fn list_artists(pool: &PgPool) -> Result, sqlx::Error> { diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs index e4fe88a..7357702 100644 --- a/furumi-web-player/src/web/api.rs +++ b/furumi-web-player/src/web/api.rs @@ -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; @@ -291,6 +293,20 @@ pub async fn search(State(state): State, Query(q): Query) -> imp } } +// --- Play tracking --- + +pub async fn record_play( + State(state): State, + Path(slug): Path, + Extension(user): Extension, +) -> 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 { diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index 98574f7..8f61b86 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -109,12 +109,23 @@ impl OidcState { } } +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: String, + pub username: String, + pub display_name: Option, + pub email: Option, +} + #[derive(Debug, serde::Deserialize)] struct BearerClaims { sub: String, + preferred_username: Option, + name: Option, + email: Option, } -async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { +async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { 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 validation.validate_aud = false; let data = decode::(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 { } /// 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>, - req: Request, + mut req: Request, next: Next, ) -> Response { + let mut auth_user: Option = None; + // 1. Check Bearer token — JWT from OIDC provider if let Some(ref oidc) = state.oidc { 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.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 let Some(ref oidc) = state.oidc { - let cookies = req - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + if auth_user.is_none() { + if let Some(ref oidc) = state.oidc { + let cookies = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); - 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; + for c in cookies.split(';') { + let c = c.trim(); + if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { + 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)] diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index c8d95b9..d35a8a1 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -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,8 @@ pub fn build_router(state: Arc) -> 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)); let api = Router::new() .nest("/api", library); @@ -44,7 +45,7 @@ pub fn build_router(state: Arc) -> 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)); From 6b1aa6b5d50ffa8580f750b51fc20496a8b0d205 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 17:08:54 +0100 Subject: [PATCH 15/21] feat: add recent plays history modal - 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) --- Cargo.lock | 1 + .../client/src/FurumiPlayer.tsx | 1 + .../client/src/components/header/Header.tsx | 62 +++++---- .../src/components/header/RecentPlays.tsx | 64 +++++++++ .../src/components/header/header.module.css | 123 ++++++++++++++++++ furumi-node-player/client/src/furumiApi.ts | 13 ++ furumi-web-player/Cargo.toml | 1 + furumi-web-player/src/db.rs | 32 +++++ furumi-web-player/src/web/api.rs | 10 ++ furumi-web-player/src/web/mod.rs | 3 +- 10 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 furumi-node-player/client/src/components/header/RecentPlays.tsx diff --git a/Cargo.lock b/Cargo.lock index 0e209f0..98a7483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1176,6 +1176,7 @@ dependencies = [ "anyhow", "axum", "base64 0.22.1", + "chrono", "clap", "hmac", "jsonwebtoken 9.3.1", diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index c2572e5..4217803 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -520,6 +520,7 @@ export function FurumiPlayer({ user }: { user: UserProfile }) { searchOpen={searchOpen} searchResults={searchResults} onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} + onPlayTrack={(slug) => searchSelectRef.current('track', slug)} user={user} /> diff --git a/furumi-node-player/client/src/components/header/Header.tsx b/furumi-node-player/client/src/components/header/Header.tsx index c227db3..9a1aada 100644 --- a/furumi-node-player/client/src/components/header/Header.tsx +++ b/furumi-node-player/client/src/components/header/Header.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { SearchDropdown } from '../SearchDropdown' +import { RecentPlays } from './RecentPlays' import styles from './header.module.css' type SearchResultItem = { @@ -19,10 +20,11 @@ type HeaderProps = { searchOpen: boolean searchResults: SearchResultItem[] onSearchSelect: (type: string, slug: string) => void + onPlayTrack: (slug: string) => void user: UserInfo } -function UserMenu({ user }: { user: UserInfo }) { +function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -52,6 +54,9 @@ function UserMenu({ user }: { user: UserInfo }) {
{user.name ?? user.sub}
{user.email &&
{user.email}
}
+ Sign out
)} @@ -63,31 +68,42 @@ export function Header({ searchOpen, searchResults, onSearchSelect, + onPlayTrack, user, }: HeaderProps) { + const [showRecent, setShowRecent] = useState(false) + return ( -
-
- - - - - - - Furumi - v -
-
-
- - + <> +
+
+ + + + + + + Furumi + v
- -
-
+
+
+ + +
+ setShowRecent(true)} /> +
+ + {showRecent && ( + setShowRecent(false)} + onPlay={onPlayTrack} + /> + )} + ) } diff --git a/furumi-node-player/client/src/components/header/RecentPlays.tsx b/furumi-node-player/client/src/components/header/RecentPlays.tsx new file mode 100644 index 0000000..fcc645f --- /dev/null +++ b/furumi-node-player/client/src/components/header/RecentPlays.tsx @@ -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(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 ( +
+
e.stopPropagation()}> +
+

Recent plays

+ +
+
+ {loading &&

Loading...

} + {!loading && (!plays || plays.length === 0) && ( +

No play history yet

+ )} + {plays?.map((p, i) => ( +
{ onPlay(p.track_slug); onClose() }} + > +
+
{p.track_title}
+
{p.artist_name}
+
+
{timeAgo(p.played_at)}
+
+ ))} +
+
+
+ ) +} diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css index 02af660..0375b19 100644 --- a/furumi-node-player/client/src/components/header/header.module.css +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -108,4 +108,127 @@ .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; } \ No newline at end of file diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index eb9aa4e..4ed3312 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -80,6 +80,19 @@ export async function getTrackInfo(trackSlug: string): Promise { + const res = await furumiApi.get('/me/recent').catch(() => null) + return res?.data ?? null +} + export async function recordPlay(trackSlug: string): Promise { await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) } diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 6638f38..17270c2 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -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"] } diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs index 0fad353..be139ac 100644 --- a/furumi-web-player/src/db.rs +++ b/furumi-web-player/src/db.rs @@ -126,6 +126,38 @@ pub async fn record_play_event( 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, + pub played_at: chrono::DateTime, +} + +pub async fn recent_plays( + pool: &PgPool, + user_id: &str, + limit: i32, +) -> Result, 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, sqlx::Error> { diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs index 7357702..b19249a 100644 --- a/furumi-web-player/src/web/api.rs +++ b/furumi-web-player/src/web/api.rs @@ -295,6 +295,16 @@ pub async fn search(State(state): State, Query(q): Query) -> imp // --- Play tracking --- +pub async fn recent_plays( + State(state): State, + Extension(user): Extension, +) -> 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, Path(slug): Path, diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index d35a8a1..c25cb71 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -30,7 +30,8 @@ pub fn build_router(state: Arc) -> Router { .route("/tracks/:slug/cover", get(api::track_cover)) .route("/stream/:slug", get(api::stream_track)) .route("/search", get(api::search)) - .route("/tracks/:slug/play", post(api::record_play)); + .route("/tracks/:slug/play", post(api::record_play)) + .route("/me/recent", get(api::recent_plays)); let api = Router::new() .nest("/api", library); From d6dd046fad076c6e6ab13c8fa43cf8617a4b6e0c Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 17:30:17 +0100 Subject: [PATCH 16/21] 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