4 Commits

Author SHA1 Message Date
ab e34440498c Merge pull request 'Disabled obsolete CI' (#5) from DEV into main
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m17s
Publish Web Player Image / build-and-push-image (push) Successful in 1m15s
Reviewed-on: #5
2026-03-20 00:49:45 +00:00
ab f873542d02 Merge pull request 'DEV' (#4) from DEV into main
Publish Metadata Agent Image / build-and-push-image (push) Failing after 10s
Publish Web Player Image / build-and-push-image (push) Failing after 10s
Reviewed-on: #4
2026-03-20 00:02:49 +00:00
ab 3f2013e9d5 Merge pull request 'Fix phantom duplicate tracks created on Merged file ingestion' (#3) from DEV into main
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m47s
Publish Web Player Image / build-and-push-image (push) Successful in 1m41s
Publish Server Image / build-and-push-image (push) Successful in 3m1s
Reviewed-on: #3
2026-03-19 23:43:36 +00:00
ab 7ede23ff94 Merge pull request 'Improved admin UI' (#2) from DEV into main
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m14s
Publish Web Player Image / build-and-push-image (push) Successful in 1m16s
Publish Server Image / build-and-push-image (push) Successful in 2m20s
Reviewed-on: #2
2026-03-19 15:33:26 +00:00
50 changed files with 760 additions and 2682 deletions
-32
View File
@@ -1,32 +0,0 @@
---
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)
```
@@ -1,49 +0,0 @@
---
description: Новые React-компоненты — папка kebab-case, TSX + CSS module, index.ts (furumi-node-player/client/src)
globs: furumi-node-player/client/src/**/*
alwaysApply: false
---
# Структура новых компонентов (furumi-node-player/client)
**Область:** при добавлении **нового** компонента в `furumi-node-player/client/src/` используйте расположение и файлы ниже.
## Расположение
- Базовая папка: `furumi-node-player/client/src/components/`
- Для каждого компонента — **отдельная подпапка** с именем в **kebab-case** (например `play-button`, `search-dropdown`).
## Файлы внутри папки компонента
1. **Компонент:** `components/<имя-kebab>/<имя-kebab>.tsx` — реализация компонента; к нему подключён CSS-модуль (`import styles from './<имя-kebab>.module.css'`).
2. **Стили:** `components/<имя-kebab>/<имя-kebab>.module.css` — модульные стили для этого компонента.
3. **Баррель:** `components/<имя-kebab>/index.ts` — **реэкспорт всего** из файла компонента: `export * from './<имя-kebab>'`.
Импорт снаружи: `import { MyWidget } from './components/my-widget'` (относительный путь к папке), без указания `index.ts`.
## Пример (`my-widget`)
```
components/my-widget/
my-widget.tsx
my-widget.module.css
index.ts
```
```typescript
// my-widget.tsx
import styles from './my-widget.module.css'
export function MyWidget() {
return <div className={styles.root}>…</div>
}
```
```typescript
// index.ts
export * from './my-widget'
```
## Примечание
Существующие файлы-компоненты в корне `components/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
@@ -1,40 +0,0 @@
name: Publish Node Player Image (dev)
on:
push:
branches:
- DEV
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.node-player
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -1,57 +0,0 @@
name: Publish Node Player Image
on:
push:
branches:
- '**'
- '!DEV'
tags:
- 'v*.*.*'
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Determine version and tags
id: info
run: |
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAG="${{ github.ref_name }}"
VERSION="${TAG#v}"
echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT"
else
echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.node-player
push: true
tags: ${{ steps.info.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -2
View File
@@ -1,4 +1,4 @@
/target /target
/docker/inbox /inbox
/docker/storage /storage
.env .env
Generated
+1 -18
View File
@@ -1114,7 +1114,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"hmac", "hmac",
"jsonwebtoken 10.3.0", "jsonwebtoken",
"libc", "libc",
"mime_guess", "mime_guess",
"ogg", "ogg",
@@ -1152,7 +1152,6 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"clap", "clap",
"hmac", "hmac",
"jsonwebtoken 9.3.1",
"mime_guess", "mime_guess",
"openidconnect", "openidconnect",
"rand 0.8.5", "rand 0.8.5",
@@ -1166,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",
@@ -1866,21 +1864,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]] [[package]]
name = "jsonwebtoken" name = "jsonwebtoken"
version = "10.3.0" version = "10.3.0"
-27
View File
@@ -8,35 +8,8 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app WORKDIR /usr/src/app
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
COPY Cargo.toml Cargo.lock ./
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
# 2. Create dummy sources so cargo can resolve and build dependencies
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
RUN cargo build --release --bin furumi-agent 2>/dev/null || true
# 4. Copy real source code
COPY . . COPY . .
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
RUN touch furumi-common/src/lib.rs furumi-agent/src/main.rs
ARG FURUMI_VERSION=dev ARG FURUMI_VERSION=dev
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
-38
View File
@@ -1,38 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /app
# 1. Install server dependencies (cached layer)
COPY furumi-node-player/server/package.json furumi-node-player/server/package-lock.json ./server/
RUN cd server && npm ci
# 2. Install client dependencies (cached layer)
COPY furumi-node-player/client/package.json furumi-node-player/client/package-lock.json ./client/
RUN cd client && npm ci
# 3. Build server
COPY furumi-node-player/server/ ./server/
RUN cd server && npm run build
# 4. Build client (VITE_FURUMI_API_URL empty = relative /api on same origin)
COPY furumi-node-player/client/ ./client/
RUN cd client && npm run build
FROM node:22-alpine
WORKDIR /app
# Server runtime
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/server/node_modules ./server/node_modules
COPY --from=build /app/server/package.json ./server/
# Client static files
COPY --from=build /app/client/dist ./client/dist
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["node", "server/dist/index.js"]
-27
View File
@@ -8,35 +8,8 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app WORKDIR /usr/src/app
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
COPY Cargo.toml Cargo.lock ./
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
# 2. Create dummy sources so cargo can resolve and build dependencies
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
RUN cargo build --release --bin furumi-web-player 2>/dev/null || true
# 4. Copy real source code
COPY . . COPY . .
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
RUN touch furumi-common/src/lib.rs furumi-web-player/src/main.rs
ARG FURUMI_VERSION=dev ARG FURUMI_VERSION=dev
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
+4 -11
View File
@@ -16,8 +16,8 @@ services:
agent: agent:
build: build:
context: .. context: .
dockerfile: docker/Dockerfile.agent dockerfile: Dockerfile.agent
container_name: furumi-agent container_name: furumi-agent
depends_on: depends_on:
db: db:
@@ -25,12 +25,10 @@ services:
ports: ports:
- "8090:8090" - "8090:8090"
environment: environment:
RUST_LOG: info
FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
FURUMI_AGENT_INBOX_DIR: "/inbox" FURUMI_AGENT_INBOX_DIR: "/inbox"
FURUMI_AGENT_STORAGE_DIR: "/storage" FURUMI_AGENT_STORAGE_DIR: "/storage"
FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}" FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
FURUMI_AGENT_OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}"
FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}" FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}"
FURUMI_PLAYER_BIND: "0.0.0.0:8090" FURUMI_PLAYER_BIND: "0.0.0.0:8090"
FURUMI_AGENT_POLL_INTERVAL_SECS: 5 FURUMI_AGENT_POLL_INTERVAL_SECS: 5
@@ -43,8 +41,8 @@ services:
web-player: web-player:
build: build:
context: .. context: .
dockerfile: docker/Dockerfile.web-player dockerfile: Dockerfile.web-player
container_name: furumi-web-player container_name: furumi-web-player
depends_on: depends_on:
db: db:
@@ -55,11 +53,6 @@ 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_OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}"
FURUMI_PLAYER_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
FURUMI_PLAYER_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
FURUMI_PLAYER_OIDC_REDIRECT_URL: "${OIDC_REDIRECT_URL}"
FURUMI_PLAYER_OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}"
volumes: volumes:
- ./storage:/storage - ./storage:/storage
restart: always restart: always
+9 -9
View File
@@ -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).
+6 -6
View File
@@ -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.
@@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada
10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations. 10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations.
11. **Confidence**: MUST be a decimal number between 0.0 and 1.0 (e.g., 0.95, 0.7, 0.3). NEVER use words like "high", "medium", "low" — only a numeric float value. 11. **Confidence**: Rate your confidence from 0.0 to 1.0.
- 1.0: All fields are clear and unambiguous. - 1.0: All fields are clear and unambiguous.
- 0.8+: Minor inferences made (e.g., year from path), but high certainty. - 0.8+: Minor inferences made (e.g., year from path), but high certainty.
- 0.5-0.8: Some guesswork involved, human review recommended. - 0.5-0.8: Some guesswork involved, human review recommended.
-12
View File
@@ -187,21 +187,9 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
continue; continue;
} }
} }
} else {
// Source file is gone — check if already in library by hash
let in_library: (bool,) = sqlx::query_as(
"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 { } else {
tracing::error!(id = %pt.id, "Source file missing: {:?}", source); tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?; db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
}
continue; continue;
}; };
+25 -101
View File
@@ -25,37 +25,16 @@ pub async fn normalize(
) -> anyhow::Result<NormalizedFields> { ) -> anyhow::Result<NormalizedFields> {
let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx); let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx);
let schema = normalize_schema();
let response = call_ollama( let response = call_ollama(
&state.config.ollama_url, &state.config.ollama_url,
&state.config.ollama_model, &state.config.ollama_model,
&state.system_prompt, &state.system_prompt,
&user_message, &user_message,
state.config.ollama_auth.as_deref(), state.config.ollama_auth.as_deref(),
0.5,
512,
Some(("normalized_metadata", schema.clone())),
) )
.await?; .await?;
match parse_response(&response) { parse_response(&response)
Ok(fields) => Ok(fields),
Err(e) => {
tracing::warn!(error = %e, "LLM parse failed, retrying with higher frequency_penalty");
let response2 = call_ollama(
&state.config.ollama_url,
&state.config.ollama_model,
&state.system_prompt,
&user_message,
state.config.ollama_auth.as_deref(),
1.5,
512,
Some(("normalized_metadata", schema)),
)
.await?;
parse_response(&response2)
}
}
} }
fn build_user_message( fn build_user_message(
@@ -134,49 +113,32 @@ fn build_user_message(
} }
#[derive(Serialize)] #[derive(Serialize)]
struct ChatRequest { struct OllamaRequest {
model: String, model: String,
messages: Vec<ChatMessage>, messages: Vec<OllamaMessage>,
#[serde(skip_serializing_if = "Option::is_none")] format: String,
response_format: Option<ChatResponseFormat>,
stream: bool, stream: bool,
temperature: f64, options: OllamaOptions,
max_tokens: u32,
frequency_penalty: f64,
} }
#[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,
json_schema: JsonSchemaWrapper,
}
#[derive(Serialize)]
struct JsonSchemaWrapper {
name: String,
strict: bool,
schema: serde_json::Value,
} }
#[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,
} }
@@ -186,40 +148,30 @@ pub async fn call_ollama(
system_prompt: &str, system_prompt: &str,
user_message: &str, user_message: &str,
auth: Option<&str>, auth: Option<&str>,
frequency_penalty: f64,
max_tokens: u32,
schema: Option<(&str, serde_json::Value)>,
) -> anyhow::Result<String> { ) -> anyhow::Result<String> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120)) .timeout(std::time::Duration::from_secs(120))
.build()?; .build()?;
let response_format = schema.map(|(name, schema)| ChatResponseFormat { let request = OllamaRequest {
kind: "json_schema".to_owned(),
json_schema: JsonSchemaWrapper { name: name.to_owned(), strict: true, schema },
});
let request = ChatRequest {
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, format: "json".to_owned(),
stream: false, stream: false,
temperature: 0.1, options: OllamaOptions { temperature: 0.1 },
max_tokens,
frequency_penalty,
}; };
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);
@@ -232,45 +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)
}
fn normalize_schema() -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"artist": { "type": ["string", "null"] },
"album": { "type": ["string", "null"] },
"title": { "type": ["string", "null"] },
"year": { "type": ["integer", "null"] },
"track_number": { "type": ["integer", "null"] },
"genre": { "type": ["string", "null"] },
"featured_artists": { "type": "array", "items": { "type": "string" } },
"release_kind": { "type": ["string", "null"] },
"confidence": { "type": ["number", "null"] },
"notes": { "type": ["string", "null"] }
},
"required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_kind", "confidence", "notes"],
"additionalProperties": false
})
} }
/// Parse the LLM JSON response into NormalizedFields. /// Parse the LLM JSON response into NormalizedFields.
@@ -297,7 +222,6 @@ fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
genre: Option<String>, genre: Option<String>,
#[serde(default)] #[serde(default)]
featured_artists: Vec<String>, featured_artists: Vec<String>,
#[serde(rename = "release_kind")]
release_type: Option<String>, release_type: Option<String>,
confidence: Option<f64>, confidence: Option<f64>,
notes: Option<String>, notes: Option<String>,
-27
View File
@@ -35,39 +35,12 @@ pub async fn propose_merge(state: &Arc<AppState>, merge_id: Uuid) -> anyhow::Res
let user_message = build_merge_message(&artists_data); let user_message = build_merge_message(&artists_data);
let schema = serde_json::json!({
"type": "object",
"properties": {
"canonical_artist_name": { "type": "string" },
"winner_artist_id": { "type": "integer" },
"album_mappings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source_album_id": { "type": "integer" },
"canonical_name": { "type": "string" },
"merge_into_album_id": { "type": ["integer", "null"] }
},
"required": ["source_album_id", "canonical_name", "merge_into_album_id"],
"additionalProperties": false
}
},
"notes": { "type": "string" }
},
"required": ["canonical_artist_name", "winner_artist_id", "album_mappings", "notes"],
"additionalProperties": false
});
let response = call_ollama( let response = call_ollama(
&state.config.ollama_url, &state.config.ollama_url,
&state.config.ollama_model, &state.config.ollama_model,
&state.merge_prompt, &state.merge_prompt,
&user_message, &user_message,
state.config.ollama_auth.as_deref(), state.config.ollama_auth.as_deref(),
0.5,
4096,
Some(("artist_merge", schema)),
).await?; ).await?;
let proposal = parse_merge_response(&response)?; let proposal = parse_merge_response(&response)?;
-1
View File
@@ -1 +0,0 @@
VITE_FURUMI_API_URL=http://localhost:8085
+3 -392
View File
@@ -8,11 +8,8 @@
"name": "client", "name": "client",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4"
"react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@@ -589,32 +586,6 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.10", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
@@ -877,18 +848,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -928,7 +887,7 @@
"version": "19.2.14", "version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -944,12 +903,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@@ -1334,23 +1287,6 @@
"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",
@@ -1416,19 +1352,6 @@
"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",
@@ -1497,18 +1420,6 @@
"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",
@@ -1542,7 +1453,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -1570,15 +1481,6 @@
"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",
@@ -1589,20 +1491,6 @@
"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",
@@ -1610,51 +1498,6 @@
"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",
@@ -1952,42 +1795,6 @@
"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",
@@ -2003,15 +1810,6 @@
"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",
@@ -2022,43 +1820,6 @@
"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",
@@ -2085,18 +1846,6 @@
"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",
@@ -2107,45 +1856,6 @@
"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",
@@ -2173,16 +1883,6 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -2625,36 +2325,6 @@
"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",
@@ -2850,12 +2520,6 @@
"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",
@@ -2887,50 +2551,6 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3194,15 +2814,6 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
+1 -4
View File
@@ -10,11 +10,8 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4"
"react-redux": "^9.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
+8 -26
View File
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { FurumiPlayer } from './FurumiPlayer' import { FurumiPlayer } from './FurumiPlayer'
import { setAuthToken, clearAuthToken } from './furumiApi'
import './App.css' import './App.css'
type UserProfile = { type UserProfile = {
@@ -23,7 +22,7 @@ function App() {
} }
}) })
const apiBase = '' const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', [])
useEffect(() => { useEffect(() => {
if (runWithoutAuth) { if (runWithoutAuth) {
@@ -35,13 +34,12 @@ function App() {
const loadMe = async () => { const loadMe = async () => {
try { try {
const response = await fetch(`${apiBase}/auth/me`, { const response = await fetch(`${apiBase}/api/me`, {
credentials: 'include', credentials: 'include',
}) })
if (response.status === 401) { if (response.status === 401) {
setUser(null) setUser(null)
clearAuthToken()
return return
} }
@@ -51,23 +49,6 @@ function App() {
const data = await response.json() const data = await response.json()
setUser(data.user ?? null) setUser(data.user ?? null)
// Fetch OIDC access token for Rust API Bearer auth
if (data.user) {
try {
const tokenRes = await fetch(`${apiBase}/auth/token`, {
credentials: 'include',
})
if (tokenRes.ok) {
const tokenData = await tokenRes.json()
if (tokenData.access_token) {
setAuthToken(tokenData.access_token)
}
}
} catch {
// Token fetch failed — API calls will fall back to other auth methods
}
}
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load session') setError(err instanceof Error ? err.message : 'Failed to load session')
} finally { } finally {
@@ -76,15 +57,16 @@ function App() {
} }
void loadMe() void loadMe()
}, [runWithoutAuth]) }, [apiBase, runWithoutAuth])
const loginUrl = `${apiBase}/auth/login` const loginUrl = `${apiBase}/api/login`
const logoutUrl = `${apiBase}/auth/logout` const logoutUrl = `${apiBase}/api/logout`
const playerApiRoot = `${apiBase}/api`
return ( return (
<> <>
{!loading && (user || runWithoutAuth) ? ( {!loading && (user || runWithoutAuth) ? (
<FurumiPlayer /> <FurumiPlayer apiRoot={playerApiRoot} />
) : ( ) : (
<main className="page"> <main className="page">
<section className="card"> <section className="card">
+479 -263
View File
@@ -1,52 +1,24 @@
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 { API_ROOT, searchTracks, preloadStream } from './furumiApi' import { createFurumiApiClient } from './furumiApi'
import { store, useAppDispatch, useAppSelector } from './store' import { SearchDropdown } from './components/SearchDropdown'
import { fetchArtists } from './store/slices/artistsSlice' import { Breadcrumbs } from './components/Breadcrumbs'
import { fetchArtistAlbums } from './store/slices/albumsSlice' import { LibraryList } from './components/LibraryList'
import { fetchArtistTracks } from './store/slices/artistTracksSlice' import { QueueList, type QueueItem } from './components/QueueList'
import { fetchAlbumTracks } from './store/slices/albumTracksSlice' import { NowPlaying } from './components/NowPlaying'
import { fetchTrackDetail } from './store/slices/trackDetailSlice'
import {
addTrack,
addTracksBatch,
replaceQueue,
clearQueue,
playAtIndex,
removeFromQueueAt,
moveQueueItemInOrder,
rebuildShuffleOrder,
selectQueueOrder,
selectPlayingOrigIdx,
selectQueueScrollSignal,
selectNowPlayingTrack,
selectQueueItems,
} from './store/slices/queueSlice'
import { attachAudioPlayback } from './audioPlaybackService'
import { fmt } from './utils'
import { Header } from './components/header'
import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar'
import type { Track } from './types'
export function FurumiPlayer() { type FurumiPlayerProps = {
const dispatch = useAppDispatch() apiRoot: string
const artistsLoading = useAppSelector((s) => s.artists.loading) }
const artistsError = useAppSelector((s) => s.artists.error)
const albumsLoading = useAppSelector((s) => s.albums.loading)
const albumsError = useAppSelector((s) => s.albums.error)
const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading)
const albumTracksError = useAppSelector((s) => s.albumTracks.error)
const queueItemsView = useAppSelector(selectQueueItems) type Crumb = { label: string; action?: () => void }
const queueOrderView = useAppSelector(selectQueueOrder)
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
const nowPlayingTrack = useAppSelector(selectNowPlayingTrack)
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>( export function FurumiPlayer({ apiRoot }: FurumiPlayerProps) {
const [breadcrumbs, setBreadcrumbs] = useState<Array<{ label: string; action?: () => void }>>(
[], [],
) )
const [libraryLoading, setLibraryLoading] = useState(false)
const [libraryError, setLibraryError] = useState<string | null>(null)
const [libraryItems, setLibraryItems] = useState< const [libraryItems, setLibraryItems] = useState<
Array<{ Array<{
key: string key: string
@@ -65,60 +37,93 @@ export function FurumiPlayer() {
const [searchOpen, setSearchOpen] = useState(false) const [searchOpen, setSearchOpen] = useState(false)
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {}) const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {})
const [nowPlayingTrack, setNowPlayingTrack] = useState<QueueItem | null>(null)
const [queueItemsView, setQueueItemsView] = useState<QueueItem[]>([])
const [queueOrderView, setQueueOrderView] = useState<number[]>([])
const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState<number>(-1)
const [queueScrollSignal, setQueueScrollSignal] = useState(0)
const queueActionsRef = useRef<{ const queueActionsRef = useRef<{
playIndex: (i: number) => void playIndex: (i: number) => void
removeFromQueue: (idx: number) => void removeFromQueue: (idx: number) => void
moveQueueItem: (fromPos: number, toPos: number) => void moveQueueItem: (fromPos: number, toPos: number) => void
clearQueue: () => void
} | null>(null) } | null>(null)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => { useEffect(() => {
if (!nowPlayingTrack) { // --- Original player script adapted for React environment ---
document.title = 'Furumi Player' const audio = document.getElementById('audioEl') as HTMLAudioElement
return if (!audio) return
}
document.title = `${nowPlayingTrack.title} — Furumi` let queue: QueueItem[] = []
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover` let queueIndex = -1
if ('mediaSession' in navigator) { let shuffle = false
let repeatAll = true
let shuffleOrder: number[] = []
let searchTimer: number | null = null
let toastTimer: number | null = null
let muted = false
// Restore prefs
try { try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment const v = window.localStorage.getItem('furumi_vol')
navigator.mediaSession.metadata = new window.MediaMetadata({ const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
title: nowPlayingTrack.title, if (v !== null && volSlider) {
artist: nowPlayingTrack.artist || '', audio.volume = Number(v) / 100
album: '', volSlider.value = v
artwork: [{ src: coverUrl, sizes: '512x512' }], }
}) const btnShuffle = document.getElementById('btnShuffle')
const btnRepeat = document.getElementById('btnRepeat')
shuffle = window.localStorage.getItem('furumi_shuffle') === '1'
repeatAll = window.localStorage.getItem('furumi_repeat') !== '0'
btnShuffle?.classList.toggle('active', shuffle)
btnRepeat?.classList.toggle('active', repeatAll)
} catch { } catch {
// ignore // ignore
} }
// --- Audio events ---
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
const fill = document.getElementById('progressFill')
const timeElapsed = document.getElementById('timeElapsed')
const timeDuration = document.getElementById('timeDuration')
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
} }
}, [nowPlayingTrack]) })
audio.addEventListener('ended', () => nextTrack())
audio.addEventListener('play', () => {
const btn = document.getElementById('btnPlayPause')
if (btn) btn.innerHTML = '&#9208;'
})
audio.addEventListener('pause', () => {
const btn = document.getElementById('btnPlayPause')
if (btn) btn.innerHTML = '&#9654;'
})
audio.addEventListener('error', () => {
showToast('Playback error')
nextTrack()
})
useEffect(() => { // --- API helper ---
const audioEl = audioRef.current const API = apiRoot
if (!audioEl) return const api = createFurumiApiClient(API)
const audio = audioEl
let searchTimer: number | null = null
let toastTimer: number | null = null
function showToast(msg: string) {
const t = document.getElementById('toast')
if (!t) return
t.textContent = msg
t.classList.add('show')
if (toastTimer) window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
}
// --- Library navigation ---
async function showArtists() { async function showArtists() {
setBreadcrumb([{ label: 'Artists', action: showArtists }]) setBreadcrumb([{ label: 'Artists', action: showArtists }])
try { setLibraryLoading(true)
const artists = await dispatch(fetchArtists()).unwrap() setLibraryError(null)
const artists = await api('/artists')
if (!artists) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
setLibraryItems( setLibraryItems(
artists.map((a) => ({ (artists as any[]).map((a) => ({
key: `artist:${a.slug}`, key: `artist:${a.slug}`,
className: 'file-item dir', className: 'file-item dir',
icon: '👤', icon: '👤',
@@ -127,9 +132,6 @@ export function FurumiPlayer() {
onClick: () => void showArtistAlbums(a.slug, a.name), onClick: () => void showArtistAlbums(a.slug, a.name),
})), })),
) )
} catch {
// Error is stored in artists.error
}
} }
async function showArtistAlbums(artistSlug: string, artistName: string) { async function showArtistAlbums(artistSlug: string, artistName: string) {
@@ -137,8 +139,15 @@ export function FurumiPlayer() {
{ label: 'Artists', action: showArtists }, { label: 'Artists', action: showArtists },
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
]) ])
try { setLibraryLoading(true)
const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap() setLibraryError(null)
const albums = await api('/artists/' + artistSlug + '/albums')
if (!albums) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
const allTracksItem = { const allTracksItem = {
key: `artist-all:${artistSlug}`, key: `artist-all:${artistSlug}`,
className: 'file-item', className: 'file-item',
@@ -147,7 +156,7 @@ export function FurumiPlayer() {
nameClassName: 'name', nameClassName: 'name',
onClick: () => void playAllArtistTracks(artistSlug), onClick: () => void playAllArtistTracks(artistSlug),
} }
const albumItems = albums.map((a) => { const albumItems = (albums as any[]).map((a) => {
const year = a.year ? ` (${a.year})` : '' const year = a.year ? ` (${a.year})` : ''
return { return {
key: `album:${a.slug}`, key: `album:${a.slug}`,
@@ -166,9 +175,6 @@ export function FurumiPlayer() {
} }
}) })
setLibraryItems([allTracksItem, ...albumItems]) setLibraryItems([allTracksItem, ...albumItems])
} catch {
// Error is stored in albums.error
}
} }
async function showAlbumTracks( async function showAlbumTracks(
@@ -182,9 +188,15 @@ export function FurumiPlayer() {
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) }, { label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
{ label: albumName }, { label: albumName },
]) ])
const result = await dispatch(fetchAlbumTracks(albumSlug)) setLibraryLoading(true)
if (result.meta.requestStatus === 'rejected') return setLibraryError(null)
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } const tracks = await api('/albums/' + albumSlug)
if (!tracks) {
setLibraryLoading(false)
setLibraryError('Error')
return
}
setLibraryLoading(false)
const playAlbumItem = { const playAlbumItem = {
key: `album-play:${albumSlug}`, key: `album-play:${albumSlug}`,
className: 'file-item', className: 'file-item',
@@ -194,7 +206,7 @@ export function FurumiPlayer() {
void addAlbumToQueue(albumSlug, true) void addAlbumToQueue(albumSlug, true)
}, },
} }
const trackItems = tracks.map((t) => { const trackItems = (tracks as any[]).map((t) => {
const num = t.track_number ? `${t.track_number}. ` : '' const num = t.track_number ? `${t.track_number}. ` : ''
const dur = t.duration_secs ? fmt(t.duration_secs) : '' const dur = t.duration_secs ? fmt(t.duration_secs) : ''
return { return {
@@ -224,6 +236,7 @@ export function FurumiPlayer() {
setBreadcrumbs(parts) setBreadcrumbs(parts)
} }
// --- Queue management ---
function addTrackToQueue( function addTrackToQueue(
track: { track: {
slug: string slug: string
@@ -234,64 +247,66 @@ export function FurumiPlayer() {
}, },
playNow?: boolean, playNow?: boolean,
) { ) {
const prevIdx = store.getState().queue.currentIndex const existing = queue.findIndex((t) => t.slug === track.slug)
dispatch(addTrack({ track, playNow })) if (existing !== -1) {
const q = store.getState().queue if (playNow) playIndex(existing)
if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) { return
playIndex(q.currentIndex) }
queue.push(track)
updateQueueModel()
if (playNow || (queueIndex === -1 && queue.length === 1)) {
playIndex(queue.length - 1)
} }
} }
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) { async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
const result = await dispatch(fetchAlbumTracks(albumSlug)) const tracks = await api('/albums/' + albumSlug)
if (result.meta.requestStatus === 'rejected') return if (!tracks || !(tracks as any[]).length) return
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } const list = tracks as any[]
if (!tracks || !tracks.length) return let firstIdx = queue.length
const list = tracks.map((t) => ({ list.forEach((t) => {
if (queue.find((q) => q.slug === t.slug)) return
queue.push({
slug: t.slug, slug: t.slug,
title: t.title, title: t.title,
artist: t.artist_name, artist: t.artist_name,
album_slug: t.album_slug, album_slug: albumSlug,
duration: t.duration_secs, duration: t.duration_secs,
})) })
const prevIdx = store.getState().queue.currentIndex })
dispatch(addTracksBatch({ tracks: list, playFirst })) updateQueueModel()
const q = store.getState().queue if (playFirst || queueIndex === -1) playIndex(firstIdx)
if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) {
playIndex(q.currentIndex)
}
showToast(`Added ${list.length} tracks`) showToast(`Added ${list.length} tracks`)
} }
async function playAllArtistTracks(artistSlug: string) { async function playAllArtistTracks(artistSlug: string) {
const result = await dispatch(fetchArtistTracks(artistSlug)) const tracks = await api('/artists/' + artistSlug + '/tracks')
if (result.meta.requestStatus === 'rejected') return if (!tracks || !(tracks as any[]).length) return
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] } const list = tracks as any[]
if (!tracks || !tracks.length) return clearQueue()
const list = tracks.map((t) => ({ list.forEach((t) => {
queue.push({
slug: t.slug, slug: t.slug,
title: t.title, title: t.title,
artist: t.artist_name, artist: t.artist_name,
album_slug: t.album_slug, album_slug: t.album_slug,
duration: t.duration_secs, duration: t.duration_secs,
})) })
dispatch(replaceQueue({ items: list, playFromIndex: 0 })) })
updateQueueModel()
playIndex(0) playIndex(0)
showToast(`Added ${list.length} tracks`) showToast(`Added ${list.length} tracks`)
} }
const playback = attachAudioPlayback(audio, {
onEnded: nextTrack,
onErrorSkip: nextTrack,
onToast: showToast,
})
function playIndex(i: number) { function playIndex(i: number) {
const q = store.getState().queue if (i < 0 || i >= queue.length) return
if (i < 0 || i >= q.items.length) return queueIndex = i
dispatch(playAtIndex(i)) const track = queue[i]
const track = store.getState().queue.items[i] audio.src = `${API}/stream/${track.slug}`
void playback.loadStreamForTrack(track.slug) void audio.play().catch(() => {})
updateNowPlaying(track)
updateQueueModel()
setQueueScrollSignal((s) => s + 1)
if (window.history && window.history.replaceState) { if (window.history && window.history.replaceState) {
const url = new URL(window.location.href) const url = new URL(window.location.href)
url.searchParams.set('t', track.slug) url.searchParams.set('t', track.slug)
@@ -299,58 +314,178 @@ export function FurumiPlayer() {
} }
} }
function removeFromQueue(idx: number) { function updateNowPlaying(track: QueueItem | null) {
const wasPlaying = store.getState().queue.currentIndex === idx setNowPlayingTrack(track)
dispatch(removeFromQueueAt(idx)) if (!track) return
if (wasPlaying) playback.pauseAndClearSource()
}
function moveQueueItem(fromPos: number, toPos: number) { document.title = `${track.title} — Furumi`
dispatch(moveQueueItemInOrder({ fromPos, toPos }))
}
function clearQueuePlayback() { const coverUrl = `${API}/tracks/${track.slug}/cover`
dispatch(clearQueue()) if ('mediaSession' in navigator) {
playback.pauseAndClearSource() // eslint-disable-next-line @typescript-eslint/ban-ts-comment
} navigator.mediaSession.metadata = new window.MediaMetadata({
title: track.title,
function nextTrack() { artist: track.artist || '',
const q = store.getState().queue album: '',
if (!q.items.length) return artwork: [{ src: coverUrl, sizes: '512x512' }],
const order = selectQueueOrder(store.getState())
const pos = order.indexOf(q.currentIndex)
if (pos < order.length - 1) playIndex(order[pos + 1])
else if (q.repeatAll) {
if (q.shuffle) dispatch(rebuildShuffleOrder())
const first = selectQueueOrder(store.getState())[0]
if (first !== undefined) playIndex(first)
}
}
function prevTrack() {
const q = store.getState().queue
if (!q.items.length) return
if (playback.rewindCurrentTrackIfPastThreshold()) return
const order = selectQueueOrder(store.getState())
const pos = order.indexOf(q.currentIndex)
if (pos > 0) playIndex(order[pos - 1])
else if (q.repeatAll) playIndex(order[order.length - 1])
}
function togglePlay() {
const q = store.getState().queue
playback.togglePlay(() => {
playIndex(q.currentIndex === -1 ? 0 : q.currentIndex)
}) })
} }
}
function currentOrder() {
if (!shuffle) return [...Array(queue.length).keys()]
if (shuffleOrder.length !== queue.length) buildShuffleOrder()
return shuffleOrder
}
function buildShuffleOrder() {
shuffleOrder = [...Array(queue.length).keys()]
for (let i = shuffleOrder.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]]
}
if (queueIndex !== -1) {
const ci = shuffleOrder.indexOf(queueIndex)
if (ci > 0) {
shuffleOrder.splice(ci, 1)
shuffleOrder.unshift(queueIndex)
}
}
}
function updateQueueModel() {
const order = currentOrder()
setQueueItemsView(queue.slice())
setQueueOrderView(order.slice())
setQueuePlayingOrigIdxView(queueIndex)
}
function removeFromQueue(idx: number) {
if (idx === queueIndex) {
queueIndex = -1
audio.pause()
audio.src = ''
updateNowPlaying(null)
} else if (queueIndex > idx) {
queueIndex--
}
queue.splice(idx, 1)
if (shuffle) {
const si = shuffleOrder.indexOf(idx)
if (si !== -1) shuffleOrder.splice(si, 1)
for (let i = 0; i < shuffleOrder.length; i++) {
if (shuffleOrder[i] > idx) shuffleOrder[i]--
}
}
updateQueueModel()
}
function moveQueueItem(from: number, to: number) {
if (from === to) return
if (shuffle) {
const item = shuffleOrder.splice(from, 1)[0]
shuffleOrder.splice(to, 0, item)
} else {
const item = queue.splice(from, 1)[0]
queue.splice(to, 0, item)
if (queueIndex === from) queueIndex = to
else if (from < queueIndex && to >= queueIndex) queueIndex--
else if (from > queueIndex && to <= queueIndex) queueIndex++
}
updateQueueModel()
}
queueActionsRef.current = { queueActionsRef.current = {
playIndex, playIndex,
removeFromQueue, removeFromQueue,
moveQueueItem, moveQueueItem,
clearQueue: clearQueuePlayback,
} }
function clearQueue() {
queue = []
queueIndex = -1
shuffleOrder = []
audio.pause()
audio.src = ''
updateNowPlaying(null)
document.title = 'Furumi Player'
updateQueueModel()
}
// --- Playback controls ---
function togglePlay() {
if (!audio.src && queue.length) {
playIndex(queueIndex === -1 ? 0 : queueIndex)
return
}
if (audio.paused) void audio.play()
else audio.pause()
}
function nextTrack() {
if (!queue.length) return
const order = currentOrder()
const pos = order.indexOf(queueIndex)
if (pos < order.length - 1) playIndex(order[pos + 1])
else if (repeatAll) {
if (shuffle) buildShuffleOrder()
playIndex(currentOrder()[0])
}
}
function prevTrack() {
if (!queue.length) return
if (audio.currentTime > 3) {
audio.currentTime = 0
return
}
const order = currentOrder()
const pos = order.indexOf(queueIndex)
if (pos > 0) playIndex(order[pos - 1])
else if (repeatAll) playIndex(order[order.length - 1])
}
function toggleShuffle() {
shuffle = !shuffle
if (shuffle) buildShuffleOrder()
const btn = document.getElementById('btnShuffle')
btn?.classList.toggle('active', shuffle)
window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0')
updateQueueModel()
}
function toggleRepeat() {
repeatAll = !repeatAll
const btn = document.getElementById('btnRepeat')
btn?.classList.toggle('active', repeatAll)
window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0')
}
// --- Seek & Volume ---
function seekTo(e: MouseEvent) {
if (!audio.duration) return
const bar = document.getElementById('progressBar') as HTMLDivElement | null
if (!bar) return
const rect = bar.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
audio.currentTime = pct * audio.duration
}
function toggleMute() {
muted = !muted
audio.muted = muted
const volIcon = document.getElementById('volIcon')
if (volIcon) volIcon.innerHTML = muted ? '&#128263;' : '&#128266;'
}
function setVolume(v: number) {
audio.volume = v / 100
const volIcon = document.getElementById('volIcon')
if (volIcon) volIcon.innerHTML = v === 0 ? '&#128263;' : '&#128266;'
window.localStorage.setItem('furumi_vol', String(v))
}
// --- Search ---
function onSearch(q: string) { function onSearch(q: string) {
if (searchTimer) { if (searchTimer) {
window.clearTimeout(searchTimer) window.clearTimeout(searchTimer)
@@ -360,7 +495,7 @@ export function FurumiPlayer() {
return return
} }
searchTimer = window.setTimeout(async () => { searchTimer = window.setTimeout(async () => {
const results = await searchTracks(q) const results = await api('/search?q=' + encodeURIComponent(q))
if (!results || !(results as any[]).length) { if (!results || !(results as any[]).length) {
closeSearch() closeSearch()
return return
@@ -384,11 +519,36 @@ export function FurumiPlayer() {
{ slug, title: '', artist: '', album_slug: null, duration: null }, { slug, title: '', artist: '', album_slug: null, duration: null },
true, true,
) )
void preloadStream(slug) void api('/stream/' + slug).catch(() => null)
} }
} }
searchSelectRef.current = onSearchSelect searchSelectRef.current = onSearchSelect
// --- 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) {
const t = document.getElementById('toast')
if (!t) return
t.textContent = msg
t.classList.add('show')
if (toastTimer) window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500)
}
function toggleSidebar() { function toggleSidebar() {
const sidebar = document.getElementById('sidebar') const sidebar = document.getElementById('sidebar')
const overlay = document.getElementById('sidebarOverlay') const overlay = document.getElementById('sidebarOverlay')
@@ -396,156 +556,212 @@ export function FurumiPlayer() {
overlay?.classList.toggle('show') overlay?.classList.toggle('show')
} }
const onMediaSeekTo = (d: { seekTime?: number }) => { // --- MediaSession ---
if (typeof d.seekTime === 'number') {
playback.seekToTime(d.seekTime)
}
}
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
try { try {
navigator.mediaSession.setActionHandler('play', togglePlay) navigator.mediaSession.setActionHandler('play', togglePlay)
navigator.mediaSession.setActionHandler('pause', togglePlay) navigator.mediaSession.setActionHandler('pause', togglePlay)
navigator.mediaSession.setActionHandler('previoustrack', prevTrack) navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
navigator.mediaSession.setActionHandler('nexttrack', nextTrack) navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
navigator.mediaSession.setActionHandler('seekto', onMediaSeekTo as (d: any) => void) navigator.mediaSession.setActionHandler('seekto', (d: any) => {
if (typeof d.seekTime === 'number') {
audio.currentTime = d.seekTime
}
})
} catch { } catch {
// ignore // ignore
} }
} }
const onMenuClick = () => toggleSidebar() // --- Wire DOM events that were inline in HTML ---
const btnMenu = document.querySelector('.btn-menu') const btnMenu = document.querySelector('.btn-menu')
btnMenu?.addEventListener('click', onMenuClick) btnMenu?.addEventListener('click', () => toggleSidebar())
const onSidebarOverlayClick = () => toggleSidebar()
const sidebarOverlay = document.getElementById('sidebarOverlay') const sidebarOverlay = document.getElementById('sidebarOverlay')
sidebarOverlay?.addEventListener('click', onSidebarOverlayClick) sidebarOverlay?.addEventListener('click', () => toggleSidebar())
const searchInput = document.getElementById('searchInput') as HTMLInputElement | null const searchInput = document.getElementById('searchInput') as HTMLInputElement | null
const onSearchInput = (e: Event) => {
onSearch((e.target as HTMLInputElement).value)
}
const onSearchKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeSearch()
}
if (searchInput) { if (searchInput) {
searchInput.addEventListener('input', onSearchInput) searchInput.addEventListener('input', (e) => {
searchInput.addEventListener('keydown', onSearchKeydown) onSearch((e.target as HTMLInputElement).value)
})
searchInput.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') closeSearch()
})
} }
const onPrevClick = () => prevTrack() const btnShuffle = document.getElementById('btnShuffle')
const onPlayClick = () => togglePlay() btnShuffle?.addEventListener('click', () => toggleShuffle())
const onNextClick = () => nextTrack() const btnRepeat = document.getElementById('btnRepeat')
btnRepeat?.addEventListener('click', () => toggleRepeat())
const btnClear = document.getElementById('btnClearQueue')
btnClear?.addEventListener('click', () => clearQueue())
const btnPrev = document.getElementById('btnPrev') const btnPrev = document.getElementById('btnPrev')
btnPrev?.addEventListener('click', onPrevClick) btnPrev?.addEventListener('click', () => prevTrack())
const btnPlay = document.getElementById('btnPlayPause') const btnPlay = document.getElementById('btnPlayPause')
btnPlay?.addEventListener('click', onPlayClick) btnPlay?.addEventListener('click', () => togglePlay())
const btnNext = document.getElementById('btnNext') const btnNext = document.getElementById('btnNext')
btnNext?.addEventListener('click', onNextClick) btnNext?.addEventListener('click', () => nextTrack())
const progressBar = document.getElementById('progressBar')
progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent))
const volIcon = document.getElementById('volIcon')
volIcon?.addEventListener('click', () => toggleMute())
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
if (volSlider) {
volSlider.addEventListener('input', (e) => {
const v = Number((e.target as HTMLInputElement).value)
setVolume(v)
})
}
const clearQueueBtn = document.getElementById('btnClearQueue')
clearQueueBtn?.addEventListener('click', () => clearQueue())
// --- Init ---
;(async () => { ;(async () => {
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) {
try { const info = await api('/tracks/' + urlSlug)
const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap() if (info) {
addTrackToQueue( addTrackToQueue(
{ {
slug: detail.slug, slug: (info as any).slug,
title: detail.title, title: (info as any).title,
artist: detail.artist_name, artist: (info as any).artist_name,
album_slug: detail.album_slug, album_slug: (info as any).album_slug,
duration: detail.duration_secs, duration: (info as any).duration_secs,
}, },
true, true,
) )
} catch {
// fetchTrackDetail rejected — track not found or error
} }
} }
void showArtists() void showArtists()
})() })()
// Cleanup: best-effort remove listeners on unmount
return () => { return () => {
queueActionsRef.current = null queueActionsRef.current = null
playback.dispose() audio.pause()
btnMenu?.removeEventListener('click', onMenuClick)
sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick)
searchInput?.removeEventListener('input', onSearchInput)
searchInput?.removeEventListener('keydown', onSearchKeydown)
btnPrev?.removeEventListener('click', onPrevClick)
btnPlay?.removeEventListener('click', onPlayClick)
btnNext?.removeEventListener('click', onNextClick)
if ('mediaSession' in navigator) {
try {
navigator.mediaSession.setActionHandler('play', null)
navigator.mediaSession.setActionHandler('pause', null)
navigator.mediaSession.setActionHandler('previoustrack', null)
navigator.mediaSession.setActionHandler('nexttrack', null)
navigator.mediaSession.setActionHandler('seekto', null)
} catch {
// ignore
} }
} }, [apiRoot])
}
}, [dispatch])
const libraryLoading =
breadcrumbs.length === 1
? artistsLoading
: breadcrumbs.length === 2
? albumsLoading
: albumTracksLoading
const libraryError =
breadcrumbs.length === 1
? artistsError
: breadcrumbs.length === 2
? albumsError
: albumTracksError
return ( return (
<div className="furumi-root"> <div className="furumi-root">
<Header <header className="header">
searchOpen={searchOpen} <div className="header-logo">
searchResults={searchResults} <button className="btn-menu">&#9776;</button>
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} <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={(type, slug) => searchSelectRef.current(type, slug)}
/> />
</div>
</div>
</header>
<MainPanel <div className="main">
breadcrumbs={breadcrumbs} <div className="sidebar-overlay" id="sidebarOverlay" />
libraryLoading={libraryLoading} <aside className="sidebar" id="sidebar">
libraryError={libraryError} <div className="sidebar-header">Library</div>
libraryItems={libraryItems} <Breadcrumbs items={breadcrumbs} />
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)} <div className="file-list" id="fileList">
onQueueRemove={(origIdx) => <LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
queueActionsRef.current?.removeFromQueue(origIdx) </div>
} </aside>
onQueueMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
}
onClearQueue={() => queueActionsRef.current?.clearQueue()}
/>
<PlayerBar <section className="queue-panel">
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} queue={queueItemsView}
order={queueOrderView} order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView} playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal} scrollSignal={queueScrollSignal}
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)} onPlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
onQueueRemove={(origIdx) => onRemove={(origIdx) =>
queueActionsRef.current?.removeFromQueue(origIdx) queueActionsRef.current?.removeFromQueue(origIdx)
} }
onQueueMove={(fromPos, toPos) => onMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(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">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</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">
&#128266;
</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 ref={audioRef} /> <audio id="audioEl" />
</div> </div>
) )
} }
@@ -1,190 +0,0 @@
import { preloadStream } from './furumiApi'
import { fmt } from './utils'
const MAX_PLAYBACK_ERROR_SKIPS = 5
/** Seconds from track start above which "previous" rewinds current track instead. */
const PREV_TRACK_REWIND_THRESHOLD_SEC = 3
export interface AudioPlaybackCallbacks {
onEnded: () => void
/** Called after a recoverable playback error (to advance queue). */
onErrorSkip: () => void
onToast: (msg: string) => void
}
export interface AudioPlaybackHandle {
loadStreamForTrack(slug: string): Promise<void>
pauseAndClearSource(): void
togglePlay(whenNoSource: () => void): void
seekFromProgressBarClick(e: MouseEvent): void
toggleMute(): void
setVolume(percent: number): void
seekToTime(seconds: number): void
/** If current time is past the threshold, seeks to 0 and returns true (caller should skip prev-track logic). */
rewindCurrentTrackIfPastThreshold(): boolean
dispose(): void
}
function syncVolumeFromStorage(audio: HTMLAudioElement) {
try {
const v = window.localStorage.getItem('furumi_vol')
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
if (v !== null && volSlider) {
audio.volume = Number(v) / 100
volSlider.value = v
}
} catch {
// ignore
}
}
export function attachAudioPlayback(
audio: HTMLAudioElement,
callbacks: AudioPlaybackCallbacks,
): AudioPlaybackHandle {
let muted = false
let playbackErrorSkips = 0
syncVolumeFromStorage(audio)
function onTimeUpdate() {
if (!audio.duration) return
const fill = document.getElementById('progressFill')
const timeElapsed = document.getElementById('timeElapsed')
const timeDuration = document.getElementById('timeDuration')
if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%`
if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime)
if (timeDuration) timeDuration.textContent = fmt(audio.duration)
}
function setPlayPauseButtonPlaying(playing: boolean) {
const btn = document.getElementById('btnPlayPause')
if (btn) btn.innerHTML = playing ? '&#9208;' : '&#9654;'
}
function onPlaying() {
playbackErrorSkips = 0
}
function onPlay() {
setPlayPauseButtonPlaying(true)
}
function onPause() {
setPlayPauseButtonPlaying(false)
}
function onError() {
callbacks.onToast('Playback error')
if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return
playbackErrorSkips += 1
callbacks.onErrorSkip()
}
function onEnded() {
callbacks.onEnded()
}
audio.addEventListener('timeupdate', onTimeUpdate)
audio.addEventListener('ended', onEnded)
audio.addEventListener('playing', onPlaying)
audio.addEventListener('play', onPlay)
audio.addEventListener('pause', onPause)
audio.addEventListener('error', onError)
const progressBar = document.getElementById('progressBar')
const onProgressClick = (e: Event) => seekFromProgressBarClick(e as MouseEvent)
progressBar?.addEventListener('click', onProgressClick)
const volIcon = document.getElementById('volIcon')
const onVolIconClick = () => toggleMute()
volIcon?.addEventListener('click', onVolIconClick)
const volSlider = document.getElementById('volSlider') as HTMLInputElement | null
const onVolInput = (e: Event) => {
const v = Number((e.target as HTMLInputElement).value)
setVolume(v)
}
volSlider?.addEventListener('input', onVolInput)
async function loadStreamForTrack(slug: string) {
const response = await preloadStream(slug)
audio.src = URL.createObjectURL(response?.data)
await audio.play().catch(() => { })
}
function pauseAndClearSource() {
audio.pause()
audio.src = ''
}
function togglePlay(whenNoSource: () => void) {
if (!audio.src) {
whenNoSource()
return
}
if (audio.paused) void audio.play()
else audio.pause()
}
function seekFromProgressBarClick(e: MouseEvent) {
if (!audio.duration) return
const bar = document.getElementById('progressBar') as HTMLDivElement | null
if (!bar) return
const rect = bar.getBoundingClientRect()
const pct = (e.clientX - rect.left) / rect.width
audio.currentTime = pct * audio.duration
}
function toggleMute() {
muted = !muted
audio.muted = muted
const icon = document.getElementById('volIcon')
if (icon) icon.innerHTML = muted ? '&#128263;' : '&#128266;'
}
function setVolume(percent: number) {
audio.volume = percent / 100
const icon = document.getElementById('volIcon')
if (icon) icon.innerHTML = percent === 0 ? '&#128263;' : '&#128266;'
window.localStorage.setItem('furumi_vol', String(percent))
}
function seekToTime(seconds: number) {
audio.currentTime = seconds
}
function rewindCurrentTrackIfPastThreshold(): boolean {
if (audio.currentTime > PREV_TRACK_REWIND_THRESHOLD_SEC) {
audio.currentTime = 0
return true
}
return false
}
function dispose() {
audio.removeEventListener('timeupdate', onTimeUpdate)
audio.removeEventListener('ended', onEnded)
audio.removeEventListener('playing', onPlaying)
audio.removeEventListener('play', onPlay)
audio.removeEventListener('pause', onPause)
audio.removeEventListener('error', onError)
progressBar?.removeEventListener('click', onProgressClick)
volIcon?.removeEventListener('click', onVolIconClick)
volSlider?.removeEventListener('input', onVolInput)
audio.pause()
}
return {
loadStreamForTrack,
pauseAndClearSource,
togglePlay,
seekFromProgressBarClick,
toggleMute,
setVolume,
seekToTime,
rewindCurrentTrackIfPastThreshold,
dispose,
}
}
@@ -1,107 +0,0 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { useAppDispatch, useAppSelector } from '../store'
import {
selectPlayingOrigIdx,
selectQueueItems,
selectQueueOrder,
selectQueueScrollSignal,
selectRepeatAll,
selectShuffle,
toggleRepeat,
toggleShuffle,
} from '../store/slices/queueSlice'
import { Breadcrumbs } from './Breadcrumbs'
import { LibraryList } from './LibraryList'
import { QueueList } 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[]
onQueuePlay: (origIdx: number) => void
onQueueRemove: (origIdx: number) => void
onQueueMove: (fromPos: number, toPos: number) => void
onClearQueue: () => void
}
export function MainPanel({
breadcrumbs,
libraryLoading,
libraryError,
libraryItems,
onQueuePlay,
onQueueRemove,
onQueueMove,
onClearQueue,
}: MainPanelProps) {
const dispatch = useAppDispatch()
const queueItemsView = useAppSelector(selectQueueItems)
const queueOrderView = useAppSelector(selectQueueOrder)
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
const shuffle = useAppSelector(selectShuffle)
const repeatAll = useAppSelector(selectRepeatAll)
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
type="button"
className={`queue-btn${shuffle ? ' active' : ''}`}
onClick={() => dispatch(toggleShuffle())}
>
Shuffle
</button>
<button
type="button"
className={`queue-btn${repeatAll ? ' active' : ''}`}
onClick={() => dispatch(toggleRepeat())}
>
Repeat
</button>
<button type="button" className="queue-btn" onClick={onClearQueue}>
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,5 +1,4 @@
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 }) {
@@ -13,7 +12,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({ track }: { track: QueueItem | null }) { export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
if (!track) { if (!track) {
return ( return (
<div className="np-info"> <div className="np-info">
@@ -32,7 +31,7 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
) )
} }
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
return ( return (
<div className="np-info"> <div className="np-info">
@@ -1,77 +0,0 @@
import { NowPlaying } from './NowPlaying'
import { QueuePopover } from './queue-popover'
import type { QueueItem } from './QueueList'
type PlayerBarProps = {
track: QueueItem | null
queue: QueueItem[]
order: number[]
playingOrigIdx: number
scrollSignal: number
onQueuePlay: (origIdx: number) => void
onQueueRemove: (origIdx: number) => void
onQueueMove: (fromPos: number, toPos: number) => void
}
export function PlayerBar({
track,
queue,
order,
playingOrigIdx,
scrollSignal,
onQueuePlay,
onQueueRemove,
onQueueMove,
}: PlayerBarProps) {
return (
<div className="player-bar">
<NowPlaying track={track} />
<div className="controls">
<div className="ctrl-btns">
<button className="ctrl-btn" id="btnPrev">
&#9198;
</button>
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
&#9654;
</button>
<button className="ctrl-btn" id="btnNext">
&#9197;
</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">
<QueuePopover
queue={queue}
order={order}
playingOrigIdx={playingOrigIdx}
scrollSignal={scrollSignal}
onPlay={onQueuePlay}
onRemove={onQueueRemove}
onMove={onQueueMove}
/>
<span className="vol-icon" id="volIcon">
&#128266;
</span>
<input
type="range"
className="volume-slider"
id="volSlider"
min={0}
max={100}
defaultValue={80}
/>
</div>
</div>
)
}
@@ -1,5 +1,4 @@
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
@@ -10,6 +9,7 @@ export type QueueItem = {
} }
type QueueListProps = { type QueueListProps = {
apiRoot: string
queue: QueueItem[] queue: QueueItem[]
order: number[] order: number[]
playingOrigIdx: number playingOrigIdx: number
@@ -43,6 +43,7 @@ function Cover({ src }: { src: string }) {
} }
export function QueueList({ export function QueueList({
apiRoot,
queue, queue,
order, order,
playingOrigIdx, playingOrigIdx,
@@ -77,7 +78,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 ? `${API_ROOT}/tracks/${t.slug}/cover` : '' const coverSrc = t.album_slug ? `${apiRoot}/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,46 +0,0 @@
import { SearchDropdown } from '../SearchDropdown'
import styles from './header.module.css'
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={styles.header}>
<div className={styles.headerLogo}>
<button className="btn-menu">&#9776;</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={styles.headerVersion}>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>
)
}
@@ -1,35 +0,0 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 10;
}
.headerLogo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 1.1rem;
color: #ffffff;
}
.headerLogo svg {
width: 22px;
height: 22px;
}
.headerVersion {
font-size: 0.7rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.25rem;
font-weight: 500;
text-decoration: none;
}
@@ -1 +0,0 @@
export * from './Header'
@@ -1 +0,0 @@
export * from './queue-popover'
@@ -1,68 +0,0 @@
.root {
position: relative;
display: flex;
align-items: center;
}
.trigger {
display: flex;
align-items: center;
justify-content: center;
padding: 0.35rem;
margin: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font: inherit;
line-height: 1;
}
.trigger:hover {
color: var(--text);
background: var(--bg-hover);
}
.triggerIcon {
font-size: 0.95rem;
}
.popover {
position: absolute;
bottom: calc(100% + 0.5rem);
right: 0;
z-index: 60;
display: flex;
flex-direction: column;
min-width: min(100vw - 2rem, 320px);
max-width: min(100vw - 2rem, 360px);
max-height: min(50vh, 360px);
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-card);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
}
.header {
flex-shrink: 0;
padding: 0.55rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0.35rem 0.5rem;
}
.body :global(.queue-empty) {
padding: 1.25rem 0.75rem;
font-size: 0.8rem;
}
@@ -1,86 +0,0 @@
import { useEffect, useId, useRef, useState } from 'react'
import { QueueList, type QueueItem } from '../QueueList'
import styles from './queue-popover.module.css'
export type QueuePopoverProps = {
queue: QueueItem[]
order: number[]
playingOrigIdx: number
scrollSignal: number
onPlay: (origIdx: number) => void
onRemove: (origIdx: number) => void
onMove: (fromPos: number, toPos: number) => void
}
export function QueuePopover({
queue,
order,
playingOrigIdx,
scrollSignal,
onPlay,
onRemove,
onMove,
}: QueuePopoverProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const titleId = useId()
const panelId = useId()
useEffect(() => {
if (!open) return
function onDocMouseDown(e: MouseEvent) {
const el = rootRef.current
if (el && !el.contains(e.target as Node)) setOpen(false)
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDocMouseDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDocMouseDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
return (
<div className={styles.root} ref={rootRef}>
<button
type="button"
className={styles.trigger}
title="Playback queue"
aria-expanded={open}
aria-haspopup="dialog"
aria-controls={open ? panelId : undefined}
onClick={() => setOpen((v) => !v)}
>
<span className={styles.triggerIcon} aria-hidden>
&#9776;
</span>
</button>
{open && (
<div
id={panelId}
className={styles.popover}
role="dialog"
aria-labelledby={titleId}
>
<div className={styles.header} id={titleId}>
Queue
</div>
<div className={styles.body}>
<QueueList
queue={queue}
order={order}
playingOrigIdx={playingOrigIdx}
scrollSignal={scrollSignal}
onPlay={onPlay}
onRemove={onRemove}
onMove={onMove}
/>
</div>
</div>
)}
</div>
)
}
@@ -31,6 +31,40 @@
--danger: #f87171; --danger: #f87171;
} }
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
background: var(--bg-panel);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 10;
}
.header-logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 700;
font-size: 1.1rem;
}
.header-logo svg {
width: 22px;
height: 22px;
}
.header-version {
font-size: 0.7rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
padding: 0.1rem 0.4rem;
border-radius: 4px;
margin-left: 0.25rem;
font-weight: 500;
text-decoration: none;
}
.btn-menu { .btn-menu {
display: none; display: none;
@@ -344,10 +378,6 @@
color: var(--accent); color: var(--accent);
} }
.qi-title {
color: #ffffff;
}
.queue-item .qi-index { .queue-item .qi-index {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
@@ -501,7 +531,6 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: #ffffff;
} }
.np-artist { .np-artist {
+8 -50
View File
@@ -1,54 +1,12 @@
import axios from 'axios' export type FurumiApiClient = (path: string) => Promise<unknown | null>
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? '' export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
export const API_ROOT = `${FURUMI_API_BASE}/api` const API = apiRoot
export const furumiApi = axios.create({ return async function api(path: string) {
baseURL: API_ROOT, const r = await fetch(API + path)
}) if (!r.ok) return null
return r.json()
export function setAuthToken(token: string) { }
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
export function clearAuthToken() {
delete furumiApi.defaults.headers.common['Authorization']
}
export async function getArtists(): Promise<Artist[] | null> {
const res = await furumiApi.get<Artist[]>('/artists').catch(() => null)
return res?.data ?? null
}
export async function getArtistAlbums(artistSlug: string): Promise<Album[] | null> {
const res = await furumiApi.get<Album[]>(`/artists/${artistSlug}/albums`).catch(() => null)
return res?.data ?? null
}
export async function getAlbumTracks(albumSlug: string): Promise<Track[] | null> {
const res = await furumiApi.get<Track[]>(`/albums/${albumSlug}`).catch(() => null)
return res?.data ?? null
}
export async function getArtistTracks(artistSlug: string): Promise<Track[] | null> {
const res = await furumiApi.get<Track[]>(`/artists/${artistSlug}/tracks`).catch(() => null)
return res?.data ?? null
}
export async function searchTracks(query: string): Promise<SearchResult[] | null> {
const res = await furumiApi
.get<SearchResult[]>(`/search?q=${encodeURIComponent(query)}`)
.catch(() => null)
return res?.data ?? null
}
export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | null> {
const res = await furumiApi.get<TrackDetail>(`/tracks/${trackSlug}`).catch(() => null)
return res?.data ?? null
}
export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
} }
-9
View File
@@ -1,8 +1,3 @@
html,
body {
height: 100%;
}
body { body {
margin: 0; margin: 0;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
@@ -10,10 +5,6 @@ body {
background-color: #f3f6fb; background-color: #f3f6fb;
} }
#root {
height: 100%;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
-4
View File
@@ -1,14 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<Provider store={store}>
<App /> <App />
</Provider>
</StrictMode>, </StrictMode>,
) )
@@ -1,25 +0,0 @@
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
import artistsReducer from './slices/artistsSlice'
import albumsReducer from './slices/albumsSlice'
import albumTracksReducer from './slices/albumTracksSlice'
import artistTracksReducer from './slices/artistTracksSlice'
import trackDetailReducer from './slices/trackDetailSlice'
import queueReducer from './slices/queueSlice'
export const store = configureStore({
reducer: {
artists: artistsReducer,
albums: albumsReducer,
albumTracks: albumTracksReducer,
artistTracks: artistTracksReducer,
trackDetail: trackDetailReducer,
queue: queueReducer,
},
})
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
@@ -1,54 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Track } from '../../types'
import { getAlbumTracks } from '../../furumiApi'
export const fetchAlbumTracks = createAsyncThunk(
'albumTracks/fetch',
async (albumSlug: string, { rejectWithValue }) => {
const data = await getAlbumTracks(albumSlug)
if (data === null) return rejectWithValue('Failed to fetch album tracks')
return { albumSlug, tracks: data }
},
)
interface AlbumTracksState {
byAlbum: Record<string, Track[]>
loading: boolean
error: string | null
}
const initialState: AlbumTracksState = {
byAlbum: {},
loading: false,
error: null,
}
const albumTracksSlice = createSlice({
name: 'albumTracks',
initialState,
reducers: {
clearAlbumTracks(state) {
state.byAlbum = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchAlbumTracks.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchAlbumTracks.fulfilled, (state, action) => {
state.loading = false
state.byAlbum[action.payload.albumSlug] = action.payload.tracks
state.error = null
})
.addCase(fetchAlbumTracks.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearAlbumTracks } = albumTracksSlice.actions
export default albumTracksSlice.reducer
@@ -1,54 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Album } from '../../types'
import { getArtistAlbums } from '../../furumiApi'
export const fetchArtistAlbums = createAsyncThunk(
'albums/fetchByArtist',
async (artistSlug: string, { rejectWithValue }) => {
const data = await getArtistAlbums(artistSlug)
if (data === null) return rejectWithValue('Failed to fetch albums')
return { artistSlug, albums: data }
},
)
interface AlbumsState {
byArtist: Record<string, Album[]>
loading: boolean
error: string | null
}
const initialState: AlbumsState = {
byArtist: {},
loading: false,
error: null,
}
const albumsSlice = createSlice({
name: 'albums',
initialState,
reducers: {
clearAlbums(state) {
state.byArtist = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtistAlbums.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtistAlbums.fulfilled, (state, action) => {
state.loading = false
state.byArtist[action.payload.artistSlug] = action.payload.albums
state.error = null
})
.addCase(fetchArtistAlbums.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearAlbums } = albumsSlice.actions
export default albumsSlice.reducer
@@ -1,54 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Track } from '../../types'
import { getArtistTracks } from '../../furumiApi'
export const fetchArtistTracks = createAsyncThunk(
'artistTracks/fetch',
async (artistSlug: string, { rejectWithValue }) => {
const data = await getArtistTracks(artistSlug)
if (data === null) return rejectWithValue('Failed to fetch artist tracks')
return { artistSlug, tracks: data }
},
)
interface ArtistTracksState {
byArtist: Record<string, Track[]>
loading: boolean
error: string | null
}
const initialState: ArtistTracksState = {
byArtist: {},
loading: false,
error: null,
}
const artistTracksSlice = createSlice({
name: 'artistTracks',
initialState,
reducers: {
clearArtistTracks(state) {
state.byArtist = {}
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtistTracks.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtistTracks.fulfilled, (state, action) => {
state.loading = false
state.byArtist[action.payload.artistSlug] = action.payload.tracks
state.error = null
})
.addCase(fetchArtistTracks.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearArtistTracks } = artistTracksSlice.actions
export default artistTracksSlice.reducer
@@ -1,54 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { Artist } from '../../types'
import { getArtists } from '../../furumiApi'
export const fetchArtists = createAsyncThunk(
'artists/fetch',
async (_, { rejectWithValue }) => {
const data = await getArtists()
if (data === null) return rejectWithValue('Failed to fetch artists')
return data
},
)
interface ArtistsState {
items: Artist[]
loading: boolean
error: string | null
}
const initialState: ArtistsState = {
items: [],
loading: false,
error: null,
}
const artistsSlice = createSlice({
name: 'artists',
initialState,
reducers: {
clearArtists(state) {
state.items = []
state.error = null
},
},
extraReducers: (builder) => {
builder
.addCase(fetchArtists.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchArtists.fulfilled, (state, action) => {
state.loading = false
state.items = action.payload
state.error = null
})
.addCase(fetchArtists.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearArtists } = artistsSlice.actions
export default artistsSlice.reducer
@@ -1,274 +0,0 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
import type { QueueItem } from '../../components/QueueList'
export interface QueueState {
items: QueueItem[]
currentIndex: number
shuffle: boolean
repeatAll: boolean
shuffleOrder: number[]
scrollSignal: number
}
function readShufflePref(): boolean {
try {
return window.localStorage.getItem('furumi_shuffle') === '1'
} catch {
return false
}
}
function readRepeatPref(): boolean {
try {
return window.localStorage.getItem('furumi_repeat') !== '0'
} catch {
return true
}
}
function buildShuffleOrder(state: QueueState) {
const n = state.items.length
if (n === 0) {
state.shuffleOrder = []
return
}
const order = [...Array(n).keys()]
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[order[i], order[j]] = [order[j], order[i]]
}
if (state.currentIndex !== -1) {
const ci = order.indexOf(state.currentIndex)
if (ci > 0) {
order.splice(ci, 1)
order.unshift(state.currentIndex)
}
}
state.shuffleOrder = order
}
function ensureShuffleOrder(state: QueueState) {
if (!state.shuffle) return
if (state.shuffleOrder.length !== state.items.length) {
buildShuffleOrder(state)
}
}
const initialState: QueueState = {
items: [],
currentIndex: -1,
shuffle: typeof window !== 'undefined' ? readShufflePref() : false,
repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true,
shuffleOrder: [],
scrollSignal: 0,
}
const queueSlice = createSlice({
name: 'queue',
initialState,
reducers: {
addTrack(
state,
action: PayloadAction<{
track: QueueItem
playNow?: boolean
}>,
) {
const { track, playNow } = action.payload
const existing = state.items.findIndex((t) => t.slug === track.slug)
if (existing !== -1) {
if (playNow) {
state.currentIndex = existing
state.scrollSignal += 1
}
return
}
const oldLen = state.items.length
const idle = state.currentIndex === -1
state.items.push(track)
ensureShuffleOrder(state)
if (playNow || (oldLen === 0 && idle)) {
state.currentIndex = state.items.length - 1
state.scrollSignal += 1
}
},
addTracksBatch(
state,
action: PayloadAction<{
tracks: QueueItem[]
playFirst?: boolean
}>,
) {
const { tracks, playFirst } = action.payload
let firstNewIdx: number | null = null
for (const t of tracks) {
if (state.items.some((q) => q.slug === t.slug)) continue
if (firstNewIdx === null) firstNewIdx = state.items.length
state.items.push(t)
}
ensureShuffleOrder(state)
if (firstNewIdx === null) return
if (playFirst || state.currentIndex === -1) {
state.currentIndex = firstNewIdx
state.scrollSignal += 1
}
},
replaceQueue(
state,
action: PayloadAction<{
items: QueueItem[]
playFromIndex?: number
}>,
) {
const { items, playFromIndex = 0 } = action.payload
state.items = items
state.currentIndex =
items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1
state.shuffleOrder = []
ensureShuffleOrder(state)
},
clearQueue(state) {
state.items = []
state.currentIndex = -1
state.shuffleOrder = []
state.scrollSignal += 1
},
playAtIndex(state, action: PayloadAction<number>) {
const i = action.payload
if (i < 0 || i >= state.items.length) return
state.currentIndex = i
state.scrollSignal += 1
},
removeFromQueueAt(state, action: PayloadAction<number>) {
const idx = action.payload
if (idx < 0 || idx >= state.items.length) return
if (idx === state.currentIndex) {
state.currentIndex = -1
} else if (state.currentIndex > idx) {
state.currentIndex -= 1
}
state.items.splice(idx, 1)
if (state.shuffle) {
const si = state.shuffleOrder.indexOf(idx)
if (si !== -1) state.shuffleOrder.splice(si, 1)
for (let i = 0; i < state.shuffleOrder.length; i++) {
if (state.shuffleOrder[i] > idx) state.shuffleOrder[i] -= 1
}
}
ensureShuffleOrder(state)
},
moveQueueItemInOrder(
state,
action: PayloadAction<{ fromPos: number; toPos: number }>,
) {
const { fromPos, toPos } = action.payload
if (fromPos === toPos) return
if (state.shuffle) {
const order = state.shuffleOrder
if (fromPos < 0 || fromPos >= order.length) return
if (toPos < 0 || toPos >= order.length) return
const item = order.splice(fromPos, 1)[0]
order.splice(toPos, 0, item)
return
}
const items = state.items
if (fromPos < 0 || fromPos >= items.length) return
if (toPos < 0 || toPos >= items.length) return
const qIdx = state.currentIndex
const item = items.splice(fromPos, 1)[0]
items.splice(toPos, 0, item)
if (qIdx === fromPos) state.currentIndex = toPos
else if (fromPos < qIdx && toPos >= qIdx) state.currentIndex -= 1
else if (fromPos > qIdx && toPos <= qIdx) state.currentIndex += 1
},
toggleShuffle(state) {
state.shuffle = !state.shuffle
try {
window.localStorage.setItem('furumi_shuffle', state.shuffle ? '1' : '0')
} catch {
// ignore
}
if (state.shuffle) buildShuffleOrder(state)
else state.shuffleOrder = []
},
toggleRepeat(state) {
state.repeatAll = !state.repeatAll
try {
window.localStorage.setItem('furumi_repeat', state.repeatAll ? '1' : '0')
} catch {
// ignore
}
},
rebuildShuffleOrder(state) {
if (state.shuffle) buildShuffleOrder(state)
},
},
})
export const {
addTrack,
addTracksBatch,
replaceQueue,
clearQueue,
playAtIndex,
removeFromQueueAt,
moveQueueItemInOrder,
toggleShuffle,
toggleRepeat,
rebuildShuffleOrder,
} = queueSlice.actions
type QueueSliceRoot = { queue: QueueState }
export function selectQueueItems(state: QueueSliceRoot) {
return state.queue.items
}
// TODO: toggle shuffle should rebuild the shuffle order
export function selectQueueOrder(state: QueueSliceRoot): number[] {
const q = state.queue
if (!q.shuffle) return q.items.map((_, i) => i)
if (q.shuffleOrder.length !== q.items.length) {
return q.items.map((_, i) => i)
}
return q.shuffleOrder
}
export function selectPlayingOrigIdx(state: QueueSliceRoot) {
return state.queue.currentIndex
}
export function selectQueueScrollSignal(state: QueueSliceRoot) {
return state.queue.scrollSignal
}
export function selectNowPlayingTrack(state: QueueSliceRoot): QueueItem | null {
const q = state.queue
if (q.currentIndex < 0 || q.currentIndex >= q.items.length) return null
return q.items[q.currentIndex]
}
export function selectShuffle(state: QueueSliceRoot) {
return state.queue.shuffle
}
export function selectRepeatAll(state: QueueSliceRoot) {
return state.queue.repeatAll
}
export default queueSlice.reducer
@@ -1,57 +0,0 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { TrackDetail } from '../../types'
import { getTrackInfo } from '../../furumiApi'
export const fetchTrackDetail = createAsyncThunk(
'trackDetail/fetch',
async (trackSlug: string, { rejectWithValue }) => {
const data = await getTrackInfo(trackSlug)
if (data === null) return rejectWithValue('Failed to fetch track detail')
return { trackSlug, detail: data }
},
)
interface TrackDetailState {
bySlug: Record<string, TrackDetail>
loading: boolean
error: string | null
}
const initialState: TrackDetailState = {
bySlug: {},
loading: false,
error: null,
}
const trackDetailSlice = createSlice({
name: 'trackDetail',
initialState,
reducers: {
clearTrackDetail(state) {
state.bySlug = {}
state.error = null
},
removeTrackDetail(state, action: { payload: string }) {
delete state.bySlug[action.payload]
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTrackDetail.pending, (state) => {
state.loading = true
state.error = null
})
.addCase(fetchTrackDetail.fulfilled, (state, action) => {
state.loading = false
state.bySlug[action.payload.trackSlug] = action.payload.detail
state.error = null
})
.addCase(fetchTrackDetail.rejected, (state, action) => {
state.loading = false
state.error = action.payload as string ?? 'Unknown error'
})
},
})
export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions
export default trackDetailSlice.reducer
-42
View File
@@ -1,42 +0,0 @@
// API entity types (see PLAYER-API.md)
export interface Artist {
slug: string
name: string
album_count: number
track_count: number
}
export interface Album {
slug: string
name: string
year: number | null
track_count: number
has_cover: boolean
}
export interface Track {
slug: string
title: string
track_number: number | null
duration_secs: number
artist_name: string
album_name: string | null
album_slug: string | null
genre: string | null
}
export interface TrackDetail extends Track {
storage_path: string
artist_slug: string
album_year: number | null
}
export type SearchResultType = 'artist' | 'album' | 'track'
export interface SearchResult {
result_type: SearchResultType
slug: string
name: string
detail: string | null
}
-14
View File
@@ -1,14 +0,0 @@
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)}`
}
+1 -5
View File
@@ -6,11 +6,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
'/auth': { '/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/callback': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },
+5 -37
View File
@@ -1,6 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import path from 'path';
import cors from 'cors'; import cors from 'cors';
import express from 'express'; import express from 'express';
import { auth } from 'express-openid-connect'; import { auth } from 'express-openid-connect';
@@ -29,6 +28,7 @@ const oidcConfig = {
}; };
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) { if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
// Keep a clear startup failure if OIDC is not configured.
throw new Error( throw new Error(
'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)', 'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)',
); );
@@ -46,11 +46,11 @@ if (!disableAuth) {
app.use(auth(oidcConfig)); app.use(auth(oidcConfig));
} }
app.get('/auth/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get('/auth/me', (req, res) => { app.get('/api/me', (req, res) => {
if (disableAuth) { if (disableAuth) {
res.json({ res.json({
authenticated: false, authenticated: false,
@@ -74,32 +74,7 @@ app.get('/auth/me', (req, res) => {
}); });
}); });
app.get('/auth/token', (req, res) => { app.get('/api/login', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
}
if (!req.oidc.isAuthenticated()) {
res.status(401).json({ authenticated: false });
return;
}
const accessToken = req.oidc.accessToken?.access_token;
const expiresAt = req.oidc.accessToken?.expires_in;
if (!accessToken) {
res.status(500).json({ error: 'no access token in session' });
return;
}
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: expiresAt,
});
});
app.get('/auth/login', (req, res) => {
if (disableAuth) { if (disableAuth) {
res.status(204).end(); res.status(204).end();
return; return;
@@ -110,7 +85,7 @@ app.get('/auth/login', (req, res) => {
}); });
}); });
app.get('/auth/logout', (req, res) => { app.get('/api/logout', (req, res) => {
if (disableAuth) { if (disableAuth) {
res.status(204).end(); res.status(204).end();
return; return;
@@ -121,13 +96,6 @@ app.get('/auth/logout', (req, res) => {
}); });
}); });
// Production: serve Vite-built client as static files
const clientDist = path.resolve(import.meta.dirname, '../../client/dist');
app.use(express.static(clientDist));
app.get('/{*path}', (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
app.listen(port, () => { app.listen(port, () => {
console.log( console.log(
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`, `${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
+1 -3
View File
@@ -18,12 +18,10 @@ mime_guess = "2.0"
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] } symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
openidconnect = "3.4" openidconnect = "3.4"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
jsonwebtoken = "9"
sha2 = "0.10" sha2 = "0.10"
hmac = "0.12" hmac = "0.12"
base64 = "0.22" 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"] }
-1
View File
@@ -39,7 +39,6 @@ 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>,
} }
#[tokio::main] #[tokio::main]
+78 -92
View File
@@ -3,9 +3,8 @@ use axum::{
extract::{Request, State}, extract::{Request, State},
http::{header, HeaderMap, StatusCode}, http::{header, HeaderMap, StatusCode},
middleware::Next, middleware::Next,
response::{IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use openidconnect::{ use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType}, core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client, reqwest::async_http_client,
@@ -17,26 +16,17 @@ use serde::Deserialize;
use base64::Engine; use base64::Engine;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation as JwtValidation};
use jsonwebtoken::jwk::JwkSet;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use super::AppState; use super::AppState;
use std::sync::Arc; use std::sync::Arc;
const SESSION_COOKIE: &str = "furumi_session"; const SESSION_COOKIE: &str = "furumi_session";
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
type HmacSha256 = Hmac<sha2::Sha256>; type HmacSha256 = Hmac<sha2::Sha256>;
pub struct OidcState { pub struct OidcState {
pub client: CoreClient, pub client: CoreClient,
pub session_secret: Vec<u8>, pub session_secret: Vec<u8>,
jwks_uri: String,
issuer_url: String,
jwks_cache: RwLock<Option<(JwkSet, Instant)>>,
http_client: reqwest::Client,
} }
pub async fn oidc_init( pub async fn oidc_init(
@@ -52,9 +42,6 @@ pub async fn oidc_init(
) )
.await?; .await?;
let jwks_uri = provider_metadata.jwks_uri().to_string();
let issuer_url = provider_metadata.issuer().to_string();
let client = CoreClient::from_provider_metadata( let client = CoreClient::from_provider_metadata(
provider_metadata, provider_metadata,
ClientId::new(client_id), ClientId::new(client_id),
@@ -73,70 +60,12 @@ pub async fn oidc_init(
b b
}; };
let http_client = reqwest::Client::new();
tracing::info!("JWKS URI: {}", jwks_uri);
Ok(OidcState { Ok(OidcState {
client, client,
session_secret, session_secret,
jwks_uri,
issuer_url,
jwks_cache: RwLock::new(None),
http_client,
}) })
} }
impl OidcState {
async fn get_jwks(&self) -> anyhow::Result<JwkSet> {
{
let cache = self.jwks_cache.read().await;
if let Some((ref jwks, fetched_at)) = *cache {
if fetched_at.elapsed() < JWKS_CACHE_TTL {
return Ok(jwks.clone());
}
}
}
self.refresh_jwks().await
}
async fn refresh_jwks(&self) -> anyhow::Result<JwkSet> {
tracing::debug!("Fetching JWKS from {}", self.jwks_uri);
let jwks: JwkSet = self.http_client.get(&self.jwks_uri).send().await?.json().await?;
let mut cache = self.jwks_cache.write().await;
*cache = Some((jwks.clone(), Instant::now()));
Ok(jwks)
}
}
#[derive(Debug, serde::Deserialize)]
struct BearerClaims {
sub: String,
}
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String> {
let header = decode_header(token).ok()?;
let kid = header.kid.as_ref()?;
let mut jwks = oidc.get_jwks().await.ok()?;
let mut jwk = jwks.find(kid);
// Handle key rotation: refresh JWKS if kid not found
if jwk.is_none() {
jwks = oidc.refresh_jwks().await.ok()?;
jwk = jwks.find(kid);
}
let key = DecodingKey::from_jwk(jwk?).ok()?;
let mut validation = JwtValidation::new(header.alg);
validation.set_issuer(&[&oidc.issuer_url]);
validation.validate_aud = false;
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
Some(data.claims.sub)
}
fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String { fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret).unwrap(); let mut mac = HmacSha256::new_from_slice(secret).unwrap();
mac.update(user_id.as_bytes()); mac.update(user_id.as_bytes());
@@ -163,29 +92,17 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
} }
} }
/// Auth middleware: requires valid Bearer JWT or SSO session cookie. /// Auth middleware: requires valid SSO session cookie.
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 {
// 1. Check Bearer token — JWT from OIDC provider let oidc = match &state.oidc {
if let Some(ref oidc) = state.oidc { Some(o) => o,
if let Some(token) = req None => return next.run(req).await, // No OIDC configured = no auth
.headers() };
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
{
if let Some(user_id) = validate_bearer_token(oidc, token).await {
tracing::debug!("Bearer auth OK for user: {}", user_id);
return next.run(req).await;
}
}
}
// 2. Check SSO session cookie (if OIDC configured)
if let Some(ref oidc) = state.oidc {
let cookies = req let cookies = req
.headers() .headers()
.get(header::COOKIE) .get(header::COOKIE)
@@ -200,9 +117,34 @@ pub async fn require_auth(
} }
} }
} }
let uri = req.uri().to_string();
if uri.starts_with("/api/") {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
} else {
Redirect::to("/login").into_response()
}
} }
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response() /// GET /login — show SSO login page.
pub async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
if state.oidc.is_none() {
return Redirect::to("/").into_response();
}
Html(LOGIN_HTML).into_response()
}
/// GET /logout — clear session cookie.
pub async fn logout() -> impl IntoResponse {
let cookie = format!(
"{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
SESSION_COOKIE
);
let mut headers = HeaderMap::new();
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
headers.insert(header::LOCATION, "/login".parse().unwrap());
(StatusCode::FOUND, headers, Body::empty()).into_response()
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -377,9 +319,9 @@ pub async fn oidc_callback(
.unwrap_or(false); .unwrap_or(false);
let session_attrs = if is_https { let session_attrs = if is_https {
"SameSite=Lax; Secure" "SameSite=Strict; Secure"
} else { } else {
"SameSite=Lax" "SameSite=Strict"
}; };
let session_cookie = format!( let session_cookie = format!(
@@ -396,3 +338,47 @@ pub async fn oidc_callback(
(StatusCode::FOUND, headers, Body::empty()).into_response() (StatusCode::FOUND, headers, Body::empty()).into_response()
} }
const LOGIN_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Furumi Player Login</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
min-height: 100vh;
display: flex; align-items: center; justify-content: center;
background: #0d0f14;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
}
.card {
background: #161b27;
border: 1px solid #2a3347;
border-radius: 16px;
padding: 2.5rem 3rem;
width: 360px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
text-align: center;
}
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
.btn-sso {
display: block; width: 100%; padding: 0.75rem; text-align: center;
background: #7c6af7; border: none; border-radius: 8px;
color: #fff; font-size: 0.95rem; font-weight: 600; text-decoration: none;
cursor: pointer; transition: background 0.2s;
}
.btn-sso:hover { background: #6b58e8; }
</style>
</head>
<body>
<div class="card">
<div class="logo">Furumi</div>
<div class="subtitle">Sign in to continue</div>
<a href="/auth/login" class="btn-sso">SSO Login</a>
</div>
</body>
</html>"#;
+15 -15
View File
@@ -3,12 +3,9 @@ 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 {
@@ -31,27 +28,30 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/stream/:slug", get(api::stream_track)) .route("/stream/:slug", get(api::stream_track))
.route("/search", get(api::search)); .route("/search", get(api::search));
let api = Router::new() let authed = Router::new()
.route("/", get(player_html))
.nest("/api", library); .nest("/api", library);
let requires_auth = state.oidc.is_some(); let has_oidc = state.oidc.is_some();
let app = if requires_auth { let app = if has_oidc {
api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) authed
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
} else { } else {
api authed
}; };
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::OPTIONS, Method::HEAD])
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
.max_age(Duration::from_secs(600));
Router::new() Router::new()
.route("/login", get(auth::login_page))
.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)
} }
async fn player_html() -> axum::response::Html<String> {
let html = include_str!("player.html")
.replace("<!-- VERSION_PLACEHOLDER -->", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")));
axum::response::Html(html)
}