New player
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
All checks were successful
Publish Server Image / build-and-push-image (push) Successful in 2m7s
This commit is contained in:
27
Cargo.lock
generated
27
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
214
PLAYER-API.md
Normal 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.).
|
||||
@@ -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?;
|
||||
|
||||
27
furumi-web-player/Cargo.toml
Normal file
27
furumi-web-player/Cargo.toml
Normal 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
229
furumi-web-player/src/db.rs
Normal 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
|
||||
}
|
||||
106
furumi-web-player/src/main.rs
Normal file
106
furumi-web-player/src/main.rs
Normal 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(())
|
||||
}
|
||||
298
furumi-web-player/src/web/api.rs
Normal file
298
furumi-web-player/src/web/api.rs
Normal 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()
|
||||
}
|
||||
384
furumi-web-player/src/web/auth.rs
Normal file
384
furumi-web-player/src/web/auth.rs
Normal 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>"#;
|
||||
57
furumi-web-player/src/web/mod.rs
Normal file
57
furumi-web-player/src/web/mod.rs
Normal 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)
|
||||
}
|
||||
589
furumi-web-player/src/web/player.html
Normal file
589
furumi-web-player/src/web/player.html
Normal 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()">☰</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">🎵</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">🎵</div>
|
||||
<div class="np-text">
|
||||
<div class="np-title" id="npTitle">Nothing playing</div>
|
||||
<div class="np-artist" id="npArtist">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="ctrl-btns">
|
||||
<button class="ctrl-btn" onclick="prevTrack()">⏮</button>
|
||||
<button class="ctrl-btn ctrl-btn-main" id="btnPlayPause" onclick="togglePlay()">▶</button>
|
||||
<button class="ctrl-btn" onclick="nextTrack()">⏭</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">🔊</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 = '⏸');
|
||||
audio.addEventListener('pause', () => document.getElementById('btnPlayPause').innerHTML = '▶');
|
||||
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">👤</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">▶</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">💿</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">➕</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">▶</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">🎵</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='🎵'">`;
|
||||
|
||||
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">🎵</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='🎵'">`
|
||||
: '🎵';
|
||||
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)">✕</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 ? '🔇' : '🔊'; }
|
||||
function setVolume(v) { audio.volume = v / 100; document.getElementById('volIcon').innerHTML = v == 0 ? '🔇' : '🔊'; 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
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>
|
||||
Reference in New Issue
Block a user