diff --git a/.github/workflows/docker-publish-node-player-dev.yml b/.github/workflows/docker-publish-node-player-dev.yml new file mode 100644 index 0000000..d0ec036 --- /dev/null +++ b/.github/workflows/docker-publish-node-player-dev.yml @@ -0,0 +1,40 @@ +name: Publish Node Player Image (dev) + +on: + push: + branches: + - DEV + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.node-player + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-publish-node-player.yml b/.github/workflows/docker-publish-node-player.yml new file mode 100644 index 0000000..41d2693 --- /dev/null +++ b/.github/workflows/docker-publish-node-player.yml @@ -0,0 +1,57 @@ +name: Publish Node Player Image + +on: + push: + branches: + - '**' + - '!DEV' + tags: + - 'v*.*.*' + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine version and tags + id: info + run: | + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)" + + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + TAG="${{ github.ref_name }}" + VERSION="${TAG#v}" + echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.node-player + push: true + tags: ${{ steps.info.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index eeb9a57..30f4604 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target -/inbox -/storage +/docker/inbox +/docker/storage .env +.DS_Store diff --git a/Cargo.lock b/Cargo.lock index 9cb8bb9..98a7483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -572,6 +578,15 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -969,6 +984,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "flume" version = "0.11.1" @@ -1017,6 +1042,7 @@ dependencies = [ "chrono", "clap", "encoding_rs", + "id3", "reqwest 0.12.28", "serde", "serde_json", @@ -1114,7 +1140,7 @@ dependencies = [ "futures-core", "futures-util", "hmac", - "jsonwebtoken", + "jsonwebtoken 10.3.0", "libc", "mime_guess", "ogg", @@ -1150,8 +1176,10 @@ dependencies = [ "anyhow", "axum", "base64 0.22.1", + "chrono", "clap", "hmac", + "jsonwebtoken 9.3.1", "mime_guess", "openidconnect", "rand 0.8.5", @@ -1165,6 +1193,7 @@ dependencies = [ "tokio", "tokio-util", "tower 0.4.13", + "tower-http", "tracing", "tracing-subscriber", "urlencoding", @@ -1748,6 +1777,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "id3" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1" +dependencies = [ + "bitflags 2.11.0", + "byteorder", + "flate2", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -1864,6 +1904,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" @@ -2021,6 +2076,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -3412,6 +3477,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simple_asn1" version = "0.6.4" diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index d0c2da4..ed676b9 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app + +# 1. Copy workspace manifests and lock file (changes rarely → cached layer) +COPY Cargo.toml Cargo.lock ./ +COPY furumi-common/Cargo.toml furumi-common/Cargo.toml +COPY furumi-server/Cargo.toml furumi-server/Cargo.toml +COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml +COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml +COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml +COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml +COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml + +# 2. Create dummy sources so cargo can resolve and build dependencies +RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \ + && mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \ + && mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \ + && mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \ + && mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \ + && mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \ + && mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs + +# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change) +RUN cargo build --release --bin furumi-agent 2>/dev/null || true + +# 4. Copy real source code COPY . . +# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps) +RUN touch furumi-common/src/lib.rs furumi-agent/src/main.rs + ARG FURUMI_VERSION=dev RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent diff --git a/docker/Dockerfile.node-player b/docker/Dockerfile.node-player new file mode 100644 index 0000000..0ad2d3d --- /dev/null +++ b/docker/Dockerfile.node-player @@ -0,0 +1,38 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +# 1. Install server dependencies (cached layer) +COPY furumi-node-player/server/package.json furumi-node-player/server/package-lock.json ./server/ +RUN cd server && npm ci + +# 2. Install client dependencies (cached layer) +COPY furumi-node-player/client/package.json furumi-node-player/client/package-lock.json ./client/ +RUN cd client && npm ci + +# 3. Build server +COPY furumi-node-player/server/ ./server/ +RUN cd server && npm run build + +# 4. Build client (VITE_FURUMI_API_URL empty = relative /api on same origin) +COPY furumi-node-player/client/ ./client/ +RUN cd client && npm run build + +FROM node:22-alpine + +WORKDIR /app + +# Server runtime +COPY --from=build /app/server/dist ./server/dist +COPY --from=build /app/server/node_modules ./server/node_modules +COPY --from=build /app/server/package.json ./server/ + +# Client static files +COPY --from=build /app/client/dist ./client/dist + +ENV NODE_ENV=production +ENV PORT=3001 + +EXPOSE 3001 + +CMD ["node", "server/dist/index.js"] diff --git a/docker/Dockerfile.web-player b/docker/Dockerfile.web-player index ee6902a..1ae113c 100644 --- a/docker/Dockerfile.web-player +++ b/docker/Dockerfile.web-player @@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app + +# 1. Copy workspace manifests and lock file (changes rarely → cached layer) +COPY Cargo.toml Cargo.lock ./ +COPY furumi-common/Cargo.toml furumi-common/Cargo.toml +COPY furumi-server/Cargo.toml furumi-server/Cargo.toml +COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml +COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml +COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml +COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml +COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml + +# 2. Create dummy sources so cargo can resolve and build dependencies +RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \ + && mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \ + && mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \ + && mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \ + && mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \ + && mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \ + && mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs + +# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change) +RUN cargo build --release --bin furumi-web-player 2>/dev/null || true + +# 4. Copy real source code COPY . . +# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps) +RUN touch furumi-common/src/lib.rs furumi-web-player/src/main.rs + ARG FURUMI_VERSION=dev RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 100a9a8..438546d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,8 +16,8 @@ services: agent: build: - context: . - dockerfile: Dockerfile.agent + context: .. + dockerfile: docker/Dockerfile.agent container_name: furumi-agent depends_on: db: @@ -25,10 +25,12 @@ services: ports: - "8090:8090" environment: + RUST_LOG: info FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_AGENT_INBOX_DIR: "/inbox" FURUMI_AGENT_STORAGE_DIR: "/storage" FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}" + FURUMI_AGENT_OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}" FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}" FURUMI_PLAYER_BIND: "0.0.0.0:8090" FURUMI_AGENT_POLL_INTERVAL_SECS: 5 @@ -41,8 +43,8 @@ services: web-player: build: - context: . - dockerfile: Dockerfile.web-player + context: .. + dockerfile: docker/Dockerfile.web-player container_name: furumi-web-player depends_on: db: @@ -53,7 +55,11 @@ services: FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_PLAYER_STORAGE_DIR: "/storage" FURUMI_PLAYER_BIND: "0.0.0.0:8085" - FURUMI_PLAYER_API_KEY: "node-player-api-key" + FURUMI_PLAYER_OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}" + FURUMI_PLAYER_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}" + FURUMI_PLAYER_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}" + FURUMI_PLAYER_OIDC_REDIRECT_URL: "${OIDC_REDIRECT_URL}" + FURUMI_PLAYER_OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}" volumes: - ./storage:/storage restart: always diff --git a/furumi-agent/Cargo.toml b/furumi-agent/Cargo.toml index 1163d95..f4c97ea 100644 --- a/furumi-agent/Cargo.toml +++ b/furumi-agent/Cargo.toml @@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] } symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] } +id3 = "1" thiserror = "2.0" tokio = { version = "1.50", features = ["full"] } tracing = "0.1" diff --git a/furumi-agent/migrations/0005_users_and_play_events.sql b/furumi-agent/migrations/0005_users_and_play_events.sql new file mode 100644 index 0000000..cae6b92 --- /dev/null +++ b/furumi-agent/migrations/0005_users_and_play_events.sql @@ -0,0 +1,20 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT, + email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE play_events ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id BIGINT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + played_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_play_events_user_id ON play_events(user_id); +CREATE INDEX idx_play_events_track_id ON play_events(track_id); +CREATE INDEX idx_play_events_user_track ON play_events(user_id, track_id); +CREATE INDEX idx_play_events_played_at ON play_events(played_at DESC); diff --git a/furumi-agent/prompts/normalize.txt b/furumi-agent/prompts/normalize.txt index fe8577d..d049bc3 100644 --- a/furumi-agent/prompts/normalize.txt +++ b/furumi-agent/prompts/normalize.txt @@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada 10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations. -11. **Confidence**: Rate your confidence from 0.0 to 1.0. +11. **Confidence**: MUST be a decimal number between 0.0 and 1.0 (e.g., 0.95, 0.7, 0.3). NEVER use words like "high", "medium", "low" — only a numeric float value. - 1.0: All fields are clear and unambiguous. - 0.8+: Minor inferences made (e.g., year from path), but high certainty. - 0.5-0.8: Some guesswork involved, human review recommended. diff --git a/furumi-agent/src/ingest/metadata.rs b/furumi-agent/src/ingest/metadata.rs index 99a3371..8331181 100644 --- a/furumi-agent/src/ingest/metadata.rs +++ b/furumi-agent/src/ingest/metadata.rs @@ -19,9 +19,25 @@ pub struct RawMetadata { pub duration_secs: Option, } -/// Extract metadata from an audio file using Symphonia. +/// Extract metadata from an audio file. +/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file +/// (e.g., ID3 tag with large embedded cover art exceeds Symphonia's 1 MB probe limit). /// Must be called from a blocking context (spawn_blocking). pub fn extract(path: &Path) -> anyhow::Result { + match extract_via_symphonia(path) { + Ok(meta) => return Ok(meta), + Err(e) => { + let is_mp3 = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("mp3")).unwrap_or(false); + if is_mp3 { + tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate"); + return extract_mp3_via_id3(path); + } + return Err(e); + } + } +} + +fn extract_via_symphonia(path: &Path) -> anyhow::Result { let file = std::fs::File::open(path)?; let mss = MediaSourceStream::new(Box::new(file), Default::default()); @@ -66,6 +82,25 @@ pub fn extract(path: &Path) -> anyhow::Result { Ok(meta) } +/// Read MP3 tags via the `id3` crate. Duration is not available this way. +fn extract_mp3_via_id3(path: &Path) -> anyhow::Result { + use id3::TagLike; + + let tag = id3::Tag::read_from_path(path) + .map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?; + + let mut meta = RawMetadata::default(); + meta.title = tag.title().map(|s| fix_encoding(s.to_owned())); + meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned())); + meta.album = tag.album().map(|s| fix_encoding(s.to_owned())); + meta.year = tag.year().and_then(|y| u32::try_from(y).ok()); + meta.track_number = tag.track(); + meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned())); + // duration_secs remains None — acceptable for large-cover files + + Ok(meta) +} + fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) { for tag in tags { let value = fix_encoding(tag.value.to_string()); diff --git a/furumi-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index bd8020c..cc19bce 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -25,16 +25,37 @@ pub async fn normalize( ) -> anyhow::Result { let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx); + let schema = normalize_schema(); let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.system_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + 512, + Some(("normalized_metadata", schema.clone())), ) .await?; - parse_response(&response) + match parse_response(&response) { + Ok(fields) => Ok(fields), + Err(e) => { + tracing::warn!(error = %e, "LLM parse failed, retrying with higher frequency_penalty"); + let response2 = call_ollama( + &state.config.ollama_url, + &state.config.ollama_model, + &state.system_prompt, + &user_message, + state.config.ollama_auth.as_deref(), + 1.5, + 512, + Some(("normalized_metadata", schema)), + ) + .await?; + parse_response(&response2) + } + } } fn build_user_message( @@ -113,32 +134,49 @@ fn build_user_message( } #[derive(Serialize)] -struct OllamaRequest { +struct ChatRequest { model: String, - messages: Vec, - format: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + response_format: Option, stream: bool, - options: OllamaOptions, + temperature: f64, + max_tokens: u32, + frequency_penalty: f64, } #[derive(Serialize)] -struct OllamaMessage { +struct ChatMessage { role: String, content: String, } #[derive(Serialize)] -struct OllamaOptions { - temperature: f64, +struct ChatResponseFormat { + #[serde(rename = "type")] + kind: String, + json_schema: JsonSchemaWrapper, +} + +#[derive(Serialize)] +struct JsonSchemaWrapper { + name: String, + strict: bool, + schema: serde_json::Value, } #[derive(Deserialize)] -struct OllamaResponse { - message: OllamaResponseMessage, +struct ChatResponse { + choices: Vec, } #[derive(Deserialize)] -struct OllamaResponseMessage { +struct ChatChoice { + message: ChatResponseMessage, +} + +#[derive(Deserialize)] +struct ChatResponseMessage { content: String, } @@ -148,30 +186,40 @@ pub async fn call_ollama( system_prompt: &str, user_message: &str, auth: Option<&str>, + frequency_penalty: f64, + max_tokens: u32, + schema: Option<(&str, serde_json::Value)>, ) -> anyhow::Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(120)) .build()?; - let request = OllamaRequest { + let response_format = schema.map(|(name, schema)| ChatResponseFormat { + kind: "json_schema".to_owned(), + json_schema: JsonSchemaWrapper { name: name.to_owned(), strict: true, schema }, + }); + + let request = ChatRequest { model: model.to_owned(), messages: vec![ - OllamaMessage { + ChatMessage { role: "system".to_owned(), content: system_prompt.to_owned(), }, - OllamaMessage { + ChatMessage { role: "user".to_owned(), content: user_message.to_owned(), }, ], - format: "json".to_owned(), + response_format, stream: false, - options: OllamaOptions { temperature: 0.1 }, + temperature: 0.1, + max_tokens, + frequency_penalty, }; - let url = format!("{}/api/chat", base_url.trim_end_matches('/')); - tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API..."); + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API..."); let start = std::time::Instant::now(); let mut req = client.post(&url).json(&request); @@ -184,18 +232,45 @@ pub async fn call_ollama( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error"); - anyhow::bail!("Ollama returned {}: {}", status, body); + tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error"); + anyhow::bail!("LLM returned {}: {}", status, body); } - let ollama_resp: OllamaResponse = resp.json().await?; + let chat_resp: ChatResponse = resp.json().await?; + let content = chat_resp + .choices + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))? + .message + .content; tracing::info!( elapsed_ms = elapsed.as_millis() as u64, - response_len = ollama_resp.message.content.len(), - "Ollama response received" + response_len = content.len(), + "LLM response received" ); - tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output"); - Ok(ollama_resp.message.content) + tracing::debug!(raw_response = %content, "LLM raw output"); + Ok(content) +} + +fn normalize_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "artist": { "type": ["string", "null"] }, + "album": { "type": ["string", "null"] }, + "title": { "type": ["string", "null"] }, + "year": { "type": ["integer", "null"] }, + "track_number": { "type": ["integer", "null"] }, + "genre": { "type": ["string", "null"] }, + "featured_artists": { "type": "array", "items": { "type": "string" } }, + "release_kind": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"] }, + "notes": { "type": ["string", "null"] } + }, + "required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_kind", "confidence", "notes"], + "additionalProperties": false + }) } /// Parse the LLM JSON response into NormalizedFields. @@ -222,6 +297,7 @@ fn parse_response(response: &str) -> anyhow::Result { genre: Option, #[serde(default)] featured_artists: Vec, + #[serde(rename = "release_kind")] release_type: Option, confidence: Option, notes: Option, diff --git a/furumi-agent/src/merge.rs b/furumi-agent/src/merge.rs index 25f6e5b..8c7f83c 100644 --- a/furumi-agent/src/merge.rs +++ b/furumi-agent/src/merge.rs @@ -35,12 +35,39 @@ pub async fn propose_merge(state: &Arc, merge_id: Uuid) -> anyhow::Res let user_message = build_merge_message(&artists_data); + let schema = serde_json::json!({ + "type": "object", + "properties": { + "canonical_artist_name": { "type": "string" }, + "winner_artist_id": { "type": "integer" }, + "album_mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_album_id": { "type": "integer" }, + "canonical_name": { "type": "string" }, + "merge_into_album_id": { "type": ["integer", "null"] } + }, + "required": ["source_album_id", "canonical_name", "merge_into_album_id"], + "additionalProperties": false + } + }, + "notes": { "type": "string" } + }, + "required": ["canonical_artist_name", "winner_artist_id", "album_mappings", "notes"], + "additionalProperties": false + }); + let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.merge_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + 4096, + Some(("artist_merge", schema)), ).await?; let proposal = parse_merge_response(&response)?; diff --git a/furumi-node-player/client/.env.example b/furumi-node-player/client/.env.example index 2312cf7..2ce30f6 100644 --- a/furumi-node-player/client/.env.example +++ b/furumi-node-player/client/.env.example @@ -1,2 +1,2 @@ -VITE_API_BASE_URL=http://localhost:8085 -VITE_API_KEY= \ No newline at end of file +# Leave empty — vite proxy handles /api in dev, same-origin in production +VITE_FURUMI_API_URL= diff --git a/furumi-node-player/client/public/sw.js b/furumi-node-player/client/public/sw.js new file mode 100644 index 0000000..a17ac24 --- /dev/null +++ b/furumi-node-player/client/public/sw.js @@ -0,0 +1,45 @@ +const DB_NAME = 'furumi-sw' +const STORE = 'auth' +const KEY = 'bearer' + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, 1) + req.onupgradeneeded = () => req.result.createObjectStore(STORE) + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) +} + +async function getToken() { + try { + const db = await openDB() + return new Promise((resolve) => { + const tx = db.transaction(STORE, 'readonly') + const req = tx.objectStore(STORE).get(KEY) + req.onsuccess = () => resolve(req.result || null) + req.onerror = () => resolve(null) + }) + } catch { + return null + } +} + +self.addEventListener('fetch', (e) => { + const url = new URL(e.request.url) + if (url.origin !== self.location.origin || !url.pathname.startsWith('/api/')) return + + e.respondWith( + (async () => { + const token = await getToken() + if (!token) return fetch(e.request) + + const headers = new Headers(e.request.headers) + headers.set('Authorization', `Bearer ${token}`) + return fetch(new Request(e.request, { headers })) + })() + ) +}) + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())) diff --git a/furumi-node-player/client/src/App.css b/furumi-node-player/client/src/App.css index 1334e57..8523c77 100644 --- a/furumi-node-player/client/src/App.css +++ b/furumi-node-player/client/src/App.css @@ -1,71 +1,102 @@ -.page { +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +.auth-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; + background: #0a0c12; + font-family: 'Inter', system-ui, sans-serif; + color: #e2e8f0; } -.card { - width: min(520px, 100%); - border: 1px solid #d8dde6; - border-radius: 14px; - padding: 24px; - background-color: #ffffff; - box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08); -} +/* ---------- loading spinner ---------- */ -.subtitle { - margin-top: 0; - margin-bottom: 20px; - color: #5a6475; -} - -.settings { - margin-bottom: 16px; - padding: 12px; - border: 1px solid #e6eaf2; - border-radius: 10px; - background: #f8fafc; -} - -.toggle { +.auth-loading { display: flex; + flex-direction: column; align-items: center; - gap: 10px; - color: #0f172a; - font-weight: 600; + gap: 20px; } -.toggle input { - width: 18px; - height: 18px; +.spinner { + width: 36px; + height: 36px; + border: 3px solid #1f2c45; + border-top-color: #7c6af7; + border-radius: 50%; + animation: spin 0.8s linear infinite; } -.hint { - margin: 10px 0 0; - color: #5a6475; +@keyframes spin { + to { transform: rotate(360deg); } } -.btn { - display: inline-block; - text-decoration: none; - background: #2251ff; - color: #ffffff; - padding: 10px 16px; +.auth-loading .logo { + font-size: 1.6rem; + font-weight: 700; + color: #7c6af7; +} + +.auth-loading p { + color: #64748b; + font-size: 0.85rem; +} + +/* ---------- login card ---------- */ + +.auth-card { + width: min(380px, 100%); + background: #111520; + border: 1px solid #1f2c45; + border-radius: 16px; + padding: 2.5rem 2rem; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.auth-card .logo { + font-size: 1.8rem; + font-weight: 700; + color: #7c6af7; + margin-bottom: 4px; +} + +.auth-card .subtitle { + font-size: 0.85rem; + color: #64748b; + margin-bottom: 2rem; +} + +.auth-card .btn-login { + display: block; + width: 100%; + padding: 0.75rem; + text-align: center; + background: #7c6af7; + border: none; border-radius: 8px; + color: #fff; + font-size: 0.95rem; font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: background 0.2s; } -.btn.ghost { - background: #edf1ff; - color: #1e3fc4; - margin-top: 10px; +.auth-card .btn-login:hover { + background: #6b58e8; } -.profile p { - margin: 8px 0; +.auth-card .error { + color: #f87171; + font-size: 0.85rem; + margin-bottom: 1rem; } -.error { - color: #cc1e1e; -} diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index d21ab3f..d628e08 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { FurumiPlayer } from './FurumiPlayer' +import { setAuthToken, clearAuthToken } from './furumiApi' import './App.css' type UserProfile = { @@ -8,30 +9,12 @@ type UserProfile = { email?: string } -const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth' - function App() { const [loading, setLoading] = useState(true) const [user, setUser] = useState(null) const [error, setError] = useState(null) - const [runWithoutAuth, setRunWithoutAuth] = useState(() => { - try { - return window.localStorage.getItem(NO_AUTH_STORAGE_KEY) === '1' - } catch { - return false - } - }) - - const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', []) useEffect(() => { - if (runWithoutAuth) { - setError(null) - setUser({ sub: 'noauth', name: 'No Auth' }) - setLoading(false) - return - } - const loadMe = async () => { try { const response = await fetch(`/api/me`, { @@ -40,6 +23,7 @@ function App() { if (response.status === 401) { setUser(null) + clearAuthToken() return } @@ -49,6 +33,20 @@ function App() { const data = await response.json() setUser(data.user ?? null) + + if (data.user) { + try { + const tokenRes = await fetch('/auth/token', { credentials: 'include' }) + if (tokenRes.ok) { + const tokenData = await tokenRes.json() + if (tokenData.access_token) { + setAuthToken(tokenData.access_token) + } + } + } catch { + // Token fetch failed + } + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load session') } finally { @@ -57,84 +55,42 @@ function App() { } void loadMe() - }, [apiBase, runWithoutAuth]) + }, []) const loginUrl = `/api/login` const logoutUrl = `/api/logout` + // Authenticated — render player immediately + if (!loading && user) { + return + } + // Loading — show spinner (no login form flash) + if (loading) { + return ( +
+
+
Furumi
+
+

Loading...

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

OIDC Login

-

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

+
+
+
Furumi
+

Sign in to continue

-
- -
+ {error &&

{error}

} - {loading &&

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

} - {error &&

Ошибка: {error}

} - - {!loading && runWithoutAuth && ( -

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

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

- ID: {user.sub} -

- {user.name && ( -

- Имя: {user.name} -

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

- Email: {user.email} -

- )} - {!runWithoutAuth && ( - - Выйти - - )} -
- )} -
-
- )} - + + Sign in with SSO + +
+
) } diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index b17985c..6c058f7 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { API_ROOT, searchTracks, preloadStream } from './furumiApi' +import { furumiApi, searchTracks, recordPlay } from './furumiApi' import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' @@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel' import { PlayerBar } from './components/PlayerBar' import type { Track } from './types' -export function FurumiPlayer() { +export type UserProfile = { + sub: string + name?: string + email?: string +} + +export function FurumiPlayer({ user }: { user: UserProfile }) { const dispatch = useAppDispatch() const artistsLoading = useAppSelector((s) => s.artists.loading) const artistsError = useAppSelector((s) => s.artists.error) @@ -80,16 +86,19 @@ export function FurumiPlayer() { return } document.title = `${nowPlayingTrack.title} — Furumi` - const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover` if ('mediaSession' in navigator) { try { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - navigator.mediaSession.metadata = new window.MediaMetadata({ + const meta = new window.MediaMetadata({ title: nowPlayingTrack.title, artist: nowPlayingTrack.artist || '', album: '', - artwork: [{ src: coverUrl, sizes: '512x512' }], }) + navigator.mediaSession.metadata = meta + furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' }) + .then((res) => { + meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }] + }) + .catch(() => {}) } catch { // ignore } @@ -292,6 +301,7 @@ export function FurumiPlayer() { dispatch(playAtIndex(i)) const track = store.getState().queue.items[i] void playback.loadStreamForTrack(track.slug) + void recordPlay(track.slug) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -384,7 +394,7 @@ export function FurumiPlayer() { { slug, title: '', artist: '', album_slug: null, duration: null }, true, ) - void preloadStream(slug) + void playback.loadStreamForTrack(slug) } } searchSelectRef.current = onSearchSelect @@ -512,6 +522,8 @@ export function FurumiPlayer() { searchOpen={searchOpen} searchResults={searchResults} onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} + onPlayTrack={(slug) => searchSelectRef.current('track', slug)} + user={user} /> { }) + try { + const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' }) + audio.src = URL.createObjectURL(res.data) + await audio.play().catch(() => { }) + } catch { + // stream failed + } } function pauseAndClearSource() { diff --git a/furumi-node-player/client/src/components/AuthImg.tsx b/furumi-node-player/client/src/components/AuthImg.tsx new file mode 100644 index 0000000..fadfedf --- /dev/null +++ b/furumi-node-player/client/src/components/AuthImg.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' +import { furumiApi } from '../furumiApi' + +export function AuthImg({ src, alt, ...props }: React.ImgHTMLAttributes) { + const [blobUrl, setBlobUrl] = useState(null) + + useEffect(() => { + if (!src) return + let revoked = false + furumiApi.get(src, { responseType: 'blob' }) + .then((res) => { + if (!revoked) setBlobUrl(URL.createObjectURL(res.data)) + }) + .catch(() => {}) + return () => { + revoked = true + if (blobUrl) URL.revokeObjectURL(blobUrl) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src]) + + if (!blobUrl) return null + return {alt +} diff --git a/furumi-node-player/client/src/components/NowPlaying.tsx b/furumi-node-player/client/src/components/NowPlaying.tsx index c3275bc..f957022 100644 --- a/furumi-node-player/client/src/components/NowPlaying.tsx +++ b/furumi-node-player/client/src/components/NowPlaying.tsx @@ -1,16 +1,13 @@ -import { useEffect, useState } from 'react' -import { API_ROOT } from '../furumiApi' +import { useState } from 'react' +import { AuthImg } from './AuthImg' import type { QueueItem } from './QueueList' -function Cover({ src }: { src: string }) { +function Cover({ slug }: { slug: string }) { const [errored, setErrored] = useState(false) - - useEffect(() => { - setErrored(false) - }, [src]) + const src = `/tracks/${slug}/cover` if (errored) return <>🎵 - return setErrored(true)} /> + return setErrored(true)} /> } export function NowPlaying({ track }: { track: QueueItem | null }) { @@ -32,12 +29,10 @@ export function NowPlaying({ track }: { track: QueueItem | null }) { ) } - const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` - return (
- +
@@ -50,4 +45,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
) } - diff --git a/furumi-node-player/client/src/components/QueueList.tsx b/furumi-node-player/client/src/components/QueueList.tsx index c371f09..57865de 100644 --- a/furumi-node-player/client/src/components/QueueList.tsx +++ b/furumi-node-player/client/src/components/QueueList.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { API_ROOT } from '../furumiApi' +import { AuthImg } from './AuthImg' export type QueueItem = { slug: string @@ -32,14 +32,10 @@ function fmt(secs: number) { return `${m}:${pad(s % 60)}` } -function Cover({ src }: { src: string }) { +function Cover({ slug }: { slug: string }) { const [errored, setErrored] = useState(false) - useEffect(() => { - setErrored(false) - }, [src]) - if (errored) return <>🎵 - return setErrored(true)} /> + return setErrored(true)} /> } export function QueueList({ @@ -77,7 +73,7 @@ export function QueueList({ if (!t) return null const isPlaying = origIdx === playingOrigIdx - const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : '' + const hasAlbum = !!t.album_slug const dur = t.duration ? fmt(t.duration) : '' const isDragging = draggingPos === pos const isDragOver = dragOverPos === pos @@ -118,7 +114,7 @@ export function QueueList({ > {isPlaying ? '' : pos + 1}
- {coverSrc ? : <>🎵} + {hasAlbum ? : <>🎵}
{t.title}
diff --git a/furumi-node-player/client/src/components/header/Header.tsx b/furumi-node-player/client/src/components/header/Header.tsx index 1a4c4fa..9a1aada 100644 --- a/furumi-node-player/client/src/components/header/Header.tsx +++ b/furumi-node-player/client/src/components/header/Header.tsx @@ -1,4 +1,6 @@ +import { useState, useRef, useEffect } from 'react' import { SearchDropdown } from '../SearchDropdown' +import { RecentPlays } from './RecentPlays' import styles from './header.module.css' type SearchResultItem = { @@ -8,39 +10,100 @@ type SearchResultItem = { detail?: string } +type UserInfo = { + sub: string + name?: string + email?: string +} + type HeaderProps = { searchOpen: boolean searchResults: SearchResultItem[] onSearchSelect: (type: string, slug: string) => void + onPlayTrack: (slug: string) => void + user: UserInfo +} + +function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + const initials = (user.name ?? user.sub) + .split(' ') + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() + + return ( +
+ + {open && ( +
+
+
{user.name ?? user.sub}
+ {user.email &&
{user.email}
} +
+ + Sign out +
+ )} +
+ ) } export function Header({ searchOpen, searchResults, onSearchSelect, + onPlayTrack, + user, }: HeaderProps) { + const [showRecent, setShowRecent] = useState(false) + return ( -
-
- - - - - - - Furumi - v -
-
-
- - + <> +
+
+ + + + + + + Furumi + v
-
-
+
+
+ + +
+ setShowRecent(true)} /> +
+ + {showRecent && ( + setShowRecent(false)} + onPlay={onPlayTrack} + /> + )} + ) } diff --git a/furumi-node-player/client/src/components/header/RecentPlays.tsx b/furumi-node-player/client/src/components/header/RecentPlays.tsx new file mode 100644 index 0000000..fcc645f --- /dev/null +++ b/furumi-node-player/client/src/components/header/RecentPlays.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react' +import { getRecentPlays, type RecentPlay } from '../../furumiApi' +import styles from './header.module.css' + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + return `${days}d ago` +} + +export function RecentPlays({ onClose, onPlay }: { onClose: () => void; onPlay: (slug: string) => void }) { + const [plays, setPlays] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + getRecentPlays().then((data) => { + setPlays(data) + setLoading(false) + }) + }, []) + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + return ( +
+
e.stopPropagation()}> +
+

Recent plays

+ +
+
+ {loading &&

Loading...

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

No play history yet

+ )} + {plays?.map((p, i) => ( +
{ onPlay(p.track_slug); onClose() }} + > +
+
{p.track_title}
+
{p.artist_name}
+
+
{timeAgo(p.played_at)}
+
+ ))} +
+
+
+ ) +} diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css index 3777ab4..0375b19 100644 --- a/furumi-node-player/client/src/components/header/header.module.css +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -32,4 +32,203 @@ margin-left: 0.25rem; font-weight: 500; text-decoration: none; +} + +/* User menu */ + +.userMenu { + position: relative; +} + +.userAvatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + color: #fff; + border: none; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.userAvatar:hover { + background: var(--accent-dim); +} + +.userDropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + z-index: 100; + overflow: hidden; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.userInfo { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.userName { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.userEmail { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.userLogout { + display: block; + padding: 10px 16px; + color: var(--danger); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s; +} + +.userLogout:hover { + background: var(--bg-hover); +} + +.userAction { + display: block; + padding: 10px 16px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + background: none; + border: none; + width: 100%; + text-align: left; + transition: background 0.15s; +} + +.userAction:hover { + background: var(--bg-hover); +} + +/* Recent plays overlay */ + +.recentOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 200; + display: grid; + place-items: center; + animation: fadeIn 0.15s ease; +} + +.recentPanel { + width: min(480px, 90vw); + max-height: 70vh; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 14px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.recentHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.recentHeader h2 { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.recentClose { + background: none; + border: none; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.recentClose:hover { + color: var(--text); + background: var(--bg-hover); +} + +.recentList { + overflow-y: auto; + flex: 1; +} + +.recentItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + cursor: pointer; + transition: background 0.15s; +} + +.recentItem:hover { + background: var(--bg-hover); +} + +.recentTrack { + min-width: 0; + flex: 1; +} + +.recentTitle { + font-size: 0.85rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recentArtist { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 1px; +} + +.recentTime { + font-size: 0.7rem; + color: var(--text-dim); + flex-shrink: 0; + margin-left: 12px; +} + +.recentEmpty { + padding: 32px 20px; + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; } \ No newline at end of file diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index bdf4114..b7fc636 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -1,16 +1,64 @@ 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 } : {}, }) +function sendTokenToSW(token: string) { + try { + const req = indexedDB.open('furumi-sw', 1) + req.onupgradeneeded = () => req.result.createObjectStore('auth') + req.onsuccess = () => { + const tx = req.result.transaction('auth', 'readwrite') + tx.objectStore('auth').put(token, 'bearer') + } + } catch { /* ignore */ } +} + +export function setAuthToken(token: string) { + furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}` + sendTokenToSW(token) +} + +export function clearAuthToken() { + delete furumiApi.defaults.headers.common['Authorization'] +} + +async function refreshToken(): Promise { + try { + const res = await fetch('/auth/token', { credentials: 'include' }) + if (!res.ok) return false + const data = await res.json() + if (data.access_token) { + setAuthToken(data.access_token) + return true + } + } catch { /* ignore */ } + return false +} + +let refreshPromise: Promise | null = null + +furumiApi.interceptors.response.use( + (response) => response, + async (error) => { + const original = error.config + if (error.response?.status === 401 && !original._retried) { + original._retried = true + if (!refreshPromise) { + refreshPromise = refreshToken().finally(() => { refreshPromise = null }) + } + const ok = await refreshPromise + if (ok) return furumiApi(original) + } + return Promise.reject(error) + }, +) + export async function getArtists(): Promise { const res = await furumiApi.get('/artists').catch(() => null) return res?.data ?? null @@ -43,7 +91,19 @@ export async function getTrackInfo(trackSlug: string): Promise null) +export type RecentPlay = { + track_slug: string + track_title: string + artist_name: string + album_slug: string | null + played_at: string } +export async function getRecentPlays(): Promise { + const res = await furumiApi.get('/me/recent').catch(() => null) + return res?.data ?? null +} + +export async function recordPlay(trackSlug: string): Promise { + await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) +} diff --git a/furumi-node-player/client/src/main.tsx b/furumi-node-player/client/src/main.tsx index 9d4c1bf..edf42b0 100644 --- a/furumi-node-player/client/src/main.tsx +++ b/furumi-node-player/client/src/main.tsx @@ -5,6 +5,10 @@ import { store } from './store' import './index.css' import App from './App.tsx' +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js') +} + createRoot(document.getElementById('root')!).render( diff --git a/furumi-node-player/client/vite.config.ts b/furumi-node-player/client/vite.config.ts index ab8ee06..9f9fba5 100644 --- a/furumi-node-player/client/vite.config.ts +++ b/furumi-node-player/client/vite.config.ts @@ -6,10 +6,18 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': { + '/auth': { target: 'http://localhost:3001', changeOrigin: true, }, + '/callback': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/api': { + target: 'http://localhost:8085', + changeOrigin: true, + }, }, }, }) diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts index 59622ad..dabe8b2 100644 --- a/furumi-node-player/server/src/index.ts +++ b/furumi-node-player/server/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; +import path from 'path'; import cors from 'cors'; import express from 'express'; import { auth } from 'express-openid-connect'; @@ -23,12 +24,11 @@ const oidcConfig = { clientSecret: process.env.OIDC_CLIENT_SECRET ?? '', authorizationParams: { response_type: 'code', - scope: process.env.OIDC_SCOPE ?? 'openid profile email', + scope: process.env.OIDC_SCOPE ?? 'openid profile email offline_access', }, }; 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,42 @@ app.get('/api/me', (req, res) => { }); }); -app.get('/api/login', (req, res) => { +app.get('/auth/token', async (req, res) => { + if (disableAuth) { + res.status(204).end(); + return; + } + + if (!req.oidc.isAuthenticated()) { + res.status(401).json({ authenticated: false }); + return; + } + + let accessToken = req.oidc.accessToken; + if (!accessToken?.access_token) { + res.status(500).json({ error: 'no access token in session' }); + return; + } + + // Refresh if expired + if (accessToken.isExpired()) { + try { + accessToken = await accessToken.refresh(); + } catch (e) { + console.error('Token refresh failed:', e); + res.status(401).json({ error: 'token refresh failed' }); + return; + } + } + + res.json({ + access_token: accessToken.access_token, + token_type: 'Bearer', + expires_in: accessToken.expires_in, + }); +}); + +app.get('/auth/login', (req, res) => { if (disableAuth) { res.status(204).end(); return; @@ -85,7 +120,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 +131,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}`, diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 822300c..17270c2 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -9,6 +9,7 @@ axum = { version = "0.7", features = ["tokio", "macros"] } clap = { version = "4.5", features = ["derive", "env"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +chrono = { version = "0.4", features = ["serde"] } sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] } tokio = { version = "1.50", features = ["full"] } tower = { version = "0.4", features = ["util"] } @@ -18,7 +19,8 @@ mime_guess = "2.0" symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] } tokio-util = { version = "0.7", features = ["io"] } openidconnect = "3.4" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +jsonwebtoken = "9" sha2 = "0.10" hmac = "0.12" base64 = "0.22" diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs index f33fac6..be139ac 100644 --- a/furumi-web-player/src/db.rs +++ b/furumi-web-player/src/db.rs @@ -82,6 +82,82 @@ pub struct SearchResult { pub detail: Option, // artist name for albums/tracks } +// --- User management --- + +pub async fn upsert_user( + pool: &PgPool, + id: &str, + username: &str, + display_name: Option<&str>, + email: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#"INSERT INTO users (id, username, display_name, email, last_seen_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (id) DO UPDATE SET + username = EXCLUDED.username, + display_name = EXCLUDED.display_name, + email = EXCLUDED.email, + last_seen_at = NOW()"# + ) + .bind(id) + .bind(username) + .bind(display_name) + .bind(email) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn record_play_event( + pool: &PgPool, + user_id: &str, + track_slug: &str, +) -> Result { + let result = sqlx::query( + r#"INSERT INTO play_events (user_id, track_id, played_at) + SELECT $1, t.id, NOW() + FROM tracks t WHERE t.slug = $2"# + ) + .bind(user_id) + .bind(track_slug) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct RecentPlay { + pub track_slug: String, + pub track_title: String, + pub artist_name: String, + pub album_slug: Option, + pub played_at: chrono::DateTime, +} + +pub async fn recent_plays( + pool: &PgPool, + user_id: &str, + limit: i32, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, RecentPlay>( + r#"SELECT t.slug AS track_slug, t.title AS track_title, + ar.name AS artist_name, al.slug AS album_slug, + pe.played_at + FROM play_events pe + JOIN tracks t ON pe.track_id = t.id + JOIN artists ar ON t.artist_id = ar.id + LEFT JOIN albums al ON t.album_id = al.id + WHERE pe.user_id = $1 + ORDER BY pe.played_at DESC + LIMIT $2"# + ) + .bind(user_id) + .bind(limit) + .fetch_all(pool) + .await +} + // --- Queries --- pub async fn list_artists(pool: &PgPool) -> Result, sqlx::Error> { diff --git a/furumi-web-player/src/main.rs b/furumi-web-player/src/main.rs index b8a8592..f95c39b 100644 --- a/furumi-web-player/src/main.rs +++ b/furumi-web-player/src/main.rs @@ -40,9 +40,6 @@ struct Args { #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] oidc_session_secret: Option, - /// API key for x-api-key header auth (alternative to OIDC session) - #[arg(long, env = "FURUMI_PLAYER_API_KEY")] - api_key: Option, } #[tokio::main] @@ -94,15 +91,10 @@ async fn main() -> Result<(), Box> { std::process::exit(1); }); - if args.api_key.is_some() { - tracing::info!("x-api-key auth: enabled"); - } - let state = Arc::new(web::AppState { pool, storage_dir: Arc::new(args.storage_dir), oidc: oidc_state, - api_key: args.api_key, }); tracing::info!("Web player: http://{}", bind_addr); diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs index e4fe88a..b19249a 100644 --- a/furumi-web-player/src/web/api.rs +++ b/furumi-web-player/src/web/api.rs @@ -9,8 +9,10 @@ use axum::{ use serde::Deserialize; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use axum::Extension; use crate::db; use super::AppState; +use super::auth::AuthUser; type S = Arc; @@ -291,6 +293,30 @@ pub async fn search(State(state): State, Query(q): Query) -> imp } } +// --- Play tracking --- + +pub async fn recent_plays( + State(state): State, + Extension(user): Extension, +) -> impl IntoResponse { + match db::recent_plays(&state.pool, &user.id, 50).await { + Ok(plays) => (StatusCode::OK, Json(serde_json::to_value(plays).unwrap())).into_response(), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn record_play( + State(state): State, + Path(slug): Path, + Extension(user): Extension, +) -> impl IntoResponse { + match db::record_play_event(&state.pool, &user.id, &slug).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => error_json(StatusCode::NOT_FOUND, "track not found"), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + // --- Helpers --- fn error_json(status: StatusCode, message: &str) -> Response { diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index 33f8184..8f61b86 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -3,10 +3,9 @@ use axum::{ extract::{Request, State}, http::{header, HeaderMap, StatusCode}, middleware::Next, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect, Response}, }; -const X_API_KEY: &str = "x-api-key"; use openidconnect::{ core::{CoreClient, CoreProviderMetadata, CoreResponseType}, reqwest::async_http_client, @@ -18,17 +17,26 @@ use serde::Deserialize; use base64::Engine; use hmac::{Hmac, Mac}; +use jsonwebtoken::{decode, decode_header, DecodingKey, Validation as JwtValidation}; +use jsonwebtoken::jwk::JwkSet; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; use super::AppState; use std::sync::Arc; const SESSION_COOKIE: &str = "furumi_session"; +const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600); type HmacSha256 = Hmac; pub struct OidcState { pub client: CoreClient, pub session_secret: Vec, + jwks_uri: String, + issuer_url: String, + jwks_cache: RwLock>, + http_client: reqwest::Client, } pub async fn oidc_init( @@ -44,6 +52,9 @@ pub async fn oidc_init( ) .await?; + let jwks_uri = provider_metadata.jwks_uri().to_string(); + let issuer_url = provider_metadata.issuer().to_string(); + let client = CoreClient::from_provider_metadata( provider_metadata, ClientId::new(client_id), @@ -62,9 +73,84 @@ pub async fn oidc_init( b }; + let http_client = reqwest::Client::new(); + + tracing::info!("JWKS URI: {}", jwks_uri); + Ok(OidcState { client, session_secret, + jwks_uri, + issuer_url, + jwks_cache: RwLock::new(None), + http_client, + }) +} + +impl OidcState { + async fn get_jwks(&self) -> anyhow::Result { + { + let cache = self.jwks_cache.read().await; + if let Some((ref jwks, fetched_at)) = *cache { + if fetched_at.elapsed() < JWKS_CACHE_TTL { + return Ok(jwks.clone()); + } + } + } + self.refresh_jwks().await + } + + async fn refresh_jwks(&self) -> anyhow::Result { + tracing::debug!("Fetching JWKS from {}", self.jwks_uri); + let jwks: JwkSet = self.http_client.get(&self.jwks_uri).send().await?.json().await?; + let mut cache = self.jwks_cache.write().await; + *cache = Some((jwks.clone(), Instant::now())); + Ok(jwks) + } +} + +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: String, + pub username: String, + pub display_name: Option, + pub email: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct BearerClaims { + sub: String, + preferred_username: Option, + name: Option, + email: Option, +} + +async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { + let header = decode_header(token).ok()?; + let kid = header.kid.as_ref()?; + + let mut jwks = oidc.get_jwks().await.ok()?; + let mut jwk = jwks.find(kid); + + // Handle key rotation: refresh JWKS if kid not found + if jwk.is_none() { + jwks = oidc.refresh_jwks().await.ok()?; + jwk = jwks.find(kid); + } + + let key = DecodingKey::from_jwk(jwk?).ok()?; + + let mut validation = JwtValidation::new(header.alg); + validation.set_issuer(&[&oidc.issuer_url]); + validation.validate_aud = false; + + let data = decode::(token, &key, &validation).ok()?; + let c = data.claims; + Some(AuthUser { + id: c.sub.clone(), + username: c.preferred_username.unwrap_or(c.sub), + display_name: c.name, + email: c.email, }) } @@ -94,75 +180,73 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option { } } -/// Auth middleware: requires valid SSO session cookie or x-api-key header. +/// Auth middleware: requires valid Bearer JWT or SSO session cookie. +/// Inserts AuthUser into request extensions and upserts user in DB. pub async fn require_auth( State(state): State>, - req: Request, + mut req: Request, next: Next, ) -> Response { - // 1. Check x-api-key header (if configured) - if let Some(ref expected) = state.api_key { - if let Some(val) = req + let mut auth_user: Option = None; + + // 1. Check Bearer token — JWT from OIDC provider + if let Some(ref oidc) = state.oidc { + if let Some(token) = req .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 { - return next.run(req).await; - } + auth_user = validate_bearer_token(oidc, token).await; } } // 2. Check SSO session cookie (if OIDC configured) - if let Some(ref oidc) = state.oidc { - let cookies = req - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + if auth_user.is_none() { + if let Some(ref oidc) = state.oidc { + let cookies = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); - for c in cookies.split(';') { - let c = c.trim(); - if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { - if verify_sso_cookie(&oidc.session_secret, val).is_some() { - return next.run(req).await; + for c in cookies.split(';') { + let c = c.trim(); + if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { + if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) { + auth_user = Some(AuthUser { + id: user_id.clone(), + username: user_id, + display_name: None, + email: None, + }); + break; + } } } } } - 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() + match auth_user { + Some(user) => { + tracing::debug!("Auth OK for user: {}", user.username); + // Upsert user in background + let pool = state.pool.clone(); + let u = user.clone(); + tokio::spawn(async move { + if let Err(e) = crate::db::upsert_user( + &pool, &u.id, &u.username, u.display_name.as_deref(), u.email.as_deref(), + ).await { + tracing::warn!("Failed to upsert user: {}", e); + } + }); + req.extensions_mut().insert(user); + next.run(req).await + } + None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), } } -/// GET /login — show SSO login page. -pub async fn login_page(State(state): State>) -> impl IntoResponse { - if state.oidc.is_none() { - return Redirect::to("/").into_response(); - } - - Html(LOGIN_HTML).into_response() -} - -/// GET /logout — clear session cookie. -pub async fn logout() -> impl IntoResponse { - let cookie = format!( - "{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", - SESSION_COOKIE - ); - let mut headers = HeaderMap::new(); - headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); - headers.insert(header::LOCATION, "/login".parse().unwrap()); - (StatusCode::FOUND, headers, Body::empty()).into_response() -} - #[derive(Deserialize)] pub struct LoginQuery { pub next: Option, @@ -335,9 +419,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 +438,3 @@ pub async fn oidc_callback( (StatusCode::FOUND, headers, Body::empty()).into_response() } - -const LOGIN_HTML: &str = r#" - - - - -Furumi Player — Login - - - -
- -
Sign in to continue
- SSO Login -
- -"#; diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index 355b4bb..c25cb71 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::path::PathBuf; use std::time::Duration; -use axum::{Router, routing::get, middleware}; +use axum::{Router, routing::{get, post}, middleware}; use axum::http::{header, Method}; use sqlx::PgPool; use tower_http::cors::{Any, CorsLayer}; @@ -16,7 +16,6 @@ pub struct AppState { #[allow(dead_code)] pub storage_dir: Arc, pub oidc: Option>, - pub api_key: Option, } pub fn build_router(state: Arc) -> Router { @@ -30,39 +29,31 @@ pub fn build_router(state: Arc) -> Router { .route("/tracks/:slug", get(api::get_track_detail)) .route("/tracks/:slug/cover", get(api::track_cover)) .route("/stream/:slug", get(api::stream_track)) - .route("/search", get(api::search)); + .route("/search", get(api::search)) + .route("/tracks/:slug/play", post(api::record_play)) + .route("/me/recent", get(api::recent_plays)); - 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_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD]) + .allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION]) .max_age(Duration::from_secs(600)); Router::new() - .route("/login", get(auth::login_page)) - .route("/logout", get(auth::logout)) .route("/auth/login", get(auth::oidc_login)) .route("/auth/callback", get(auth::oidc_callback)) .merge(app) .layer(cors) .with_state(state) } - -async fn player_html() -> axum::response::Html { - let html = include_str!("player.html") - .replace("", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))); - axum::response::Html(html) -}