Compare commits
4 Commits
main
..
ebf0256c74
| Author | SHA1 | Date | |
|---|---|---|---|
| ebf0256c74 | |||
| c4f2421099 | |||
| 003919b4ed | |||
| 03f95cfd05 |
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client)
|
||||||
|
globs: furumi-node-player/client/**/*.{ts,tsx}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# REST API в furumi-node-player
|
||||||
|
|
||||||
|
**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход.
|
||||||
|
|
||||||
|
Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей.
|
||||||
|
2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта.
|
||||||
|
3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке).
|
||||||
|
|
||||||
|
## Пример
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// furumiApi.ts — добавлять сюда
|
||||||
|
export async function getSomething(id: string) {
|
||||||
|
const res = await furumiApi.get(`/something/${id}`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую
|
||||||
|
const data = await getSomething(id)
|
||||||
|
```
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile.agent
|
file: Dockerfile.agent
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile.agent
|
file: Dockerfile.agent
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile.web-player
|
file: Dockerfile.web-player
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile.web-player
|
file: Dockerfile.web-player
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ jobs:
|
|||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile
|
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
tags: ${{ steps.info.outputs.tags }}
|
||||||
build-args: |
|
build-args: |
|
||||||
Generated
-54
@@ -2,12 +2,6 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "adler2"
|
|
||||||
version = "2.0.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -578,15 +572,6 @@ version = "2.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crc32fast"
|
|
||||||
version = "1.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.15"
|
version = "0.5.15"
|
||||||
@@ -984,16 +969,6 @@ version = "0.5.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "flate2"
|
|
||||||
version = "1.1.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
|
||||||
dependencies = [
|
|
||||||
"crc32fast",
|
|
||||||
"miniz_oxide",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1042,7 +1017,6 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"id3",
|
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1191,7 +1165,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
"tower-http",
|
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
@@ -1775,17 +1748,6 @@ version = "2.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "id3"
|
|
||||||
version = "1.16.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"byteorder",
|
|
||||||
"flate2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -2059,16 +2021,6 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "miniz_oxide"
|
|
||||||
version = "0.8.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
|
||||||
dependencies = [
|
|
||||||
"adler2",
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -3460,12 +3412,6 @@ dependencies = [
|
|||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "simd-adler32"
|
|
||||||
version = "0.3.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simple_asn1"
|
name = "simple_asn1"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ services:
|
|||||||
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
||||||
FURUMI_PLAYER_STORAGE_DIR: "/storage"
|
FURUMI_PLAYER_STORAGE_DIR: "/storage"
|
||||||
FURUMI_PLAYER_BIND: "0.0.0.0:8085"
|
FURUMI_PLAYER_BIND: "0.0.0.0:8085"
|
||||||
|
FURUMI_PLAYER_API_KEY: "node-player-api-key"
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
restart: always
|
restart: always
|
||||||
@@ -14,7 +14,6 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
|
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
|
||||||
id3 = "1"
|
|
||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
tokio = { version = "1.50", features = ["full"] }
|
tokio = { version = "1.50", features = ["full"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ You are a music library artist merge assistant. You will receive a list of artis
|
|||||||
|
|
||||||
You will receive a structured list like:
|
You will receive a structured list like:
|
||||||
|
|
||||||
### Artist ID 42: "deep purple"
|
### Artist ID 42: "pink floyd"
|
||||||
Album ID 10: "machine head" (1972)
|
Album ID 10: "the wall" (1979)
|
||||||
- 01. "Highway Star" [track_id=100]
|
- 01. "In the Flesh?" [track_id=100]
|
||||||
- 02. "Maybe I'm a Leo" [track_id=101]
|
- 02. "The Thin Ice" [track_id=101]
|
||||||
|
|
||||||
### Artist ID 43: "Deep Purple"
|
### Artist ID 43: "Pink Floyd"
|
||||||
Album ID 11: "Burn" (1974)
|
Album ID 11: "Wish You Were Here" (1975)
|
||||||
- 01. "Burn" [track_id=200]
|
- 01. "Shine On You Crazy Diamond (Parts I-V)" [track_id=200]
|
||||||
|
|
||||||
## Your task
|
## Your task
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Determine if the artists are duplicates and produce a merge plan.
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
### 1. Canonical artist name
|
### 1. Canonical artist name
|
||||||
- Use correct capitalization and canonical spelling (e.g., "deep purple" → "Deep Purple", "AC DC" → "AC/DC").
|
- Use correct capitalization and canonical spelling (e.g., "pink floyd" → "Pink Floyd", "AC DC" → "AC/DC").
|
||||||
- If the database already contains an artist with a well-formed name, prefer that exact form.
|
- If the database already contains an artist with a well-formed name, prefer that exact form.
|
||||||
- If one artist has clearly more tracks or albums, their name spelling may be more authoritative.
|
- If one artist has clearly more tracks or albums, their name spelling may be more authoritative.
|
||||||
- Fix obvious typos or casing errors.
|
- Fix obvious typos or casing errors.
|
||||||
@@ -54,7 +54,7 @@ Determine if the artists are duplicates and produce a merge plan.
|
|||||||
|
|
||||||
You MUST respond with a single JSON object, no markdown fences, no extra text:
|
You MUST respond with a single JSON object, no markdown fences, no extra text:
|
||||||
|
|
||||||
{"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "Machine Head", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Burn", "merge_into_album_id": null}], "notes": "..."}
|
{"canonical_artist_name": "...", "winner_artist_id": 42, "album_mappings": [{"source_album_id": 10, "canonical_name": "The Wall", "merge_into_album_id": null}, {"source_album_id": 11, "canonical_name": "Wish You Were Here", "merge_into_album_id": null}], "notes": "..."}
|
||||||
|
|
||||||
- `canonical_artist_name`: the single correct name for this artist after merging.
|
- `canonical_artist_name`: the single correct name for this artist after merging.
|
||||||
- `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided).
|
- `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided).
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
|
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
|
||||||
- "deep purple" → "Deep Purple"
|
- "pink floyd" → "Pink Floyd"
|
||||||
- "AC DC" → "AC/DC"
|
- "AC DC" → "AC/DC"
|
||||||
- "guns n roses" → "Guns N' Roses"
|
- "Guns n roses" → "Guns N' Roses"
|
||||||
- "led zepplin" → "Led Zeppelin" (fix common misspellings)
|
- "Led zepplin" → "Led Zeppelin" (fix common misspellings)
|
||||||
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
|
- "саша скул" → "Саша Скул" (fix capitalization, keep the language as-is)
|
||||||
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
|
- If the database already contains a matching artist (same name in any case or transliteration), always use the existing canonical name exactly. For example, if the DB has "Саша Скул" and the file says "саша скул" or "Sasha Skul", use "Саша Скул".
|
||||||
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
|
- **Compound artist fields**: When the artist field or path contains multiple artist names joined by "и", "and", "&", "/", ",", "x", or "vs", you MUST split them. The "artist" field must contain ONLY ONE primary artist. All others go into "featured_artists". If one of the names already exists in the database, prefer that one as the primary artist.
|
||||||
@@ -43,12 +43,12 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
|||||||
- Preserve original language for non-English albums.
|
- Preserve original language for non-English albums.
|
||||||
- If the database already contains a matching album under the same artist, use the existing name exactly.
|
- If the database already contains a matching album under the same artist, use the existing name exactly.
|
||||||
- Do not alter the creative content of album names (same principle as track titles).
|
- Do not alter the creative content of album names (same principle as track titles).
|
||||||
- **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "Paranoid" (1970) remastered in 2009 → album: "Paranoid (Remastered)", year: 2009.
|
- **Remastered editions**: A remastered release is a separate album entity, even if it shares the same title and tracks as the original. If the tags or path indicate a remaster (e.g., "Remastered", "Remaster", "REMASTERED" anywhere in tags, filename, or path), append " (Remastered)" to the album name if not already present, and use the year of the remaster release (not the original). Example: original album "The Wall" (1979) remastered in 2011 → album: "The Wall (Remastered)", year: 2011.
|
||||||
|
|
||||||
4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
|
4. **Track titles** must use correct capitalization, but their content must be preserved exactly.
|
||||||
- Use title case for English titles.
|
- Use title case for English titles.
|
||||||
- Preserve original language for non-English titles.
|
- Preserve original language for non-English titles.
|
||||||
- Remove leading track numbers if present (e.g., "01 - Smoke on the Water" → "Smoke on the Water").
|
- Remove leading track numbers if present (e.g., "01 - Have a Cigar" → "Have a Cigar").
|
||||||
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content. If a title contains unusual punctuation, numbers, apostrophes, or symbols — they are intentional and must be kept as-is.
|
- **NEVER remove, add, or alter words, numbers, suffixes, punctuation marks, or special characters in titles.** Your job is to fix capitalization and encoding, not to edit the creative content. If a title contains unusual punctuation, numbers, apostrophes, or symbols — they are intentional and must be kept as-is.
|
||||||
- If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names.
|
- If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names.
|
||||||
|
|
||||||
|
|||||||
+2
-29
@@ -334,31 +334,18 @@ pub async fn approve_and_finalize(
|
|||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Check if track already exists by file_hash (re-approval of same file)
|
// Check if track already exists (e.g. previously approved but pending not cleaned up)
|
||||||
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
|
||||||
.bind(&pt.file_hash)
|
.bind(&pt.file_hash)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some((track_id,)) = existing {
|
if let Some((track_id,)) = existing {
|
||||||
|
// Already finalized — just mark pending as approved
|
||||||
update_pending_status(pool, pending_id, "approved", None).await?;
|
update_pending_status(pool, pending_id, "approved", None).await?;
|
||||||
return Ok(track_id);
|
return Ok(track_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if track already exists by storage_path (Merged: different quality file landed
|
|
||||||
// at the same destination, source was deleted — don't create a phantom duplicate)
|
|
||||||
let existing_path: Option<(i64,)> = sqlx::query_as(
|
|
||||||
"SELECT id FROM tracks WHERE storage_path = $1 AND NOT hidden"
|
|
||||||
)
|
|
||||||
.bind(storage_path)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some((track_id,)) = existing_path {
|
|
||||||
update_pending_status(pool, pending_id, "merged", None).await?;
|
|
||||||
return Ok(track_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
|
||||||
let artist_id = upsert_artist(pool, artist_name).await?;
|
let artist_id = upsert_artist(pool, artist_name).await?;
|
||||||
|
|
||||||
@@ -850,12 +837,6 @@ pub struct AlbumTrackRow {
|
|||||||
pub genre: Option<String>,
|
pub genre: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_album_tracks_genre(pool: &PgPool, album_id: i64, genre: &str) -> Result<(), sqlx::Error> {
|
|
||||||
sqlx::query("UPDATE tracks SET genre = $2 WHERE album_id = $1")
|
|
||||||
.bind(album_id).bind(genre).execute(pool).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> {
|
pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> {
|
||||||
let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as(
|
let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as(
|
||||||
"SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1"
|
"SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1"
|
||||||
@@ -892,14 +873,6 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result<Option<(Str
|
|||||||
Ok(row)
|
Ok(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the storage_path of the first track in an album (for embedded cover fallback).
|
|
||||||
pub async fn get_album_first_track_path(pool: &PgPool, album_id: i64) -> Result<Option<String>, sqlx::Error> {
|
|
||||||
let row: Option<(String,)> = sqlx::query_as(
|
|
||||||
"SELECT storage_path FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title LIMIT 1"
|
|
||||||
).bind(album_id).fetch_optional(pool).await?;
|
|
||||||
Ok(row.map(|(p,)| p))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_artist_by_id(pool: &PgPool, id: i64) -> Result<Option<Artist>, sqlx::Error> {
|
pub async fn get_artist_by_id(pool: &PgPool, id: i64) -> Result<Option<Artist>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
|
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
|
||||||
.bind(id).fetch_optional(pool).await
|
.bind(id).fetch_optional(pool).await
|
||||||
|
|||||||
@@ -19,25 +19,9 @@ pub struct RawMetadata {
|
|||||||
pub duration_secs: Option<f64>,
|
pub duration_secs: Option<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract metadata from an audio file.
|
/// Extract metadata from an audio file using Symphonia.
|
||||||
/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file
|
|
||||||
/// (e.g., ID3 tag with large embedded cover art exceeds Symphonia's 1 MB probe limit).
|
|
||||||
/// Must be called from a blocking context (spawn_blocking).
|
/// Must be called from a blocking context (spawn_blocking).
|
||||||
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||||
match extract_via_symphonia(path) {
|
|
||||||
Ok(meta) => return Ok(meta),
|
|
||||||
Err(e) => {
|
|
||||||
let is_mp3 = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("mp3")).unwrap_or(false);
|
|
||||||
if is_mp3 {
|
|
||||||
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
|
|
||||||
return extract_mp3_via_id3(path);
|
|
||||||
}
|
|
||||||
return Err(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
|
||||||
let file = std::fs::File::open(path)?;
|
let file = std::fs::File::open(path)?;
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||||
|
|
||||||
@@ -82,25 +66,6 @@ fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
|||||||
Ok(meta)
|
Ok(meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
|
|
||||||
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
|
|
||||||
use id3::TagLike;
|
|
||||||
|
|
||||||
let tag = id3::Tag::read_from_path(path)
|
|
||||||
.map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
|
|
||||||
|
|
||||||
let mut meta = RawMetadata::default();
|
|
||||||
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
|
|
||||||
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
|
|
||||||
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
|
|
||||||
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
|
|
||||||
meta.track_number = tag.track();
|
|
||||||
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
|
|
||||||
// duration_secs remains None — acceptable for large-cover files
|
|
||||||
|
|
||||||
Ok(meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
|
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
|
||||||
for tag in tags {
|
for tag in tags {
|
||||||
let value = fix_encoding(tag.value.to_string());
|
let value = fix_encoding(tag.value.to_string());
|
||||||
|
|||||||
@@ -188,20 +188,8 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Source file is gone — check if already in library by hash
|
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
|
||||||
let in_library: (bool,) = sqlx::query_as(
|
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
|
||||||
"SELECT EXISTS(SELECT 1 FROM tracks WHERE file_hash = $1)"
|
|
||||||
)
|
|
||||||
.bind(&pt.file_hash)
|
|
||||||
.fetch_one(&state.pool).await.unwrap_or((false,));
|
|
||||||
|
|
||||||
if in_library.0 {
|
|
||||||
tracing::info!(id = %pt.id, "Source missing but track already in library — merging");
|
|
||||||
db::update_pending_status(&state.pool, pt.id, "merged", None).await?;
|
|
||||||
} else {
|
|
||||||
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
|
|
||||||
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -113,38 +113,32 @@ fn build_user_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ChatRequest {
|
struct OllamaRequest {
|
||||||
model: String,
|
model: String,
|
||||||
messages: Vec<ChatMessage>,
|
messages: Vec<OllamaMessage>,
|
||||||
response_format: ChatResponseFormat,
|
format: String,
|
||||||
stream: bool,
|
stream: bool,
|
||||||
temperature: f64,
|
options: OllamaOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ChatMessage {
|
struct OllamaMessage {
|
||||||
role: String,
|
role: String,
|
||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ChatResponseFormat {
|
struct OllamaOptions {
|
||||||
#[serde(rename = "type")]
|
temperature: f64,
|
||||||
kind: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ChatResponse {
|
struct OllamaResponse {
|
||||||
choices: Vec<ChatChoice>,
|
message: OllamaResponseMessage,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ChatChoice {
|
struct OllamaResponseMessage {
|
||||||
message: ChatResponseMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct ChatResponseMessage {
|
|
||||||
content: String,
|
content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,25 +153,25 @@ pub async fn call_ollama(
|
|||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let request = ChatRequest {
|
let request = OllamaRequest {
|
||||||
model: model.to_owned(),
|
model: model.to_owned(),
|
||||||
messages: vec![
|
messages: vec![
|
||||||
ChatMessage {
|
OllamaMessage {
|
||||||
role: "system".to_owned(),
|
role: "system".to_owned(),
|
||||||
content: system_prompt.to_owned(),
|
content: system_prompt.to_owned(),
|
||||||
},
|
},
|
||||||
ChatMessage {
|
OllamaMessage {
|
||||||
role: "user".to_owned(),
|
role: "user".to_owned(),
|
||||||
content: user_message.to_owned(),
|
content: user_message.to_owned(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response_format: ChatResponseFormat { kind: "json_object".to_owned() },
|
format: "json".to_owned(),
|
||||||
stream: false,
|
stream: false,
|
||||||
temperature: 0.1,
|
options: OllamaOptions { temperature: 0.1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
let url = format!("{}/api/chat", base_url.trim_end_matches('/'));
|
||||||
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API...");
|
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API...");
|
||||||
|
|
||||||
let start = std::time::Instant::now();
|
let start = std::time::Instant::now();
|
||||||
let mut req = client.post(&url).json(&request);
|
let mut req = client.post(&url).json(&request);
|
||||||
@@ -190,25 +184,18 @@ pub async fn call_ollama(
|
|||||||
if !resp.status().is_success() {
|
if !resp.status().is_success() {
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
let body = resp.text().await.unwrap_or_default();
|
let body = resp.text().await.unwrap_or_default();
|
||||||
tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error");
|
tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error");
|
||||||
anyhow::bail!("LLM returned {}: {}", status, body);
|
anyhow::bail!("Ollama returned {}: {}", status, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
let chat_resp: ChatResponse = resp.json().await?;
|
let ollama_resp: OllamaResponse = resp.json().await?;
|
||||||
let content = chat_resp
|
|
||||||
.choices
|
|
||||||
.into_iter()
|
|
||||||
.next()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))?
|
|
||||||
.message
|
|
||||||
.content;
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
elapsed_ms = elapsed.as_millis() as u64,
|
elapsed_ms = elapsed.as_millis() as u64,
|
||||||
response_len = content.len(),
|
response_len = ollama_resp.message.content.len(),
|
||||||
"LLM response received"
|
"Ollama response received"
|
||||||
);
|
);
|
||||||
tracing::debug!(raw_response = %content, "LLM raw output");
|
tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output");
|
||||||
Ok(content)
|
Ok(ollama_resp.message.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the LLM JSON response into NormalizedFields.
|
/// Parse the LLM JSON response into NormalizedFields.
|
||||||
|
|||||||
@@ -295,14 +295,13 @@ function renderFilterBar(s) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTab(tab, btn, noHash) {
|
function showTab(tab, btn) {
|
||||||
currentTab = tab;
|
currentTab = tab;
|
||||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
const pag = document.getElementById('lib-pagination');
|
const pag = document.getElementById('lib-pagination');
|
||||||
if (pag) pag.style.display = 'none';
|
if (pag) pag.style.display = 'none';
|
||||||
if (!noHash) location.hash = tab;
|
|
||||||
if (tab === 'queue') { loadQueue(); loadStats(); }
|
if (tab === 'queue') { loadQueue(); loadStats(); }
|
||||||
else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); }
|
else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); }
|
||||||
else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); }
|
else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); }
|
||||||
@@ -313,11 +312,7 @@ function showTab(tab, btn, noHash) {
|
|||||||
// --- Queue ---
|
// --- Queue ---
|
||||||
async function loadQueue(status, keepSelection) {
|
async function loadQueue(status, keepSelection) {
|
||||||
currentFilter = status;
|
currentFilter = status;
|
||||||
if (!keepSelection) {
|
if (!keepSelection) { clearSelection(); queueOffset = 0; }
|
||||||
clearSelection();
|
|
||||||
queueOffset = 0;
|
|
||||||
location.hash = status ? 'queue/' + status : 'queue';
|
|
||||||
}
|
|
||||||
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
|
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
|
||||||
const raw = await api(`/queue${qs}`) || [];
|
const raw = await api(`/queue${qs}`) || [];
|
||||||
const hasMore = raw.length > queuePageSize;
|
const hasMore = raw.length > queuePageSize;
|
||||||
@@ -364,9 +359,6 @@ function renderQueue(hasMore) {
|
|||||||
const artist = it.norm_artist || it.raw_artist || '-';
|
const artist = it.norm_artist || it.raw_artist || '-';
|
||||||
const title = it.norm_title || it.raw_title || '-';
|
const title = it.norm_title || it.raw_title || '-';
|
||||||
const album = it.norm_album || it.raw_album || '-';
|
const album = it.norm_album || it.raw_album || '-';
|
||||||
const albumCoverUrl = (it.norm_album || it.raw_album) && (it.norm_artist || it.raw_artist)
|
|
||||||
? `${API}/albums/cover-by-name?artist=${encodeURIComponent(it.norm_artist||it.raw_artist||'')}&name=${encodeURIComponent(it.norm_album||it.raw_album||'')}`
|
|
||||||
: null;
|
|
||||||
const year = it.norm_year || it.raw_year || '';
|
const year = it.norm_year || it.raw_year || '';
|
||||||
const tnum = it.norm_track_number || it.raw_track_number || '';
|
const tnum = it.norm_track_number || it.raw_track_number || '';
|
||||||
const canApprove = it.status === 'review';
|
const canApprove = it.status === 'review';
|
||||||
@@ -376,7 +368,7 @@ function renderQueue(hasMore) {
|
|||||||
<td><span class="status status-${it.status}">${it.status}</span></td>
|
<td><span class="status status-${it.status}">${it.status}</span></td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
|
||||||
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}"><span style="display:inline-flex;align-items:center;gap:4px">${albumCoverUrl?`<img src="${albumCoverUrl}" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">`:''}${esc(album)}</span></td>
|
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td>
|
||||||
<td>${year}</td>
|
<td>${year}</td>
|
||||||
<td>${tnum}</td>
|
<td>${tnum}</td>
|
||||||
<td>${conf}</td>
|
<td>${conf}</td>
|
||||||
@@ -1171,37 +1163,6 @@ function openTrackEditForArtist(trackId, artistId) {
|
|||||||
openTrackEdit(trackId, () => openArtistForm(artistId));
|
openTrackEdit(trackId, () => openArtistForm(artistId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Album inline meta edit (from artist form) ---
|
|
||||||
async function saveAlbumMeta(albumId, artistId) {
|
|
||||||
const name = document.getElementById(`alb-name-${albumId}`)?.value?.trim();
|
|
||||||
const yearRaw = document.getElementById(`alb-year-${albumId}`)?.value;
|
|
||||||
if (!name) return;
|
|
||||||
await api(`/albums/${albumId}/edit`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ name, year: parseInt(yearRaw) || null, artist_id: artistId }),
|
|
||||||
});
|
|
||||||
// Update header display in place
|
|
||||||
const block = document.getElementById(`album-block-${albumId}`);
|
|
||||||
if (block) {
|
|
||||||
const nameSpan = block.querySelector('.ab-name');
|
|
||||||
if (nameSpan) nameSpan.textContent = name;
|
|
||||||
const yearSpan = block.querySelector('.ab-year');
|
|
||||||
if (yearSpan) yearSpan.textContent = yearRaw || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyAlbumGenre(albumId) {
|
|
||||||
const genre = document.getElementById(`alb-genre-${albumId}`)?.value?.trim();
|
|
||||||
if (!genre) return;
|
|
||||||
await api(`/albums/${albumId}/genre`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {'Content-Type':'application/json'},
|
|
||||||
body: JSON.stringify({ genre }),
|
|
||||||
});
|
|
||||||
document.getElementById(`alb-genre-${albumId}`).value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
||||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
||||||
@@ -1534,20 +1495,7 @@ async function openArtistForm(id) {
|
|||||||
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
|
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
|
||||||
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
|
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
const albumMeta = `<div style="padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg-panel)">
|
body.innerHTML = tracks;
|
||||||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
|
|
||||||
<input id="alb-name-${alb.id}" value="${esc(alb.name)}" placeholder="Album name"
|
|
||||||
style="flex:2;min-width:140px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
|
||||||
<input id="alb-year-${alb.id}" type="number" value="${alb.year??''}" placeholder="Year"
|
|
||||||
style="width:70px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
|
||||||
<button class="btn btn-primary" style="font-size:10px;padding:3px 10px" onclick="saveAlbumMeta(${alb.id},${id})">Save</button>
|
|
||||||
<span style="color:var(--border);user-select:none">|</span>
|
|
||||||
<input id="alb-genre-${alb.id}" placeholder="Apply genre to all tracks…"
|
|
||||||
style="flex:1;min-width:130px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
|
|
||||||
<button class="btn btn-edit" style="font-size:10px;padding:3px 10px" onclick="applyAlbumGenre(${alb.id})">Apply</button>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
body.innerHTML = albumMeta + tracks;
|
|
||||||
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
|
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1638,82 +1586,9 @@ async function removeAppearance(artistId, trackId, btn) {
|
|||||||
btn.closest('.appearance-row').remove();
|
btn.closest('.appearance-row').remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cover preview ---
|
|
||||||
(function() {
|
|
||||||
const box = document.createElement('div');
|
|
||||||
box.id = 'cover-preview';
|
|
||||||
box.style.cssText = [
|
|
||||||
'display:none', 'position:fixed', 'z-index:9999', 'pointer-events:none',
|
|
||||||
'border-radius:10px', 'overflow:hidden',
|
|
||||||
'box-shadow:0 12px 40px rgba(0,0,0,0.85)',
|
|
||||||
'border:1px solid rgba(255,255,255,0.08)',
|
|
||||||
'background:#0a0c12', 'transition:opacity 0.1s',
|
|
||||||
].join(';');
|
|
||||||
const img = document.createElement('img');
|
|
||||||
img.style.cssText = 'display:block;width:280px;height:280px;object-fit:contain';
|
|
||||||
box.appendChild(img);
|
|
||||||
document.body.appendChild(box);
|
|
||||||
|
|
||||||
let showTimer = null;
|
|
||||||
|
|
||||||
function isCoverImg(el) {
|
|
||||||
return el && el.tagName === 'IMG' && el.src && el.src.includes('/cover');
|
|
||||||
}
|
|
||||||
|
|
||||||
function place(e) {
|
|
||||||
const margin = 16, pw = 280, ph = 280;
|
|
||||||
const vw = window.innerWidth, vh = window.innerHeight;
|
|
||||||
let x = e.clientX + margin, y = e.clientY + margin;
|
|
||||||
if (x + pw > vw - 8) x = e.clientX - pw - margin;
|
|
||||||
if (y + ph > vh - 8) y = e.clientY - ph - margin;
|
|
||||||
box.style.left = x + 'px';
|
|
||||||
box.style.top = y + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('mouseover', e => {
|
|
||||||
if (!isCoverImg(e.target)) return;
|
|
||||||
clearTimeout(showTimer);
|
|
||||||
showTimer = setTimeout(() => {
|
|
||||||
img.src = e.target.src;
|
|
||||||
box.style.display = 'block';
|
|
||||||
place(e);
|
|
||||||
}, 120);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', e => {
|
|
||||||
if (box.style.display === 'none') return;
|
|
||||||
if (!isCoverImg(e.target)) { clearTimeout(showTimer); box.style.display = 'none'; return; }
|
|
||||||
place(e);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mouseout', e => {
|
|
||||||
if (!isCoverImg(e.target)) return;
|
|
||||||
clearTimeout(showTimer);
|
|
||||||
box.style.display = 'none';
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
(function restoreFromHash() {
|
|
||||||
const hash = location.hash.slice(1); // strip #
|
|
||||||
if (!hash) return;
|
|
||||||
const [tab, filter] = hash.split('/');
|
|
||||||
const validTabs = ['queue','tracks','albums','artists','merges'];
|
|
||||||
if (!validTabs.includes(tab)) return;
|
|
||||||
const btn = Array.from(document.querySelectorAll('nav button'))
|
|
||||||
.find(b => (b.getAttribute('onclick') || '').includes(`'${tab}'`));
|
|
||||||
if (!btn) return;
|
|
||||||
// Switch tab without overwriting the hash
|
|
||||||
showTab(tab, btn, true);
|
|
||||||
// For queue, also restore the filter
|
|
||||||
if (tab === 'queue' && filter) {
|
|
||||||
currentFilter = filter;
|
|
||||||
loadQueue(filter);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
loadStats();
|
loadStats();
|
||||||
if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
|
loadQueue();
|
||||||
setInterval(loadStats, 5000);
|
setInterval(loadStats, 5000);
|
||||||
// Auto-refresh queue when on queue tab
|
// Auto-refresh queue when on queue tab
|
||||||
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
||||||
|
|||||||
+10
-87
@@ -528,20 +528,6 @@ pub async fn update_album_full(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct SetGenreBody { pub genre: String }
|
|
||||||
|
|
||||||
pub async fn set_album_tracks_genre(
|
|
||||||
State(state): State<S>,
|
|
||||||
Path(id): Path<i64>,
|
|
||||||
Json(body): Json<SetGenreBody>,
|
|
||||||
) -> impl IntoResponse {
|
|
||||||
match db::set_album_tracks_genre(&state.pool, id, &body.genre).await {
|
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
|
||||||
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ReorderBody {
|
pub struct ReorderBody {
|
||||||
pub orders: Vec<(i64, i32)>,
|
pub orders: Vec<(i64, i32)>,
|
||||||
@@ -558,82 +544,19 @@ pub async fn reorder_album_tracks(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cover by artist+album name — used for queue items that may not have an album_id yet.
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
pub struct CoverByNameQuery {
|
|
||||||
#[serde(default)] pub artist: String,
|
|
||||||
#[serde(default)] pub name: String,
|
|
||||||
}
|
|
||||||
pub async fn album_cover_by_name(State(state): State<S>, Query(q): Query<CoverByNameQuery>) -> impl IntoResponse {
|
|
||||||
let album_id = match db::find_album_id(&state.pool, &q.artist, &q.name).await {
|
|
||||||
Ok(Some(id)) => id,
|
|
||||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
|
||||||
};
|
|
||||||
album_cover_by_id(&state, album_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
||||||
album_cover_by_id(&state, id).await
|
let cover = match db::get_album_cover(&state.pool, id).await {
|
||||||
}
|
Ok(Some(c)) => c,
|
||||||
|
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||||
async fn album_cover_by_id(state: &super::AppState, id: i64) -> axum::response::Response {
|
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||||
// 1. Try album_images table
|
|
||||||
if let Ok(Some((file_path, mime_type))) = db::get_album_cover(&state.pool, id).await {
|
|
||||||
if let Ok(bytes) = tokio::fs::read(&file_path).await {
|
|
||||||
return ([(axum::http::header::CONTENT_TYPE, mime_type)], bytes).into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fallback: extract embedded cover from first track in album
|
|
||||||
if let Ok(Some(track_path)) = db::get_album_first_track_path(&state.pool, id).await {
|
|
||||||
let path = std::path::PathBuf::from(track_path);
|
|
||||||
if path.exists() {
|
|
||||||
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
|
|
||||||
if let Ok(Some((bytes, mime))) = result {
|
|
||||||
return ([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusCode::NOT_FOUND.into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec<u8>, String)> {
|
|
||||||
use symphonia::core::{
|
|
||||||
formats::FormatOptions,
|
|
||||||
io::MediaSourceStream,
|
|
||||||
meta::MetadataOptions,
|
|
||||||
probe::Hint,
|
|
||||||
};
|
};
|
||||||
|
match tokio::fs::read(&cover.0).await {
|
||||||
let file = std::fs::File::open(path).ok()?;
|
Ok(bytes) => (
|
||||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
[(axum::http::header::CONTENT_TYPE, cover.1)],
|
||||||
|
bytes,
|
||||||
let mut hint = Hint::new();
|
).into_response(),
|
||||||
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||||
hint.with_extension(ext);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut probed = symphonia::default::get_probe()
|
|
||||||
.format(
|
|
||||||
&hint,
|
|
||||||
mss,
|
|
||||||
&FormatOptions { enable_gapless: false, ..Default::default() },
|
|
||||||
&MetadataOptions::default(),
|
|
||||||
)
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
|
|
||||||
if let Some(v) = rev.visuals().first() {
|
|
||||||
return Some((v.data.to_vec(), v.media_type.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(rev) = probed.format.metadata().current() {
|
|
||||||
if let Some(v) = rev.visuals().first() {
|
|
||||||
return Some((v.data.to_vec(), v.media_type.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -41,12 +41,10 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
.route("/tracks/:id", get(api::get_track).put(api::update_track))
|
||||||
.route("/tracks/:id/hidden", put(api::set_track_hidden))
|
.route("/tracks/:id/hidden", put(api::set_track_hidden))
|
||||||
.route("/albums/search", get(api::search_albums_for_artist))
|
.route("/albums/search", get(api::search_albums_for_artist))
|
||||||
.route("/albums/cover-by-name", get(api::album_cover_by_name))
|
|
||||||
.route("/albums/:id/cover", get(api::album_cover))
|
.route("/albums/:id/cover", get(api::album_cover))
|
||||||
.route("/albums/:id/full", get(api::get_album_full))
|
.route("/albums/:id/full", get(api::get_album_full))
|
||||||
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
||||||
.route("/albums/:id/edit", put(api::update_album_full))
|
.route("/albums/:id/edit", put(api::update_album_full))
|
||||||
.route("/albums/:id/genre", put(api::set_album_tracks_genre))
|
|
||||||
.route("/albums/:id/hidden", put(api::set_album_hidden))
|
.route("/albums/:id/hidden", put(api::set_album_hidden))
|
||||||
.route("/albums/:id/release_type", put(api::set_album_release_type))
|
.route("/albums/:id/release_type", put(api::set_album_release_type))
|
||||||
.route("/albums/:id", put(api::update_album))
|
.route("/albums/:id", put(api::update_album))
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8085
|
||||||
|
VITE_API_KEY=
|
||||||
+280
@@ -8,6 +8,7 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
},
|
},
|
||||||
@@ -1287,6 +1288,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.13.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||||
|
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.11",
|
||||||
|
"form-data": "^4.0.5",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -1352,6 +1370,19 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/call-bind-apply-helpers": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1420,6 +1451,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -1481,6 +1524,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1491,6 +1543,20 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dunder-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"gopd": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.321",
|
"version": "1.5.321",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
||||||
@@ -1498,6 +1564,51 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/es-define-property": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-errors": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-object-atoms": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es-set-tostringtag": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"get-intrinsic": "^1.2.6",
|
||||||
|
"has-tostringtag": "^1.0.2",
|
||||||
|
"hasown": "^2.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1795,6 +1906,42 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/form-data": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1810,6 +1957,15 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/function-bind": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -1820,6 +1976,43 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-intrinsic": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
|
"es-define-property": "^1.0.1",
|
||||||
|
"es-errors": "^1.3.0",
|
||||||
|
"es-object-atoms": "^1.1.1",
|
||||||
|
"function-bind": "^1.1.2",
|
||||||
|
"get-proto": "^1.0.1",
|
||||||
|
"gopd": "^1.2.0",
|
||||||
|
"has-symbols": "^1.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
|
"math-intrinsics": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/get-proto": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dunder-proto": "^1.0.1",
|
||||||
|
"es-object-atoms": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -1846,6 +2039,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gopd": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -1856,6 +2061,45 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/has-symbols": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/has-tostringtag": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"has-symbols": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hasown": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"function-bind": "^1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -2325,6 +2569,36 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/math-intrinsics": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -2520,6 +2794,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -61,12 +61,11 @@ function App() {
|
|||||||
|
|
||||||
const loginUrl = `${apiBase}/api/login`
|
const loginUrl = `${apiBase}/api/login`
|
||||||
const logoutUrl = `${apiBase}/api/logout`
|
const logoutUrl = `${apiBase}/api/logout`
|
||||||
const playerApiRoot = `${apiBase}/api`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!loading && (user || runWithoutAuth) ? (
|
{!loading && (user || runWithoutAuth) ? (
|
||||||
<FurumiPlayer apiRoot={playerApiRoot} />
|
<FurumiPlayer />
|
||||||
) : (
|
) : (
|
||||||
<main className="page">
|
<main className="page">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
import './furumi-player.css'
|
import './furumi-player.css'
|
||||||
import { createFurumiApiClient } from './furumiApi'
|
import {
|
||||||
import { SearchDropdown } from './components/SearchDropdown'
|
API_ROOT,
|
||||||
import { Breadcrumbs } from './components/Breadcrumbs'
|
getArtists,
|
||||||
import { LibraryList } from './components/LibraryList'
|
getArtistAlbums,
|
||||||
import { QueueList, type QueueItem } from './components/QueueList'
|
getAlbumTracks,
|
||||||
import { NowPlaying } from './components/NowPlaying'
|
getArtistTracks,
|
||||||
|
searchTracks,
|
||||||
|
getTrackInfo,
|
||||||
|
preloadStream,
|
||||||
|
} from './furumiApi'
|
||||||
|
import { fmt } from './utils'
|
||||||
|
import { Header } from './components/Header'
|
||||||
|
import { MainPanel, type Crumb } from './components/MainPanel'
|
||||||
|
import { PlayerBar } from './components/PlayerBar'
|
||||||
|
import type { QueueItem } from './components/QueueList'
|
||||||
|
|
||||||
type FurumiPlayerProps = {
|
export function FurumiPlayer() {
|
||||||
apiRoot: string
|
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
|
||||||
}
|
|
||||||
|
|
||||||
type Crumb = { label: string; action?: () => void }
|
|
||||||
|
|
||||||
export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|
||||||
const [breadcrumbs, setBreadcrumbs] = useState<Array<{ label: string; action?: () => void }>>(
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
const [libraryLoading, setLibraryLoading] = useState(false)
|
const [libraryLoading, setLibraryLoading] = useState(false)
|
||||||
@@ -42,6 +45,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
|
||||||
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
|
||||||
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
|
||||||
|
const [queue, setQueue] = useState<QueueItem[]>([])
|
||||||
|
|
||||||
const queueActionsRef = useRef<{
|
const queueActionsRef = useRef<{
|
||||||
playIndex: (i: number) => void
|
playIndex: (i: number) => void
|
||||||
@@ -49,12 +53,14 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
moveQueueItem: (fromPos: number, toPos: number) => void
|
moveQueueItem: (fromPos: number, toPos: number) => void
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// --- Original player script adapted for React environment ---
|
// --- Original player script adapted for React environment ---
|
||||||
const audio = document.getElementById('audioEl') as HTMLAudioElement
|
const audioEl = audioRef.current
|
||||||
if (!audio) return
|
if (!audioEl) return
|
||||||
|
const audio = audioEl
|
||||||
|
|
||||||
let queue: QueueItem[] = []
|
|
||||||
let queueIndex = -1
|
let queueIndex = -1
|
||||||
let shuffle = false
|
let shuffle = false
|
||||||
let repeatAll = true
|
let repeatAll = true
|
||||||
@@ -106,16 +112,12 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
nextTrack()
|
nextTrack()
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- API helper ---
|
|
||||||
const API = apiRoot
|
|
||||||
const api = createFurumiApiClient(API)
|
|
||||||
|
|
||||||
// --- Library navigation ---
|
// --- Library navigation ---
|
||||||
async function showArtists() {
|
async function showArtists() {
|
||||||
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
setBreadcrumb([{ label: 'Artists', action: showArtists }])
|
||||||
setLibraryLoading(true)
|
setLibraryLoading(true)
|
||||||
setLibraryError(null)
|
setLibraryError(null)
|
||||||
const artists = await api('/artists')
|
const artists = await getArtists()
|
||||||
if (!artists) {
|
if (!artists) {
|
||||||
setLibraryLoading(false)
|
setLibraryLoading(false)
|
||||||
setLibraryError('Error')
|
setLibraryError('Error')
|
||||||
@@ -141,7 +143,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
])
|
])
|
||||||
setLibraryLoading(true)
|
setLibraryLoading(true)
|
||||||
setLibraryError(null)
|
setLibraryError(null)
|
||||||
const albums = await api('/artists/' + artistSlug + '/albums')
|
const albums = await getArtistAlbums(artistSlug)
|
||||||
if (!albums) {
|
if (!albums) {
|
||||||
setLibraryLoading(false)
|
setLibraryLoading(false)
|
||||||
setLibraryError('Error')
|
setLibraryError('Error')
|
||||||
@@ -190,7 +192,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
])
|
])
|
||||||
setLibraryLoading(true)
|
setLibraryLoading(true)
|
||||||
setLibraryError(null)
|
setLibraryError(null)
|
||||||
const tracks = await api('/albums/' + albumSlug)
|
const tracks = await getAlbumTracks(albumSlug)
|
||||||
if (!tracks) {
|
if (!tracks) {
|
||||||
setLibraryLoading(false)
|
setLibraryLoading(false)
|
||||||
setLibraryError('Error')
|
setLibraryError('Error')
|
||||||
@@ -252,7 +254,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
if (playNow) playIndex(existing)
|
if (playNow) playIndex(existing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
queue.push(track)
|
setQueue((q) => [...q, track]);
|
||||||
updateQueueModel()
|
updateQueueModel()
|
||||||
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
if (playNow || (queueIndex === -1 && queue.length === 1)) {
|
||||||
playIndex(queue.length - 1)
|
playIndex(queue.length - 1)
|
||||||
@@ -260,19 +262,13 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
|
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
|
||||||
const tracks = await api('/albums/' + albumSlug)
|
const tracks = await getAlbumTracks(albumSlug)
|
||||||
if (!tracks || !(tracks as any[]).length) return
|
if (!tracks || !(tracks as any[]).length) return
|
||||||
const list = tracks as any[]
|
const list = tracks as any[]
|
||||||
let firstIdx = queue.length
|
let firstIdx = queue.length
|
||||||
list.forEach((t) => {
|
list.forEach((t) => {
|
||||||
if (queue.find((q) => q.slug === t.slug)) return
|
if (queue.find((q) => q.slug === t.slug)) return
|
||||||
queue.push({
|
setQueue((q) => [...q, t])
|
||||||
slug: t.slug,
|
|
||||||
title: t.title,
|
|
||||||
artist: t.artist_name,
|
|
||||||
album_slug: albumSlug,
|
|
||||||
duration: t.duration_secs,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
updateQueueModel()
|
updateQueueModel()
|
||||||
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
if (playFirst || queueIndex === -1) playIndex(firstIdx)
|
||||||
@@ -280,7 +276,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function playAllArtistTracks(artistSlug: string) {
|
async function playAllArtistTracks(artistSlug: string) {
|
||||||
const tracks = await api('/artists/' + artistSlug + '/tracks')
|
const tracks = await getArtistTracks(artistSlug)
|
||||||
if (!tracks || !(tracks as any[]).length) return
|
if (!tracks || !(tracks as any[]).length) return
|
||||||
const list = tracks as any[]
|
const list = tracks as any[]
|
||||||
clearQueue()
|
clearQueue()
|
||||||
@@ -302,7 +298,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
if (i < 0 || i >= queue.length) return
|
if (i < 0 || i >= queue.length) return
|
||||||
queueIndex = i
|
queueIndex = i
|
||||||
const track = queue[i]
|
const track = queue[i]
|
||||||
audio.src = `${API}/stream/${track.slug}`
|
audio.src = `${API_ROOT}/stream/${track.slug}`
|
||||||
void audio.play().catch(() => {})
|
void audio.play().catch(() => {})
|
||||||
updateNowPlaying(track)
|
updateNowPlaying(track)
|
||||||
updateQueueModel()
|
updateQueueModel()
|
||||||
@@ -320,7 +316,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
|
|
||||||
document.title = `${track.title} — Furumi`
|
document.title = `${track.title} — Furumi`
|
||||||
|
|
||||||
const coverUrl = `${API}/tracks/${track.slug}/cover`
|
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
||||||
if ('mediaSession' in navigator) {
|
if ('mediaSession' in navigator) {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
navigator.mediaSession.metadata = new window.MediaMetadata({
|
navigator.mediaSession.metadata = new window.MediaMetadata({
|
||||||
@@ -355,7 +351,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
|
|
||||||
function updateQueueModel() {
|
function updateQueueModel() {
|
||||||
const order = currentOrder()
|
const order = currentOrder()
|
||||||
setQueueItemsView(queue.slice())
|
setQueueItemsView(queue)
|
||||||
setQueueOrderView(order.slice())
|
setQueueOrderView(order.slice())
|
||||||
setQueuePlayingOrigIdxView(queueIndex)
|
setQueuePlayingOrigIdxView(queueIndex)
|
||||||
}
|
}
|
||||||
@@ -369,7 +365,10 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
} else if (queueIndex > idx) {
|
} else if (queueIndex > idx) {
|
||||||
queueIndex--
|
queueIndex--
|
||||||
}
|
}
|
||||||
queue.splice(idx, 1)
|
|
||||||
|
// queue.splice(idx, 1)
|
||||||
|
setQueue((q) => q.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
if (shuffle) {
|
if (shuffle) {
|
||||||
const si = shuffleOrder.indexOf(idx)
|
const si = shuffleOrder.indexOf(idx)
|
||||||
if (si !== -1) shuffleOrder.splice(si, 1)
|
if (si !== -1) shuffleOrder.splice(si, 1)
|
||||||
@@ -402,7 +401,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearQueue() {
|
function clearQueue() {
|
||||||
queue = []
|
setQueue([]);
|
||||||
queueIndex = -1
|
queueIndex = -1
|
||||||
shuffleOrder = []
|
shuffleOrder = []
|
||||||
audio.pause()
|
audio.pause()
|
||||||
@@ -495,7 +494,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
searchTimer = window.setTimeout(async () => {
|
searchTimer = window.setTimeout(async () => {
|
||||||
const results = await api('/search?q=' + encodeURIComponent(q))
|
const results = await searchTracks(q)
|
||||||
if (!results || !(results as any[]).length) {
|
if (!results || !(results as any[]).length) {
|
||||||
closeSearch()
|
closeSearch()
|
||||||
return
|
return
|
||||||
@@ -519,27 +518,12 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
{ slug, title: '', artist: '', album_slug: null, duration: null },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
void api('/stream/' + slug).catch(() => null)
|
void preloadStream(slug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
searchSelectRef.current = onSearchSelect
|
searchSelectRef.current = onSearchSelect
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
function fmt(secs: number) {
|
|
||||||
if (!secs || Number.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: number) {
|
|
||||||
return String(n).padStart(2, '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg: string) {
|
function showToast(msg: string) {
|
||||||
const t = document.getElementById('toast')
|
const t = document.getElementById('toast')
|
||||||
if (!t) return
|
if (!t) return
|
||||||
@@ -625,7 +609,7 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
const urlSlug = url.searchParams.get('t')
|
const urlSlug = url.searchParams.get('t')
|
||||||
if (urlSlug) {
|
if (urlSlug) {
|
||||||
const info = await api('/tracks/' + urlSlug)
|
const info = await getTrackInfo(urlSlug)
|
||||||
if (info) {
|
if (info) {
|
||||||
addTrackToQueue(
|
addTrackToQueue(
|
||||||
{
|
{
|
||||||
@@ -647,120 +631,38 @@ export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
|
|||||||
queueActionsRef.current = null
|
queueActionsRef.current = null
|
||||||
audio.pause()
|
audio.pause()
|
||||||
}
|
}
|
||||||
}, [apiRoot])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="furumi-root">
|
<div className="furumi-root">
|
||||||
<header className="header">
|
<Header
|
||||||
<div className="header-logo">
|
searchOpen={searchOpen}
|
||||||
<button className="btn-menu">☰</button>
|
searchResults={searchResults}
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
<circle cx="9" cy="18" r="3" />
|
/>
|
||||||
<circle cx="18" cy="15" r="3" />
|
|
||||||
<path d="M12 18V6l9-3v3" />
|
|
||||||
</svg>
|
|
||||||
Furumi
|
|
||||||
<span className="header-version">v</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
|
||||||
<div className="search-wrap">
|
|
||||||
<input id="searchInput" placeholder="Search..." />
|
|
||||||
<SearchDropdown
|
|
||||||
isOpen={searchOpen}
|
|
||||||
results={searchResults}
|
|
||||||
onSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="main">
|
<MainPanel
|
||||||
<div className="sidebar-overlay" id="sidebarOverlay" />
|
breadcrumbs={breadcrumbs}
|
||||||
<aside className="sidebar" id="sidebar">
|
libraryLoading={libraryLoading}
|
||||||
<div className="sidebar-header">Library</div>
|
libraryError={libraryError}
|
||||||
<Breadcrumbs items={breadcrumbs} />
|
libraryItems={libraryItems}
|
||||||
<div className="file-list" id="fileList">
|
queueItemsView={queueItemsView}
|
||||||
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
|
queueOrderView={queueOrderView}
|
||||||
</div>
|
queuePlayingOrigIdxView={queuePlayingOrigIdxView}
|
||||||
</aside>
|
queueScrollSignal={queueScrollSignal}
|
||||||
|
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
||||||
|
onQueueRemove={(origIdx) =>
|
||||||
|
queueActionsRef.current?.removeFromQueue(origIdx)
|
||||||
|
}
|
||||||
|
onQueueMove={(fromPos, toPos) =>
|
||||||
|
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<section className="queue-panel">
|
<PlayerBar track={nowPlayingTrack} />
|
||||||
<div className="queue-header">
|
|
||||||
<span>Queue</span>
|
|
||||||
<div className="queue-actions">
|
|
||||||
<button className="queue-btn active" id="btnShuffle">
|
|
||||||
Shuffle
|
|
||||||
</button>
|
|
||||||
<button className="queue-btn active" id="btnRepeat">
|
|
||||||
Repeat
|
|
||||||
</button>
|
|
||||||
<button className="queue-btn" id="btnClearQueue">
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="queue-list" id="queueList">
|
|
||||||
<QueueList
|
|
||||||
apiRoot={apiRoot}
|
|
||||||
queue={queueItemsView}
|
|
||||||
order={queueOrderView}
|
|
||||||
playingOrigIdx={queuePlayingOrigIdxView}
|
|
||||||
scrollSignal={queueScrollSignal}
|
|
||||||
onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
|
|
||||||
onRemove={(origIdx) =>
|
|
||||||
queueActionsRef.current?.removeFromQueue(origIdx)
|
|
||||||
}
|
|
||||||
onMove={(fromPos, toPos) =>
|
|
||||||
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="player-bar">
|
|
||||||
<NowPlaying apiRoot={apiRoot} track={nowPlayingTrack} />
|
|
||||||
<div className="controls">
|
|
||||||
<div className="ctrl-btns">
|
|
||||||
<button className="ctrl-btn" id="btnPrev">
|
|
||||||
⏮
|
|
||||||
</button>
|
|
||||||
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
|
||||||
▶
|
|
||||||
</button>
|
|
||||||
<button className="ctrl-btn" id="btnNext">
|
|
||||||
⏭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="progress-row">
|
|
||||||
<span className="time" id="timeElapsed">
|
|
||||||
0:00
|
|
||||||
</span>
|
|
||||||
<div className="progress-bar" id="progressBar">
|
|
||||||
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
|
|
||||||
</div>
|
|
||||||
<span className="time" id="timeDuration">
|
|
||||||
0:00
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="volume-row">
|
|
||||||
<span className="vol-icon" id="volIcon">
|
|
||||||
🔊
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
className="volume-slider"
|
|
||||||
id="volSlider"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
defaultValue={80}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toast" id="toast" />
|
<div className="toast" id="toast" />
|
||||||
<audio id="audioEl" />
|
<audio ref={audioRef} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { SearchDropdown } from './SearchDropdown'
|
||||||
|
|
||||||
|
type SearchResultItem = {
|
||||||
|
result_type: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderProps = {
|
||||||
|
searchOpen: boolean
|
||||||
|
searchResults: SearchResultItem[]
|
||||||
|
onSearchSelect: (type: string, slug: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({
|
||||||
|
searchOpen,
|
||||||
|
searchResults,
|
||||||
|
onSearchSelect,
|
||||||
|
}: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header className="header">
|
||||||
|
<div className="header-logo">
|
||||||
|
<button className="btn-menu">☰</button>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="9" cy="18" r="3" />
|
||||||
|
<circle cx="18" cy="15" r="3" />
|
||||||
|
<path d="M12 18V6l9-3v3" />
|
||||||
|
</svg>
|
||||||
|
Furumi
|
||||||
|
<span className="header-version">v</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
<div className="search-wrap">
|
||||||
|
<input id="searchInput" placeholder="Search..." />
|
||||||
|
<SearchDropdown
|
||||||
|
isOpen={searchOpen}
|
||||||
|
results={searchResults}
|
||||||
|
onSelect={onSearchSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
||||||
|
import { Breadcrumbs } from './Breadcrumbs'
|
||||||
|
import { LibraryList } from './LibraryList'
|
||||||
|
import { QueueList, type QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
export type Crumb = { label: string; action?: () => void }
|
||||||
|
|
||||||
|
export type LibraryListItem = {
|
||||||
|
key: string
|
||||||
|
className: string
|
||||||
|
icon: string
|
||||||
|
name: string
|
||||||
|
detail?: string
|
||||||
|
nameClassName?: string
|
||||||
|
onClick: () => void
|
||||||
|
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
|
||||||
|
}
|
||||||
|
|
||||||
|
type MainPanelProps = {
|
||||||
|
breadcrumbs: Crumb[]
|
||||||
|
libraryLoading: boolean
|
||||||
|
libraryError: string | null
|
||||||
|
libraryItems: LibraryListItem[]
|
||||||
|
queueItemsView: QueueItem[]
|
||||||
|
queueOrderView: number[]
|
||||||
|
queuePlayingOrigIdxView: number
|
||||||
|
queueScrollSignal: number
|
||||||
|
onQueuePlay: (origIdx: number) => void
|
||||||
|
onQueueRemove: (origIdx: number) => void
|
||||||
|
onQueueMove: (fromPos: number, toPos: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainPanel({
|
||||||
|
breadcrumbs,
|
||||||
|
libraryLoading,
|
||||||
|
libraryError,
|
||||||
|
libraryItems,
|
||||||
|
queueItemsView,
|
||||||
|
queueOrderView,
|
||||||
|
queuePlayingOrigIdxView,
|
||||||
|
queueScrollSignal,
|
||||||
|
onQueuePlay,
|
||||||
|
onQueueRemove,
|
||||||
|
onQueueMove,
|
||||||
|
}: MainPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="main">
|
||||||
|
<div className="sidebar-overlay" id="sidebarOverlay" />
|
||||||
|
<aside className="sidebar" id="sidebar">
|
||||||
|
<div className="sidebar-header">Library</div>
|
||||||
|
<Breadcrumbs items={breadcrumbs} />
|
||||||
|
<div className="file-list" id="fileList">
|
||||||
|
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="queue-panel">
|
||||||
|
<div className="queue-header">
|
||||||
|
<span>Queue</span>
|
||||||
|
<div className="queue-actions">
|
||||||
|
<button className="queue-btn active" id="btnShuffle">
|
||||||
|
Shuffle
|
||||||
|
</button>
|
||||||
|
<button className="queue-btn active" id="btnRepeat">
|
||||||
|
Repeat
|
||||||
|
</button>
|
||||||
|
<button className="queue-btn" id="btnClearQueue">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="queue-list" id="queueList">
|
||||||
|
<QueueList
|
||||||
|
queue={queueItemsView}
|
||||||
|
order={queueOrderView}
|
||||||
|
playingOrigIdx={queuePlayingOrigIdxView}
|
||||||
|
scrollSignal={queueScrollSignal}
|
||||||
|
onPlay={onQueuePlay}
|
||||||
|
onRemove={onQueueRemove}
|
||||||
|
onMove={onQueueMove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { API_ROOT } from '../furumiApi'
|
||||||
import type { QueueItem } from './QueueList'
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
function Cover({ src }: { src: string }) {
|
function Cover({ src }: { src: string }) {
|
||||||
@@ -12,7 +13,7 @@ function Cover({ src }: { src: string }) {
|
|||||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
|
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||||
if (!track) {
|
if (!track) {
|
||||||
return (
|
return (
|
||||||
<div className="np-info">
|
<div className="np-info">
|
||||||
@@ -31,7 +32,7 @@ export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueIt
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
|
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="np-info">
|
<div className="np-info">
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NowPlaying } from './NowPlaying'
|
||||||
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
|
export function PlayerBar({ track }: { track: QueueItem | null }) {
|
||||||
|
return (
|
||||||
|
<div className="player-bar">
|
||||||
|
<NowPlaying track={track} />
|
||||||
|
<div className="controls">
|
||||||
|
<div className="ctrl-btns">
|
||||||
|
<button className="ctrl-btn" id="btnPrev">
|
||||||
|
⏮
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
<button className="ctrl-btn" id="btnNext">
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="progress-row">
|
||||||
|
<span className="time" id="timeElapsed">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
<div className="progress-bar" id="progressBar">
|
||||||
|
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
|
||||||
|
</div>
|
||||||
|
<span className="time" id="timeDuration">
|
||||||
|
0:00
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="volume-row">
|
||||||
|
<span className="vol-icon" id="volIcon">
|
||||||
|
🔊
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className="volume-slider"
|
||||||
|
id="volSlider"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
defaultValue={80}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { API_ROOT } from '../furumiApi'
|
||||||
|
|
||||||
export type QueueItem = {
|
export type QueueItem = {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -9,7 +10,6 @@ export type QueueItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type QueueListProps = {
|
type QueueListProps = {
|
||||||
apiRoot: string
|
|
||||||
queue: QueueItem[]
|
queue: QueueItem[]
|
||||||
order: number[]
|
order: number[]
|
||||||
playingOrigIdx: number
|
playingOrigIdx: number
|
||||||
@@ -43,7 +43,6 @@ function Cover({ src }: { src: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function QueueList({
|
export function QueueList({
|
||||||
apiRoot,
|
|
||||||
queue,
|
queue,
|
||||||
order,
|
order,
|
||||||
playingOrigIdx,
|
playingOrigIdx,
|
||||||
@@ -78,7 +77,7 @@ export function QueueList({
|
|||||||
if (!t) return null
|
if (!t) return null
|
||||||
|
|
||||||
const isPlaying = origIdx === playingOrigIdx
|
const isPlaying = origIdx === playingOrigIdx
|
||||||
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
|
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
|
||||||
const dur = t.duration ? fmt(t.duration) : ''
|
const dur = t.duration ? fmt(t.duration) : ''
|
||||||
const isDragging = draggingPos === pos
|
const isDragging = draggingPos === pos
|
||||||
const isDragOver = dragOverPos === pos
|
const isDragOver = dragOverPos === pos
|
||||||
|
|||||||
@@ -1,12 +1,46 @@
|
|||||||
export type FurumiApiClient = (path: string) => Promise<unknown | null>
|
import axios from 'axios'
|
||||||
|
|
||||||
export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||||
const API = apiRoot
|
export const API_ROOT = `${API_BASE}/api`
|
||||||
|
|
||||||
return async function api(path: string) {
|
const apiKey = import.meta.env.VITE_API_KEY
|
||||||
const r = await fetch(API + path)
|
|
||||||
if (!r.ok) return null
|
export const furumiApi = axios.create({
|
||||||
return r.json()
|
baseURL: API_ROOT,
|
||||||
}
|
headers: apiKey ? { 'x-api-key': apiKey } : {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function getArtists() {
|
||||||
|
const res = await furumiApi.get('/artists').catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistAlbums(artistSlug: string) {
|
||||||
|
const res = await furumiApi.get(`/artists/${artistSlug}/albums`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbumTracks(albumSlug: string) {
|
||||||
|
const res = await furumiApi.get(`/albums/${albumSlug}`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistTracks(artistSlug: string) {
|
||||||
|
const res = await furumiApi.get(`/artists/${artistSlug}/tracks`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchTracks(query: string) {
|
||||||
|
const res = await furumiApi.get(`/search?q=${encodeURIComponent(query)}`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrackInfo(trackSlug: string) {
|
||||||
|
const res = await furumiApi.get(`/tracks/${trackSlug}`).catch(() => null)
|
||||||
|
return res?.data ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preloadStream(trackSlug: string) {
|
||||||
|
await furumiApi.get(`/stream/${trackSlug}`).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmt(secs: number) {
|
||||||
|
if (!secs || Number.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)}`
|
||||||
|
}
|
||||||
@@ -25,3 +25,4 @@ base64 = "0.22"
|
|||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
|
tower-http = { version = "0.6", features = ["cors"] }
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ struct Args {
|
|||||||
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
|
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
|
||||||
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
|
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
|
||||||
oidc_session_secret: Option<String>,
|
oidc_session_secret: Option<String>,
|
||||||
|
|
||||||
|
/// API key for x-api-key header auth (alternative to OIDC session)
|
||||||
|
#[arg(long, env = "FURUMI_PLAYER_API_KEY")]
|
||||||
|
api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -90,10 +94,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if args.api_key.is_some() {
|
||||||
|
tracing::info!("x-api-key auth: enabled");
|
||||||
|
}
|
||||||
|
|
||||||
let state = Arc::new(web::AppState {
|
let state = Arc::new(web::AppState {
|
||||||
pool,
|
pool,
|
||||||
storage_dir: Arc::new(args.storage_dir),
|
storage_dir: Arc::new(args.storage_dir),
|
||||||
oidc: oidc_state,
|
oidc: oidc_state,
|
||||||
|
api_key: args.api_key,
|
||||||
});
|
});
|
||||||
|
|
||||||
tracing::info!("Web player: http://{}", bind_addr);
|
tracing::info!("Web player: http://{}", bind_addr);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use axum::{
|
|||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const X_API_KEY: &str = "x-api-key";
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
||||||
reqwest::async_http_client,
|
reqwest::async_http_client,
|
||||||
@@ -92,37 +94,51 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auth middleware: requires valid SSO session cookie.
|
/// Auth middleware: requires valid SSO session cookie or x-api-key header.
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
req: Request,
|
req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let oidc = match &state.oidc {
|
// 1. Check x-api-key header (if configured)
|
||||||
Some(o) => o,
|
if let Some(ref expected) = state.api_key {
|
||||||
None => return next.run(req).await, // No OIDC configured = no auth
|
if let Some(val) = req
|
||||||
};
|
.headers()
|
||||||
|
.get(X_API_KEY)
|
||||||
let cookies = req
|
.and_then(|v| v.to_str().ok())
|
||||||
.headers()
|
{
|
||||||
.get(header::COOKIE)
|
if val == expected {
|
||||||
.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;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Check SSO session cookie (if OIDC configured)
|
||||||
|
if let Some(ref oidc) = state.oidc {
|
||||||
|
let cookies = req
|
||||||
|
.headers()
|
||||||
|
.get(header::COOKIE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
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();
|
let uri = req.uri().to_string();
|
||||||
if uri.starts_with("/api/") {
|
if uri.starts_with("/api/") {
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||||
} else {
|
} else if state.oidc.is_some() {
|
||||||
Redirect::to("/login").into_response()
|
Redirect::to("/login").into_response()
|
||||||
|
} else {
|
||||||
|
// Only API key configured — no web login available
|
||||||
|
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ pub mod auth;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::{Router, routing::get, middleware};
|
use axum::{Router, routing::get, middleware};
|
||||||
|
use axum::http::{header, Method};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -13,6 +16,7 @@ pub struct AppState {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub storage_dir: Arc<PathBuf>,
|
pub storage_dir: Arc<PathBuf>,
|
||||||
pub oidc: Option<Arc<auth::OidcState>>,
|
pub oidc: Option<Arc<auth::OidcState>>,
|
||||||
|
pub api_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_router(state: Arc<AppState>) -> Router {
|
pub fn build_router(state: Arc<AppState>) -> Router {
|
||||||
@@ -32,21 +36,28 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/", get(player_html))
|
.route("/", get(player_html))
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
|
|
||||||
let has_oidc = state.oidc.is_some();
|
let requires_auth = state.oidc.is_some();
|
||||||
|
|
||||||
let app = if has_oidc {
|
let app = if requires_auth {
|
||||||
authed
|
authed
|
||||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
||||||
} else {
|
} else {
|
||||||
authed
|
authed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
|
||||||
|
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")])
|
||||||
|
.max_age(Duration::from_secs(600));
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/login", get(auth::login_page))
|
.route("/login", get(auth::login_page))
|
||||||
.route("/logout", get(auth::logout))
|
.route("/logout", get(auth::logout))
|
||||||
.route("/auth/login", get(auth::oidc_login))
|
.route("/auth/login", get(auth::oidc_login))
|
||||||
.route("/auth/callback", get(auth::oidc_callback))
|
.route("/auth/callback", get(auth::oidc_callback))
|
||||||
.merge(app)
|
.merge(app)
|
||||||
|
.layer(cors)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user