12 Commits

Author SHA1 Message Date
Boris Cherepanov e42566f44e fix: correct behavior click of buttons
Publish Metadata Agent Image / build-and-push-image (push) Successful in 3m46s
Publish Web Player Image / build-and-push-image (push) Successful in 5m1s
2026-04-02 00:48:57 +03:00
Boris Cherepanov 30c6400354 feat: create playback service
Publish Metadata Agent Image / build-and-push-image (push) Successful in 7m58s
Publish Web Player Image / build-and-push-image (push) Has been cancelled
2026-04-02 00:38:30 +03:00
Boris Cherepanov 480880f292 fix: load audio thmb 2026-04-02 00:29:21 +03:00
Boris Cherepanov 83a145d0a8 feat: work with order in state 2026-04-02 00:13:30 +03:00
Boris Cherepanov 8ceee6028a fix: then height 100%
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m5s
Publish Web Player Image / build-and-push-image (push) Successful in 1m6s
2026-03-23 16:55:22 +03:00
Boris Cherepanov 3491c52793 feat: refactoring data fetching 2026-03-23 16:55:22 +03:00
Boris Cherepanov f0e1bbc7f8 feat: added redux 2026-03-23 16:55:22 +03:00
Boris Cherepanov 8f38e27eb0 feat: addex type declaration 2026-03-23 16:55:22 +03:00
Boris Cherepanov 8cac2d1160 feat: refactoring 2026-03-23 16:55:22 +03:00
Boris Cherepanov 5a5dab85d0 feat: added api conversation + api review 2026-03-23 16:55:22 +03:00
Boris Cherepanov 310f0061d3 feat: added cors for web-player-backend 2026-03-23 16:55:22 +03:00
Boris Cherepanov f26135ca25 feat: added auth by api key 2026-03-23 16:55:22 +03:00
33 changed files with 1916 additions and 706 deletions
+32
View File
@@ -0,0 +1,32 @@
---
description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client)
globs: furumi-node-player/client/**/*.{ts,tsx}
alwaysApply: false
---
# REST API в furumi-node-player
**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход.
Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`.
## Правила
1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей.
2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта.
3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке).
## Пример
```typescript
// furumiApi.ts — добавлять сюда
export async function getSomething(id: string) {
const res = await furumiApi.get(`/something/${id}`).catch(() => null)
return res?.data ?? null
}
```
```typescript
// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую
const data = await getSomething(id)
```
Generated
-54
View File
@@ -2,12 +2,6 @@
# 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"
@@ -578,15 +572,6 @@ 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"
@@ -984,16 +969,6 @@ 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"
@@ -1042,7 +1017,6 @@ dependencies = [
"chrono",
"clap",
"encoding_rs",
"id3",
"reqwest 0.12.28",
"serde",
"serde_json",
@@ -1191,7 +1165,6 @@ dependencies = [
"tokio",
"tokio-util",
"tower 0.4.13",
"tower-http",
"tracing",
"tracing-subscriber",
"urlencoding",
@@ -1775,17 +1748,6 @@ 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"
@@ -2059,16 +2021,6 @@ 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"
@@ -3460,12 +3412,6 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simple_asn1"
version = "0.6.4"
+1
View File
@@ -53,6 +53,7 @@ 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"
volumes:
- ./storage:/storage
restart: always
-1
View File
@@ -14,7 +14,6 @@ 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"
+1 -36
View File
@@ -19,25 +19,9 @@ pub struct RawMetadata {
pub duration_secs: Option<f64>,
}
/// 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).
/// Extract metadata from an audio file using Symphonia.
/// Must be called from a blocking context (spawn_blocking).
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
match extract_via_symphonia(path) {
Ok(meta) => return Ok(meta),
Err(e) => {
let is_mp3 = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("mp3")).unwrap_or(false);
if is_mp3 {
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
return extract_mp3_via_id3(path);
}
return Err(e);
}
}
}
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
let file = std::fs::File::open(path)?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
@@ -82,25 +66,6 @@ fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
Ok(meta)
}
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
use id3::TagLike;
let tag = id3::Tag::read_from_path(path)
.map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
let mut meta = RawMetadata::default();
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
meta.track_number = tag.track();
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
// duration_secs remains None — acceptable for large-cover files
Ok(meta)
}
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
for tag in tags {
let value = fix_encoding(tag.value.to_string());
+24 -37
View File
@@ -113,38 +113,32 @@ fn build_user_message(
}
#[derive(Serialize)]
struct ChatRequest {
struct OllamaRequest {
model: String,
messages: Vec<ChatMessage>,
response_format: ChatResponseFormat,
messages: Vec<OllamaMessage>,
format: String,
stream: bool,
temperature: f64,
options: OllamaOptions,
}
#[derive(Serialize)]
struct ChatMessage {
struct OllamaMessage {
role: String,
content: String,
}
#[derive(Serialize)]
struct ChatResponseFormat {
#[serde(rename = "type")]
kind: String,
struct OllamaOptions {
temperature: f64,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
struct OllamaResponse {
message: OllamaResponseMessage,
}
#[derive(Deserialize)]
struct ChatChoice {
message: ChatResponseMessage,
}
#[derive(Deserialize)]
struct ChatResponseMessage {
struct OllamaResponseMessage {
content: String,
}
@@ -159,25 +153,25 @@ pub async fn call_ollama(
.timeout(std::time::Duration::from_secs(120))
.build()?;
let request = ChatRequest {
let request = OllamaRequest {
model: model.to_owned(),
messages: vec![
ChatMessage {
OllamaMessage {
role: "system".to_owned(),
content: system_prompt.to_owned(),
},
ChatMessage {
OllamaMessage {
role: "user".to_owned(),
content: user_message.to_owned(),
},
],
response_format: ChatResponseFormat { kind: "json_object".to_owned() },
format: "json".to_owned(),
stream: false,
temperature: 0.1,
options: OllamaOptions { temperature: 0.1 },
};
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API...");
let url = format!("{}/api/chat", base_url.trim_end_matches('/'));
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API...");
let start = std::time::Instant::now();
let mut req = client.post(&url).json(&request);
@@ -190,25 +184,18 @@ 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)], "LLM API error");
anyhow::bail!("LLM returned {}: {}", status, body);
tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error");
anyhow::bail!("Ollama returned {}: {}", status, body);
}
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;
let ollama_resp: OllamaResponse = resp.json().await?;
tracing::info!(
elapsed_ms = elapsed.as_millis() as u64,
response_len = content.len(),
"LLM response received"
response_len = ollama_resp.message.content.len(),
"Ollama response received"
);
tracing::debug!(raw_response = %content, "LLM raw output");
Ok(content)
tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output");
Ok(ollama_resp.message.content)
}
/// Parse the LLM JSON response into NormalizedFields.
+2
View File
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8085
VITE_API_KEY=
+392 -3
View File
@@ -8,8 +8,11 @@
"name": "client",
"version": "0.0.0",
"dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -586,6 +589,32 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
@@ -848,6 +877,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -887,7 +928,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -903,6 +944,12 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@@ -1287,6 +1334,23 @@
"dev": true,
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1352,6 +1416,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -1420,6 +1497,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1453,7 +1542,7 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -1481,6 +1570,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1491,6 +1589,20 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
@@ -1498,6 +1610,51 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1795,6 +1952,42 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1810,6 +2003,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1820,6 +2022,43 @@
"node": ">=6.9.0"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1846,6 +2085,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -1856,6 +2107,45 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -1883,6 +2173,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2325,6 +2625,36 @@
"yallist": "^3.0.2"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -2520,6 +2850,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2551,6 +2887,50 @@
"react": "^19.2.4"
}
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -2814,6 +3194,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
+4 -1
View File
@@ -10,8 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+1 -2
View File
@@ -61,12 +61,11 @@ function App() {
const loginUrl = `${apiBase}/api/login`
const logoutUrl = `${apiBase}/api/logout`
const playerApiRoot = `${apiBase}/api`
return (
<>
{!loading && (user || runWithoutAuth) ? (
<FurumiPlayer apiRoot={playerApiRoot} />
<FurumiPlayer />
) : (
<main className="page">
<section className="card">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,190 @@
import { preloadStream } from './furumiApi'
import { fmt } from './utils'
const MAX_PLAYBACK_ERROR_SKIPS = 5
/** Seconds from track start above which "previous" rewinds current track instead. */
const PREV_TRACK_REWIND_THRESHOLD_SEC = 3
export interface AudioPlaybackCallbacks {
onEnded: () => void
/** Called after a recoverable playback error (to advance queue). */
onErrorSkip: () => void
onToast: (msg: string) => void
}
export interface AudioPlaybackHandle {
loadStreamForTrack(slug: string): Promise<void>
pauseAndClearSource(): void
togglePlay(whenNoSource: () => void): void
seekFromProgressBarClick(e: MouseEvent): void
toggleMute(): void
setVolume(percent: number): void
seekToTime(seconds: number): void
/** If current time is past the threshold, seeks to 0 and returns true (caller should skip prev-track logic). */
rewindCurrentTrackIfPastThreshold(): boolean
dispose(): void
}
function syncVolumeFromStorage(audio: HTMLAudioElement) {
try {
const v = window.localStorage.getItem('furumi_vol')
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
if (v !== null && volSlider) {
audio.volume = Number(v) / 100
volSlider.value = v
}
} catch {
// ignore
}
}
export function attachAudioPlayback(
audio: HTMLAudioElement,
callbacks: AudioPlaybackCallbacks,
): AudioPlaybackHandle {
let muted = false
let playbackErrorSkips = 0
syncVolumeFromStorage(audio)
function onTimeUpdate() {
if (!audio.duration) return
const fill = document.getElementById('progressFill')
const timeElapsed = document.getElementById('timeElapsed')
const timeDuration = document.getElementById('timeDuration')
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
}
function setPlayPauseButtonPlaying(playing: boolean) {
const btn = document.getElementById('btnPlayPause')
if (btn) btn.innerHTML = playing ? '&#9208;' : '&#9654;'
}
function onPlaying() {
playbackErrorSkips = 0
}
function onPlay() {
setPlayPauseButtonPlaying(true)
}
function onPause() {
setPlayPauseButtonPlaying(false)
}
function onError() {
callbacks.onToast('Playback error')
if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return
playbackErrorSkips += 1
callbacks.onErrorSkip()
}
function onEnded() {
callbacks.onEnded()
}
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('ended', onEnded)
audio.addEventListener('playing', onPlaying)
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onPause)
audio.addEventListener('error', onError)
const progressBar = document.getElementById('progressBar')
const onProgressClick = (e: Event) => seekFromProgressBarClick(e as MouseEvent)
progressBar?.addEventListener('click', onProgressClick)
const volIcon = document.getElementById('volIcon')
const onVolIconClick = () => toggleMute()
volIcon?.addEventListener('click', onVolIconClick)
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
const onVolInput = (e: Event) => {
const v = Number((e.target as HTMLInputElement).value)
setVolume(v)
}
volSlider?.addEventListener('input', onVolInput)
async function loadStreamForTrack(slug: string) {
const response = await preloadStream(slug)
audio.src = URL.createObjectURL(response?.data)
await audio.play().catch(() => { })
}
function pauseAndClearSource() {
audio.pause()
audio.src = ''
}
function togglePlay(whenNoSource: () => void) {
if (!audio.src) {
whenNoSource()
return
}
if (audio.paused) void audio.play()
else audio.pause()
}
function seekFromProgressBarClick(e: MouseEvent) {
if (!audio.duration) return
const bar = document.getElementById('progressBar') as HTMLDivElement | null
if (!bar) return
const rect = bar.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
audio.currentTime = pct * audio.duration
}
function toggleMute() {
muted = !muted
audio.muted = muted
const icon = document.getElementById('volIcon')
if (icon) icon.innerHTML = muted ? '&#128263;' : '&#128266;'
}
function setVolume(percent: number) {
audio.volume = percent / 100
const icon = document.getElementById('volIcon')
if (icon) icon.innerHTML = percent === 0 ? '&#128263;' : '&#128266;'
window.localStorage.setItem('furumi_vol', String(percent))
}
function seekToTime(seconds: number) {
audio.currentTime = seconds
}
function rewindCurrentTrackIfPastThreshold(): boolean {
if (audio.currentTime > PREV_TRACK_REWIND_THRESHOLD_SEC) {
audio.currentTime = 0
return true
}
return false
}
function dispose() {
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('playing', onPlaying)
audio.removeEventListener('play', onPlay)
audio.removeEventListener('pause', onPause)
audio.removeEventListener('error', onError)
progressBar?.removeEventListener('click', onProgressClick)
volIcon?.removeEventListener('click', onVolIconClick)
volSlider?.removeEventListener('input', onVolInput)
audio.pause()
}
return {
loadStreamForTrack,
pauseAndClearSource,
togglePlay,
seekFromProgressBarClick,
toggleMute,
setVolume,
seekToTime,
rewindCurrentTrackIfPastThreshold,
dispose,
}
}
@@ -0,0 +1,45 @@
import { SearchDropdown } from './SearchDropdown'
type SearchResultItem = {
result_type: string
slug: string
name: string
detail?: string
}
type HeaderProps = {
searchOpen: boolean
searchResults: SearchResultItem[]
onSearchSelect: (type: string, slug: string) => void
}
export function Header({
searchOpen,
searchResults,
onSearchSelect,
}: HeaderProps) {
return (
<header className="header">
<div className="header-logo">
<button className="btn-menu">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className="header-version">v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={onSearchSelect}
/>
</div>
</div>
</header>
)
}
@@ -0,0 +1,86 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { Breadcrumbs } from './Breadcrumbs'
import { LibraryList } from './LibraryList'
import { QueueList, type QueueItem } from './QueueList'
export type Crumb = { label: string; action?: () => void }
export type LibraryListItem = {
key: string
className: string
icon: string
name: string
detail?: string
nameClassName?: string
onClick: () => void
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
}
type MainPanelProps = {
breadcrumbs: Crumb[]
libraryLoading: boolean
libraryError: string | null
libraryItems: LibraryListItem[]
queueItemsView: QueueItem[]
queueOrderView: number[]
queuePlayingOrigIdxView: number
queueScrollSignal: number
onQueuePlay: (origIdx: number) => void
onQueueRemove: (origIdx: number) => void
onQueueMove: (fromPos: number, toPos: number) => void
}
export function MainPanel({
breadcrumbs,
libraryLoading,
libraryError,
libraryItems,
queueItemsView,
queueOrderView,
queuePlayingOrigIdxView,
queueScrollSignal,
onQueuePlay,
onQueueRemove,
onQueueMove,
}: MainPanelProps) {
return (
<div className="main">
<div className="sidebar-overlay" id="sidebarOverlay" />
<aside className="sidebar" id="sidebar">
<div className="sidebar-header">Library</div>
<Breadcrumbs items={breadcrumbs} />
<div className="file-list" id="fileList">
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
</div>
</aside>
<section className="queue-panel">
<div className="queue-header">
<span>Queue</span>
<div className="queue-actions">
<button className="queue-btn active" id="btnShuffle">
Shuffle
</button>
<button className="queue-btn active" id="btnRepeat">
Repeat
</button>
<button className="queue-btn" id="btnClearQueue">
Clear
</button>
</div>
</div>
<div className="queue-list" id="queueList">
<QueueList
queue={queueItemsView}
order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal}
onPlay={onQueuePlay}
onRemove={onQueueRemove}
onMove={onQueueMove}
/>
</div>
</section>
</div>
)
}
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { API_ROOT } from '../furumiApi'
import type { QueueItem } from './QueueList'
function Cover({ src }: { src: string }) {
@@ -12,7 +13,7 @@ function Cover({ src }: { src: string }) {
return <img src={src} alt="" onError={() => setErrored(true)} />
}
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
export function NowPlaying({ track }: { track: QueueItem | null }) {
if (!track) {
return (
<div className="np-info">
@@ -31,7 +32,7 @@ export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueIt
)
}
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
return (
<div className="np-info">
@@ -0,0 +1,47 @@
import { NowPlaying } from './NowPlaying'
import type { QueueItem } from './QueueList'
export function PlayerBar({ track }: { track: QueueItem | null }) {
return (
<div className="player-bar">
<NowPlaying track={track} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</button>
</div>
<div className="progress-row">
<span className="time" id="timeElapsed">
0:00
</span>
<div className="progress-bar" id="progressBar">
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
</div>
<span className="time" id="timeDuration">
0:00
</span>
</div>
</div>
<div className="volume-row">
<span className="vol-icon" id="volIcon">
&#128266;
</span>
<input
type="range"
className="volume-slider"
id="volSlider"
min={0}
max={100}
defaultValue={80}
/>
</div>
</div>
)
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { API_ROOT } from '../furumiApi'
export type QueueItem = {
slug: string
@@ -9,7 +10,6 @@ export type QueueItem = {
}
type QueueListProps = {
apiRoot: string
queue: QueueItem[]
order: number[]
playingOrigIdx: number
@@ -43,7 +43,6 @@ function Cover({ src }: { src: string }) {
}
export function QueueList({
apiRoot,
queue,
order,
playingOrigIdx,
@@ -78,7 +77,7 @@ export function QueueList({
if (!t) return null
const isPlaying = origIdx === playingOrigIdx
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
const dur = t.duration ? fmt(t.duration) : ''
const isDragging = draggingPos === pos
const isDragOver = dragOverPos === pos
+45 -8
View File
@@ -1,12 +1,49 @@
export type FurumiApiClient = (path: string) => Promise<unknown | null>
import axios from 'axios'
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
const API = apiRoot
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
export const API_ROOT = `${API_BASE}/api`
return async function api(path: string) {
const r = await fetch(API + path)
if (!r.ok) return null
return r.json()
}
const API_KEY = import.meta.env.VITE_API_KEY
export const furumiApi = axios.create({
baseURL: API_ROOT,
headers: API_KEY ? { 'x-api-key': API_KEY } : {},
})
export async function getArtists(): Promise<Artist[] | null> {
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
return res?.data ?? null
}
export async function getArtistAlbums(artistSlug: string): Promise<Album[] | null> {
const res = await furumiApi.get<Album[]>(`/artists/${artistSlug}/albums`).catch(() => null)
return res?.data ?? null
}
export async function getAlbumTracks(albumSlug: string): Promise<Track[] | null> {
const res = await furumiApi.get<Track[]>(`/albums/${albumSlug}`).catch(() => null)
return res?.data ?? null
}
export async function getArtistTracks(artistSlug: string): Promise<Track[] | null> {
const res = await furumiApi.get<Track[]>(`/artists/${artistSlug}/tracks`).catch(() => null)
return res?.data ?? null
}
export async function searchTracks(query: string): Promise<SearchResult[] | null> {
const res = await furumiApi
.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
.catch(() => null)
return res?.data ?? null
}
export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | null> {
const res = await furumiApi.get<TrackDetail>(`/tracks/${trackSlug}`).catch(() => null)
return res?.data ?? null
}
export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
}
+9
View File
@@ -1,3 +1,8 @@
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -5,6 +10,10 @@ body {
background-color: #f3f6fb;
}
#root {
height: 100%;
}
* {
box-sizing: border-box;
}
+5 -1
View File
@@ -1,10 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)
@@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
import artistsReducer from './slices/artistsSlice'
import albumsReducer from './slices/albumsSlice'
import albumTracksReducer from './slices/albumTracksSlice'
import artistTracksReducer from './slices/artistTracksSlice'
import trackDetailReducer from './slices/trackDetailSlice'
import queueReducer from './slices/queueSlice'
export const store = configureStore({
reducer: {
artists: artistsReducer,
albums: albumsReducer,
albumTracks: albumTracksReducer,
artistTracks: artistTracksReducer,
trackDetail: trackDetailReducer,
queue: queueReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Track } from '../../types'
import { getAlbumTracks } from '../../furumiApi'
export const fetchAlbumTracks = createAsyncThunk(
'albumTracks/fetch',
async (albumSlug: string, { rejectWithValue }) => {
const data = await getAlbumTracks(albumSlug)
if (data === null) return rejectWithValue('Failed to fetch album tracks')
return { albumSlug, tracks: data }
},
)
interface AlbumTracksState {
byAlbum: Record<string, Track[]>
loading: boolean
error: string | null
}
const initialState: AlbumTracksState = {
byAlbum: {},
loading: false,
error: null,
}
const albumTracksSlice = createSlice({
name: 'albumTracks',
initialState,
reducers: {
clearAlbumTracks(state) {
state.byAlbum = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchAlbumTracks.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchAlbumTracks.fulfilled, (state, action) => {
state.loading = false
state.byAlbum[action.payload.albumSlug] = action.payload.tracks
state.error = null
})
.addCase(fetchAlbumTracks.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearAlbumTracks } = albumTracksSlice.actions
export default albumTracksSlice.reducer
@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Album } from '../../types'
import { getArtistAlbums } from '../../furumiApi'
export const fetchArtistAlbums = createAsyncThunk(
'albums/fetchByArtist',
async (artistSlug: string, { rejectWithValue }) => {
const data = await getArtistAlbums(artistSlug)
if (data === null) return rejectWithValue('Failed to fetch albums')
return { artistSlug, albums: data }
},
)
interface AlbumsState {
byArtist: Record<string, Album[]>
loading: boolean
error: string | null
}
const initialState: AlbumsState = {
byArtist: {},
loading: false,
error: null,
}
const albumsSlice = createSlice({
name: 'albums',
initialState,
reducers: {
clearAlbums(state) {
state.byArtist = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtistAlbums.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtistAlbums.fulfilled, (state, action) => {
state.loading = false
state.byArtist[action.payload.artistSlug] = action.payload.albums
state.error = null
})
.addCase(fetchArtistAlbums.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearAlbums } = albumsSlice.actions
export default albumsSlice.reducer
@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Track } from '../../types'
import { getArtistTracks } from '../../furumiApi'
export const fetchArtistTracks = createAsyncThunk(
'artistTracks/fetch',
async (artistSlug: string, { rejectWithValue }) => {
const data = await getArtistTracks(artistSlug)
if (data === null) return rejectWithValue('Failed to fetch artist tracks')
return { artistSlug, tracks: data }
},
)
interface ArtistTracksState {
byArtist: Record<string, Track[]>
loading: boolean
error: string | null
}
const initialState: ArtistTracksState = {
byArtist: {},
loading: false,
error: null,
}
const artistTracksSlice = createSlice({
name: 'artistTracks',
initialState,
reducers: {
clearArtistTracks(state) {
state.byArtist = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtistTracks.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtistTracks.fulfilled, (state, action) => {
state.loading = false
state.byArtist[action.payload.artistSlug] = action.payload.tracks
state.error = null
})
.addCase(fetchArtistTracks.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearArtistTracks } = artistTracksSlice.actions
export default artistTracksSlice.reducer
@@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Artist } from '../../types'
import { getArtists } from '../../furumiApi'
export const fetchArtists = createAsyncThunk(
'artists/fetch',
async (_, { rejectWithValue }) => {
const data = await getArtists()
if (data === null) return rejectWithValue('Failed to fetch artists')
return data
},
)
interface ArtistsState {
items: Artist[]
loading: boolean
error: string | null
}
const initialState: ArtistsState = {
items: [],
loading: false,
error: null,
}
const artistsSlice = createSlice({
name: 'artists',
initialState,
reducers: {
clearArtists(state) {
state.items = []
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtists.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtists.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload
state.error = null
})
.addCase(fetchArtists.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearArtists } = artistsSlice.actions
export default artistsSlice.reducer
@@ -0,0 +1,273 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import type { QueueItem } from '../../components/QueueList'
export interface QueueState {
items: QueueItem[]
currentIndex: number
shuffle: boolean
repeatAll: boolean
shuffleOrder: number[]
scrollSignal: number
}
function readShufflePref(): boolean {
try {
return window.localStorage.getItem('furumi_shuffle') === '1'
} catch {
return false
}
}
function readRepeatPref(): boolean {
try {
return window.localStorage.getItem('furumi_repeat') !== '0'
} catch {
return true
}
}
function buildShuffleOrder(state: QueueState) {
const n = state.items.length
if (n === 0) {
state.shuffleOrder = []
return
}
const order = [...Array(n).keys()]
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[order[i], order[j]] = [order[j], order[i]]
}
if (state.currentIndex !== -1) {
const ci = order.indexOf(state.currentIndex)
if (ci > 0) {
order.splice(ci, 1)
order.unshift(state.currentIndex)
}
}
state.shuffleOrder = order
}
function ensureShuffleOrder(state: QueueState) {
if (!state.shuffle) return
if (state.shuffleOrder.length !== state.items.length) {
buildShuffleOrder(state)
}
}
const initialState: QueueState = {
items: [],
currentIndex: -1,
shuffle: typeof window !== 'undefined' ? readShufflePref() : false,
repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true,
shuffleOrder: [],
scrollSignal: 0,
}
const queueSlice = createSlice({
name: 'queue',
initialState,
reducers: {
addTrack(
state,
action: PayloadAction<{
track: QueueItem
playNow?: boolean
}>,
) {
const { track, playNow } = action.payload
const existing = state.items.findIndex((t) => t.slug === track.slug)
if (existing !== -1) {
if (playNow) {
state.currentIndex = existing
state.scrollSignal += 1
}
return
}
const oldLen = state.items.length
const idle = state.currentIndex === -1
state.items.push(track)
ensureShuffleOrder(state)
if (playNow || (oldLen === 0 && idle)) {
state.currentIndex = state.items.length - 1
state.scrollSignal += 1
}
},
addTracksBatch(
state,
action: PayloadAction<{
tracks: QueueItem[]
playFirst?: boolean
}>,
) {
const { tracks, playFirst } = action.payload
let firstNewIdx: number | null = null
for (const t of tracks) {
if (state.items.some((q) => q.slug === t.slug)) continue
if (firstNewIdx === null) firstNewIdx = state.items.length
state.items.push(t)
}
ensureShuffleOrder(state)
if (firstNewIdx === null) return
if (playFirst || state.currentIndex === -1) {
state.currentIndex = firstNewIdx
state.scrollSignal += 1
}
},
replaceQueue(
state,
action: PayloadAction<{
items: QueueItem[]
playFromIndex?: number
}>,
) {
const { items, playFromIndex = 0 } = action.payload
state.items = items
state.currentIndex =
items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1
state.shuffleOrder = []
ensureShuffleOrder(state)
},
clearQueue(state) {
state.items = []
state.currentIndex = -1
state.shuffleOrder = []
state.scrollSignal += 1
},
playAtIndex(state, action: PayloadAction<number>) {
const i = action.payload
if (i < 0 || i >= state.items.length) return
state.currentIndex = i
state.scrollSignal += 1
},
removeFromQueueAt(state, action: PayloadAction<number>) {
const idx = action.payload
if (idx < 0 || idx >= state.items.length) return
if (idx === state.currentIndex) {
state.currentIndex = -1
} else if (state.currentIndex > idx) {
state.currentIndex -= 1
}
state.items.splice(idx, 1)
if (state.shuffle) {
const si = state.shuffleOrder.indexOf(idx)
if (si !== -1) state.shuffleOrder.splice(si, 1)
for (let i = 0; i < state.shuffleOrder.length; i++) {
if (state.shuffleOrder[i] > idx) state.shuffleOrder[i] -= 1
}
}
ensureShuffleOrder(state)
},
moveQueueItemInOrder(
state,
action: PayloadAction<{ fromPos: number; toPos: number }>,
) {
const { fromPos, toPos } = action.payload
if (fromPos === toPos) return
if (state.shuffle) {
const order = state.shuffleOrder
if (fromPos < 0 || fromPos >= order.length) return
if (toPos < 0 || toPos >= order.length) return
const item = order.splice(fromPos, 1)[0]
order.splice(toPos, 0, item)
return
}
const items = state.items
if (fromPos < 0 || fromPos >= items.length) return
if (toPos < 0 || toPos >= items.length) return
const qIdx = state.currentIndex
const item = items.splice(fromPos, 1)[0]
items.splice(toPos, 0, item)
if (qIdx === fromPos) state.currentIndex = toPos
else if (fromPos < qIdx && toPos >= qIdx) state.currentIndex -= 1
else if (fromPos > qIdx && toPos <= qIdx) state.currentIndex += 1
},
toggleShuffle(state) {
state.shuffle = !state.shuffle
try {
window.localStorage.setItem('furumi_shuffle', state.shuffle ? '1' : '0')
} catch {
// ignore
}
if (state.shuffle) buildShuffleOrder(state)
else state.shuffleOrder = []
},
toggleRepeat(state) {
state.repeatAll = !state.repeatAll
try {
window.localStorage.setItem('furumi_repeat', state.repeatAll ? '1' : '0')
} catch {
// ignore
}
},
rebuildShuffleOrder(state) {
if (state.shuffle) buildShuffleOrder(state)
},
},
})
export const {
addTrack,
addTracksBatch,
replaceQueue,
clearQueue,
playAtIndex,
removeFromQueueAt,
moveQueueItemInOrder,
toggleShuffle,
toggleRepeat,
rebuildShuffleOrder,
} = queueSlice.actions
type QueueSliceRoot = { queue: QueueState }
export function selectQueueItems(state: QueueSliceRoot) {
return state.queue.items
}
export function selectQueueOrder(state: QueueSliceRoot): number[] {
const q = state.queue
if (!q.shuffle) return q.items.map((_, i) => i)
if (q.shuffleOrder.length !== q.items.length) {
return q.items.map((_, i) => i)
}
return q.shuffleOrder
}
export function selectPlayingOrigIdx(state: QueueSliceRoot) {
return state.queue.currentIndex
}
export function selectQueueScrollSignal(state: QueueSliceRoot) {
return state.queue.scrollSignal
}
export function selectNowPlayingTrack(state: QueueSliceRoot): QueueItem | null {
const q = state.queue
if (q.currentIndex < 0 || q.currentIndex >= q.items.length) return null
return q.items[q.currentIndex]
}
export function selectShuffle(state: QueueSliceRoot) {
return state.queue.shuffle
}
export function selectRepeatAll(state: QueueSliceRoot) {
return state.queue.repeatAll
}
export default queueSlice.reducer
@@ -0,0 +1,57 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { TrackDetail } from '../../types'
import { getTrackInfo } from '../../furumiApi'
export const fetchTrackDetail = createAsyncThunk(
'trackDetail/fetch',
async (trackSlug: string, { rejectWithValue }) => {
const data = await getTrackInfo(trackSlug)
if (data === null) return rejectWithValue('Failed to fetch track detail')
return { trackSlug, detail: data }
},
)
interface TrackDetailState {
bySlug: Record<string, TrackDetail>
loading: boolean
error: string | null
}
const initialState: TrackDetailState = {
bySlug: {},
loading: false,
error: null,
}
const trackDetailSlice = createSlice({
name: 'trackDetail',
initialState,
reducers: {
clearTrackDetail(state) {
state.bySlug = {}
state.error = null
},
removeTrackDetail(state, action: { payload: string }) {
delete state.bySlug[action.payload]
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTrackDetail.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchTrackDetail.fulfilled, (state, action) => {
state.loading = false
state.bySlug[action.payload.trackSlug] = action.payload.detail
state.error = null
})
.addCase(fetchTrackDetail.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions
export default trackDetailSlice.reducer
+42
View File
@@ -0,0 +1,42 @@
// API entity types (see PLAYER-API.md)
export interface Artist {
slug: string
name: string
album_count: number
track_count: number
}
export interface Album {
slug: string
name: string
year: number | null
track_count: number
has_cover: boolean
}
export interface Track {
slug: string
title: string
track_number: number | null
duration_secs: number
artist_name: string
album_name: string | null
album_slug: string | null
genre: string | null
}
export interface TrackDetail extends Track {
storage_path: string
artist_slug: string
album_year: number | null
}
export type SearchResultType = 'artist' | 'album' | 'track'
export interface SearchResult {
result_type: SearchResultType
slug: string
name: string
detail: string | null
}
+14
View File
@@ -0,0 +1,14 @@
function pad(n: number) {
return String(n).padStart(2, '0')
}
export function fmt(secs: number) {
if (!secs || Number.isNaN(secs)) return '0:00'
const s = Math.floor(secs)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h > 0) {
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
}
return `${m}:${pad(s % 60)}`
}
+1
View File
@@ -25,3 +25,4 @@ base64 = "0.22"
rand = "0.8"
urlencoding = "2.1.3"
rustls = { version = "0.23", features = ["ring"] }
tower-http = { version = "0.6", features = ["cors"] }
+9
View File
@@ -39,6 +39,10 @@ struct Args {
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
oidc_session_secret: Option<String>,
/// API key for x-api-key header auth (alternative to OIDC session)
#[arg(long, env = "FURUMI_PLAYER_API_KEY")]
api_key: Option<String>,
}
#[tokio::main]
@@ -90,10 +94,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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);
+33 -17
View File
@@ -5,6 +5,8 @@ use axum::{
middleware::Next,
response::{Html, IntoResponse, Redirect, Response},
};
const X_API_KEY: &str = "x-api-key";
use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client,
@@ -92,37 +94,51 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
}
}
/// Auth middleware: requires valid SSO session cookie.
/// Auth middleware: requires valid SSO session cookie or x-api-key header.
pub async fn require_auth(
State(state): State<Arc<AppState>>,
req: Request,
next: Next,
) -> Response {
let oidc = match &state.oidc {
Some(o) => o,
None => return next.run(req).await, // No OIDC configured = no auth
};
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() {
// 1. Check x-api-key header (if configured)
if let Some(ref expected) = state.api_key {
if let Some(val) = req
.headers()
.get(X_API_KEY)
.and_then(|v| v.to_str().ok())
{
if val == expected {
return next.run(req).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("");
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;
}
}
}
}
let uri = req.uri().to_string();
if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else {
} 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()
}
}
+13 -2
View File
@@ -3,9 +3,12 @@ pub mod auth;
use std::sync::Arc;
use std::path::PathBuf;
use std::time::Duration;
use axum::{Router, routing::get, middleware};
use axum::http::{header, Method};
use sqlx::PgPool;
use tower_http::cors::{Any, CorsLayer};
#[derive(Clone)]
pub struct AppState {
@@ -13,6 +16,7 @@ pub struct AppState {
#[allow(dead_code)]
pub storage_dir: Arc<PathBuf>,
pub oidc: Option<Arc<auth::OidcState>>,
pub api_key: Option<String>,
}
pub fn build_router(state: Arc<AppState>) -> Router {
@@ -32,21 +36,28 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/", get(player_html))
.nest("/api", library);
let has_oidc = state.oidc.is_some();
let requires_auth = state.oidc.is_some();
let app = if has_oidc {
let app = if requires_auth {
authed
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
} else {
authed
};
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")])
.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)
}