From ff3ad15b953f7425696826ec1183807e04c6c5a9 Mon Sep 17 00:00:00 2001 From: AB-UK Date: Wed, 18 Mar 2026 02:44:59 +0000 Subject: [PATCH] New player --- Cargo.lock | 27 ++ Cargo.toml | 2 + PLAYER-API.md | 214 ++++++++++ furumi-agent/src/ingest/mod.rs | 37 ++ furumi-web-player/Cargo.toml | 27 ++ furumi-web-player/src/db.rs | 229 ++++++++++ furumi-web-player/src/main.rs | 106 +++++ furumi-web-player/src/web/api.rs | 298 +++++++++++++ furumi-web-player/src/web/auth.rs | 384 +++++++++++++++++ furumi-web-player/src/web/mod.rs | 57 +++ furumi-web-player/src/web/player.html | 589 ++++++++++++++++++++++++++ 11 files changed, 1970 insertions(+) create mode 100644 PLAYER-API.md create mode 100644 furumi-web-player/Cargo.toml create mode 100644 furumi-web-player/src/db.rs create mode 100644 furumi-web-player/src/main.rs create mode 100644 furumi-web-player/src/web/api.rs create mode 100644 furumi-web-player/src/web/auth.rs create mode 100644 furumi-web-player/src/web/mod.rs create mode 100644 furumi-web-player/src/web/player.html diff --git a/Cargo.lock b/Cargo.lock index 7e69ef4..9cb8bb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,6 +1143,33 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "furumi-web-player" +version = "0.3.4" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "clap", + "hmac", + "mime_guess", + "openidconnect", + "rand 0.8.5", + "reqwest 0.12.28", + "rustls 0.23.37", + "serde", + "serde_json", + "sha2", + "sqlx", + "symphonia", + "tokio", + "tokio-util", + "tower 0.4.13", + "tracing", + "tracing-subscriber", + "urlencoding", +] + [[package]] name = "fuser" version = "0.15.1" diff --git a/Cargo.toml b/Cargo.toml index b6b87e4..c985c75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,13 @@ members = [ "furumi-mount-linux", "furumi-mount-macos", "furumi-agent", + "furumi-web-player", ] default-members = [ "furumi-common", "furumi-server", "furumi-client-core", "furumi-agent", + "furumi-web-player", ] resolver = "2" diff --git a/PLAYER-API.md b/PLAYER-API.md new file mode 100644 index 0000000..b6bf2db --- /dev/null +++ b/PLAYER-API.md @@ -0,0 +1,214 @@ +# Furumi Web Player API + +Base URL: `http://:/api` + +All endpoints require authentication when `--token` is set (via cookie `furumi_token=` or query param `?token=`). + +All entity references use **slugs** — 12-character hex identifiers (not sequential IDs). + +## Artists + +### `GET /api/artists` + +List all artists that have at least one track. + +**Response:** +```json +[ + { + "slug": "a1b2c3d4e5f6", + "name": "Pink Floyd", + "album_count": 5, + "track_count": 42 + } +] +``` + +Sorted alphabetically by name. + +### `GET /api/artists/:slug` + +Get artist details. + +**Response:** +```json +{ + "slug": "a1b2c3d4e5f6", + "name": "Pink Floyd" +} +``` + +**Errors:** `404` if not found. + +### `GET /api/artists/:slug/albums` + +List all albums by an artist. + +**Response:** +```json +[ + { + "slug": "b2c3d4e5f6a7", + "name": "Wish You Were Here", + "year": 1975, + "track_count": 5, + "has_cover": true + } +] +``` + +Sorted by year (nulls last), then name. + +### `GET /api/artists/:slug/tracks` + +List all tracks by an artist across all albums. + +**Response:** same as album tracks (see below). + +Sorted by album year, album name, track number, title. + +## Albums + +### `GET /api/albums/:slug` + +List all tracks in an album. + +**Response:** +```json +[ + { + "slug": "c3d4e5f6a7b8", + "title": "Have a Cigar", + "track_number": 3, + "duration_secs": 312.5, + "artist_name": "Pink Floyd", + "album_name": "Wish You Were Here", + "album_slug": "b2c3d4e5f6a7", + "genre": "Progressive Rock" + } +] +``` + +Sorted by track number (nulls last), then title. Fields `album_name`, `album_slug` may be `null` for tracks without an album. + +### `GET /api/albums/:slug/cover` + +Serve the album cover image from the `album_images` table. + +**Response:** Binary image data with appropriate `Content-Type` (`image/jpeg`, `image/png`, etc.) and `Cache-Control: public, max-age=86400`. + +**Errors:** `404` if no cover exists. + +## Tracks + +### `GET /api/tracks/:slug` + +Get full track details. + +**Response:** +```json +{ + "slug": "c3d4e5f6a7b8", + "title": "Have a Cigar", + "track_number": 3, + "duration_secs": 312.5, + "genre": "Progressive Rock", + "storage_path": "/music/storage/Pink Floyd/Wish You Were Here/03 - Have a Cigar.flac", + "artist_name": "Pink Floyd", + "artist_slug": "a1b2c3d4e5f6", + "album_name": "Wish You Were Here", + "album_slug": "b2c3d4e5f6a7", + "album_year": 1975 +} +``` + +**Errors:** `404` if not found. + +### `GET /api/tracks/:slug/cover` + +Serve cover art for a specific track. Resolution order: + +1. Album cover from `album_images` table (if the track belongs to an album with a cover) +2. Embedded cover art extracted from the audio file metadata (ID3/Vorbis/etc. via Symphonia) +3. `404` if no cover art is available + +**Response:** Binary image data with `Content-Type` and `Cache-Control: public, max-age=86400`. + +**Errors:** `404` if no cover art found. + +## Streaming + +### `GET /api/stream/:slug` + +Stream the audio file for a track. + +Supports HTTP **Range requests** for seeking: +- Full response: `200 OK` with `Content-Length` and `Accept-Ranges: bytes` +- Partial response: `206 Partial Content` with `Content-Range` +- Invalid range: `416 Range Not Satisfiable` + +`Content-Type` is determined by the file extension (e.g. `audio/flac`, `audio/mpeg`). + +**Errors:** `404` if track or file not found. + +## Search + +### `GET /api/search?q=&limit=` + +Search across artists, albums, and tracks by name (case-insensitive substring match). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `q` | yes | — | Search query | +| `limit` | no | 20 | Max results | + +**Response:** +```json +[ + { + "result_type": "artist", + "slug": "a1b2c3d4e5f6", + "name": "Pink Floyd", + "detail": null + }, + { + "result_type": "album", + "slug": "b2c3d4e5f6a7", + "name": "Wish You Were Here", + "detail": "Pink Floyd" + }, + { + "result_type": "track", + "slug": "c3d4e5f6a7b8", + "name": "Have a Cigar", + "detail": "Pink Floyd" + } +] +``` + +`detail` contains the artist name for albums and tracks, `null` for artists. + +Sorted by result type (artist → album → track), then by name. + +## Authentication + +When `--token` / `FURUMI_PLAYER_TOKEN` is set: + +- **Cookie:** `furumi_token=` — set after login +- **Query parameter:** `?token=` — redirects to player and sets cookie + +When token is empty, authentication is disabled and all endpoints are public. + +Unauthenticated requests receive `401 Unauthorized` with a login form. + +## Error format + +All errors return JSON: + +```json +{ + "error": "description of the error" +} +``` + +With appropriate HTTP status code (`400`, `404`, `500`, etc.). diff --git a/furumi-agent/src/ingest/mod.rs b/furumi-agent/src/ingest/mod.rs index 7ff8d13..3c13e7a 100644 --- a/furumi-agent/src/ingest/mod.rs +++ b/furumi-agent/src/ingest/mod.rs @@ -53,9 +53,46 @@ async fn scan_inbox(state: &Arc) -> anyhow::Result { } } + // Clean up empty directories in inbox + if count > 0 { + cleanup_empty_dirs(&state.config.inbox_dir).await; + } + Ok(count) } +/// Recursively remove empty directories inside the inbox. +/// Does not remove the inbox root itself. +async fn cleanup_empty_dirs(dir: &std::path::Path) -> bool { + let mut entries = match tokio::fs::read_dir(dir).await { + Ok(e) => e, + Err(_) => return false, + }; + + let mut is_empty = true; + while let Ok(Some(entry)) = entries.next_entry().await { + let ft = match entry.file_type().await { + Ok(ft) => ft, + Err(_) => { is_empty = false; continue; } + }; + if ft.is_dir() { + let child_empty = Box::pin(cleanup_empty_dirs(&entry.path())).await; + if child_empty { + if let Err(e) = tokio::fs::remove_dir(&entry.path()).await { + tracing::warn!(?e, path = ?entry.path(), "Failed to remove empty directory"); + } else { + tracing::info!(path = ?entry.path(), "Removed empty inbox directory"); + } + } else { + is_empty = false; + } + } else { + is_empty = false; + } + } + is_empty +} + /// Recursively collect all audio files and image files under a directory. async fn collect_files(dir: &std::path::Path, audio: &mut Vec, images: &mut Vec) -> anyhow::Result<()> { let mut entries = tokio::fs::read_dir(dir).await?; diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml new file mode 100644 index 0000000..03088db --- /dev/null +++ b/furumi-web-player/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "furumi-web-player" +version = "0.3.4" +edition = "2024" + +[dependencies] +anyhow = "1.0" +axum = { version = "0.7", features = ["tokio", "macros"] } +clap = { version = "4.5", features = ["derive", "env"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] } +tokio = { version = "1.50", features = ["full"] } +tower = { version = "0.4", features = ["util"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +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"] } +sha2 = "0.10" +hmac = "0.12" +base64 = "0.22" +rand = "0.8" +urlencoding = "2.1.3" +rustls = { version = "0.23", features = ["ring"] } diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs new file mode 100644 index 0000000..c3adca8 --- /dev/null +++ b/furumi-web-player/src/db.rs @@ -0,0 +1,229 @@ +use serde::Serialize; +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; + +pub async fn connect(database_url: &str) -> Result { + PgPoolOptions::new() + .max_connections(10) + .connect(database_url) + .await +} + +// --- Models --- + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct ArtistListItem { + pub slug: String, + pub name: String, + pub album_count: i64, + pub track_count: i64, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct ArtistDetail { + pub slug: String, + pub name: String, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct AlbumListItem { + pub slug: String, + pub name: String, + pub year: Option, + pub track_count: i64, + pub has_cover: bool, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct TrackListItem { + pub slug: String, + pub title: String, + pub track_number: Option, + pub duration_secs: Option, + pub artist_name: String, + pub album_name: Option, + pub album_slug: Option, + pub genre: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct TrackDetail { + pub slug: String, + pub title: String, + pub track_number: Option, + pub duration_secs: Option, + pub genre: Option, + pub storage_path: String, + pub artist_name: String, + pub artist_slug: String, + pub album_name: Option, + pub album_slug: Option, + pub album_year: Option, +} + + +#[derive(Debug, sqlx::FromRow)] +pub struct CoverInfo { + pub file_path: String, + pub mime_type: String, +} + +#[derive(Debug, sqlx::FromRow)] +pub struct TrackCoverLookup { + pub storage_path: String, + pub album_id: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct SearchResult { + pub result_type: String, // "artist", "album", "track" + pub slug: String, + pub name: String, + pub detail: Option, // artist name for albums/tracks +} + +// --- Queries --- + +pub async fn list_artists(pool: &PgPool) -> Result, sqlx::Error> { + sqlx::query_as::<_, ArtistListItem>( + r#"SELECT ar.slug, ar.name, + COUNT(DISTINCT al.id) AS album_count, + COUNT(DISTINCT t.id) AS track_count + FROM artists ar + LEFT JOIN albums al ON al.artist_id = ar.id + LEFT JOIN tracks t ON t.artist_id = ar.id + GROUP BY ar.id, ar.slug, ar.name + HAVING COUNT(DISTINCT t.id) > 0 + ORDER BY ar.name"# + ) + .fetch_all(pool) + .await +} + +pub async fn get_artist(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, ArtistDetail>( + "SELECT slug, name FROM artists WHERE slug = $1" + ) + .bind(slug) + .fetch_optional(pool) + .await +} + +pub async fn list_albums_by_artist(pool: &PgPool, artist_slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, AlbumListItem>( + r#"SELECT al.slug, al.name, al.year, + COUNT(t.id) AS track_count, + EXISTS(SELECT 1 FROM album_images ai WHERE ai.album_id = al.id AND ai.image_type = 'cover') AS has_cover + FROM albums al + JOIN artists ar ON al.artist_id = ar.id + LEFT JOIN tracks t ON t.album_id = al.id + WHERE ar.slug = $1 + GROUP BY al.id, al.slug, al.name, al.year + ORDER BY al.year NULLS LAST, al.name"# + ) + .bind(artist_slug) + .fetch_all(pool) + .await +} + +pub async fn list_tracks_by_album(pool: &PgPool, album_slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, TrackListItem>( + r#"SELECT t.slug, t.title, t.track_number, t.duration_secs, + ar.name AS artist_name, + al.name AS album_name, al.slug AS album_slug, t.genre + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + LEFT JOIN albums al ON t.album_id = al.id + WHERE al.slug = $1 + ORDER BY t.track_number NULLS LAST, t.title"# + ) + .bind(album_slug) + .fetch_all(pool) + .await +} + +pub async fn get_track(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, TrackDetail>( + r#"SELECT t.slug, t.title, t.track_number, t.duration_secs, t.genre, + t.storage_path, ar.name AS artist_name, ar.slug AS artist_slug, + al.name AS album_name, al.slug AS album_slug, al.year AS album_year + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + LEFT JOIN albums al ON t.album_id = al.id + WHERE t.slug = $1"# + ) + .bind(slug) + .fetch_optional(pool) + .await +} + + +pub async fn get_track_cover_lookup(pool: &PgPool, track_slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, TrackCoverLookup>( + "SELECT storage_path, album_id FROM tracks WHERE slug = $1" + ) + .bind(track_slug) + .fetch_optional(pool) + .await +} + +pub async fn get_album_cover_by_id(pool: &PgPool, album_id: i64) -> Result, sqlx::Error> { + sqlx::query_as::<_, CoverInfo>( + r#"SELECT file_path, mime_type FROM album_images + WHERE album_id = $1 AND image_type = 'cover' LIMIT 1"# + ) + .bind(album_id) + .fetch_optional(pool) + .await +} + +pub async fn get_album_cover(pool: &PgPool, album_slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, CoverInfo>( + r#"SELECT ai.file_path, ai.mime_type + FROM album_images ai + JOIN albums al ON ai.album_id = al.id + WHERE al.slug = $1 AND ai.image_type = 'cover' + LIMIT 1"# + ) + .bind(album_slug) + .fetch_optional(pool) + .await +} + +pub async fn search(pool: &PgPool, query: &str, limit: i32) -> Result, sqlx::Error> { + let pattern = format!("%{}%", query); + sqlx::query_as::<_, SearchResult>( + r#"SELECT * FROM ( + SELECT 'artist' AS result_type, slug, name, NULL AS detail + FROM artists WHERE name ILIKE $1 + UNION ALL + SELECT 'album' AS result_type, al.slug, al.name, ar.name AS detail + FROM albums al JOIN artists ar ON al.artist_id = ar.id + WHERE al.name ILIKE $1 + UNION ALL + SELECT 'track' AS result_type, t.slug, t.title AS name, ar.name AS detail + FROM tracks t JOIN artists ar ON t.artist_id = ar.id + WHERE t.title ILIKE $1 + ) sub ORDER BY result_type, name LIMIT $2"# + ) + .bind(&pattern) + .bind(limit) + .fetch_all(pool) + .await +} + +pub async fn list_all_tracks_by_artist(pool: &PgPool, artist_slug: &str) -> Result, sqlx::Error> { + sqlx::query_as::<_, TrackListItem>( + r#"SELECT t.slug, t.title, t.track_number, t.duration_secs, + ar.name AS artist_name, + al.name AS album_name, al.slug AS album_slug, t.genre + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + LEFT JOIN albums al ON t.album_id = al.id + WHERE ar.slug = $1 + ORDER BY al.year NULLS LAST, al.name, t.track_number NULLS LAST, t.title"# + ) + .bind(artist_slug) + .fetch_all(pool) + .await +} diff --git a/furumi-web-player/src/main.rs b/furumi-web-player/src/main.rs new file mode 100644 index 0000000..66696dc --- /dev/null +++ b/furumi-web-player/src/main.rs @@ -0,0 +1,106 @@ +mod db; +mod web; + +use std::sync::Arc; + +use clap::Parser; + +#[derive(Parser, Debug)] +#[command(version, about = "Furumi Web Player: database-backed music player")] +struct Args { + /// IP address and port for the web player + #[arg(long, env = "FURUMI_PLAYER_BIND", default_value = "0.0.0.0:8080")] + bind: String, + + /// PostgreSQL connection URL + #[arg(long, env = "FURUMI_PLAYER_DATABASE_URL")] + database_url: String, + + /// Root directory where music files are stored (agent's storage_dir) + #[arg(long, env = "FURUMI_PLAYER_STORAGE_DIR")] + storage_dir: std::path::PathBuf, + + /// OIDC Issuer URL (e.g. https://auth.example.com/application/o/furumi/) + #[arg(long, env = "FURUMI_PLAYER_OIDC_ISSUER_URL")] + oidc_issuer_url: Option, + + /// OIDC Client ID + #[arg(long, env = "FURUMI_PLAYER_OIDC_CLIENT_ID")] + oidc_client_id: Option, + + /// OIDC Client Secret + #[arg(long, env = "FURUMI_PLAYER_OIDC_CLIENT_SECRET")] + oidc_client_secret: Option, + + /// OIDC Redirect URL (e.g. https://music.example.com/auth/callback) + #[arg(long, env = "FURUMI_PLAYER_OIDC_REDIRECT_URL")] + oidc_redirect_url: Option, + + /// OIDC Session Secret (32+ chars, for HMAC). Random if not provided. + #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] + oidc_session_secret: Option, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Install ring as the default crypto provider for rustls + rustls::crypto::ring::default_provider() + .install_default() + .expect("Failed to install rustls crypto provider"); + + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + + let version = option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")); + tracing::info!("Furumi Web Player v{} starting", version); + tracing::info!("Storage directory: {:?}", args.storage_dir); + + if !args.storage_dir.exists() || !args.storage_dir.is_dir() { + eprintln!("Error: Storage directory {:?} does not exist or is not a directory", args.storage_dir); + std::process::exit(1); + } + + tracing::info!("Connecting to database..."); + let pool = db::connect(&args.database_url).await?; + tracing::info!("Database connected"); + + // Initialize OIDC if configured + let oidc_state = if let (Some(issuer), Some(client_id), Some(secret), Some(redirect)) = ( + args.oidc_issuer_url, + args.oidc_client_id, + args.oidc_client_secret, + args.oidc_redirect_url, + ) { + tracing::info!("OIDC (SSO): enabled (issuer: {})", issuer); + match web::auth::oidc_init(issuer, client_id, secret, redirect, args.oidc_session_secret).await { + Ok(state) => Some(Arc::new(state)), + Err(e) => { + eprintln!("Error initializing OIDC: {}", e); + std::process::exit(1); + } + } + } else { + tracing::info!("OIDC (SSO): disabled (no OIDC configuration provided)"); + None + }; + + let bind_addr: std::net::SocketAddr = args.bind.parse().unwrap_or_else(|e| { + eprintln!("Error: Invalid bind address '{}': {}", args.bind, e); + std::process::exit(1); + }); + + let state = Arc::new(web::AppState { + pool, + storage_dir: Arc::new(args.storage_dir), + oidc: oidc_state, + }); + + tracing::info!("Web player: http://{}", bind_addr); + + let app = web::build_router(state); + let listener = tokio::net::TcpListener::bind(bind_addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs new file mode 100644 index 0000000..e4fe88a --- /dev/null +++ b/furumi-web-player/src/web/api.rs @@ -0,0 +1,298 @@ +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Json, Response}, +}; +use serde::Deserialize; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; + +use crate::db; +use super::AppState; + +type S = Arc; + +// --- Library browsing --- + +pub async fn list_artists(State(state): State) -> impl IntoResponse { + match db::list_artists(&state.pool).await { + Ok(artists) => (StatusCode::OK, Json(serde_json::to_value(artists).unwrap())).into_response(), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn get_artist(State(state): State, Path(slug): Path) -> impl IntoResponse { + match db::get_artist(&state.pool, &slug).await { + Ok(Some(artist)) => (StatusCode::OK, Json(serde_json::to_value(artist).unwrap())).into_response(), + Ok(None) => error_json(StatusCode::NOT_FOUND, "artist not found"), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn list_artist_albums(State(state): State, Path(slug): Path) -> impl IntoResponse { + match db::list_albums_by_artist(&state.pool, &slug).await { + Ok(albums) => (StatusCode::OK, Json(serde_json::to_value(albums).unwrap())).into_response(), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn list_artist_all_tracks(State(state): State, Path(slug): Path) -> impl IntoResponse { + match db::list_all_tracks_by_artist(&state.pool, &slug).await { + Ok(tracks) => (StatusCode::OK, Json(serde_json::to_value(tracks).unwrap())).into_response(), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn get_track_detail(State(state): State, Path(slug): Path) -> impl IntoResponse { + match db::get_track(&state.pool, &slug).await { + Ok(Some(track)) => (StatusCode::OK, Json(serde_json::to_value(track).unwrap())).into_response(), + Ok(None) => error_json(StatusCode::NOT_FOUND, "track not found"), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +pub async fn get_album_tracks(State(state): State, Path(slug): Path) -> impl IntoResponse { + match db::list_tracks_by_album(&state.pool, &slug).await { + Ok(tracks) => (StatusCode::OK, Json(serde_json::to_value(tracks).unwrap())).into_response(), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +// --- Stream --- + +pub async fn stream_track( + State(state): State, + Path(slug): Path, + headers: HeaderMap, +) -> impl IntoResponse { + let track = match db::get_track(&state.pool, &slug).await { + Ok(Some(t)) => t, + Ok(None) => return error_json(StatusCode::NOT_FOUND, "track not found"), + Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + + let file_path = std::path::Path::new(&track.storage_path); + if !file_path.exists() { + return error_json(StatusCode::NOT_FOUND, "file not found on disk"); + } + + let file_size = match tokio::fs::metadata(file_path).await { + Ok(m) => m.len(), + Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + + let content_type = mime_guess::from_path(file_path) + .first_or_octet_stream() + .to_string(); + + // Parse Range header + let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()); + + if let Some(range_str) = range { + stream_range(file_path, file_size, &content_type, range_str).await + } else { + stream_full(file_path, file_size, &content_type).await + } +} + +async fn stream_full(path: &std::path::Path, size: u64, content_type: &str) -> Response { + let file = match tokio::fs::File::open(path).await { + Ok(f) => f, + Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + + let stream = tokio_util::io::ReaderStream::new(file); + let body = Body::from_stream(stream); + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, size) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .unwrap() +} + +async fn stream_range(path: &std::path::Path, size: u64, content_type: &str, range_str: &str) -> Response { + // Parse "bytes=START-END" + let range = range_str.strip_prefix("bytes=").unwrap_or(""); + let parts: Vec<&str> = range.split('-').collect(); + + let start: u64 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0); + let end: u64 = parts.get(1).and_then(|s| if s.is_empty() { None } else { s.parse().ok() }).unwrap_or(size - 1); + + if start >= size || end >= size || start > end { + return Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{}", size)) + .body(Body::empty()) + .unwrap(); + } + + let length = end - start + 1; + + let mut file = match tokio::fs::File::open(path).await { + Ok(f) => f, + Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + + if start > 0 { + if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await { + return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()); + } + } + + let limited = file.take(length); + let stream = tokio_util::io::ReaderStream::new(limited); + let body = Body::from_stream(stream); + + Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, length) + .header(header::CONTENT_RANGE, format!("bytes {}-{}/{}", start, end, size)) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .unwrap() +} + +// --- Cover art --- + +pub async fn album_cover(State(state): State, Path(slug): Path) -> impl IntoResponse { + serve_album_cover_by_slug(&state, &slug).await +} + +/// Cover for a specific track: album_images → embedded in file → 404 +pub async fn track_cover(State(state): State, Path(slug): Path) -> impl IntoResponse { + let lookup = match db::get_track_cover_lookup(&state.pool, &slug).await { + Ok(Some(l)) => l, + Ok(None) => return error_json(StatusCode::NOT_FOUND, "track not found"), + Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + + // 1) Try album cover from DB + if let Some(album_id) = lookup.album_id { + if let Ok(Some(cover)) = db::get_album_cover_by_id(&state.pool, album_id).await { + let path = std::path::Path::new(&cover.file_path); + if path.exists() { + if let Ok(data) = tokio::fs::read(path).await { + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &cover.mime_type) + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(data)) + .unwrap(); + } + } + } + } + + // 2) Try extracting embedded cover from the audio file + let file_path = std::path::PathBuf::from(&lookup.storage_path); + if file_path.exists() { + let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&file_path)).await; + if let Ok(Some((data, mime))) = result { + return Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, mime) + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(data)) + .unwrap(); + } + } + + error_json(StatusCode::NOT_FOUND, "no cover art available") +} + +/// Extract embedded cover art from an audio file using Symphonia. +fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec, String)> { + use symphonia::core::{ + formats::FormatOptions, + io::MediaSourceStream, + meta::MetadataOptions, + probe::Hint, + }; + + let file = std::fs::File::open(path).ok()?; + let mss = MediaSourceStream::new(Box::new(file), Default::default()); + + let mut hint = Hint::new(); + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + hint.with_extension(ext); + } + + let mut probed = symphonia::default::get_probe().format( + &hint, + mss, + &FormatOptions { enable_gapless: false, ..Default::default() }, + &MetadataOptions::default(), + ).ok()?; + + // Check metadata side-data + if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) { + if let Some(visual) = rev.visuals().first() { + return Some((visual.data.to_vec(), visual.media_type.clone())); + } + } + + // Check format-embedded metadata + if let Some(rev) = probed.format.metadata().current() { + if let Some(visual) = rev.visuals().first() { + return Some((visual.data.to_vec(), visual.media_type.clone())); + } + } + + None +} + +async fn serve_album_cover_by_slug(state: &AppState, slug: &str) -> Response { + let cover = match db::get_album_cover(&state.pool, slug).await { + Ok(Some(c)) => c, + Ok(None) => return error_json(StatusCode::NOT_FOUND, "no cover"), + Err(e) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + }; + + let path = std::path::Path::new(&cover.file_path); + if !path.exists() { + return error_json(StatusCode::NOT_FOUND, "cover file missing"); + } + + match tokio::fs::read(path).await { + Ok(data) => Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, &cover.mime_type) + .header(header::CACHE_CONTROL, "public, max-age=86400") + .body(Body::from(data)) + .unwrap(), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +// --- Search --- + +#[derive(Deserialize)] +pub struct SearchQuery { + pub q: String, + #[serde(default = "default_limit")] + pub limit: i32, +} + +fn default_limit() -> i32 { 20 } + +pub async fn search(State(state): State, Query(q): Query) -> impl IntoResponse { + if q.q.is_empty() { + return (StatusCode::OK, Json(serde_json::json!([]))).into_response(); + } + match db::search(&state.pool, &q.q, q.limit).await { + Ok(results) => (StatusCode::OK, Json(serde_json::to_value(results).unwrap())).into_response(), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + +// --- Helpers --- + +fn error_json(status: StatusCode, message: &str) -> Response { + (status, Json(serde_json::json!({"error": message}))).into_response() +} diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs new file mode 100644 index 0000000..c2f626c --- /dev/null +++ b/furumi-web-player/src/web/auth.rs @@ -0,0 +1,384 @@ +use axum::{ + body::Body, + extract::{Request, State}, + http::{header, HeaderMap, StatusCode}, + middleware::Next, + response::{Html, IntoResponse, Redirect, Response}, +}; +use openidconnect::{ + core::{CoreClient, CoreProviderMetadata, CoreResponseType}, + reqwest::async_http_client, + AuthenticationFlow, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, +}; +use rand::RngCore; +use serde::Deserialize; + +use base64::Engine; +use hmac::{Hmac, Mac}; + +use super::AppState; +use std::sync::Arc; + +const SESSION_COOKIE: &str = "furumi_session"; + +type HmacSha256 = Hmac; + +pub struct OidcState { + pub client: CoreClient, + pub session_secret: Vec, +} + +pub async fn oidc_init( + issuer: String, + client_id: String, + client_secret: String, + redirect: String, + session_secret_override: Option, +) -> anyhow::Result { + let provider_metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(issuer)?, + async_http_client, + ) + .await?; + + let client = CoreClient::from_provider_metadata( + provider_metadata, + ClientId::new(client_id), + Some(ClientSecret::new(client_secret)), + ) + .set_auth_type(openidconnect::AuthType::RequestBody) + .set_redirect_uri(RedirectUrl::new(redirect)?); + + let session_secret = if let Some(s) = session_secret_override { + let mut b = s.into_bytes(); + b.resize(32, 0); + b + } else { + let mut b = vec![0u8; 32]; + rand::thread_rng().fill_bytes(&mut b); + b + }; + + Ok(OidcState { + client, + session_secret, + }) +} + +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()); + let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + format!("sso:{}:{}", user_id, sig) +} + +fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option { + let parts: Vec<&str> = cookie_val.split(':').collect(); + if parts.len() != 3 || parts[0] != "sso" { + return None; + } + let user_id = parts[1]; + let sig = parts[2]; + + let mut mac = HmacSha256::new_from_slice(secret).unwrap(); + mac.update(user_id.as_bytes()); + + let expected_sig = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + if sig == expected_sig { + Some(user_id.to_string()) + } else { + None + } +} + +/// Auth middleware: requires valid SSO session cookie. +pub async fn require_auth( + State(state): State>, + req: Request, + next: Next, +) -> Response { + let oidc = match &state.oidc { + Some(o) => o, + None => return next.run(req).await, // No OIDC configured = no auth + }; + + let cookies = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + for c in cookies.split(';') { + let c = c.trim(); + if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { + if verify_sso_cookie(&oidc.session_secret, val).is_some() { + return next.run(req).await; + } + } + } + + let uri = req.uri().to_string(); + if uri.starts_with("/api/") { + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + } else { + Redirect::to("/login").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, +} + +/// GET /auth/login — initiate OIDC flow. +pub async fn oidc_login( + State(state): State>, + axum::extract::Query(query): axum::extract::Query, + req: Request, +) -> impl IntoResponse { + let oidc = match &state.oidc { + Some(o) => o, + None => return Redirect::to("/").into_response(), + }; + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (auth_url, csrf_token, nonce) = oidc + .client + .authorize_url( + AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("openid".to_string())) + .add_scope(Scope::new("profile".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + + let next_url = query.next.unwrap_or_else(|| "/".to_string()); + let cookie_val = format!( + "{}:{}:{}:{}", + csrf_token.secret(), + nonce.secret(), + pkce_verifier.secret(), + urlencoding::encode(&next_url) + ); + + let is_https = req + .headers() + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|s| s == "https") + .unwrap_or(false); + + let cookie_attrs = if is_https { + "SameSite=None; Secure" + } else { + "SameSite=Lax" + }; + + let cookie = format!( + "furumi_oidc_state={}; HttpOnly; {}; Path=/; Max-Age=3600", + cookie_val, cookie_attrs + ); + + let mut headers = HeaderMap::new(); + headers.insert(header::SET_COOKIE, cookie.parse().unwrap()); + headers.insert(header::LOCATION, auth_url.as_str().parse().unwrap()); + headers.insert( + header::CACHE_CONTROL, + "no-store, no-cache, must-revalidate".parse().unwrap(), + ); + + (StatusCode::FOUND, headers, Body::empty()).into_response() +} + +#[derive(Deserialize)] +pub struct AuthCallbackQuery { + code: String, + state: String, +} + +/// GET /auth/callback — handle OIDC callback. +pub async fn oidc_callback( + State(state): State>, + axum::extract::Query(query): axum::extract::Query, + req: Request, +) -> impl IntoResponse { + let oidc = match &state.oidc { + Some(o) => o, + None => return Redirect::to("/").into_response(), + }; + + let cookies = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let mut matching_val = None; + for c in cookies.split(';') { + let c = c.trim(); + if let Some(val) = c.strip_prefix("furumi_oidc_state=") { + let parts: Vec<&str> = val.split(':').collect(); + if parts.len() >= 3 && parts[0] == query.state { + matching_val = Some(val.to_string()); + break; + } + } + } + + let cookie_val = match matching_val { + Some(c) => c, + None => { + tracing::warn!("OIDC callback: invalid state or missing cookie"); + return (StatusCode::BAD_REQUEST, "Invalid state").into_response(); + } + }; + + let parts: Vec<&str> = cookie_val.split(':').collect(); + let nonce = Nonce::new(parts[1].to_string()); + let pkce_verifier = PkceCodeVerifier::new(parts[2].to_string()); + + let token_response = oidc + .client + .exchange_code(AuthorizationCode::new(query.code)) + .set_pkce_verifier(pkce_verifier) + .request_async(async_http_client) + .await; + + let token_response = match token_response { + Ok(tr) => tr, + Err(e) => { + tracing::error!("OIDC token exchange error: {:?}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, format!("OIDC error: {}", e)) + .into_response(); + } + }; + + let id_token = match token_response.id_token() { + Some(t) => t, + None => { + return (StatusCode::INTERNAL_SERVER_ERROR, "No ID token").into_response(); + } + }; + + let claims = match id_token.claims(&oidc.client.id_token_verifier(), &nonce) { + Ok(c) => c, + Err(e) => { + return (StatusCode::UNAUTHORIZED, format!("Invalid ID token: {}", e)).into_response(); + } + }; + + let user_id = claims + .preferred_username() + .map(|u| u.to_string()) + .or_else(|| claims.email().map(|e| e.to_string())) + .unwrap_or_else(|| claims.subject().to_string()); + + let session_val = generate_sso_cookie(&oidc.session_secret, &user_id); + + let redirect_to = parts + .get(3) + .and_then(|&s| urlencoding::decode(s).ok()) + .map(|v| v.into_owned()) + .unwrap_or_else(|| "/".to_string()); + let redirect_to = if redirect_to.is_empty() { + "/".to_string() + } else { + redirect_to + }; + + let is_https = req + .headers() + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|s| s == "https") + .unwrap_or(false); + + let session_attrs = if is_https { + "SameSite=Strict; Secure" + } else { + "SameSite=Strict" + }; + + let session_cookie = format!( + "{}={}; HttpOnly; {}; Path=/; Max-Age=604800", + SESSION_COOKIE, session_val, session_attrs + ); + let clear_state = + "furumi_oidc_state=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT"; + + let mut headers = HeaderMap::new(); + headers.insert(header::SET_COOKIE, session_cookie.parse().unwrap()); + headers.append(header::SET_COOKIE, clear_state.parse().unwrap()); + headers.insert(header::LOCATION, redirect_to.parse().unwrap()); + + (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 new file mode 100644 index 0000000..e53ff2c --- /dev/null +++ b/furumi-web-player/src/web/mod.rs @@ -0,0 +1,57 @@ +pub mod api; +pub mod auth; + +use std::sync::Arc; +use std::path::PathBuf; + +use axum::{Router, routing::get, middleware}; +use sqlx::PgPool; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + #[allow(dead_code)] + pub storage_dir: Arc, + pub oidc: Option>, +} + +pub fn build_router(state: Arc) -> Router { + let library = Router::new() + .route("/artists", get(api::list_artists)) + .route("/artists/:slug", get(api::get_artist)) + .route("/artists/:slug/albums", get(api::list_artist_albums)) + .route("/artists/:slug/tracks", get(api::list_artist_all_tracks)) + .route("/albums/:slug", get(api::get_album_tracks)) + .route("/albums/:slug/cover", get(api::album_cover)) + .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)); + + let authed = Router::new() + .route("/", get(player_html)) + .nest("/api", library); + + let has_oidc = state.oidc.is_some(); + + let app = if has_oidc { + authed + .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) + } else { + authed + }; + + 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) + .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) +} diff --git a/furumi-web-player/src/web/player.html b/furumi-web-player/src/web/player.html new file mode 100644 index 0000000..631297c --- /dev/null +++ b/furumi-web-player/src/web/player.html @@ -0,0 +1,589 @@ + + + + + +Furumi Player + + + + +
+ +
+
+ +
+
+
+
+ +
+ + + +
+
+ Queue +
+ + + +
+
+
+
🎵
Select an album to start
+
+
+
+ +
+
+
🎵
+
+
Nothing playing
+
+
+
+
+
+ + + +
+
+ 0:00 +
+
+
+ 0:00 +
+
+
+ 🔊 + +
+
+ +
+ + + + +