Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1f75b3ee2 | |||
| f3392eff9f | |||
| e99cacae8b | |||
| 94d14e8fc8 | |||
| 0b6f518b72 | |||
| 3199c12af5 | |||
| daaa3b0814 |
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Новые React-компоненты — папка kebab-case, TSX + CSS module, index.ts (furumi-node-player/client/src)
|
||||
globs: furumi-node-player/client/src/**/*
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Структура новых компонентов (furumi-node-player/client)
|
||||
|
||||
**Область:** при добавлении **нового** компонента в `furumi-node-player/client/src/` используйте расположение и файлы ниже.
|
||||
|
||||
## Расположение
|
||||
|
||||
- Базовая папка: `furumi-node-player/client/src/components/`
|
||||
- Для каждого компонента — **отдельная подпапка** с именем в **kebab-case** (например `play-button`, `search-dropdown`).
|
||||
|
||||
## Файлы внутри папки компонента
|
||||
|
||||
1. **Компонент:** `components/<имя-kebab>/<имя-kebab>.tsx` — реализация компонента; к нему подключён CSS-модуль (`import styles from './<имя-kebab>.module.css'`).
|
||||
2. **Стили:** `components/<имя-kebab>/<имя-kebab>.module.css` — модульные стили для этого компонента.
|
||||
3. **Баррель:** `components/<имя-kebab>/index.ts` — **реэкспорт всего** из файла компонента: `export * from './<имя-kebab>'`.
|
||||
|
||||
Импорт снаружи: `import { MyWidget } from './components/my-widget'` (относительный путь к папке), без указания `index.ts`.
|
||||
|
||||
## Пример (`my-widget`)
|
||||
|
||||
```
|
||||
components/my-widget/
|
||||
my-widget.tsx
|
||||
my-widget.module.css
|
||||
index.ts
|
||||
```
|
||||
|
||||
```typescript
|
||||
// my-widget.tsx
|
||||
import styles from './my-widget.module.css'
|
||||
|
||||
export function MyWidget() {
|
||||
return <div className={styles.root}>…</div>
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// index.ts
|
||||
export * from './my-widget'
|
||||
```
|
||||
|
||||
## Примечание
|
||||
|
||||
Существующие файлы-компоненты в корне `components/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
|
||||
@@ -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
|
||||
@@ -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
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
/target
|
||||
/inbox
|
||||
/storage
|
||||
/docker/inbox
|
||||
/docker/storage
|
||||
.env
|
||||
|
||||
Generated
+18
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,16 +25,37 @@ pub async fn normalize(
|
||||
) -> anyhow::Result<NormalizedFields> {
|
||||
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<OllamaMessage>,
|
||||
format: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
response_format: Option<ChatResponseFormat>,
|
||||
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<ChatChoice>,
|
||||
}
|
||||
|
||||
#[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<String> {
|
||||
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<NormalizedFields> {
|
||||
genre: Option<String>,
|
||||
#[serde(default)]
|
||||
featured_artists: Vec<String>,
|
||||
#[serde(rename = "release_kind")]
|
||||
release_type: Option<String>,
|
||||
confidence: Option<f64>,
|
||||
notes: Option<String>,
|
||||
|
||||
@@ -35,12 +35,39 @@ pub async fn propose_merge(state: &Arc<AppState>, 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)?;
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8085
|
||||
VITE_API_KEY=
|
||||
VITE_FURUMI_API_URL=http://localhost:8085
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
playAtIndex,
|
||||
removeFromQueueAt,
|
||||
moveQueueItemInOrder,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
rebuildShuffleOrder,
|
||||
selectQueueOrder,
|
||||
selectPlayingOrigIdx,
|
||||
@@ -26,7 +24,7 @@ import {
|
||||
} from './store/slices/queueSlice'
|
||||
import { attachAudioPlayback } from './audioPlaybackService'
|
||||
import { fmt } from './utils'
|
||||
import { Header } from './components/Header'
|
||||
import { Header } from './components/header'
|
||||
import { MainPanel, type Crumb } from './components/MainPanel'
|
||||
import { PlayerBar } from './components/PlayerBar'
|
||||
import type { Track } from './types'
|
||||
@@ -71,6 +69,7 @@ export function FurumiPlayer() {
|
||||
playIndex: (i: number) => void
|
||||
removeFromQueue: (idx: number) => void
|
||||
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||
clearQueue: () => void
|
||||
} | null>(null)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null)
|
||||
@@ -97,16 +96,6 @@ export function FurumiPlayer() {
|
||||
}
|
||||
}, [nowPlayingTrack])
|
||||
|
||||
const shuffle = useAppSelector((s) => s.queue.shuffle)
|
||||
const repeatAll = useAppSelector((s) => s.queue.repeatAll)
|
||||
|
||||
useEffect(() => {
|
||||
const btnShuffle = document.getElementById('btnShuffle')
|
||||
const btnRepeat = document.getElementById('btnRepeat')
|
||||
btnShuffle?.classList.toggle('active', shuffle)
|
||||
btnRepeat?.classList.toggle('active', repeatAll)
|
||||
}, [shuffle, repeatAll])
|
||||
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current
|
||||
if (!audioEl) return
|
||||
@@ -359,14 +348,7 @@ export function FurumiPlayer() {
|
||||
playIndex,
|
||||
removeFromQueue,
|
||||
moveQueueItem,
|
||||
}
|
||||
|
||||
function onToggleShuffle() {
|
||||
dispatch(toggleShuffle())
|
||||
}
|
||||
|
||||
function onToggleRepeat() {
|
||||
dispatch(toggleRepeat())
|
||||
clearQueue: clearQueuePlayback,
|
||||
}
|
||||
|
||||
function onSearch(q: string) {
|
||||
@@ -452,20 +434,10 @@ export function FurumiPlayer() {
|
||||
searchInput.addEventListener('keydown', onSearchKeydown)
|
||||
}
|
||||
|
||||
const onShuffleClick = () => onToggleShuffle()
|
||||
const onRepeatClick = () => onToggleRepeat()
|
||||
const onClearClick = () => clearQueuePlayback()
|
||||
const onPrevClick = () => prevTrack()
|
||||
const onPlayClick = () => togglePlay()
|
||||
const onNextClick = () => nextTrack()
|
||||
|
||||
const btnShuffle = document.getElementById('btnShuffle')
|
||||
btnShuffle?.addEventListener('click', onShuffleClick)
|
||||
const btnRepeat = document.getElementById('btnRepeat')
|
||||
btnRepeat?.addEventListener('click', onRepeatClick)
|
||||
const btnClear = document.getElementById('btnClearQueue')
|
||||
btnClear?.addEventListener('click', onClearClick)
|
||||
|
||||
const btnPrev = document.getElementById('btnPrev')
|
||||
btnPrev?.addEventListener('click', onPrevClick)
|
||||
const btnPlay = document.getElementById('btnPlayPause')
|
||||
@@ -503,9 +475,6 @@ export function FurumiPlayer() {
|
||||
sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick)
|
||||
searchInput?.removeEventListener('input', onSearchInput)
|
||||
searchInput?.removeEventListener('keydown', onSearchKeydown)
|
||||
btnShuffle?.removeEventListener('click', onShuffleClick)
|
||||
btnRepeat?.removeEventListener('click', onRepeatClick)
|
||||
btnClear?.removeEventListener('click', onClearClick)
|
||||
btnPrev?.removeEventListener('click', onPrevClick)
|
||||
btnPlay?.removeEventListener('click', onPlayClick)
|
||||
btnNext?.removeEventListener('click', onNextClick)
|
||||
@@ -550,10 +519,22 @@ export function FurumiPlayer() {
|
||||
libraryLoading={libraryLoading}
|
||||
libraryError={libraryError}
|
||||
libraryItems={libraryItems}
|
||||
queueItemsView={queueItemsView}
|
||||
queueOrderView={queueOrderView}
|
||||
queuePlayingOrigIdxView={queuePlayingOrigIdxView}
|
||||
queueScrollSignal={queueScrollSignal}
|
||||
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||
onQueueRemove={(origIdx) =>
|
||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||
}
|
||||
onQueueMove={(fromPos, toPos) =>
|
||||
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||
}
|
||||
onClearQueue={() => queueActionsRef.current?.clearQueue()}
|
||||
/>
|
||||
|
||||
<PlayerBar
|
||||
track={nowPlayingTrack}
|
||||
queue={queueItemsView}
|
||||
order={queueOrderView}
|
||||
playingOrigIdx={queuePlayingOrigIdxView}
|
||||
scrollSignal={queueScrollSignal}
|
||||
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||
onQueueRemove={(origIdx) =>
|
||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||
@@ -563,8 +544,6 @@ export function FurumiPlayer() {
|
||||
}
|
||||
/>
|
||||
|
||||
<PlayerBar track={nowPlayingTrack} />
|
||||
|
||||
<div className="toast" id="toast" />
|
||||
<audio ref={audioRef} />
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,18 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useAppDispatch, useAppSelector } from '../store'
|
||||
import {
|
||||
selectPlayingOrigIdx,
|
||||
selectQueueItems,
|
||||
selectQueueOrder,
|
||||
selectQueueScrollSignal,
|
||||
selectRepeatAll,
|
||||
selectShuffle,
|
||||
toggleRepeat,
|
||||
toggleShuffle,
|
||||
} from '../store/slices/queueSlice'
|
||||
import { Breadcrumbs } from './Breadcrumbs'
|
||||
import { LibraryList } from './LibraryList'
|
||||
import { QueueList, type QueueItem } from './QueueList'
|
||||
import { QueueList } from './QueueList'
|
||||
|
||||
export type Crumb = { label: string; action?: () => void }
|
||||
|
||||
@@ -21,13 +32,10 @@ type MainPanelProps = {
|
||||
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
|
||||
onClearQueue: () => void
|
||||
}
|
||||
|
||||
export function MainPanel({
|
||||
@@ -35,14 +43,19 @@ export function MainPanel({
|
||||
libraryLoading,
|
||||
libraryError,
|
||||
libraryItems,
|
||||
queueItemsView,
|
||||
queueOrderView,
|
||||
queuePlayingOrigIdxView,
|
||||
queueScrollSignal,
|
||||
onQueuePlay,
|
||||
onQueueRemove,
|
||||
onQueueMove,
|
||||
onClearQueue,
|
||||
}: MainPanelProps) {
|
||||
const dispatch = useAppDispatch()
|
||||
const queueItemsView = useAppSelector(selectQueueItems)
|
||||
const queueOrderView = useAppSelector(selectQueueOrder)
|
||||
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
|
||||
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
|
||||
const shuffle = useAppSelector(selectShuffle)
|
||||
const repeatAll = useAppSelector(selectRepeatAll)
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div className="sidebar-overlay" id="sidebarOverlay" />
|
||||
@@ -58,13 +71,21 @@ export function MainPanel({
|
||||
<div className="queue-header">
|
||||
<span>Queue</span>
|
||||
<div className="queue-actions">
|
||||
<button className="queue-btn active" id="btnShuffle">
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-btn${shuffle ? ' active' : ''}`}
|
||||
onClick={() => dispatch(toggleShuffle())}
|
||||
>
|
||||
Shuffle
|
||||
</button>
|
||||
<button className="queue-btn active" id="btnRepeat">
|
||||
<button
|
||||
type="button"
|
||||
className={`queue-btn${repeatAll ? ' active' : ''}`}
|
||||
onClick={() => dispatch(toggleRepeat())}
|
||||
>
|
||||
Repeat
|
||||
</button>
|
||||
<button className="queue-btn" id="btnClearQueue">
|
||||
<button type="button" className="queue-btn" onClick={onClearQueue}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
import { NowPlaying } from './NowPlaying'
|
||||
import { QueuePopover } from './queue-popover'
|
||||
import type { QueueItem } from './QueueList'
|
||||
|
||||
export function PlayerBar({ track }: { track: QueueItem | null }) {
|
||||
type PlayerBarProps = {
|
||||
track: QueueItem | null
|
||||
queue: QueueItem[]
|
||||
order: number[]
|
||||
playingOrigIdx: number
|
||||
scrollSignal: number
|
||||
onQueuePlay: (origIdx: number) => void
|
||||
onQueueRemove: (origIdx: number) => void
|
||||
onQueueMove: (fromPos: number, toPos: number) => void
|
||||
}
|
||||
|
||||
export function PlayerBar({
|
||||
track,
|
||||
queue,
|
||||
order,
|
||||
playingOrigIdx,
|
||||
scrollSignal,
|
||||
onQueuePlay,
|
||||
onQueueRemove,
|
||||
onQueueMove,
|
||||
}: PlayerBarProps) {
|
||||
return (
|
||||
<div className="player-bar">
|
||||
<NowPlaying track={track} />
|
||||
@@ -30,6 +51,15 @@ export function PlayerBar({ track }: { track: QueueItem | null }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="volume-row">
|
||||
<QueuePopover
|
||||
queue={queue}
|
||||
order={order}
|
||||
playingOrigIdx={playingOrigIdx}
|
||||
scrollSignal={scrollSignal}
|
||||
onPlay={onQueuePlay}
|
||||
onRemove={onQueueRemove}
|
||||
onMove={onQueueMove}
|
||||
/>
|
||||
<span className="vol-icon" id="volIcon">
|
||||
🔊
|
||||
</span>
|
||||
|
||||
+5
-4
@@ -1,4 +1,5 @@
|
||||
import { SearchDropdown } from './SearchDropdown'
|
||||
import { SearchDropdown } from '../SearchDropdown'
|
||||
import styles from './header.module.css'
|
||||
|
||||
type SearchResultItem = {
|
||||
result_type: string
|
||||
@@ -19,8 +20,8 @@ export function Header({
|
||||
onSearchSelect,
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<header className="header">
|
||||
<div className="header-logo">
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLogo}>
|
||||
<button className="btn-menu">☰</button>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="9" cy="18" r="3" />
|
||||
@@ -28,7 +29,7 @@ export function Header({
|
||||
<path d="M12 18V6l9-3v3" />
|
||||
</svg>
|
||||
Furumi
|
||||
<span className="header-version">v</span>
|
||||
<span className={styles.headerVersion}>v</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div className="search-wrap">
|
||||
@@ -0,0 +1,35 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.headerLogo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.headerLogo svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.headerVersion {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Header'
|
||||
@@ -0,0 +1 @@
|
||||
export * from './queue-popover'
|
||||
@@ -0,0 +1,68 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.35rem;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.triggerIcon {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: min(100vw - 2rem, 320px);
|
||||
max-width: min(100vw - 2rem, 360px);
|
||||
max-height: min(50vh, 360px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.body :global(.queue-empty) {
|
||||
padding: 1.25rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { QueueList, type QueueItem } from '../QueueList'
|
||||
import styles from './queue-popover.module.css'
|
||||
|
||||
export type QueuePopoverProps = {
|
||||
queue: QueueItem[]
|
||||
order: number[]
|
||||
playingOrigIdx: number
|
||||
scrollSignal: number
|
||||
onPlay: (origIdx: number) => void
|
||||
onRemove: (origIdx: number) => void
|
||||
onMove: (fromPos: number, toPos: number) => void
|
||||
}
|
||||
|
||||
export function QueuePopover({
|
||||
queue,
|
||||
order,
|
||||
playingOrigIdx,
|
||||
scrollSignal,
|
||||
onPlay,
|
||||
onRemove,
|
||||
onMove,
|
||||
}: QueuePopoverProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const titleId = useId()
|
||||
const panelId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onDocMouseDown(e: MouseEvent) {
|
||||
const el = rootRef.current
|
||||
if (el && !el.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocMouseDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
title="Playback queue"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-controls={open ? panelId : undefined}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className={styles.triggerIcon} aria-hidden>
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={panelId}
|
||||
className={styles.popover}
|
||||
role="dialog"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className={styles.header} id={titleId}>
|
||||
Queue
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<QueueList
|
||||
queue={queue}
|
||||
order={order}
|
||||
playingOrigIdx={playingOrigIdx}
|
||||
scrollSignal={scrollSignal}
|
||||
onPlay={onPlay}
|
||||
onRemove={onRemove}
|
||||
onMove={onMove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,40 +31,6 @@
|
||||
--danger: #f87171;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.header-logo svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.header-version {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-menu {
|
||||
display: none;
|
||||
@@ -378,6 +344,10 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.qi-title {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.queue-item .qi-index {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
@@ -531,6 +501,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.np-artist {
|
||||
|
||||
@@ -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<Artist[] | null> {
|
||||
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
|
||||
return res?.data ?? null
|
||||
|
||||
@@ -239,6 +239,7 @@ export function selectQueueItems(state: QueueSliceRoot) {
|
||||
return state.queue.items
|
||||
}
|
||||
|
||||
// TODO: toggle shuffle should rebuild the shuffle order
|
||||
export function selectQueueOrder(state: QueueSliceRoot): number[] {
|
||||
const q = state.queue
|
||||
if (!q.shuffle) return q.items.map((_, i) => i)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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_in;
|
||||
if (!accessToken) {
|
||||
res.status(500).json({ error: 'no access token in session' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 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('/{*path}', (_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}`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -40,9 +40,6 @@ struct Args {
|
||||
#[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]
|
||||
@@ -94,15 +91,10 @@ 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);
|
||||
|
||||
@@ -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<sha2::Sha256>;
|
||||
|
||||
pub struct OidcState {
|
||||
pub client: CoreClient,
|
||||
pub session_secret: Vec<u8>,
|
||||
jwks_uri: String,
|
||||
issuer_url: String,
|
||||
jwks_cache: RwLock<Option<(JwkSet, Instant)>>,
|
||||
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<JwkSet> {
|
||||
{
|
||||
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<JwkSet> {
|
||||
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<String> {
|
||||
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::<BearerClaims>(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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Arc<AppState>>,
|
||||
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<Arc<AppState>>) -> 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#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Furumi Player — Login</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: #0d0f14;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.card {
|
||||
background: #161b27;
|
||||
border: 1px solid #2a3347;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 3rem;
|
||||
width: 360px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||
text-align: center;
|
||||
}
|
||||
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
||||
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
||||
.btn-sso {
|
||||
display: block; width: 100%; padding: 0.75rem; text-align: center;
|
||||
background: #7c6af7; border: none; border-radius: 8px;
|
||||
color: #fff; font-size: 0.95rem; font-weight: 600; text-decoration: none;
|
||||
cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.btn-sso:hover { background: #6b58e8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">Furumi</div>
|
||||
<div class="subtitle">Sign in to continue</div>
|
||||
<a href="/auth/login" class="btn-sso">SSO Login</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
@@ -16,7 +16,6 @@ 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,37 +31,27 @@ pub fn build_router(state: Arc<AppState>) -> 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<String> {
|
||||
let html = include_str!("player.html")
|
||||
.replace("<!-- VERSION_PLACEHOLDER -->", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")));
|
||||
axum::response::Html(html)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user