New player
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s

This commit is contained in:
2026-03-18 02:44:59 +00:00
parent d5068aaa33
commit ff3ad15b95
11 changed files with 1970 additions and 0 deletions

27
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

214
PLAYER-API.md Normal file
View File

@@ -0,0 +1,214 @@
# Furumi Web Player API
Base URL: `http://<host>:<port>/api`
All endpoints require authentication when `--token` is set (via cookie `furumi_token=<token>` or query param `?token=<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=<query>&limit=<n>`
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=<token>` — set after login
- **Query parameter:** `?token=<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.).

View File

@@ -53,9 +53,46 @@ async fn scan_inbox(state: &Arc<AppState>) -> anyhow::Result<usize> {
}
}
// 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<std::path::PathBuf>, images: &mut Vec<std::path::PathBuf>) -> anyhow::Result<()> {
let mut entries = tokio::fs::read_dir(dir).await?;

View File

@@ -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"] }

229
furumi-web-player/src/db.rs Normal file
View File

@@ -0,0 +1,229 @@
use serde::Serialize;
use sqlx::PgPool;
use sqlx::postgres::PgPoolOptions;
pub async fn connect(database_url: &str) -> Result<PgPool, sqlx::Error> {
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<i32>,
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<i32>,
pub duration_secs: Option<f64>,
pub artist_name: String,
pub album_name: Option<String>,
pub album_slug: Option<String>,
pub genre: Option<String>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct TrackDetail {
pub slug: String,
pub title: String,
pub track_number: Option<i32>,
pub duration_secs: Option<f64>,
pub genre: Option<String>,
pub storage_path: String,
pub artist_name: String,
pub artist_slug: String,
pub album_name: Option<String>,
pub album_slug: Option<String>,
pub album_year: Option<i32>,
}
#[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<i64>,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct SearchResult {
pub result_type: String, // "artist", "album", "track"
pub slug: String,
pub name: String,
pub detail: Option<String>, // artist name for albums/tracks
}
// --- Queries ---
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, 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<Option<ArtistDetail>, 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<Vec<AlbumListItem>, 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<Vec<TrackListItem>, 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<Option<TrackDetail>, 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<Option<TrackCoverLookup>, 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<Option<CoverInfo>, 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<Option<CoverInfo>, 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<Vec<SearchResult>, 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<Vec<TrackListItem>, 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
}

View File

@@ -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<String>,
/// OIDC Client ID
#[arg(long, env = "FURUMI_PLAYER_OIDC_CLIENT_ID")]
oidc_client_id: Option<String>,
/// OIDC Client Secret
#[arg(long, env = "FURUMI_PLAYER_OIDC_CLIENT_SECRET")]
oidc_client_secret: Option<String>,
/// OIDC Redirect URL (e.g. https://music.example.com/auth/callback)
#[arg(long, env = "FURUMI_PLAYER_OIDC_REDIRECT_URL")]
oidc_redirect_url: Option<String>,
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
oidc_session_secret: Option<String>,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 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(())
}

View File

@@ -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<AppState>;
// --- Library browsing ---
pub async fn list_artists(State(state): State<S>) -> 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<S>, Path(slug): Path<String>) -> 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<S>, Path(slug): Path<String>) -> 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<S>, Path(slug): Path<String>) -> 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<S>, Path(slug): Path<String>) -> 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<S>, Path(slug): Path<String>) -> 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<S>,
Path(slug): Path<String>,
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<S>, Path(slug): Path<String>) -> 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<S>, Path(slug): Path<String>) -> 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<u8>, 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<S>, Query(q): Query<SearchQuery>) -> 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()
}

View File

@@ -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<sha2::Sha256>;
pub struct OidcState {
pub client: CoreClient,
pub session_secret: Vec<u8>,
}
pub async fn oidc_init(
issuer: String,
client_id: String,
client_secret: String,
redirect: String,
session_secret_override: Option<String>,
) -> anyhow::Result<OidcState> {
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<String> {
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<Arc<AppState>>,
req: Request,
next: Next,
) -> Response {
let oidc = match &state.oidc {
Some(o) => o,
None => return next.run(req).await, // No OIDC configured = no auth
};
let cookies = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
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<Arc<AppState>>) -> impl IntoResponse {
if state.oidc.is_none() {
return Redirect::to("/").into_response();
}
Html(LOGIN_HTML).into_response()
}
/// GET /logout — clear session cookie.
pub async fn logout() -> impl IntoResponse {
let cookie = format!(
"{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
SESSION_COOKIE
);
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
headers.insert(header::LOCATION, "/login".parse().unwrap());
(StatusCode::FOUND, headers, Body::empty()).into_response()
}
#[derive(Deserialize)]
pub struct LoginQuery {
pub next: Option<String>,
}
/// GET /auth/login — initiate OIDC flow.
pub async fn oidc_login(
State(state): State<Arc<AppState>>,
axum::extract::Query(query): axum::extract::Query<LoginQuery>,
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::<CoreResponseType>::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<Arc<AppState>>,
axum::extract::Query(query): axum::extract::Query<AuthCallbackQuery>,
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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Furumi Player — Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #0d0f14;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
}
.card {
background: #161b27;
border: 1px solid #2a3347;
border-radius: 16px;
padding: 2.5rem 3rem;
width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
text-align: center;
}
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
.btn-sso {
display: block; width: 100%; padding: 0.75rem; text-align: center;
background: #7c6af7; border: none; border-radius: 8px;
color: #fff; font-size: 0.95rem; font-weight: 600; text-decoration: none;
cursor: pointer; transition: background 0.2s;
}
.btn-sso:hover { background: #6b58e8; }
</style>
</head>
<body>
<div class="card">
<div class="logo">Furumi</div>
<div class="subtitle">Sign in to continue</div>
<a href="/auth/login" class="btn-sso">SSO Login</a>
</div>
</body>
</html>"#;

View File

@@ -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<PathBuf>,
pub oidc: Option<Arc<auth::OidcState>>,
}
pub fn build_router(state: Arc<AppState>) -> 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<String> {
let html = include_str!("player.html")
.replace("<!-- VERSION_PLACEHOLDER -->", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")));
axum::response::Html(html)
}

View File

@@ -0,0 +1,589 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Furumi Player</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-base: #0a0c12;
--bg-panel: #111520;
--bg-card: #161d2e;
--bg-hover: #1e2740;
--bg-active: #252f4a;
--border: #1f2c45;
--accent: #7c6af7;
--accent-dim: #5a4fcf;
--accent-glow:rgba(124,106,247,0.3);
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--success: #34d399;
--danger: #f87171;
}
html, body { height: 100%; overflow: hidden; }
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg-base); color: var(--text); display: flex; flex-direction: column; }
.header { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.5rem; background: var(--bg-panel); border-bottom: 1px solid var(--border); flex-shrink: 0; z-index: 10; }
.header-logo { display: flex; align-items: center; gap: 0.75rem; font-weight: 700; font-size: 1.1rem; }
.header-logo svg { width: 22px; height: 22px; }
.header-version { font-size: 0.7rem; color: var(--text-muted); background: rgba(255,255,255,0.05); padding: 0.1rem 0.4rem; border-radius: 4px; margin-left: 0.25rem; font-weight: 500; text-decoration: none; }
.btn-menu { display: none; background: none; border: none; color: var(--text); font-size: 1.2rem; cursor: pointer; padding: 0.1rem 0.5rem; margin-right: 0.2rem; border-radius: 4px; }
/* Search bar */
.search-wrap { position: relative; }
.search-wrap input { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: 6px 12px 6px 30px; color: var(--text); font-size: 13px; width: 220px; font-family: inherit; }
.search-wrap::before { content: '🔍'; position: absolute; left: 8px; top: 50%; transform: translateY(-50%); font-size: 12px; }
.search-dropdown { position: absolute; top: 100%; left: 0; right: 0; background: var(--bg-card); border: 1px solid var(--border); border-radius: 0 0 6px 6px; max-height: 300px; overflow-y: auto; z-index: 50; display: none; }
.search-dropdown.open { display: block; }
.search-result { padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border); }
.search-result:hover { background: var(--bg-hover); }
.search-result .sr-type { font-size: 10px; color: var(--text-muted); text-transform: uppercase; margin-right: 6px; }
.search-result .sr-detail { font-size: 11px; color: var(--text-muted); margin-left: 4px; }
.main { display: flex; flex: 1; overflow: hidden; position: relative; }
.sidebar-overlay { display: none; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 20; }
.sidebar-overlay.show { display: block; }
.sidebar { width: 280px; min-width: 200px; max-width: 400px; flex-shrink: 0; display: flex; flex-direction: column; background: var(--bg-panel); border-right: 1px solid var(--border); overflow: hidden; resize: horizontal; }
.sidebar-header { padding: 0.85rem 1rem 0.6rem; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border); flex-shrink: 0; display: flex; align-items: center; gap: 0.5rem; }
.breadcrumb { padding: 0.5rem 1rem; font-size: 0.78rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-bottom: 1px solid var(--border); flex-shrink: 0; }
.breadcrumb span { color: var(--accent); cursor: pointer; }
.breadcrumb span:hover { text-decoration: underline; }
.file-list { flex: 1; overflow-y: auto; padding: 0.3rem 0; }
.file-list::-webkit-scrollbar { width: 4px; }
.file-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.file-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.45rem 1rem; cursor: pointer; font-size: 0.875rem; color: var(--text-dim); user-select: none; transition: background 0.12s; }
.file-item:hover { background: var(--bg-hover); color: var(--text); }
.file-item.dir { color: var(--accent); }
.file-item .icon { font-size: 0.95rem; flex-shrink: 0; opacity: 0.8; }
.file-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-item .detail { font-size: 0.7rem; color: var(--text-muted); flex-shrink: 0; }
.file-item .add-btn { opacity: 0; font-size: 0.75rem; background: var(--bg-hover); color: var(--text); border: 1px solid var(--border); border-radius: 4px; padding: 0.2rem 0.4rem; cursor: pointer; flex-shrink: 0; }
.file-item:hover .add-btn { opacity: 1; }
.file-item .add-btn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
.queue-panel { flex: 1; display: flex; flex-direction: column; overflow: hidden; background: var(--bg-base); }
.queue-header { padding: 0.85rem 1.25rem 0.6rem; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); border-bottom: 1px solid var(--border); flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; }
.queue-actions { display: flex; gap: 0.5rem; }
.queue-btn { font-size: 0.7rem; padding: 0.2rem 0.55rem; background: none; border: 1px solid var(--border); border-radius: 5px; color: var(--text-muted); cursor: pointer; }
.queue-btn:hover { border-color: var(--accent); color: var(--accent); }
.queue-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.queue-list { flex: 1; overflow-y: auto; padding: 0.3rem 0; }
.queue-list::-webkit-scrollbar { width: 4px; }
.queue-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.queue-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.55rem 1.25rem; cursor: pointer; border-left: 2px solid transparent; transition: background 0.12s; }
.queue-item:hover { background: var(--bg-hover); }
.queue-item.playing { background: var(--bg-active); border-left-color: var(--accent); }
.queue-item.playing .qi-title { color: var(--accent); }
.queue-item .qi-index { font-size: 0.75rem; color: var(--text-muted); width: 1.5rem; text-align: right; flex-shrink: 0; }
.queue-item.playing .qi-index::before { content: '▶'; font-size: 0.6rem; color: var(--accent); }
.queue-item .qi-cover { width: 36px; height: 36px; border-radius: 5px; background: var(--bg-card); flex-shrink: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; font-size: 1.1rem; }
.queue-item .qi-cover img { width: 100%; height: 100%; object-fit: cover; }
.queue-item .qi-info { flex: 1; overflow: hidden; }
.queue-item .qi-title { font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-item .qi-artist { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-item .qi-dur { font-size: 0.75rem; color: var(--text-muted); margin-left: auto; margin-right: 0.5rem; }
.qi-remove { background: none; border: none; font-size: 0.9rem; color: var(--text-muted); cursor: pointer; padding: 0.3rem; border-radius: 4px; opacity: 0; }
.queue-item:hover .qi-remove { opacity: 1; }
.qi-remove:hover { background: rgba(248,113,113,0.15); color: var(--danger); }
.queue-item.dragging { opacity: 0.5; }
.queue-item.drag-over { border-top: 2px solid var(--accent); margin-top: -2px; }
.queue-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-muted); font-size: 0.875rem; gap: 0.5rem; padding: 2rem; }
.queue-empty .empty-icon { font-size: 2.5rem; opacity: 0.3; }
.player-bar { background: var(--bg-panel); border-top: 1px solid var(--border); padding: 0.9rem 1.5rem; flex-shrink: 0; display: grid; grid-template-columns: 1fr 2fr 1fr; align-items: center; gap: 1rem; }
.np-info { display: flex; align-items: center; gap: 0.75rem; min-width: 0; }
.np-cover { width: 44px; height: 44px; border-radius: 6px; background: var(--bg-card); flex-shrink: 0; overflow: hidden; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; }
.np-cover img { width: 100%; height: 100%; object-fit: cover; }
.np-text { min-width: 0; }
.np-title { font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.np-artist { font-size: 0.75rem; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.controls { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
.ctrl-btns { display: flex; align-items: center; gap: 0.5rem; }
.ctrl-btn { background: none; border: none; color: var(--text-dim); cursor: pointer; padding: 0.35rem; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem; }
.ctrl-btn:hover { color: var(--text); background: var(--bg-hover); }
.ctrl-btn.active { color: var(--accent); }
.ctrl-btn-main { width: 38px; height: 38px; background: var(--accent); color: #fff !important; font-size: 1.1rem; box-shadow: 0 0 14px var(--accent-glow); }
.ctrl-btn-main:hover { background: var(--accent-dim) !important; }
.progress-row { display: flex; align-items: center; gap: 0.6rem; width: 100%; }
.time { font-size: 0.7rem; color: var(--text-muted); flex-shrink: 0; font-variant-numeric: tabular-nums; min-width: 2.5rem; text-align: center; }
.progress-bar { flex: 1; height: 4px; background: var(--bg-hover); border-radius: 2px; cursor: pointer; position: relative; }
.progress-fill { height: 100%; background: var(--accent); border-radius: 2px; pointer-events: none; }
.progress-fill::after { content: ''; position: absolute; right: -5px; top: 50%; transform: translateY(-50%); width: 10px; height: 10px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 6px var(--accent-glow); opacity: 0; transition: opacity 0.15s; }
.progress-bar:hover .progress-fill::after { opacity: 1; }
.volume-row { display: flex; align-items: center; gap: 0.5rem; justify-content: flex-end; }
.vol-icon { font-size: 0.9rem; color: var(--text-muted); cursor: pointer; }
.volume-slider { -webkit-appearance: none; appearance: none; width: 80px; height: 4px; border-radius: 2px; background: var(--bg-hover); cursor: pointer; outline: none; }
.volume-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent); cursor: pointer; }
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; }
.toast { position: fixed; bottom: 90px; right: 1.5rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 1rem; font-size: 0.8rem; color: var(--text-dim); box-shadow: 0 8px 24px rgba(0,0,0,0.4); opacity: 0; transform: translateY(8px); transition: all 0.25s; pointer-events: none; z-index: 100; }
.toast.show { opacity: 1; transform: translateY(0); }
@media (max-width: 768px) {
.btn-menu { display: inline-block; }
.header { padding: 0.75rem 1rem; }
.sidebar { position: absolute; top: 0; bottom: 0; left: -100%; width: 85%; max-width: 320px; z-index: 30; transition: left 0.3s; box-shadow: 4px 0 20px rgba(0,0,0,0.6); }
.sidebar.open { left: 0; }
.player-bar { grid-template-columns: 1fr; gap: 0.75rem; padding: 0.75rem 1rem; }
.volume-row { display: none; }
.search-wrap input { width: 140px; }
}
</style>
</head>
<body>
<header class="header">
<div class="header-logo">
<button class="btn-menu" onclick="toggleSidebar()">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="9" cy="18" r="3"/><circle cx="18" cy="15" r="3"/><path d="M12 18V6l9-3v3"/></svg>
Furumi
<span class="header-version">v<!-- VERSION_PLACEHOLDER --></span>
</div>
<div style="display:flex;align-items:center;gap:1rem">
<div class="search-wrap">
<input id="searchInput" placeholder="Search..." oninput="onSearch(this.value)" onkeydown="if(event.key==='Escape'){closeSearch();}">
<div class="search-dropdown" id="searchDropdown"></div>
</div>
</div>
</header>
<div class="main">
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-header">Library</div>
<div class="breadcrumb" id="breadcrumb"><span onclick="showArtists()">Artists</span></div>
<div class="file-list" id="fileList"></div>
</aside>
<section class="queue-panel">
<div class="queue-header">
<span>Queue</span>
<div class="queue-actions">
<button class="queue-btn active" id="btnShuffle" onclick="toggleShuffle()">Shuffle</button>
<button class="queue-btn active" id="btnRepeat" onclick="toggleRepeat()">Repeat</button>
<button class="queue-btn" onclick="clearQueue()">Clear</button>
</div>
</div>
<div class="queue-list" id="queueList">
<div class="queue-empty"><div class="empty-icon">&#127925;</div><div>Select an album to start</div></div>
</div>
</section>
</div>
<div class="player-bar">
<div class="np-info">
<div class="np-cover" id="npCover">&#127925;</div>
<div class="np-text">
<div class="np-title" id="npTitle">Nothing playing</div>
<div class="np-artist" id="npArtist">&mdash;</div>
</div>
</div>
<div class="controls">
<div class="ctrl-btns">
<button class="ctrl-btn" onclick="prevTrack()">&#9198;</button>
<button class="ctrl-btn ctrl-btn-main" id="btnPlayPause" onclick="togglePlay()">&#9654;</button>
<button class="ctrl-btn" onclick="nextTrack()">&#9197;</button>
</div>
<div class="progress-row">
<span class="time" id="timeElapsed">0:00</span>
<div class="progress-bar" id="progressBar" onclick="seekTo(event)">
<div class="progress-fill" id="progressFill" style="width:0%"></div>
</div>
<span class="time" id="timeDuration">0:00</span>
</div>
</div>
<div class="volume-row">
<span class="vol-icon" onclick="toggleMute()" id="volIcon">&#128266;</span>
<input type="range" class="volume-slider" id="volSlider" min="0" max="100" value="80" oninput="setVolume(this.value)">
</div>
</div>
<div class="toast" id="toast"></div>
<audio id="audioEl"></audio>
<script>
const audio = document.getElementById('audioEl');
let queue = []; // [{slug, title, artist, album_slug, duration, cover}]
let queueIndex = -1;
let shuffle = false;
let repeatAll = true;
let shuffleOrder = [];
let searchTimer = null;
// Restore prefs
try {
const v = localStorage.getItem('furumi_vol');
if (v !== null) { audio.volume = v / 100; document.getElementById('volSlider').value = v; }
shuffle = localStorage.getItem('furumi_shuffle') === '1';
repeatAll = localStorage.getItem('furumi_repeat') !== '0';
document.getElementById('btnShuffle').classList.toggle('active', shuffle);
document.getElementById('btnRepeat').classList.toggle('active', repeatAll);
} catch(e) {}
// --- Audio events ---
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
document.getElementById('progressFill').style.width = (audio.currentTime / audio.duration * 100) + '%';
document.getElementById('timeElapsed').textContent = fmt(audio.currentTime);
document.getElementById('timeDuration').textContent = fmt(audio.duration);
}
});
audio.addEventListener('ended', () => nextTrack());
audio.addEventListener('play', () => document.getElementById('btnPlayPause').innerHTML = '&#9208;');
audio.addEventListener('pause', () => document.getElementById('btnPlayPause').innerHTML = '&#9654;');
audio.addEventListener('error', () => { showToast('Playback error'); nextTrack(); });
// --- API helper ---
async function api(path) {
const r = await fetch('/api' + path);
if (!r.ok) return null;
return r.json();
}
// --- Library navigation ---
async function showArtists() {
setBreadcrumb([{label: 'Artists', action: 'showArtists()'}]);
const el = document.getElementById('fileList');
el.innerHTML = '<div style="padding:2rem;text-align:center"><div class="spinner"></div></div>';
const artists = await api('/artists');
if (!artists) { el.innerHTML = '<div style="padding:1rem;color:var(--danger)">Error</div>'; return; }
el.innerHTML = '';
artists.forEach(a => {
const div = document.createElement('div');
div.className = 'file-item dir';
div.innerHTML = `<span class="icon">&#128100;</span><span class="name">${esc(a.name)}</span><span class="detail">${a.album_count} albums</span>`;
div.onclick = () => showArtistAlbums(a.slug, a.name);
el.appendChild(div);
});
}
async function showArtistAlbums(artistSlug, artistName) {
setBreadcrumb([
{label: 'Artists', action: 'showArtists()'},
{label: artistName, action: `showArtistAlbums('${artistSlug}','${esc(artistName)}')`}
]);
const el = document.getElementById('fileList');
el.innerHTML = '<div style="padding:2rem;text-align:center"><div class="spinner"></div></div>';
const albums = await api('/artists/' + artistSlug + '/albums');
if (!albums) { el.innerHTML = '<div style="padding:1rem;color:var(--danger)">Error</div>'; return; }
el.innerHTML = '';
// "Play all" button
const allBtn = document.createElement('div');
allBtn.className = 'file-item';
allBtn.innerHTML = '<span class="icon">&#9654;</span><span class="name" style="color:var(--accent);font-weight:500">Play all tracks</span>';
allBtn.onclick = () => playAllArtistTracks(artistSlug);
el.appendChild(allBtn);
albums.forEach(a => {
const div = document.createElement('div');
div.className = 'file-item dir';
const year = a.year ? `(${a.year})` : '';
div.innerHTML = `<span class="icon">&#128191;</span><span class="name">${esc(a.name)} ${year}</span>
<span class="detail">${a.track_count} tracks</span>
<button class="add-btn" title="Add album to queue">&#10133;</button>`;
div.querySelector('.add-btn').onclick = (ev) => { ev.stopPropagation(); addAlbumToQueue(a.slug); };
div.onclick = () => showAlbumTracks(a.slug, a.name, artistSlug, artistName);
el.appendChild(div);
});
}
async function showAlbumTracks(albumSlug, albumName, artistSlug, artistName) {
setBreadcrumb([
{label: 'Artists', action: 'showArtists()'},
{label: artistName, action: `showArtistAlbums('${artistSlug}','${esc(artistName)}')`},
{label: albumName}
]);
const el = document.getElementById('fileList');
el.innerHTML = '<div style="padding:2rem;text-align:center"><div class="spinner"></div></div>';
const tracks = await api('/albums/' + albumSlug);
if (!tracks) { el.innerHTML = '<div style="padding:1rem;color:var(--danger)">Error</div>'; return; }
el.innerHTML = '';
// "Play album" button
const allBtn = document.createElement('div');
allBtn.className = 'file-item';
allBtn.innerHTML = '<span class="icon">&#9654;</span><span class="name" style="color:var(--accent);font-weight:500">Play album</span>';
allBtn.onclick = () => addAlbumToQueue(albumSlug, true);
el.appendChild(allBtn);
const coverUrl = '/api/albums/' + albumSlug + '/cover';
tracks.forEach(t => {
const div = document.createElement('div');
div.className = 'file-item';
const num = t.track_number ? t.track_number + '. ' : '';
const dur = t.duration_secs ? fmt(t.duration_secs) : '';
div.innerHTML = `<span class="icon">&#127925;</span><span class="name">${num}${esc(t.title)}</span>
<span class="detail">${dur}</span>`;
div.onclick = () => {
addTrackToQueue({slug: t.slug, title: t.title, artist: t.artist_name, album_slug: albumSlug, duration: t.duration_secs}, true);
};
el.appendChild(div);
});
}
function setBreadcrumb(parts) {
const el = document.getElementById('breadcrumb');
el.innerHTML = parts.map((p, i) => {
if (i < parts.length - 1 && p.action) {
return `<span onclick="${p.action}">${esc(p.label)}</span>`;
}
return esc(p.label);
}).join(' / ');
}
// --- Queue management ---
function addTrackToQueue(track, playNow) {
const existing = queue.findIndex(t => t.slug === track.slug);
if (existing !== -1) {
if (playNow) playIndex(existing);
return;
}
queue.push(track);
renderQueue();
if (playNow || (queueIndex === -1 && queue.length === 1)) {
playIndex(queue.length - 1);
}
}
async function addAlbumToQueue(albumSlug, playFirst) {
const tracks = await api('/albums/' + albumSlug);
if (!tracks || !tracks.length) return;
let firstIdx = queue.length;
tracks.forEach(t => {
if (queue.find(q => q.slug === t.slug)) return;
queue.push({slug: t.slug, title: t.title, artist: t.artist_name, album_slug: albumSlug, duration: t.duration_secs});
});
renderQueue();
if (playFirst || queueIndex === -1) playIndex(firstIdx);
showToast(`Added ${tracks.length} tracks`);
}
async function playAllArtistTracks(artistSlug) {
const tracks = await api('/artists/' + artistSlug + '/tracks');
if (!tracks || !tracks.length) return;
clearQueue();
tracks.forEach(t => {
queue.push({slug: t.slug, title: t.title, artist: t.artist_name, album_slug: t.album_slug, duration: t.duration_secs});
});
renderQueue();
playIndex(0);
showToast(`Added ${tracks.length} tracks`);
}
function playIndex(i) {
if (i < 0 || i >= queue.length) return;
queueIndex = i;
const track = queue[i];
audio.src = '/api/stream/' + track.slug;
audio.play().catch(() => {});
updateNowPlaying(track);
renderQueue();
scrollQueueToActive();
history.replaceState(null, '', '?t=' + track.slug);
}
function updateNowPlaying(track) {
if (!track) { document.getElementById('npTitle').textContent = 'Nothing playing'; document.getElementById('npArtist').textContent = '\u2014'; return; }
document.getElementById('npTitle').textContent = track.title;
document.getElementById('npArtist').textContent = track.artist || '\u2014';
document.title = track.title + ' \u2014 Furumi';
const cover = document.getElementById('npCover');
const coverUrl = '/api/tracks/' + track.slug + '/cover';
cover.innerHTML = `<img src="${coverUrl}" alt="" onerror="this.parentElement.innerHTML='&#127925;'">`;
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist || '',
album: '',
artwork: [{src: coverUrl, sizes: '512x512'}]
});
}
}
function renderQueue() {
const el = document.getElementById('queueList');
if (!queue.length) {
el.innerHTML = '<div class="queue-empty"><div class="empty-icon">&#127925;</div><div>Select an album to start</div></div>';
return;
}
const order = currentOrder();
el.innerHTML = '';
order.forEach((origIdx, pos) => {
const t = queue[origIdx];
const isPlaying = origIdx === queueIndex;
const div = document.createElement('div');
div.className = 'queue-item' + (isPlaying ? ' playing' : '');
const coverSrc = t.album_slug ? `/api/tracks/${t.slug}/cover` : '';
const coverHtml = coverSrc
? `<img src="${coverSrc}" alt="" onerror="this.parentElement.innerHTML='&#127925;'">`
: '&#127925;';
const dur = t.duration ? fmt(t.duration) : '';
div.innerHTML = `
<span class="qi-index">${isPlaying ? '' : pos + 1}</span>
<div class="qi-cover">${coverHtml}</div>
<div class="qi-info"><div class="qi-title">${esc(t.title)}</div><div class="qi-artist">${esc(t.artist || '')}</div></div>
<span class="qi-dur">${dur}</span>
<button class="qi-remove" onclick="removeFromQueue(${origIdx},event)">&#10005;</button>
`;
div.addEventListener('click', () => playIndex(origIdx));
// Drag & drop
div.draggable = true;
div.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', pos); div.classList.add('dragging'); });
div.addEventListener('dragend', () => { div.classList.remove('dragging'); el.querySelectorAll('.queue-item').forEach(x => x.classList.remove('drag-over')); });
div.addEventListener('dragover', e => { e.preventDefault(); });
div.addEventListener('dragenter', () => div.classList.add('drag-over'));
div.addEventListener('dragleave', () => div.classList.remove('drag-over'));
div.addEventListener('drop', e => { e.preventDefault(); div.classList.remove('drag-over'); const from = parseInt(e.dataTransfer.getData('text/plain')); if (!isNaN(from)) moveQueueItem(from, pos); });
el.appendChild(div);
});
}
function scrollQueueToActive() {
const el = document.querySelector('.queue-item.playing');
if (el) el.scrollIntoView({behavior: 'smooth', block: 'nearest'});
}
function removeFromQueue(idx, ev) {
if (ev) ev.stopPropagation();
if (idx === queueIndex) { queueIndex = -1; audio.pause(); audio.src = ''; updateNowPlaying(null); }
else if (queueIndex > idx) queueIndex--;
queue.splice(idx, 1);
if (shuffle) { const si = shuffleOrder.indexOf(idx); if (si !== -1) shuffleOrder.splice(si, 1); for (let i = 0; i < shuffleOrder.length; i++) if (shuffleOrder[i] > idx) shuffleOrder[i]--; }
renderQueue();
}
function moveQueueItem(from, to) {
if (from === to) return;
if (shuffle) { const item = shuffleOrder.splice(from, 1)[0]; shuffleOrder.splice(to, 0, item); }
else { const item = queue.splice(from, 1)[0]; queue.splice(to, 0, item); if (queueIndex === from) queueIndex = to; else if (from < queueIndex && to >= queueIndex) queueIndex--; else if (from > queueIndex && to <= queueIndex) queueIndex++; }
renderQueue();
}
function clearQueue() {
queue = []; queueIndex = -1; shuffleOrder = [];
audio.pause(); audio.src = '';
updateNowPlaying(null);
document.title = 'Furumi Player';
renderQueue();
}
// --- Playback controls ---
function togglePlay() {
if (!audio.src && queue.length) { playIndex(queueIndex === -1 ? 0 : queueIndex); return; }
if (audio.paused) audio.play(); else audio.pause();
}
function nextTrack() {
if (!queue.length) return;
const order = currentOrder();
const pos = order.indexOf(queueIndex);
if (pos < order.length - 1) playIndex(order[pos + 1]);
else if (repeatAll) { if (shuffle) buildShuffleOrder(); playIndex(currentOrder()[0]); }
}
function prevTrack() {
if (!queue.length) return;
if (audio.currentTime > 3) { audio.currentTime = 0; return; }
const order = currentOrder();
const pos = order.indexOf(queueIndex);
if (pos > 0) playIndex(order[pos - 1]);
else if (repeatAll) playIndex(order[order.length - 1]);
}
function toggleShuffle() { shuffle = !shuffle; if (shuffle) buildShuffleOrder(); document.getElementById('btnShuffle').classList.toggle('active', shuffle); localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0'); renderQueue(); }
function toggleRepeat() { repeatAll = !repeatAll; document.getElementById('btnRepeat').classList.toggle('active', repeatAll); localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0'); }
function buildShuffleOrder() { shuffleOrder = [...Array(queue.length).keys()]; for (let i = shuffleOrder.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]; } if (queueIndex !== -1) { const ci = shuffleOrder.indexOf(queueIndex); if (ci > 0) { shuffleOrder.splice(ci, 1); shuffleOrder.unshift(queueIndex); } } }
function currentOrder() { if (!shuffle) return [...Array(queue.length).keys()]; if (shuffleOrder.length !== queue.length) buildShuffleOrder(); return shuffleOrder; }
// --- Seek & Volume ---
function seekTo(e) { if (!audio.duration) return; const bar = document.getElementById('progressBar'); const pct = (e.clientX - bar.getBoundingClientRect().left) / bar.offsetWidth; audio.currentTime = pct * audio.duration; }
let muted = false;
function toggleMute() { muted = !muted; audio.muted = muted; document.getElementById('volIcon').innerHTML = muted ? '&#128263;' : '&#128266;'; }
function setVolume(v) { audio.volume = v / 100; document.getElementById('volIcon').innerHTML = v == 0 ? '&#128263;' : '&#128266;'; localStorage.setItem('furumi_vol', v); }
// --- Search ---
function onSearch(q) {
clearTimeout(searchTimer);
if (q.length < 2) { closeSearch(); return; }
searchTimer = setTimeout(async () => {
const results = await api('/search?q=' + encodeURIComponent(q));
if (!results || !results.length) { closeSearch(); return; }
const dd = document.getElementById('searchDropdown');
dd.innerHTML = results.map(r => {
const detail = r.detail ? `<span class="sr-detail">${esc(r.detail)}</span>` : '';
return `<div class="search-result" onclick="onSearchSelect('${r.result_type}','${r.slug}')"><span class="sr-type">${r.result_type}</span>${esc(r.name)}${detail}</div>`;
}).join('');
dd.classList.add('open');
}, 250);
}
function closeSearch() { document.getElementById('searchDropdown').classList.remove('open'); }
function onSearchSelect(type, slug) {
closeSearch();
document.getElementById('searchInput').value = '';
if (type === 'artist') showArtistAlbums(slug, '');
else if (type === 'album') addAlbumToQueue(slug, true);
else if (type === 'track') {
addTrackToQueue({slug, title: '', artist: '', album_slug: null, duration: null}, true);
// Fetch full info
api('/stream/' + slug).catch(() => {});
}
}
// --- Helpers ---
function fmt(secs) { if (!secs || isNaN(secs)) return '0:00'; const s = Math.floor(secs); const m = Math.floor(s / 60); const h = Math.floor(m / 60); if (h > 0) return h + ':' + pad(m % 60) + ':' + pad(s % 60); return m + ':' + pad(s % 60); }
function pad(n) { return String(n).padStart(2, '0'); }
function esc(s) { return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
let toastTimer;
function showToast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('show'), 2500); }
function toggleSidebar() { document.getElementById('sidebar').classList.toggle('open'); document.getElementById('sidebarOverlay').classList.toggle('show'); }
// --- MediaSession ---
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', togglePlay);
navigator.mediaSession.setActionHandler('pause', togglePlay);
navigator.mediaSession.setActionHandler('previoustrack', prevTrack);
navigator.mediaSession.setActionHandler('nexttrack', nextTrack);
navigator.mediaSession.setActionHandler('seekto', d => { audio.currentTime = d.seekTime; });
}
// --- Init ---
(async () => {
const urlSlug = new URLSearchParams(window.location.search).get('t');
if (urlSlug) {
const info = await api('/tracks/' + urlSlug);
if (info) {
addTrackToQueue({
slug: info.slug,
title: info.title,
artist: info.artist_name,
album_slug: info.album_slug,
duration: info.duration_secs
}, true);
}
}
showArtists();
})();
</script>
</body>
</html>