33 Commits

Author SHA1 Message Date
Ultradesu b1f75b3ee2 fix(node-player): use Express 5 catch-all route syntax
Publish Metadata Agent Image / build-and-push-image (push) Has been cancelled
Publish Web Player Image / build-and-push-image (push) Has been cancelled
Publish Node Player Image / build-and-push-image (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:20:02 +01:00
Ultradesu f3392eff9f fix(node-player): use expires_in instead of expires_at on AccessToken type
Publish Web Player Image / build-and-push-image (push) Waiting to run
Publish Metadata Agent Image / build-and-push-image (push) Successful in 4m18s
Publish Node Player Image / build-and-push-image (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:01:30 +01:00
Ultradesu e99cacae8b feat(auth): replace cookie/api-key auth with JWT Bearer tokens, separate UI from API
Publish Metadata Agent Image / build-and-push-image (push) Successful in 6m3s
Publish Node Player Image / build-and-push-image (push) Failing after 58s
Publish Web Player Image / build-and-push-image (push) Has been cancelled
- Add JWT Bearer token validation to Rust API via OIDC provider JWKS
  with automatic key rotation and 1-hour cache
- Remove x-api-key auth support and built-in web UI from furumi-web-player,
  leaving it as a pure API server
- Add /auth/token endpoint to Node player server to expose OIDC access
  tokens to the frontend
- Move Node player auth endpoints from /api/* to /auth/* to avoid
  path conflicts with Rust API
- Add static file serving to Node Express server for production
  single-container deployment
- Fix SameSite=Strict cookie issue breaking OIDC redirect flow (use Lax)
- Add Dockerfile.node-player with multi-stage Node.js build
- Add CI workflows for node-player Docker image (dev + release)
- Optimize Rust Dockerfiles with dependency caching layer
- Update docker-compose with OIDC env vars and OLLAMA_MODEL support
- Cherry-pick agent LLM client fixes from DEV branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 14:51:52 +01:00
Boris Cherepanov 94d14e8fc8 feat: update styles
Publish Metadata Agent Image / build-and-push-image (push) Successful in 5m22s
Publish Web Player Image / build-and-push-image (push) Successful in 4m15s
2026-04-04 19:34:20 +03:00
Boris Cherepanov 0b6f518b72 feat: refactoring
Publish Metadata Agent Image / build-and-push-image (push) Successful in 4m2s
Publish Web Player Image / build-and-push-image (push) Successful in 4m47s
2026-04-04 19:17:33 +03:00
Boris Cherepanov 3199c12af5 feat: added alternative queue display
Publish Metadata Agent Image / build-and-push-image (push) Successful in 3m58s
Publish Web Player Image / build-and-push-image (push) Successful in 4m16s
2026-04-04 18:49:29 +03:00
Boris Cherepanov daaa3b0814 feat: update styles 2026-04-04 18:33:45 +03:00
Boris Cherepanov e42566f44e fix: correct behavior click of buttons
Publish Metadata Agent Image / build-and-push-image (push) Successful in 3m46s
Publish Web Player Image / build-and-push-image (push) Successful in 5m1s
2026-04-02 00:48:57 +03:00
Boris Cherepanov 30c6400354 feat: create playback service
Publish Metadata Agent Image / build-and-push-image (push) Successful in 7m58s
Publish Web Player Image / build-and-push-image (push) Has been cancelled
2026-04-02 00:38:30 +03:00
Boris Cherepanov 480880f292 fix: load audio thmb 2026-04-02 00:29:21 +03:00
Boris Cherepanov 83a145d0a8 feat: work with order in state 2026-04-02 00:13:30 +03:00
Boris Cherepanov 8ceee6028a fix: then height 100%
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m5s
Publish Web Player Image / build-and-push-image (push) Successful in 1m6s
2026-03-23 16:55:22 +03:00
Boris Cherepanov 3491c52793 feat: refactoring data fetching 2026-03-23 16:55:22 +03:00
Boris Cherepanov f0e1bbc7f8 feat: added redux 2026-03-23 16:55:22 +03:00
Boris Cherepanov 8f38e27eb0 feat: addex type declaration 2026-03-23 16:55:22 +03:00
Boris Cherepanov 8cac2d1160 feat: refactoring 2026-03-23 16:55:22 +03:00
Boris Cherepanov 5a5dab85d0 feat: added api conversation + api review 2026-03-23 16:55:22 +03:00
Boris Cherepanov 310f0061d3 feat: added cors for web-player-backend 2026-03-23 16:55:22 +03:00
Boris Cherepanov f26135ca25 feat: added auth by api key 2026-03-23 16:55:22 +03:00
ab 71d5a38f21 Fix source-missing auto-merge and remove Pink Floyd examples from prompts
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m10s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m10s
Auto-merge: when ingest pipeline detects "source file missing", now checks
if the track already exists in the library by file_hash. If so, marks the
pending entry as 'merged' instead of 'error' — avoiding stale error entries
for files that were already successfully ingested in a previous run.

Prompts: replaced Pink Floyd/The Wall/Have a Cigar examples in both
normalize.txt and merge.txt with Deep Purple examples. The LLM was using
these famous artist/album/track names as fallback output when raw metadata
was empty or ambiguous, causing hallucinated metadata like
"artist: Pink Floyd, title: Have a Cigar" for completely unrelated tracks.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 01:05:22 +00:00
ab 8d70a5133a Disabled obsolete CI
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m17s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m14s
2026-03-20 00:49:27 +00:00
ab 56760be586 Disabled obsolete CI
Publish Metadata Agent Image (dev) / build-and-push-image (push) Failing after 10s
Publish Web Player Image (dev) / build-and-push-image (push) Failing after 9s
2026-03-20 00:01:30 +00:00
ab 108c374c6d ci: update Dockerfile paths after moving to docker/ directory
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:00:42 +00:00
ab 2129dc8007 Merge pull request 'feat: added express + vite app + oidc' (#1) from feature/node-app into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m18s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m25s
Publish Server Image / build-and-push-image (push) Successful in 2m56s
Reviewed-on: #1
2026-03-19 23:44:29 +00:00
ab cc3ef04cbe Fix phantom duplicate tracks created on Merged file ingestion
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m16s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m10s
Publish Server Image / build-and-push-image (push) Successful in 2m36s
When the mover returns MoveOutcome::Merged (destination already exists,
source deleted), approve_and_finalize was checking only file_hash to
detect duplicates. Since the second ingestion had a different hash
(different quality/mastering), it bypassed the check and created a
phantom track record pointing to an existing storage_path with the
wrong hash (of the now-deleted source file).

Added a second dedup check by storage_path: if a non-hidden track
already exists at that path, mark pending as 'merged' instead of
inserting a new track row. This prevents phantom entries for any
subsequent ingestion of a different-quality version of an already
stored file.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 23:37:33 +00:00
ab a730ab568c Improved admin UI
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m6s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m7s
Publish Server Image / build-and-push-image (push) Successful in 2m13s
2026-03-19 15:28:25 +00:00
Boris Cherepanov c30a3aff5d feat: refactoring
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m10s
Publish Web Player Image / build-and-push-image (push) Successful in 1m7s
Publish Server Image / build-and-push-image (push) Successful in 2m7s
2026-03-19 18:04:13 +03:00
Boris Cherepanov 71d88bacf2 feat: refactoring modules
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m9s
Publish Web Player Image / build-and-push-image (push) Successful in 1m15s
Publish Server Image / build-and-push-image (push) Successful in 2m11s
2026-03-19 17:32:27 +03:00
ab 5fb8821709 Fixed merge
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m7s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m11s
Publish Server Image / build-and-push-image (push) Successful in 2m12s
2026-03-19 14:16:45 +00:00
ab b1eaa1b6e9 Reworked agent UI. Artist management form.
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m8s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m9s
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m7s
Publish Web Player Image / build-and-push-image (push) Successful in 1m10s
Publish Server Image / build-and-push-image (push) Successful in 2m23s
2026-03-19 13:25:37 +00:00
ab 7c2c7b0ce5 Fix dev CI
Publish Server Image / build-and-push-image (push) Successful in 2m11s
2026-03-19 13:16:24 +00:00
Boris Cherepanov 4f239c2546 feat: added disable auth mode
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m6s
Publish Web Player Image / build-and-push-image (push) Successful in 1m14s
Publish Server Image / build-and-push-image (push) Successful in 2m9s
2026-03-19 15:47:21 +03:00
Boris Cherepanov cfcf6e4029 feat: added express + vite app + oidc
Publish Metadata Agent Image / build-and-push-image (push) Successful in 3m28s
Publish Web Player Image / build-and-push-image (push) Successful in 1m9s
Publish Server Image / build-and-push-image (push) Successful in 2m16s
2026-03-19 15:06:32 +03:00
91 changed files with 12029 additions and 273 deletions
+32
View File
@@ -0,0 +1,32 @@
---
description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client)
globs: furumi-node-player/client/**/*.{ts,tsx}
alwaysApply: false
---
# REST API в furumi-node-player
**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход.
Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`.
## Правила
1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей.
2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта.
3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке).
## Пример
```typescript
// furumiApi.ts — добавлять сюда
export async function getSomething(id: string) {
const res = await furumiApi.get(`/something/${id}`).catch(() => null)
return res?.data ?? null
}
```
```typescript
// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую
const data = await getSomething(id)
```
@@ -0,0 +1,49 @@
---
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/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
@@ -0,0 +1,50 @@
name: Publish Metadata Agent Image (dev)
on:
push:
branches:
- DEV
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-metadata-agent
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)"
echo "tags=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "version=dev-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.agent
push: true
tags: ${{ steps.info.outputs.tags }}
build-args: |
FURUMI_VERSION=${{ steps.info.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- '**' - '**'
- '!DEV'
tags: tags:
- 'v*.*.*' - 'v*.*.*'
@@ -51,7 +52,7 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile.agent file: docker/Dockerfile.agent
push: true push: true
tags: ${{ steps.info.outputs.tags }} tags: ${{ steps.info.outputs.tags }}
build-args: | build-args: |
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,57 @@
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
@@ -0,0 +1,50 @@
name: Publish Web Player Image (dev)
on:
push:
branches:
- DEV
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-web-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)"
echo "tags=${IMAGE}:dev" >> "$GITHUB_OUTPUT"
echo "version=dev-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile.web-player
push: true
tags: ${{ steps.info.outputs.tags }}
build-args: |
FURUMI_VERSION=${{ steps.info.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+2 -1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- '**' - '**'
- '!DEV'
tags: tags:
- 'v*.*.*' - 'v*.*.*'
@@ -51,7 +52,7 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: Dockerfile.web-player file: docker/Dockerfile.web-player
push: true push: true
tags: ${{ steps.info.outputs.tags }} tags: ${{ steps.info.outputs.tags }}
build-args: | build-args: |
@@ -51,6 +51,7 @@ jobs:
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
file: docker/Dockerfile
push: true push: true
tags: ${{ steps.info.outputs.tags }} tags: ${{ steps.info.outputs.tags }}
build-args: | build-args: |
+2 -2
View File
@@ -1,4 +1,4 @@
/target /target
/inbox /docker/inbox
/storage /docker/storage
.env .env
Generated
+18 -1
View File
@@ -1114,7 +1114,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-util", "futures-util",
"hmac", "hmac",
"jsonwebtoken", "jsonwebtoken 10.3.0",
"libc", "libc",
"mime_guess", "mime_guess",
"ogg", "ogg",
@@ -1152,6 +1152,7 @@ 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",
@@ -1165,6 +1166,7 @@ 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",
@@ -1864,6 +1866,21 @@ 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"
-32
View File
@@ -1,32 +0,0 @@
FROM rust:1.88.0-bookworm AS builder
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
protobuf-compiler \
cmake \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
COPY . .
ARG FURUMI_VERSION=dev
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash appuser
WORKDIR /home/appuser
COPY --from=builder /usr/src/app/target/release/furumi-agent /usr/local/bin/furumi-agent
USER appuser
EXPOSE 8090
ENTRYPOINT ["furumi-agent"]
-32
View File
@@ -1,32 +0,0 @@
FROM rust:1.88.0-bookworm AS builder
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
protobuf-compiler \
cmake \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
COPY . .
ARG FURUMI_VERSION=dev
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash appuser
WORKDIR /home/appuser
COPY --from=builder /usr/src/app/target/release/furumi-web-player /usr/local/bin/furumi-web-player
USER appuser
EXPOSE 8080
ENTRYPOINT ["furumi-web-player"]
View File
+59
View File
@@ -0,0 +1,59 @@
FROM rust:1.88.0-bookworm AS builder
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
protobuf-compiler \
cmake \
&& rm -rf /var/lib/apt/lists/*
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 . .
# 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
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash appuser
WORKDIR /home/appuser
COPY --from=builder /usr/src/app/target/release/furumi-agent /usr/local/bin/furumi-agent
USER appuser
EXPOSE 8090
ENTRYPOINT ["furumi-agent"]
+38
View File
@@ -0,0 +1,38 @@
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"]
+59
View File
@@ -0,0 +1,59 @@
FROM rust:1.88.0-bookworm AS builder
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
protobuf-compiler \
cmake \
&& rm -rf /var/lib/apt/lists/*
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 . .
# 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
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y \
ca-certificates \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -ms /bin/bash appuser
WORKDIR /home/appuser
COPY --from=builder /usr/src/app/target/release/furumi-web-player /usr/local/bin/furumi-web-player
USER appuser
EXPOSE 8080
ENTRYPOINT ["furumi-web-player"]
@@ -16,8 +16,8 @@ services:
agent: agent:
build: build:
context: . context: ..
dockerfile: Dockerfile.agent dockerfile: docker/Dockerfile.agent
container_name: furumi-agent container_name: furumi-agent
depends_on: depends_on:
db: db:
@@ -25,10 +25,12 @@ 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
@@ -41,8 +43,8 @@ services:
web-player: web-player:
build: build:
context: . context: ..
dockerfile: Dockerfile.web-player dockerfile: docker/Dockerfile.web-player
container_name: furumi-web-player container_name: furumi-web-player
depends_on: depends_on:
db: db:
@@ -53,6 +55,11 @@ 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
View File
@@ -0,0 +1,12 @@
ALTER TABLE albums
ADD COLUMN IF NOT EXISTS release_type TEXT NOT NULL DEFAULT 'album',
ADD COLUMN IF NOT EXISTS hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE tracks
ADD COLUMN IF NOT EXISTS hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE artists
ADD COLUMN IF NOT EXISTS hidden BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE pending_tracks
ADD COLUMN IF NOT EXISTS norm_release_type TEXT;
+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: "pink floyd" ### Artist ID 42: "deep purple"
Album ID 10: "the wall" (1979) Album ID 10: "machine head" (1972)
- 01. "In the Flesh?" [track_id=100] - 01. "Highway Star" [track_id=100]
- 02. "The Thin Ice" [track_id=101] - 02. "Maybe I'm a Leo" [track_id=101]
### Artist ID 43: "Pink Floyd" ### Artist ID 43: "Deep Purple"
Album ID 11: "Wish You Were Here" (1975) Album ID 11: "Burn" (1974)
- 01. "Shine On You Crazy Diamond (Parts I-V)" [track_id=200] - 01. "Burn" [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., "pink floyd" → "Pink Floyd", "AC DC" → "AC/DC"). - Use correct capitalization and canonical spelling (e.g., "deep purple" → "Deep Purple", "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": "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": "...", "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`: 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).
+28 -7
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:
- "pink floyd" → "Pink Floyd" - "deep purple" → "Deep Purple"
- "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 "The Wall" (1979) remastered in 2011 → album: "The Wall (Remastered)", year: 2011. - **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.
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 - Have a Cigar" → "Have a Cigar"). - Remove leading track numbers if present (e.g., "01 - Smoke on the Water" → "Smoke on the Water").
- **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,18 +64,39 @@ 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**: Rate your confidence from 0.0 to 1.0. 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.
- 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.
- Below 0.5: Significant uncertainty, definitely needs review. - Below 0.5: Significant uncertainty, definitely needs review.
12. **Release type**: Determine the type of release based on all available evidence.
Allowed values (use exactly one, lowercase):
- `album`: Full-length release, typically 4+ tracks
- `single`: One or two tracks released as a single, OR folder/tag explicitly says "Single", "Сингл"
- `ep`: Short release, typically 3-6 tracks, OR folder/path contains "EP" or "ЕП"
- `compilation`: Best-of, greatest hits, anthology, сборник, compilation
- `live`: Live recording, concert, live album — folder or tags contain "Live", "Concert", "Концерт"
Determination rules (in priority order):
- If the folder path contains keywords like "Single", "Сингл", "single" → `single`
- If the folder path contains "EP", "ЕП", "ep" (case-insensitive) → `ep`
- If the folder path contains "Live", "Concert", "Концерт", "live" → `live`
- If the folder path contains "Compilation", "сборник", "Anthology", "Greatest Hits" → `compilation`
- If album name contains these keywords → apply same logic
- If track count in folder is 12 → likely `single`
- If track count in folder is 36 and no other evidence → likely `ep`
- If track count is 7+ → likely `album`
- When in doubt with 36 tracks, prefer `ep` over `album` only if EP indicators present, otherwise `album`
## Response format ## Response format
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:
{"artist": "...", "album": "...", "title": "...", "year": 2000, "track_number": 1, "genre": "...", "featured_artists": ["...", "..."], "confidence": 0.95, "notes": "brief explanation of changes made"} {"artist": "...", "album": "...", "title": "...", "year": 2000, "track_number": 1, "genre": "...", "featured_artists": [], "release_type": "album", "confidence": 0.95, "notes": "brief explanation of changes made"}
- Use null for fields you cannot determine. - Use null for fields you cannot determine.
- Use an empty array [] for "featured_artists" if there are no featured artists. - Use an empty array [] for "featured_artists" if there are no featured artists.
- The "notes" field should briefly explain what you changed and why. - The "notes" field should briefly explain what you changed and why.
- "release_type" must be exactly one of: "album", "single", "ep", "compilation", "live"
+181 -15
View File
@@ -25,6 +25,7 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> {
pub struct Artist { pub struct Artist {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
pub hidden: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -33,6 +34,8 @@ pub struct Album {
pub artist_id: i64, pub artist_id: i64,
pub name: String, pub name: String,
pub year: Option<i32>, pub year: Option<i32>,
pub release_type: String,
pub hidden: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -64,6 +67,7 @@ pub struct PendingTrack {
pub norm_track_number: Option<i32>, pub norm_track_number: Option<i32>,
pub norm_genre: Option<String>, pub norm_genre: Option<String>,
pub norm_featured_artists: Option<String>, // JSON array pub norm_featured_artists: Option<String>, // JSON array
pub norm_release_type: Option<String>,
pub confidence: Option<f64>, pub confidence: Option<f64>,
pub llm_notes: Option<String>, pub llm_notes: Option<String>,
pub error_message: Option<String>, pub error_message: Option<String>,
@@ -172,6 +176,7 @@ pub async fn update_pending_normalized(
norm_year = $6, norm_track_number = $7, norm_genre = $8, norm_year = $6, norm_track_number = $7, norm_genre = $8,
norm_featured_artists = $9, norm_featured_artists = $9,
confidence = $10, llm_notes = $11, error_message = $12, confidence = $10, llm_notes = $11, error_message = $12,
norm_release_type = $13,
updated_at = NOW() updated_at = NOW()
WHERE id = $1"#, WHERE id = $1"#,
) )
@@ -187,6 +192,7 @@ pub async fn update_pending_normalized(
.bind(norm.confidence) .bind(norm.confidence)
.bind(&norm.notes) .bind(&norm.notes)
.bind(error_message) .bind(error_message)
.bind(&norm.release_type)
.execute(pool) .execute(pool)
.await?; .await?;
Ok(()) Ok(())
@@ -254,18 +260,19 @@ pub async fn upsert_artist(pool: &PgPool, name: &str) -> Result<i64, sqlx::Error
Ok(row.0) Ok(row.0)
} }
pub async fn upsert_album(pool: &PgPool, artist_id: i64, name: &str, year: Option<i32>) -> Result<i64, sqlx::Error> { pub async fn upsert_album(pool: &PgPool, artist_id: i64, name: &str, year: Option<i32>, release_type: &str) -> Result<i64, sqlx::Error> {
let slug = generate_slug(); let slug = generate_slug();
let row: (i64,) = sqlx::query_as( let row: (i64,) = sqlx::query_as(
r#"INSERT INTO albums (artist_id, name, year, slug) r#"INSERT INTO albums (artist_id, name, year, slug, release_type)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (artist_id, name) DO UPDATE SET year = COALESCE(EXCLUDED.year, albums.year) ON CONFLICT (artist_id, name) DO UPDATE SET year = COALESCE(EXCLUDED.year, albums.year), release_type = EXCLUDED.release_type
RETURNING id"# RETURNING id"#
) )
.bind(artist_id) .bind(artist_id)
.bind(name) .bind(name)
.bind(year) .bind(year)
.bind(&slug) .bind(&slug)
.bind(release_type)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
Ok(row.0) Ok(row.0)
@@ -327,23 +334,36 @@ pub async fn approve_and_finalize(
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
// Check if track already exists (e.g. previously approved but pending not cleaned up) // Check if track already exists by file_hash (re-approval of same file)
let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1") let existing: Option<(i64,)> = sqlx::query_as("SELECT id FROM tracks WHERE file_hash = $1")
.bind(&pt.file_hash) .bind(&pt.file_hash)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
if let Some((track_id,)) = existing { if let Some((track_id,)) = existing {
// Already finalized — just mark pending as approved
update_pending_status(pool, pending_id, "approved", None).await?; update_pending_status(pool, pending_id, "approved", None).await?;
return Ok(track_id); return Ok(track_id);
} }
// Check if track already exists by storage_path (Merged: different quality file landed
// at the same destination, source was deleted — don't create a phantom duplicate)
let existing_path: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM tracks WHERE storage_path = $1 AND NOT hidden"
)
.bind(storage_path)
.fetch_optional(pool)
.await?;
if let Some((track_id,)) = existing_path {
update_pending_status(pool, pending_id, "merged", None).await?;
return Ok(track_id);
}
let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist"); let artist_name = pt.norm_artist.as_deref().unwrap_or("Unknown Artist");
let artist_id = upsert_artist(pool, artist_name).await?; let artist_id = upsert_artist(pool, artist_name).await?;
let album_id = match pt.norm_album.as_deref() { let album_id = match pt.norm_album.as_deref() {
Some(album_name) => Some(upsert_album(pool, artist_id, album_name, pt.norm_year).await?), Some(album_name) => Some(upsert_album(pool, artist_id, album_name, pt.norm_year, pt.norm_release_type.as_deref().unwrap_or("album")).await?),
None => None, None => None,
}; };
@@ -480,6 +500,7 @@ pub struct NormalizedFields {
pub genre: Option<String>, pub genre: Option<String>,
#[serde(default)] #[serde(default)]
pub featured_artists: Vec<String>, pub featured_artists: Vec<String>,
pub release_type: Option<String>,
pub confidence: Option<f64>, pub confidence: Option<f64>,
pub notes: Option<String>, pub notes: Option<String>,
} }
@@ -526,13 +547,13 @@ pub async fn delete_pending(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error
} }
pub async fn list_artists_all(pool: &PgPool) -> Result<Vec<Artist>, sqlx::Error> { pub async fn list_artists_all(pool: &PgPool) -> Result<Vec<Artist>, sqlx::Error> {
sqlx::query_as::<_, Artist>("SELECT id, name FROM artists ORDER BY name") sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists ORDER BY name")
.fetch_all(pool) .fetch_all(pool)
.await .await
} }
pub async fn list_albums_by_artist(pool: &PgPool, artist_id: i64) -> Result<Vec<Album>, sqlx::Error> { pub async fn list_albums_by_artist(pool: &PgPool, artist_id: i64) -> Result<Vec<Album>, sqlx::Error> {
sqlx::query_as::<_, Album>("SELECT id, artist_id, name, year FROM albums WHERE artist_id = $1 ORDER BY year, name") sqlx::query_as::<_, Album>("SELECT id, artist_id, name, year, release_type, hidden FROM albums WHERE artist_id = $1 ORDER BY year, name")
.bind(artist_id) .bind(artist_id)
.fetch_all(pool) .fetch_all(pool)
.await .await
@@ -603,6 +624,8 @@ pub struct AlbumRow {
pub artist_name: String, pub artist_name: String,
pub year: Option<i32>, pub year: Option<i32>,
pub track_count: i64, pub track_count: i64,
pub release_type: String,
pub hidden: bool,
} }
#[derive(Debug, Serialize, sqlx::FromRow)] #[derive(Debug, Serialize, sqlx::FromRow)]
@@ -610,7 +633,12 @@ pub struct ArtistRow {
pub id: i64, pub id: i64,
pub name: String, pub name: String,
pub album_count: i64, pub album_count: i64,
pub single_count: i64,
pub ep_count: i64,
pub compilation_count: i64,
pub live_count: i64,
pub track_count: i64, pub track_count: i64,
pub hidden: bool,
} }
pub async fn search_tracks( pub async fn search_tracks(
@@ -657,13 +685,13 @@ pub async fn search_albums(
) -> Result<Vec<AlbumRow>, sqlx::Error> { ) -> Result<Vec<AlbumRow>, sqlx::Error> {
sqlx::query_as::<_, AlbumRow>( sqlx::query_as::<_, AlbumRow>(
r#"SELECT a.id, a.name, ar.name AS artist_name, a.year, r#"SELECT a.id, a.name, ar.name AS artist_name, a.year,
COUNT(t.id) AS track_count COUNT(t.id) AS track_count, a.release_type, a.hidden
FROM albums a FROM albums a
JOIN artists ar ON ar.id = a.artist_id JOIN artists ar ON ar.id = a.artist_id
LEFT JOIN tracks t ON t.album_id = a.id LEFT JOIN tracks t ON t.album_id = a.id
WHERE ($1 = '' OR a.name ILIKE '%' || $1 || '%') WHERE ($1 = '' OR a.name ILIKE '%' || $1 || '%')
AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%') AND ($2 = '' OR ar.name ILIKE '%' || $2 || '%')
GROUP BY a.id, a.name, ar.name, a.year GROUP BY a.id, a.name, ar.name, a.year, a.release_type, a.hidden
ORDER BY ar.name, a.year NULLS LAST, a.name ORDER BY ar.name, a.year NULLS LAST, a.name
LIMIT $3 OFFSET $4"#, LIMIT $3 OFFSET $4"#,
) )
@@ -690,13 +718,18 @@ pub async fn search_artists_lib(
) -> Result<Vec<ArtistRow>, sqlx::Error> { ) -> Result<Vec<ArtistRow>, sqlx::Error> {
sqlx::query_as::<_, ArtistRow>( sqlx::query_as::<_, ArtistRow>(
r#"SELECT ar.id, ar.name, r#"SELECT ar.id, ar.name,
COUNT(DISTINCT al.id) AS album_count, COUNT(DISTINCT CASE WHEN al.release_type = 'album' THEN al.id END) AS album_count,
COUNT(DISTINCT ta.track_id) AS track_count COUNT(DISTINCT CASE WHEN al.release_type = 'single' THEN al.id END) AS single_count,
COUNT(DISTINCT CASE WHEN al.release_type = 'ep' THEN al.id END) AS ep_count,
COUNT(DISTINCT CASE WHEN al.release_type = 'compilation' THEN al.id END) AS compilation_count,
COUNT(DISTINCT CASE WHEN al.release_type = 'live' THEN al.id END) AS live_count,
COUNT(DISTINCT ta.track_id) AS track_count,
ar.hidden
FROM artists ar FROM artists ar
LEFT JOIN albums al ON al.artist_id = ar.id LEFT JOIN albums al ON al.artist_id = ar.id
LEFT JOIN track_artists ta ON ta.artist_id = ar.id AND ta.role = 'primary' LEFT JOIN track_artists ta ON ta.artist_id = ar.id AND ta.role = 'primary'
WHERE ($1 = '' OR ar.name ILIKE '%' || $1 || '%') WHERE ($1 = '' OR ar.name ILIKE '%' || $1 || '%')
GROUP BY ar.id, ar.name GROUP BY ar.id, ar.name, ar.hidden
ORDER BY ar.name ORDER BY ar.name
LIMIT $2 OFFSET $3"#, LIMIT $2 OFFSET $3"#,
) )
@@ -817,6 +850,12 @@ pub struct AlbumTrackRow {
pub genre: Option<String>, pub genre: Option<String>,
} }
pub async fn set_album_tracks_genre(pool: &PgPool, album_id: i64, genre: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE tracks SET genre = $2 WHERE album_id = $1")
.bind(album_id).bind(genre).execute(pool).await?;
Ok(())
}
pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> { pub async fn get_album_details(pool: &PgPool, id: i64) -> Result<Option<AlbumDetails>, sqlx::Error> {
let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as( let row: Option<(i64, String, Option<i32>, i64, String)> = sqlx::query_as(
"SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1" "SELECT a.id, a.name, a.year, ar.id, ar.name FROM albums a JOIN artists ar ON ar.id=a.artist_id WHERE a.id=$1"
@@ -853,6 +892,19 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result<Option<(Str
Ok(row) Ok(row)
} }
/// Returns the storage_path of the first track in an album (for embedded cover fallback).
pub async fn get_album_first_track_path(pool: &PgPool, album_id: i64) -> Result<Option<String>, sqlx::Error> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT storage_path FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title LIMIT 1"
).bind(album_id).fetch_optional(pool).await?;
Ok(row.map(|(p,)| p))
}
pub async fn get_artist_by_id(pool: &PgPool, id: i64) -> Result<Option<Artist>, sqlx::Error> {
sqlx::query_as::<_, Artist>("SELECT id, name, hidden FROM artists WHERE id=$1")
.bind(id).fetch_optional(pool).await
}
pub async fn search_albums_for_artist(pool: &PgPool, q: &str, artist_id: Option<i64>) -> Result<Vec<(i64, String)>, sqlx::Error> { pub async fn search_albums_for_artist(pool: &PgPool, q: &str, artist_id: Option<i64>) -> Result<Vec<(i64, String)>, sqlx::Error> {
if let Some(aid) = artist_id { if let Some(aid) = artist_id {
let rows: Vec<(i64, String)> = sqlx::query_as( let rows: Vec<(i64, String)> = sqlx::query_as(
@@ -867,6 +919,120 @@ pub async fn search_albums_for_artist(pool: &PgPool, q: &str, artist_id: Option<
} }
} }
pub async fn set_track_hidden(pool: &PgPool, id: i64, hidden: bool) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE tracks SET hidden=$2 WHERE id=$1").bind(id).bind(hidden).execute(pool).await?;
Ok(())
}
pub async fn set_album_hidden(pool: &PgPool, id: i64, hidden: bool) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE albums SET hidden=$2 WHERE id=$1").bind(id).bind(hidden).execute(pool).await?;
Ok(())
}
pub async fn set_artist_hidden(pool: &PgPool, id: i64, hidden: bool) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE artists SET hidden=$2 WHERE id=$1").bind(id).bind(hidden).execute(pool).await?;
Ok(())
}
pub async fn set_album_release_type(pool: &PgPool, id: i64, release_type: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE albums SET release_type=$2 WHERE id=$1").bind(id).bind(release_type).execute(pool).await?;
Ok(())
}
pub async fn rename_artist_name(pool: &PgPool, id: i64, name: &str) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE artists SET name=$2 WHERE id=$1").bind(id).bind(name).execute(pool).await?;
Ok(())
}
// Full artist data for admin form
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ArtistAlbumRow {
pub id: i64,
pub name: String,
pub year: Option<i32>,
pub release_type: String,
pub hidden: bool,
pub track_count: i64,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct ArtistAlbumTrack {
pub id: i64,
pub title: String,
pub track_number: Option<i32>,
pub duration_secs: Option<f64>,
pub hidden: bool,
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct AppearanceRow {
pub track_id: i64,
pub track_title: String,
pub primary_artist_id: i64,
pub primary_artist_name: String,
pub album_id: Option<i64>,
pub album_name: Option<String>,
}
pub async fn get_artist_albums(pool: &PgPool, artist_id: i64) -> Result<Vec<ArtistAlbumRow>, sqlx::Error> {
sqlx::query_as::<_, ArtistAlbumRow>(
r#"SELECT a.id, a.name, a.year, a.release_type, a.hidden,
COUNT(t.id) AS track_count
FROM albums a
LEFT JOIN tracks t ON t.album_id = a.id
WHERE a.artist_id = $1
GROUP BY a.id, a.name, a.year, a.release_type, a.hidden
ORDER BY a.year NULLS LAST, a.name"#
).bind(artist_id).fetch_all(pool).await
}
pub async fn get_album_tracks_admin(pool: &PgPool, album_id: i64) -> Result<Vec<ArtistAlbumTrack>, sqlx::Error> {
sqlx::query_as::<_, ArtistAlbumTrack>(
"SELECT id, title, track_number, duration_secs, hidden FROM tracks WHERE album_id=$1 ORDER BY track_number NULLS LAST, title"
).bind(album_id).fetch_all(pool).await
}
pub async fn get_artist_appearances(pool: &PgPool, artist_id: i64) -> Result<Vec<AppearanceRow>, sqlx::Error> {
sqlx::query_as::<_, AppearanceRow>(
r#"SELECT ta.track_id, t.title AS track_title,
ta_p.artist_id AS primary_artist_id, ar_p.name AS primary_artist_name,
t.album_id, al.name AS album_name
FROM track_artists ta
JOIN tracks t ON t.id = ta.track_id
JOIN track_artists ta_p ON ta_p.track_id = t.id AND ta_p.role = 'primary'
JOIN artists ar_p ON ar_p.id = ta_p.artist_id
LEFT JOIN albums al ON al.id = t.album_id
WHERE ta.artist_id = $1 AND ta.role = 'featured'
ORDER BY ar_p.name, al.name NULLS LAST, t.title"#
).bind(artist_id).fetch_all(pool).await
}
pub async fn add_track_appearance(pool: &PgPool, track_id: i64, artist_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(
"INSERT INTO track_artists (track_id, artist_id, role) VALUES ($1, $2, 'featured') ON CONFLICT DO NOTHING"
).bind(track_id).bind(artist_id).execute(pool).await?;
Ok(())
}
pub async fn remove_track_appearance(pool: &PgPool, track_id: i64, artist_id: i64) -> Result<(), sqlx::Error> {
sqlx::query(
"DELETE FROM track_artists WHERE track_id=$1 AND artist_id=$2 AND role='featured'"
).bind(track_id).bind(artist_id).execute(pool).await?;
Ok(())
}
pub async fn search_tracks_for_feat(pool: &PgPool, q: &str) -> Result<Vec<(i64, String, String)>, sqlx::Error> {
// Returns (track_id, track_title, primary_artist_name)
sqlx::query_as::<_, (i64, String, String)>(
r#"SELECT t.id, t.title, ar.name FROM tracks t
JOIN track_artists ta ON ta.track_id=t.id AND ta.role='primary'
JOIN artists ar ON ar.id=ta.artist_id
WHERE t.title ILIKE '%'||$1||'%' OR ar.name ILIKE '%'||$1||'%'
ORDER BY ar.name, t.title LIMIT 15"#
).bind(q).fetch_all(pool).await
}
// =================== Artist Merges =================== // =================== Artist Merges ===================
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
@@ -951,7 +1117,7 @@ pub async fn get_pending_merges_for_processing(pool: &PgPool) -> Result<Vec<Uuid
pub async fn get_artists_full_data(pool: &PgPool, ids: &[i64]) -> Result<Vec<ArtistFullData>, sqlx::Error> { pub async fn get_artists_full_data(pool: &PgPool, ids: &[i64]) -> Result<Vec<ArtistFullData>, sqlx::Error> {
let mut result = Vec::new(); let mut result = Vec::new();
for &id in ids { for &id in ids {
let artist: Artist = sqlx::query_as("SELECT id, name FROM artists WHERE id = $1") let artist: Artist = sqlx::query_as("SELECT id, name, hidden FROM artists WHERE id = $1")
.bind(id).fetch_one(pool).await?; .bind(id).fetch_one(pool).await?;
let albums: Vec<Album> = sqlx::query_as("SELECT * FROM albums WHERE artist_id = $1 ORDER BY year NULLS LAST, name") let albums: Vec<Album> = sqlx::query_as("SELECT * FROM albums WHERE artist_id = $1 ORDER BY year NULLS LAST, name")
.bind(id).fetch_all(pool).await?; .bind(id).fetch_all(pool).await?;
+43 -5
View File
@@ -132,8 +132,8 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
Vec::new() Vec::new()
}; };
// LLM normalization // LLM normalization (no folder context available for reprocessing from DB)
match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums).await { match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums, None).await {
Ok(normalized) => { Ok(normalized) => {
let confidence = normalized.confidence.unwrap_or(0.0); let confidence = normalized.confidence.unwrap_or(0.0);
let status = if confidence >= state.config.confidence_threshold { let status = if confidence >= state.config.confidence_threshold {
@@ -188,8 +188,20 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
} }
} }
} else { } else {
tracing::error!(id = %pt.id, "Source file missing: {:?}", source); // Source file is gone — check if already in library by hash
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?; 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 {
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
}
continue; continue;
}; };
@@ -428,9 +440,35 @@ async fn process_file(state: &Arc<AppState>, file_path: &std::path::Path) -> any
tracing::info!(file = filename, matches = ?names, "Found similar albums in DB"); tracing::info!(file = filename, matches = ?names, "Found similar albums in DB");
} }
// Build folder context for the LLM
let audio_extensions = ["flac", "mp3", "ogg", "wav", "aac", "m4a", "opus", "wma", "ape", "alac"];
let folder_ctx = {
let folder = file_path.parent().unwrap_or(file_path);
let mut folder_files: Vec<String> = std::fs::read_dir(folder)
.ok()
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
if audio_extensions.contains(&ext.as_str()) { Some(name) } else { None }
})
.collect()
})
.unwrap_or_default();
folder_files.sort();
let track_count = folder_files.len();
let folder_path = folder
.strip_prefix(&state.config.inbox_dir)
.unwrap_or(folder)
.to_string_lossy()
.into_owned();
normalize::FolderContext { folder_path, folder_files, track_count }
};
// Call LLM for normalization // Call LLM for normalization
tracing::info!(file = filename, model = %state.config.ollama_model, "Sending to LLM for normalization..."); tracing::info!(file = filename, model = %state.config.ollama_model, "Sending to LLM for normalization...");
match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums).await { match normalize::normalize(state, &raw_meta, &hints, &similar_artists, &similar_albums, Some(&folder_ctx)).await {
Ok(normalized) => { Ok(normalized) => {
let confidence = normalized.confidence.unwrap_or(0.0); let confidence = normalized.confidence.unwrap_or(0.0);
let status = if confidence >= state.config.confidence_threshold { let status = if confidence >= state.config.confidence_threshold {
+125 -26
View File
@@ -7,6 +7,13 @@ use crate::web::AppState;
use super::metadata::RawMetadata; use super::metadata::RawMetadata;
#[derive(Debug)]
pub struct FolderContext {
pub folder_path: String, // path relative to inbox_dir (e.g. "Kunteynir/Синглы/Пьюк")
pub folder_files: Vec<String>, // audio filenames in the same folder
pub track_count: usize, // number of audio files in folder
}
/// Build the user message with all context and call Ollama for normalization. /// Build the user message with all context and call Ollama for normalization.
pub async fn normalize( pub async fn normalize(
state: &Arc<AppState>, state: &Arc<AppState>,
@@ -14,19 +21,41 @@ pub async fn normalize(
hints: &crate::db::PathHints, hints: &crate::db::PathHints,
similar_artists: &[SimilarArtist], similar_artists: &[SimilarArtist],
similar_albums: &[SimilarAlbum], similar_albums: &[SimilarAlbum],
folder_ctx: Option<&FolderContext>,
) -> anyhow::Result<NormalizedFields> { ) -> anyhow::Result<NormalizedFields> {
let user_message = build_user_message(raw, hints, similar_artists, similar_albums); 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?;
parse_response(&response) match 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(
@@ -34,6 +63,7 @@ fn build_user_message(
hints: &crate::db::PathHints, hints: &crate::db::PathHints,
similar_artists: &[SimilarArtist], similar_artists: &[SimilarArtist],
similar_albums: &[SimilarAlbum], similar_albums: &[SimilarAlbum],
folder_ctx: Option<&FolderContext>,
) -> String { ) -> String {
let mut msg = String::from("## Raw metadata from file tags\n"); let mut msg = String::from("## Raw metadata from file tags\n");
@@ -88,36 +118,65 @@ fn build_user_message(
} }
} }
if let Some(ctx) = folder_ctx {
msg.push_str("\n## Folder context\n");
msg.push_str(&format!("Folder path: \"{}\"\n", ctx.folder_path));
msg.push_str(&format!("Track count in folder: {}\n", ctx.track_count));
if !ctx.folder_files.is_empty() {
msg.push_str("Files in folder:\n");
for f in &ctx.folder_files {
msg.push_str(&format!(" - {}\n", f));
}
}
}
msg msg
} }
#[derive(Serialize)] #[derive(Serialize)]
struct OllamaRequest { struct ChatRequest {
model: String, model: String,
messages: Vec<OllamaMessage>, messages: Vec<ChatMessage>,
format: String, #[serde(skip_serializing_if = "Option::is_none")]
response_format: Option<ChatResponseFormat>,
stream: bool, stream: bool,
options: OllamaOptions, temperature: f64,
max_tokens: u32,
frequency_penalty: f64,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct OllamaMessage { struct ChatMessage {
role: String, role: String,
content: String, content: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct OllamaOptions { struct ChatResponseFormat {
temperature: f64, #[serde(rename = "type")]
kind: String,
json_schema: JsonSchemaWrapper,
}
#[derive(Serialize)]
struct JsonSchemaWrapper {
name: String,
strict: bool,
schema: serde_json::Value,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct OllamaResponse { struct ChatResponse {
message: OllamaResponseMessage, choices: Vec<ChatChoice>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct OllamaResponseMessage { struct ChatChoice {
message: ChatResponseMessage,
}
#[derive(Deserialize)]
struct ChatResponseMessage {
content: String, content: String,
} }
@@ -127,30 +186,40 @@ 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 request = OllamaRequest { let response_format = schema.map(|(name, schema)| ChatResponseFormat {
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![
OllamaMessage { ChatMessage {
role: "system".to_owned(), role: "system".to_owned(),
content: system_prompt.to_owned(), content: system_prompt.to_owned(),
}, },
OllamaMessage { ChatMessage {
role: "user".to_owned(), role: "user".to_owned(),
content: user_message.to_owned(), content: user_message.to_owned(),
}, },
], ],
format: "json".to_owned(), response_format,
stream: false, stream: false,
options: OllamaOptions { temperature: 0.1 }, temperature: 0.1,
max_tokens,
frequency_penalty,
}; };
let url = format!("{}/api/chat", base_url.trim_end_matches('/')); let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API..."); tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM 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);
@@ -163,18 +232,45 @@ 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)], "Ollama API error"); tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error");
anyhow::bail!("Ollama returned {}: {}", status, body); anyhow::bail!("LLM returned {}: {}", status, body);
} }
let ollama_resp: OllamaResponse = resp.json().await?; let chat_resp: ChatResponse = 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 = ollama_resp.message.content.len(), response_len = content.len(),
"Ollama response received" "LLM response received"
); );
tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output"); tracing::debug!(raw_response = %content, "LLM raw output");
Ok(ollama_resp.message.content) Ok(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.
@@ -201,6 +297,8 @@ 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>,
confidence: Option<f64>, confidence: Option<f64>,
notes: Option<String>, notes: Option<String>,
} }
@@ -216,6 +314,7 @@ fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
track_number: parsed.track_number, track_number: parsed.track_number,
genre: parsed.genre, genre: parsed.genre,
featured_artists: parsed.featured_artists, featured_artists: parsed.featured_artists,
release_type: parsed.release_type,
confidence: parsed.confidence, confidence: parsed.confidence,
notes: parsed.notes, notes: parsed.notes,
}) })
+57
View File
@@ -35,12 +35,39 @@ 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)?;
@@ -147,6 +174,27 @@ async fn merge_db(
proposal: &MergeProposal, proposal: &MergeProposal,
loser_ids: &[i64], loser_ids: &[i64],
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// 0. Validate proposal — ensure winner and all album IDs belong to source artists
let source_ids: Vec<i64> = loser_ids.iter().copied()
.chain(std::iter::once(proposal.winner_artist_id))
.collect();
// Verify winner_artist_id is one of the source artists
if !source_ids.contains(&proposal.winner_artist_id) {
anyhow::bail!(
"winner_artist_id {} is not among source artists {:?}",
proposal.winner_artist_id, source_ids
);
}
// Build set of valid album IDs (albums that actually belong to source artists)
let mut valid_album_ids = std::collections::HashSet::<i64>::new();
for &src_id in &source_ids {
let rows: Vec<(i64,)> = sqlx::query_as("SELECT id FROM albums WHERE artist_id = $1")
.bind(src_id).fetch_all(&mut **tx).await?;
for (id,) in rows { valid_album_ids.insert(id); }
}
// 1. Rename winner artist to canonical name // 1. Rename winner artist to canonical name
sqlx::query("UPDATE artists SET name = $2 WHERE id = $1") sqlx::query("UPDATE artists SET name = $2 WHERE id = $1")
.bind(proposal.winner_artist_id) .bind(proposal.winner_artist_id)
@@ -155,6 +203,15 @@ async fn merge_db(
// 2. Process album mappings from the proposal // 2. Process album mappings from the proposal
for mapping in &proposal.album_mappings { for mapping in &proposal.album_mappings {
// Skip albums that don't belong to any source artist (LLM hallucinated IDs)
if !valid_album_ids.contains(&mapping.source_album_id) {
tracing::warn!(
album_id = mapping.source_album_id,
"Skipping album mapping: album does not belong to source artists"
);
continue;
}
// Skip if source was already processed (idempotent retry support) // Skip if source was already processed (idempotent retry support)
let src_exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)") let src_exists: (bool,) = sqlx::query_as("SELECT EXISTS(SELECT 1 FROM albums WHERE id = $1)")
.bind(mapping.source_album_id) .bind(mapping.source_album_id)
+404 -22
View File
@@ -162,6 +162,37 @@ details.llm-expand pre { background: var(--bg-card); border: 1px solid var(--bor
.pagination button:hover:not(:disabled) { border-color: var(--accent); color: var(--text); } .pagination button:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }
.pagination button.active { background: var(--accent); border-color: var(--accent); color: #fff; } .pagination button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.pagination button:disabled { opacity: 0.3; cursor: default; } .pagination button:disabled { opacity: 0.3; cursor: default; }
/* Release type badges */
.release-badge { font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 1px 5px; border-radius: 3px; letter-spacing: 0.04em; }
.rb-album { background: #1e2740; color: var(--text-dim); }
.rb-single { background: #1e3a2e; color: #6ee7b7; }
.rb-ep { background: #2e1e3a; color: #c4b5fd; }
.rb-compilation{ background: #3a2e1e; color: #fcd34d; }
.rb-live { background: #3a1e1e; color: #fca5a5; }
.hidden-badge { font-size: 9px; font-weight: 700; text-transform: uppercase; padding: 1px 5px; border-radius: 3px; background: #1a1a1a; color: #555; letter-spacing: 0.04em; }
/* Artist admin form */
.artist-section { margin-top: 14px; }
.artist-section-title { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; display: flex; align-items: center; gap: 8px; }
.album-block { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 8px; overflow: hidden; }
.album-block-header { display: flex; align-items: center; gap: 8px; padding: 7px 10px; background: var(--bg-card); cursor: pointer; }
.album-block-header img { width: 36px; height: 36px; border-radius: 3px; object-fit: cover; flex-shrink: 0; }
.album-block-header .ab-name { flex: 1; font-size: 12px; font-weight: 500; }
.album-block-header .ab-year { font-size: 10px; color: var(--text-muted); flex-shrink: 0; }
.album-block-body { display: none; padding: 0; }
.album-block-body.open { display: block; }
.album-track-row { display: flex; align-items: center; gap: 8px; padding: 4px 10px; border-top: 1px solid var(--border); font-size: 11px; background: var(--bg-base); }
.album-track-row.hidden-track { opacity: 0.45; }
.album-track-row .atr-num { color: var(--text-muted); width: 22px; text-align: right; flex-shrink: 0; }
.album-track-row .atr-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.album-track-row .atr-dur { color: var(--text-muted); font-size: 10px; flex-shrink: 0; }
.appearance-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; border-bottom: 1px solid var(--border); }
.appearance-row:last-child { border-bottom: none; }
.btn-hide { background: #1e293b; color: var(--text-muted); border: none; padding: 2px 7px; border-radius: 3px; cursor: pointer; font-size: 10px; font-family: inherit; }
.btn-hide:hover { background: #334155; }
.btn-show { background: #052e16; color: var(--success); border: none; padding: 2px 7px; border-radius: 3px; cursor: pointer; font-size: 10px; font-family: inherit; }
.btn-show:hover { background: #065f46; }
</style> </style>
</head> </head>
<body> <body>
@@ -264,13 +295,14 @@ function renderFilterBar(s) {
`; `;
} }
function showTab(tab, btn) { function showTab(tab, btn, noHash) {
currentTab = tab; currentTab = tab;
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active')); document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active'); btn.classList.add('active');
clearSelection(); clearSelection();
const pag = document.getElementById('lib-pagination'); const pag = document.getElementById('lib-pagination');
if (pag) pag.style.display = 'none'; if (pag) pag.style.display = 'none';
if (!noHash) location.hash = tab;
if (tab === 'queue') { loadQueue(); loadStats(); } if (tab === 'queue') { loadQueue(); loadStats(); }
else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); } else if (tab === 'artists') { libPage.artists = 0; renderLibSearchBar([{ label: 'Artist', key: 'q', tab: 'artists', value: libSearch.artists.q, placeholder: 'search artist…' }], ''); loadLibArtists(); }
else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); } else if (tab === 'tracks') { libPage.tracks = 0; renderLibSearchBar([{ label: 'Title', key: 'q', tab: 'tracks', value: libSearch.tracks.q, placeholder: 'search title…' }, { label: 'Artist', key: 'artist', tab: 'tracks', value: libSearch.tracks.artist, placeholder: 'search artist…' }, { label: 'Album', key: 'album', tab: 'tracks', value: libSearch.tracks.album, placeholder: 'search album…' }], ''); loadLibTracks(); }
@@ -281,7 +313,11 @@ function showTab(tab, btn) {
// --- Queue --- // --- Queue ---
async function loadQueue(status, keepSelection) { async function loadQueue(status, keepSelection) {
currentFilter = status; currentFilter = status;
if (!keepSelection) { clearSelection(); queueOffset = 0; } if (!keepSelection) {
clearSelection();
queueOffset = 0;
location.hash = status ? 'queue/' + status : 'queue';
}
const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`; const qs = `?${status ? 'status='+status+'&' : ''}limit=${queuePageSize+1}&offset=${queueOffset}`;
const raw = await api(`/queue${qs}`) || []; const raw = await api(`/queue${qs}`) || [];
const hasMore = raw.length > queuePageSize; const hasMore = raw.length > queuePageSize;
@@ -328,6 +364,9 @@ function renderQueue(hasMore) {
const artist = it.norm_artist || it.raw_artist || '-'; const artist = it.norm_artist || it.raw_artist || '-';
const title = it.norm_title || it.raw_title || '-'; const title = it.norm_title || it.raw_title || '-';
const album = it.norm_album || it.raw_album || '-'; const album = it.norm_album || it.raw_album || '-';
const albumCoverUrl = (it.norm_album || it.raw_album) && (it.norm_artist || it.raw_artist)
? `${API}/albums/cover-by-name?artist=${encodeURIComponent(it.norm_artist||it.raw_artist||'')}&name=${encodeURIComponent(it.norm_album||it.raw_album||'')}`
: null;
const year = it.norm_year || it.raw_year || ''; const year = it.norm_year || it.raw_year || '';
const tnum = it.norm_track_number || it.raw_track_number || ''; const tnum = it.norm_track_number || it.raw_track_number || '';
const canApprove = it.status === 'review'; const canApprove = it.status === 'review';
@@ -337,7 +376,7 @@ function renderQueue(hasMore) {
<td><span class="status status-${it.status}">${it.status}</span></td> <td><span class="status status-${it.status}">${it.status}</span></td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td> <td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_artist')" title="${esc(it.raw_artist||'')}">${esc(artist)}</td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td> <td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_title')" title="${esc(it.raw_title||'')}">${esc(title)}</td>
<td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}">${esc(album)}</td> <td class="editable" ondblclick="inlineEdit(this,'${it.id}','norm_album')" title="${esc(it.raw_album||'')}"><span style="display:inline-flex;align-items:center;gap:4px">${albumCoverUrl?`<img src="${albumCoverUrl}" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'">`:''}${esc(album)}</span></td>
<td>${year}</td> <td>${year}</td>
<td>${tnum}</td> <td>${tnum}</td>
<td>${conf}</td> <td>${conf}</td>
@@ -740,7 +779,7 @@ async function loadLibAlbums() {
let html = `<table><tr><th>Album</th><th>Artist</th><th style="width:50px">Year</th><th style="width:60px">Tracks</th><th style="width:50px">Edit</th></tr>`; let html = `<table><tr><th>Album</th><th>Artist</th><th style="width:50px">Year</th><th style="width:60px">Tracks</th><th style="width:50px">Edit</th></tr>`;
for (const a of data.items) { for (const a of data.items) {
html += `<tr> html += `<tr>
<td><span style="display:inline-flex;align-items:center;gap:5px"><img src="${API}/albums/${a.id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'"><span>${esc(a.name)}</span></span></td> <td><span style="display:inline-flex;align-items:center;gap:5px"><img src="${API}/albums/${a.id}/cover" style="width:20px;height:20px;border-radius:2px;object-fit:cover;flex-shrink:0" onerror="this.style.display='none'"><span>${esc(a.name)}</span></span> ${releaseBadge(a.release_type)} ${a.hidden?'<span class="hidden-badge">Hidden</span>':''}</td>
<td>${esc(a.artist_name)}</td> <td>${esc(a.artist_name)}</td>
<td>${a.year ?? ''}</td> <td>${a.year ?? ''}</td>
<td style="color:var(--text-muted)">${a.track_count}</td> <td style="color:var(--text-muted)">${a.track_count}</td>
@@ -768,18 +807,30 @@ async function loadLibArtists() {
el.innerHTML = '<div class="empty">No artists found</div>'; el.innerHTML = '<div class="empty">No artists found</div>';
} else { } else {
let html = `<table><tr> let html = `<table><tr>
<th style="width:30px"></th><th style="width:50px">ID</th><th>Name</th> <th style="width:30px"></th><th style="width:40px">ID</th><th>Name</th>
<th style="width:60px">Albums</th><th style="width:60px">Tracks</th> <th style="width:46px" title="Albums">LP</th>
<th style="width:80px">Actions</th> <th style="width:42px" title="Singles">Sng</th>
<th style="width:34px" title="EPs">EP</th>
<th style="width:40px" title="Compilations">Cmp</th>
<th style="width:36px" title="Live">Live</th>
<th style="width:46px" title="Tracks">Trk</th>
<th style="width:60px">Actions</th>
</tr>`; </tr>`;
for (const a of data.items) { for (const a of data.items) {
html += `<tr> const dim = 'style="color:var(--text-muted);text-align:center"';
html += `<tr ${a.hidden?'style="opacity:0.5"':''}>
<td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td> <td><input type="checkbox" class="cb" ${selectedArtists.has(a.id)?'checked':''} onchange="toggleSelectArtist(${a.id},this.checked)"></td>
<td style="color:var(--text-muted)">${a.id}</td> <td style="color:var(--text-muted)">${a.id}</td>
<td class="editable" ondblclick="inlineEditArtist(this,${a.id})">${esc(a.name)}</td> <td>${a.hidden?'<span class="hidden-badge" style="margin-right:4px">H</span>':''}${esc(a.name)}</td>
<td style="color:var(--text-muted)">${a.album_count}</td> <td ${dim}>${a.album_count||''}</td>
<td style="color:var(--text-muted)">${a.track_count}</td> <td ${dim}>${a.single_count||''}</td>
<td class="actions"><button class="btn btn-edit" onclick="editArtist(${a.id},'${esc(a.name)}')">Rename</button></td> <td ${dim}>${a.ep_count||''}</td>
<td ${dim}>${a.compilation_count||''}</td>
<td ${dim}>${a.live_count||''}</td>
<td ${dim}>${a.track_count||''}</td>
<td class="actions">
<button class="btn btn-edit" onclick="openArtistForm(${a.id})">Edit</button>
</td>
</tr>`; </tr>`;
} }
el.innerHTML = html + '</table>'; el.innerHTML = html + '</table>';
@@ -844,10 +895,11 @@ async function editArtist(id, currentName) {
} }
// --- Track edit modal --- // --- Track edit modal ---
async function openTrackEdit(id) { async function openTrackEdit(id, returnCb) {
const t = await api(`/tracks/${id}`); const t = await api(`/tracks/${id}`);
if (!t) return; if (!t) return;
editReturnCallback = returnCb || null;
editFeatured = [...(t.featured_artists || [])]; editFeatured = [...(t.featured_artists || [])];
document.getElementById('modal').className = 'modal'; document.getElementById('modal').className = 'modal';
@@ -899,7 +951,7 @@ async function openTrackEdit(id) {
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button> <button class="btn btn-cancel" onclick="cancelTrackEdit()">Cancel</button>
<button class="btn btn-primary" onclick="saveTrackEdit(${t.id})">Save</button> <button class="btn btn-primary" onclick="saveTrackEdit(${t.id})">Save</button>
</div> </div>
`; `;
@@ -908,6 +960,7 @@ async function openTrackEdit(id) {
} }
let teArtistTimer = null, teAlbumTimer = null; let teArtistTimer = null, teAlbumTimer = null;
let editReturnCallback = null;
function onTeArtistSearch(q) { function onTeArtistSearch(q) {
clearTimeout(teArtistTimer); clearTimeout(teArtistTimer);
@@ -973,12 +1026,21 @@ async function saveTrackEdit(id) {
featured_artists: editFeatured, featured_artists: editFeatured,
}; };
await api(`/tracks/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); await api(`/tracks/${id}`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
closeModal(); const cb = editReturnCallback;
if (currentTab === 'tracks') loadLibTracks(); editReturnCallback = null;
if (cb) { cb(); } else { closeModal(); if (currentTab === 'tracks') loadLibTracks(); }
}
function cancelTrackEdit() {
const cb = editReturnCallback;
editReturnCallback = null;
if (cb) { cb(); } else { closeModal(); }
} }
// --- Album edit modal --- // --- Album edit modal ---
async function openAlbumEdit(id) { let albumEditReturnCallback = null;
async function openAlbumEdit(id, returnCb) {
albumEditReturnCallback = returnCb || null;
const d = await api(`/albums/${id}/full`); const d = await api(`/albums/${id}/full`);
if (!d) return; if (!d) return;
@@ -1023,7 +1085,7 @@ async function openAlbumEdit(id) {
</ul> </ul>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Cancel</button> <button class="btn btn-cancel" onclick="cancelAlbumEdit()">Cancel</button>
<button class="btn btn-edit" onclick="saveAlbumReorder(${id})">Save Order</button> <button class="btn btn-edit" onclick="saveAlbumReorder(${id})">Save Order</button>
<button class="btn btn-primary" onclick="saveAlbumEdit(${id})">Save Album</button> <button class="btn btn-primary" onclick="saveAlbumEdit(${id})">Save Album</button>
</div> </div>
@@ -1073,6 +1135,12 @@ function selectAeArtist(id, name) {
document.getElementById('ae-artist-dropdown').classList.remove('open'); document.getElementById('ae-artist-dropdown').classList.remove('open');
} }
function cancelAlbumEdit() {
const cb = albumEditReturnCallback;
albumEditReturnCallback = null;
if (cb) { cb(); } else { closeModal(); }
}
async function saveAlbumEdit(id) { async function saveAlbumEdit(id) {
const artistId = parseInt(document.getElementById('ae-artist-id').value); const artistId = parseInt(document.getElementById('ae-artist-id').value);
if (!artistId) { alert('Please select an artist from the dropdown'); return; } if (!artistId) { alert('Please select an artist from the dropdown'); return; }
@@ -1082,7 +1150,9 @@ async function saveAlbumEdit(id) {
artist_id: artistId, artist_id: artistId,
}; };
await api(`/albums/${id}/edit`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); await api(`/albums/${id}/edit`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
if (currentTab === 'albums') loadLibAlbums(); const cb = albumEditReturnCallback;
albumEditReturnCallback = null;
if (cb) { cb(); } else { closeModal(); if (currentTab === 'albums') loadLibAlbums(); }
} }
async function saveAlbumReorder(id) { async function saveAlbumReorder(id) {
@@ -1094,8 +1164,42 @@ async function saveAlbumReorder(id) {
} }
async function openTrackEditFromAlbum(trackId, albumId) { async function openTrackEditFromAlbum(trackId, albumId) {
closeModal(); const parentCb = albumEditReturnCallback;
await openTrackEdit(trackId); await openTrackEdit(trackId, () => openAlbumEdit(albumId, parentCb));
}
function openTrackEditForArtist(trackId, artistId) {
openTrackEdit(trackId, () => openArtistForm(artistId));
}
// --- Album inline meta edit (from artist form) ---
async function saveAlbumMeta(albumId, artistId) {
const name = document.getElementById(`alb-name-${albumId}`)?.value?.trim();
const yearRaw = document.getElementById(`alb-year-${albumId}`)?.value;
if (!name) return;
await api(`/albums/${albumId}/edit`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name, year: parseInt(yearRaw) || null, artist_id: artistId }),
});
// Update header display in place
const block = document.getElementById(`album-block-${albumId}`);
if (block) {
const nameSpan = block.querySelector('.ab-name');
if (nameSpan) nameSpan.textContent = name;
const yearSpan = block.querySelector('.ab-year');
if (yearSpan) yearSpan.textContent = yearRaw || '';
}
}
async function applyAlbumGenre(albumId) {
const genre = document.getElementById(`alb-genre-${albumId}`)?.value?.trim();
if (!genre) return;
await api(`/albums/${albumId}/genre`, {
method: 'PUT',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ genre }),
});
document.getElementById(`alb-genre-${albumId}`).value = '';
} }
// --- Helpers --- // --- Helpers ---
@@ -1329,9 +1433,287 @@ async function retryMerge(id) {
loadMerges(); loadMerges();
} }
// --- Release badge helper ---
function releaseBadge(t) {
const labels = {album:'Album',single:'Single',ep:'EP',compilation:'Comp',live:'Live'};
return `<span class="release-badge rb-${t||'album'}">${labels[t]||t||'Album'}</span>`;
}
// --- Artist full admin form ---
async function openArtistForm(id) {
const d = await api(`/artists/${id}/full`);
if (!d) return;
const { artist, albums, appearances } = d;
// Separate albums by type
const mainAlbums = albums.filter(a => a.release_type === 'album' || a.release_type === 'compilation' || a.release_type === 'live');
const singles = albums.filter(a => a.release_type === 'single' || a.release_type === 'ep');
function renderAlbumBlock(alb) {
const hiddenCls = alb.hidden ? ' style="opacity:0.55"' : '';
const tracks = (alb.tracks || []).map(t => `
<div class="album-track-row ${t.hidden?'hidden-track':''}" data-tid="${t.id}">
<span class="atr-num">${t.track_number??''}</span>
<span class="atr-title">${esc(t.title)}</span>
<span class="atr-dur">${fmtDuration(t.duration_secs)}</span>
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
</div>`).join('');
const releaseTypes = ['album','single','ep','compilation','live'];
const typeOpts = releaseTypes.map(rt => `<option value="${rt}" ${alb.release_type===rt?'selected':''}>${rt.charAt(0).toUpperCase()+rt.slice(1)}</option>`).join('');
return `<div class="album-block" id="album-block-${alb.id}"${hiddenCls}>
<div class="album-block-header" onclick="toggleAlbumBlock(${alb.id})">
<img src="${API}/albums/${alb.id}/cover" onerror="this.style.display='none'">
<span class="ab-name">${esc(alb.name)}</span>
${alb.year ? `<span class="ab-year">${alb.year}</span>` : ''}
${releaseBadge(alb.release_type)}
${alb.hidden?'<span class="hidden-badge">Hidden</span>':''}
<select onclick="event.stopPropagation()" onchange="changeReleaseType(${alb.id},this.value)" style="background:var(--bg-base);border:1px solid var(--border);border-radius:3px;color:var(--text);font-size:10px;padding:2px 4px;font-family:inherit">${typeOpts}</select>
<button data-hidden="${alb.hidden}" onclick="event.stopPropagation();toggleAlbumHidden(${alb.id},this)">${alb.hidden?'Show':'Hide'}</button>
</div>
<div class="album-block-body" id="album-body-${alb.id}"></div>
</div>`;
}
const albumsHtml = mainAlbums.map(renderAlbumBlock).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No albums</div>';
const singlesHtml = singles.map(renderAlbumBlock).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No singles/EPs</div>';
const appHtml = appearances.map(ap => `
<div class="appearance-row">
<span style="flex:1">${esc(ap.primary_artist_name)} — <strong>${esc(ap.track_title)}</strong>${ap.album_name?` <span style="color:var(--text-muted)">(${esc(ap.album_name)})</span>`:''}</span>
<button class="btn btn-reject" style="font-size:10px;padding:2px 6px" onclick="removeAppearance(${id},${ap.track_id},this)">Remove</button>
</div>`).join('') || '<div style="color:var(--text-muted);font-size:12px;padding:4px 0">No appearances</div>';
document.getElementById('modal').className = 'modal modal-wide';
document.getElementById('modal').innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
<h2 style="flex:1;margin:0">${esc(artist.name)}</h2>
${artist.hidden?'<span class="hidden-badge" style="font-size:11px">Hidden</span>':''}
<button data-hidden="${artist.hidden}" onclick="toggleArtistHidden(${id},this)">${artist.hidden?'Unhide Artist':'Hide Artist'}</button>
<button class="btn btn-edit" onclick="promptRenameArtist(${id})">Rename</button>
</div>
<div class="artist-section">
<div class="artist-section-title">Albums &amp; Compilations <span style="color:var(--text-dim);font-weight:400">(${mainAlbums.length})</span></div>
${albumsHtml}
</div>
<div class="artist-section">
<div class="artist-section-title">Singles &amp; EPs <span style="color:var(--text-dim);font-weight:400">(${singles.length})</span></div>
${singlesHtml}
</div>
<div class="artist-section">
<div class="artist-section-title">Appearances (feat.) <span style="color:var(--text-dim);font-weight:400">(${appearances.length})</span></div>
<div id="appearances-list">${appHtml}</div>
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
<div style="position:relative;flex:1">
<input id="feat-track-search" placeholder="Search track to add appearance…" autocomplete="off"
oninput="onFeatTrackSearch(${id},this.value)" style="width:100%;background:var(--bg-card);border:1px solid var(--border);border-radius:5px;padding:6px 9px;color:var(--text);font-family:inherit;font-size:12px">
<div class="artist-dropdown" id="feat-track-dropdown"></div>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeModal()">Close</button>
</div>
`;
// Fill album body content and restore open state
for (const alb of albums) {
const body = document.getElementById(`album-body-${alb.id}`);
if (!body) continue;
const tracks = (alb.tracks || []).map(t => `
<div class="album-track-row ${t.hidden?'hidden-track':''}" data-tid="${t.id}">
<span class="atr-num">${t.track_number??''}</span>
<span class="atr-title">${esc(t.title)}</span>
<span class="atr-dur">${fmtDuration(t.duration_secs)}</span>
<button class="btn btn-edit" style="font-size:10px;padding:2px 6px" onclick="openTrackEditForArtist(${t.id},${id})">Edit</button>
<button data-hidden="${t.hidden}" onclick="toggleTrackHidden(${t.id},this)">${t.hidden?'Show':'Hide'}</button>
</div>`).join('');
const albumMeta = `<div style="padding:8px 10px;border-bottom:1px solid var(--border);background:var(--bg-panel)">
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<input id="alb-name-${alb.id}" value="${esc(alb.name)}" placeholder="Album name"
style="flex:2;min-width:140px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<input id="alb-year-${alb.id}" type="number" value="${alb.year??''}" placeholder="Year"
style="width:70px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<button class="btn btn-primary" style="font-size:10px;padding:3px 10px" onclick="saveAlbumMeta(${alb.id},${id})">Save</button>
<span style="color:var(--border);user-select:none">|</span>
<input id="alb-genre-${alb.id}" placeholder="Apply genre to all tracks…"
style="flex:1;min-width:130px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px;padding:4px 8px;color:var(--text);font-size:11px;font-family:inherit">
<button class="btn btn-edit" style="font-size:10px;padding:3px 10px" onclick="applyAlbumGenre(${alb.id})">Apply</button>
</div>
</div>`;
body.innerHTML = albumMeta + tracks;
if (openAlbumBlocks.has(alb.id)) body.classList.add('open');
}
openModal();
}
const openAlbumBlocks = new Set();
function toggleAlbumBlock(id) {
const body = document.getElementById(`album-body-${id}`);
if (!body) return;
body.classList.toggle('open');
if (body.classList.contains('open')) openAlbumBlocks.add(id);
else openAlbumBlocks.delete(id);
}
async function toggleTrackHidden(id, btn) {
const hidden = btn.dataset.hidden !== 'true'; // toggle
await api(`/tracks/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
btn.dataset.hidden = String(hidden);
btn.textContent = hidden ? 'Show' : 'Hide';
const row = btn.closest('.album-track-row');
if (row) row.classList.toggle('hidden-track', hidden);
}
async function toggleAlbumHidden(id, btn) {
const hidden = btn.dataset.hidden !== 'true'; // toggle
await api(`/albums/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
btn.dataset.hidden = String(hidden);
btn.textContent = hidden ? 'Show' : 'Hide';
const block = btn.closest('.album-block');
if (block) block.style.opacity = hidden ? '0.55' : '';
const header = block?.querySelector('.album-block-header');
let badge = header?.querySelector('.hidden-badge');
if (hidden && !badge) { badge = document.createElement('span'); badge.className='hidden-badge'; badge.textContent='Hidden'; header.insertBefore(badge, btn); }
else if (!hidden && badge) badge.remove();
}
async function toggleArtistHidden(id, btn) {
const hidden = btn.dataset.hidden !== 'true'; // toggle
await api(`/artists/${id}/hidden`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({hidden}) });
btn.dataset.hidden = String(hidden);
btn.textContent = hidden ? 'Unhide Artist' : 'Hide Artist';
}
async function changeReleaseType(id, type) {
await api(`/albums/${id}/release_type`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({release_type: type}) });
}
async function promptRenameArtist(id) {
const name = prompt('New artist name:');
if (!name) return;
await api(`/artists/${id}/rename`, { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name}) });
// refresh
closeModal();
openArtistForm(id);
}
let featTrackTimer = null;
function onFeatTrackSearch(artistId, q) {
clearTimeout(featTrackTimer);
const dd = document.getElementById('feat-track-dropdown');
if (q.length < 2) { dd.classList.remove('open'); return; }
featTrackTimer = setTimeout(async () => {
const results = await api(`/tracks/search?q=${encodeURIComponent(q)}`);
if (!results || !results.length) { dd.classList.remove('open'); return; }
dd.innerHTML = results.map(t =>
`<div class="artist-option" onclick="addAppearance(${artistId},${t.id},'${esc(t.artist_name+' — '+t.title)}')">${esc(t.artist_name)} — ${esc(t.title)}</div>`
).join('');
dd.classList.add('open');
}, 250);
}
async function addAppearance(artistId, trackId, label) {
await api(`/artists/${artistId}/appearances`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({track_id: trackId}) });
document.getElementById('feat-track-search').value = '';
document.getElementById('feat-track-dropdown').classList.remove('open');
// Add row to appearances list
const list = document.getElementById('appearances-list');
const row = document.createElement('div');
row.className = 'appearance-row';
row.innerHTML = `<span style="flex:1">${esc(label)}</span><button class="btn btn-reject" style="font-size:10px;padding:2px 6px" onclick="removeAppearance(${artistId},${trackId},this)">Remove</button>`;
list.appendChild(row);
}
async function removeAppearance(artistId, trackId, btn) {
await api(`/artists/${artistId}/appearances/${trackId}`, { method:'DELETE' });
btn.closest('.appearance-row').remove();
}
// --- Cover preview ---
(function() {
const box = document.createElement('div');
box.id = 'cover-preview';
box.style.cssText = [
'display:none', 'position:fixed', 'z-index:9999', 'pointer-events:none',
'border-radius:10px', 'overflow:hidden',
'box-shadow:0 12px 40px rgba(0,0,0,0.85)',
'border:1px solid rgba(255,255,255,0.08)',
'background:#0a0c12', 'transition:opacity 0.1s',
].join(';');
const img = document.createElement('img');
img.style.cssText = 'display:block;width:280px;height:280px;object-fit:contain';
box.appendChild(img);
document.body.appendChild(box);
let showTimer = null;
function isCoverImg(el) {
return el && el.tagName === 'IMG' && el.src && el.src.includes('/cover');
}
function place(e) {
const margin = 16, pw = 280, ph = 280;
const vw = window.innerWidth, vh = window.innerHeight;
let x = e.clientX + margin, y = e.clientY + margin;
if (x + pw > vw - 8) x = e.clientX - pw - margin;
if (y + ph > vh - 8) y = e.clientY - ph - margin;
box.style.left = x + 'px';
box.style.top = y + 'px';
}
document.addEventListener('mouseover', e => {
if (!isCoverImg(e.target)) return;
clearTimeout(showTimer);
showTimer = setTimeout(() => {
img.src = e.target.src;
box.style.display = 'block';
place(e);
}, 120);
});
document.addEventListener('mousemove', e => {
if (box.style.display === 'none') return;
if (!isCoverImg(e.target)) { clearTimeout(showTimer); box.style.display = 'none'; return; }
place(e);
});
document.addEventListener('mouseout', e => {
if (!isCoverImg(e.target)) return;
clearTimeout(showTimer);
box.style.display = 'none';
});
})();
// --- Init --- // --- Init ---
(function restoreFromHash() {
const hash = location.hash.slice(1); // strip #
if (!hash) return;
const [tab, filter] = hash.split('/');
const validTabs = ['queue','tracks','albums','artists','merges'];
if (!validTabs.includes(tab)) return;
const btn = Array.from(document.querySelectorAll('nav button'))
.find(b => (b.getAttribute('onclick') || '').includes(`'${tab}'`));
if (!btn) return;
// Switch tab without overwriting the hash
showTab(tab, btn, true);
// For queue, also restore the filter
if (tab === 'queue' && filter) {
currentFilter = filter;
loadQueue(filter);
}
})();
loadStats(); loadStats();
loadQueue(); if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
setInterval(loadStats, 5000); setInterval(loadStats, 5000);
// Auto-refresh queue when on queue tab // Auto-refresh queue when on queue tab
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000); setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
+199 -11
View File
@@ -143,6 +143,7 @@ pub async fn update_queue_item(
track_number: body.norm_track_number, track_number: body.norm_track_number,
genre: body.norm_genre, genre: body.norm_genre,
featured_artists: body.featured_artists, featured_artists: body.featured_artists,
release_type: None,
confidence: Some(1.0), // manual edit = full confidence confidence: Some(1.0), // manual edit = full confidence
notes: Some("Manually edited".to_owned()), notes: Some("Manually edited".to_owned()),
}; };
@@ -527,6 +528,20 @@ pub async fn update_album_full(
} }
} }
#[derive(Deserialize)]
pub struct SetGenreBody { pub genre: String }
pub async fn set_album_tracks_genre(
State(state): State<S>,
Path(id): Path<i64>,
Json(body): Json<SetGenreBody>,
) -> impl IntoResponse {
match db::set_album_tracks_genre(&state.pool, id, &body.genre).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ReorderBody { pub struct ReorderBody {
pub orders: Vec<(i64, i32)>, pub orders: Vec<(i64, i32)>,
@@ -543,19 +558,82 @@ pub async fn reorder_album_tracks(
} }
} }
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse { /// Cover by artist+album name — used for queue items that may not have an album_id yet.
let cover = match db::get_album_cover(&state.pool, id).await { #[derive(Deserialize)]
Ok(Some(c)) => c, pub struct CoverByNameQuery {
Ok(None) => return StatusCode::NOT_FOUND.into_response(), #[serde(default)] pub artist: String,
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), #[serde(default)] pub name: String,
}
pub async fn album_cover_by_name(State(state): State<S>, Query(q): Query<CoverByNameQuery>) -> impl IntoResponse {
let album_id = match db::find_album_id(&state.pool, &q.artist, &q.name).await {
Ok(Some(id)) => id,
_ => return StatusCode::NOT_FOUND.into_response(),
}; };
match tokio::fs::read(&cover.0).await { album_cover_by_id(&state, album_id).await
Ok(bytes) => ( }
[(axum::http::header::CONTENT_TYPE, cover.1)],
bytes, pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
).into_response(), album_cover_by_id(&state, id).await
Err(_) => StatusCode::NOT_FOUND.into_response(), }
async fn album_cover_by_id(state: &super::AppState, id: i64) -> axum::response::Response {
// 1. Try album_images table
if let Ok(Some((file_path, mime_type))) = db::get_album_cover(&state.pool, id).await {
if let Ok(bytes) = tokio::fs::read(&file_path).await {
return ([(axum::http::header::CONTENT_TYPE, mime_type)], bytes).into_response();
}
} }
// 2. Fallback: extract embedded cover from first track in album
if let Ok(Some(track_path)) = db::get_album_first_track_path(&state.pool, id).await {
let path = std::path::PathBuf::from(track_path);
if path.exists() {
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
if let Ok(Some((bytes, mime))) = result {
return ([(axum::http::header::CONTENT_TYPE, mime)], bytes).into_response();
}
}
}
StatusCode::NOT_FOUND.into_response()
}
fn extract_embedded_cover(path: &std::path::Path) -> Option<(Vec<u8>, String)> {
use symphonia::core::{
formats::FormatOptions,
io::MediaSourceStream,
meta::MetadataOptions,
probe::Hint,
};
let file = std::fs::File::open(path).ok()?;
let mss = MediaSourceStream::new(Box::new(file), Default::default());
let mut hint = Hint::new();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
hint.with_extension(ext);
}
let mut probed = symphonia::default::get_probe()
.format(
&hint,
mss,
&FormatOptions { enable_gapless: false, ..Default::default() },
&MetadataOptions::default(),
)
.ok()?;
if let Some(rev) = probed.metadata.get().as_ref().and_then(|m| m.current()) {
if let Some(v) = rev.visuals().first() {
return Some((v.data.to_vec(), v.media_type.clone()));
}
}
if let Some(rev) = probed.format.metadata().current() {
if let Some(v) = rev.visuals().first() {
return Some((v.data.to_vec(), v.media_type.clone()));
}
}
None
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -574,6 +652,116 @@ pub async fn search_albums_for_artist(State(state): State<S>, Query(q): Query<Al
} }
} }
// --- Artist full admin form ---
pub async fn get_artist_full(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
let artist = match db::get_artist_by_id(&state.pool, id).await {
Ok(Some(a)) => a,
Ok(None) => return error_response(StatusCode::NOT_FOUND, "not found"),
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let (albums, appearances) = tokio::join!(
db::get_artist_albums(&state.pool, id),
db::get_artist_appearances(&state.pool, id),
);
// For each album, load tracks
let albums = match albums {
Ok(a) => a,
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
};
let mut albums_with_tracks = Vec::new();
for album in albums {
let tracks = db::get_album_tracks_admin(&state.pool, album.id).await.unwrap_or_default();
albums_with_tracks.push(serde_json::json!({
"id": album.id, "name": album.name, "year": album.year,
"release_type": album.release_type, "hidden": album.hidden,
"track_count": album.track_count, "tracks": tracks,
}));
}
(StatusCode::OK, Json(serde_json::json!({
"artist": artist,
"albums": albums_with_tracks,
"appearances": appearances.unwrap_or_default(),
}))).into_response()
}
#[derive(Deserialize)]
pub struct SetHiddenBody { pub hidden: bool }
pub async fn set_track_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
match db::set_track_hidden(&state.pool, id, b.hidden).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn set_album_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
match db::set_album_hidden(&state.pool, id, b.hidden).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn set_artist_hidden(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetHiddenBody>) -> impl IntoResponse {
match db::set_artist_hidden(&state.pool, id, b.hidden).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
#[derive(Deserialize)]
pub struct SetReleaseTypeBody { pub release_type: String }
pub async fn set_album_release_type(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<SetReleaseTypeBody>) -> impl IntoResponse {
let valid = ["album","single","ep","compilation","live"];
if !valid.contains(&b.release_type.as_str()) {
return error_response(StatusCode::BAD_REQUEST, "invalid release_type");
}
match db::set_album_release_type(&state.pool, id, &b.release_type).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
#[derive(Deserialize)]
pub struct RenameArtistBody { pub name: String }
pub async fn rename_artist_api(State(state): State<S>, Path(id): Path<i64>, Json(b): Json<RenameArtistBody>) -> impl IntoResponse {
match db::rename_artist_name(&state.pool, id, &b.name).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
#[derive(Deserialize)]
pub struct AddAppearanceBody { pub track_id: i64 }
pub async fn add_appearance(State(state): State<S>, Path(artist_id): Path<i64>, Json(b): Json<AddAppearanceBody>) -> impl IntoResponse {
match db::add_track_appearance(&state.pool, b.track_id, artist_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn remove_appearance(State(state): State<S>, Path((artist_id, track_id)): Path<(i64, i64)>) -> impl IntoResponse {
match db::remove_track_appearance(&state.pool, track_id, artist_id).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
#[derive(Deserialize)]
pub struct SearchTracksQuery { #[serde(default)] pub q: String }
pub async fn search_tracks_feat(State(state): State<S>, Query(q): Query<SearchTracksQuery>) -> impl IntoResponse {
match db::search_tracks_for_feat(&state.pool, &q.q).await {
Ok(rows) => (StatusCode::OK, Json(serde_json::to_value(
rows.iter().map(|(id, title, artist)| serde_json::json!({"id": id, "title": title, "artist_name": artist})).collect::<Vec<_>>()
).unwrap())).into_response(),
Err(e) => error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
// --- Helpers --- // --- Helpers ---
fn error_response(status: StatusCode, message: &str) -> axum::response::Response { fn error_response(status: StatusCode, message: &str) -> axum::response::Response {
+12 -1
View File
@@ -2,7 +2,7 @@ pub mod api;
use std::sync::Arc; use std::sync::Arc;
use axum::{Router, routing::{get, post, put}}; use axum::{Router, routing::{delete, get, post, put}};
use sqlx::PgPool; use sqlx::PgPool;
use crate::config::Args; use crate::config::Args;
@@ -32,12 +32,23 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/artists", get(api::list_artists)) .route("/artists", get(api::list_artists))
.route("/artists/:id", put(api::update_artist)) .route("/artists/:id", put(api::update_artist))
.route("/artists/:id/albums", get(api::list_albums)) .route("/artists/:id/albums", get(api::list_albums))
.route("/artists/:id/full", get(api::get_artist_full))
.route("/artists/:id/hidden", put(api::set_artist_hidden))
.route("/artists/:id/rename", put(api::rename_artist_api))
.route("/artists/:id/appearances", post(api::add_appearance))
.route("/artists/:id/appearances/:track_id", delete(api::remove_appearance))
.route("/tracks/search", get(api::search_tracks_feat))
.route("/tracks/:id", get(api::get_track).put(api::update_track)) .route("/tracks/:id", get(api::get_track).put(api::update_track))
.route("/tracks/:id/hidden", put(api::set_track_hidden))
.route("/albums/search", get(api::search_albums_for_artist)) .route("/albums/search", get(api::search_albums_for_artist))
.route("/albums/cover-by-name", get(api::album_cover_by_name))
.route("/albums/:id/cover", get(api::album_cover)) .route("/albums/:id/cover", get(api::album_cover))
.route("/albums/:id/full", get(api::get_album_full)) .route("/albums/:id/full", get(api::get_album_full))
.route("/albums/:id/reorder", put(api::reorder_album_tracks)) .route("/albums/:id/reorder", put(api::reorder_album_tracks))
.route("/albums/:id/edit", put(api::update_album_full)) .route("/albums/:id/edit", put(api::update_album_full))
.route("/albums/:id/genre", put(api::set_album_tracks_genre))
.route("/albums/:id/hidden", put(api::set_album_hidden))
.route("/albums/:id/release_type", put(api::set_album_release_type))
.route("/albums/:id", put(api::update_album)) .route("/albums/:id", put(api::update_album))
.route("/merges", get(api::list_merges).post(api::create_merge)) .route("/merges", get(api::list_merges).post(api::create_merge))
.route("/merges/:id", get(api::get_merge).put(api::update_merge)) .route("/merges/:id", get(api::get_merge).put(api::update_merge))
+19
View File
@@ -0,0 +1,19 @@
# auth-app
Проект состоит из двух частей:
- `client` - Vite + React + TypeScript
- `server` - Express + TypeScript + OIDC авторизация
## Запуск
1. Скопируй `server/.env.example` в `server/.env` и заполни OIDC параметры.
- Если нужно запустить без авторизации, поставь `DISABLE_AUTH=true` (OIDC параметры тогда не требуются).
2. В одном терминале:
- `cd server`
- `npm run dev`
3. В другом терминале:
- `cd client`
- `npm run dev`
Клиент откроется на `http://localhost:5173`, сервер на `http://localhost:3001`.
+1
View File
@@ -0,0 +1 @@
VITE_FURUMI_API_URL=http://localhost:8085
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.11.2",
"axios": "^1.7.9",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-redux": "^9.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+71
View File
@@ -0,0 +1,71 @@
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.card {
width: min(520px, 100%);
border: 1px solid #d8dde6;
border-radius: 14px;
padding: 24px;
background-color: #ffffff;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
}
.subtitle {
margin-top: 0;
margin-bottom: 20px;
color: #5a6475;
}
.settings {
margin-bottom: 16px;
padding: 12px;
border: 1px solid #e6eaf2;
border-radius: 10px;
background: #f8fafc;
}
.toggle {
display: flex;
align-items: center;
gap: 10px;
color: #0f172a;
font-weight: 600;
}
.toggle input {
width: 18px;
height: 18px;
}
.hint {
margin: 10px 0 0;
color: #5a6475;
}
.btn {
display: inline-block;
text-decoration: none;
background: #2251ff;
color: #ffffff;
padding: 10px 16px;
border-radius: 8px;
font-weight: 600;
}
.btn.ghost {
background: #edf1ff;
color: #1e3fc4;
margin-top: 10px;
}
.profile p {
margin: 8px 0;
}
.error {
color: #cc1e1e;
}
+160
View File
@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import { FurumiPlayer } from './FurumiPlayer'
import { setAuthToken, clearAuthToken } from './furumiApi'
import './App.css'
type UserProfile = {
sub: string
name?: string
email?: string
}
const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth'
function App() {
const [loading, setLoading] = useState(true)
const [user, setUser] = useState<UserProfile | null>(null)
const [error, setError] = useState<string | null>(null)
const [runWithoutAuth, setRunWithoutAuth] = useState(() => {
try {
return window.localStorage.getItem(NO_AUTH_STORAGE_KEY) === '1'
} catch {
return false
}
})
const apiBase = ''
useEffect(() => {
if (runWithoutAuth) {
setError(null)
setUser({ sub: 'noauth', name: 'No Auth' })
setLoading(false)
return
}
const loadMe = async () => {
try {
const response = await fetch(`${apiBase}/auth/me`, {
credentials: 'include',
})
if (response.status === 401) {
setUser(null)
clearAuthToken()
return
}
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`)
}
const data = await response.json()
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) {
setError(err instanceof Error ? err.message : 'Failed to load session')
} finally {
setLoading(false)
}
}
void loadMe()
}, [runWithoutAuth])
const loginUrl = `${apiBase}/auth/login`
const logoutUrl = `${apiBase}/auth/logout`
return (
<>
{!loading && (user || runWithoutAuth) ? (
<FurumiPlayer />
) : (
<main className="page">
<section className="card">
<h1>OIDC Login</h1>
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
<div className="settings">
<label className="toggle">
<input
type="checkbox"
checked={runWithoutAuth}
onChange={(e) => {
const next = e.target.checked
setRunWithoutAuth(next)
try {
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
// ignore
}
setLoading(true)
setUser(null)
}}
/>
<span>Запускать без авторизации</span>
</label>
</div>
{loading && <p>Проверяю сессию...</p>}
{error && <p className="error">Ошибка: {error}</p>}
{!loading && runWithoutAuth && (
<p className="hint">
Режим без авторизации включён. Для входа отключи настройку выше.
</p>
)}
{!loading && !user && (
<a className="btn" href={loginUrl}>
Войти через OIDC
</a>
)}
{!loading && user && (
<div className="profile">
<p>
<strong>ID:</strong> {user.sub}
</p>
{user.name && (
<p>
<strong>Имя:</strong> {user.name}
</p>
)}
{user.email && (
<p>
<strong>Email:</strong> {user.email}
</p>
)}
{!runWithoutAuth && (
<a className="btn ghost" href={logoutUrl}>
Выйти
</a>
)}
</div>
)}
</section>
</main>
)}
</>
)
}
export default App
@@ -0,0 +1,551 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css'
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
import { store, useAppDispatch, useAppSelector } from './store'
import { fetchArtists } from './store/slices/artistsSlice'
import { fetchArtistAlbums } from './store/slices/albumsSlice'
import { fetchArtistTracks } from './store/slices/artistTracksSlice'
import { fetchAlbumTracks } from './store/slices/albumTracksSlice'
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() {
const dispatch = useAppDispatch()
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)
const queueOrderView = useAppSelector(selectQueueOrder)
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
const nowPlayingTrack = useAppSelector(selectNowPlayingTrack)
const [breadcrumbs, setBreadcrumbs] = useState<Crumb[]>(
[],
)
const [libraryItems, setLibraryItems] = useState<
Array<{
key: string
className: string
icon: string
name: string
detail?: string
nameClassName?: string
onClick: () => void
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
}>
>([])
const [searchResults, setSearchResults] = useState<
Array<{ result_type: string; slug: string; name: string; detail?: string }>
>([])
const [searchOpen, setSearchOpen] = useState(false)
const searchSelectRef = useRef<(type: string, slug: string) => void>(() => { })
const queueActionsRef = useRef<{
playIndex: (i: number) => void
removeFromQueue: (idx: number) => void
moveQueueItem: (fromPos: number, toPos: number) => void
clearQueue: () => void
} | null>(null)
const audioRef = useRef<HTMLAudioElement>(null)
useEffect(() => {
if (!nowPlayingTrack) {
document.title = 'Furumi Player'
return
}
document.title = `${nowPlayingTrack.title} — Furumi`
const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover`
if ('mediaSession' in navigator) {
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
navigator.mediaSession.metadata = new window.MediaMetadata({
title: nowPlayingTrack.title,
artist: nowPlayingTrack.artist || '',
album: '',
artwork: [{ src: coverUrl, sizes: '512x512' }],
})
} catch {
// ignore
}
}
}, [nowPlayingTrack])
useEffect(() => {
const audioEl = audioRef.current
if (!audioEl) return
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)
}
async function showArtists() {
setBreadcrumb([{ label: 'Artists', action: showArtists }])
try {
const artists = await dispatch(fetchArtists()).unwrap()
setLibraryItems(
artists.map((a) => ({
key: `artist:${a.slug}`,
className: 'file-item dir',
icon: '👤',
name: a.name,
detail: `${a.album_count} albums`,
onClick: () => void showArtistAlbums(a.slug, a.name),
})),
)
} catch {
// Error is stored in artists.error
}
}
async function showArtistAlbums(artistSlug: string, artistName: string) {
setBreadcrumb([
{ label: 'Artists', action: showArtists },
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
])
try {
const { albums } = await dispatch(fetchArtistAlbums(artistSlug)).unwrap()
const allTracksItem = {
key: `artist-all:${artistSlug}`,
className: 'file-item',
icon: '▶',
name: 'Play all tracks',
nameClassName: 'name',
onClick: () => void playAllArtistTracks(artistSlug),
}
const albumItems = albums.map((a) => {
const year = a.year ? ` (${a.year})` : ''
return {
key: `album:${a.slug}`,
className: 'file-item dir',
icon: '💿',
name: `${a.name}${year}`,
detail: `${a.track_count} tracks`,
onClick: () => void showAlbumTracks(a.slug, a.name, artistSlug, artistName),
button: {
title: 'Add album to queue',
onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => {
ev.stopPropagation()
void addAlbumToQueue(a.slug)
},
},
}
})
setLibraryItems([allTracksItem, ...albumItems])
} catch {
// Error is stored in albums.error
}
}
async function showAlbumTracks(
albumSlug: string,
albumName: string,
artistSlug: string,
artistName: string,
) {
setBreadcrumb([
{ label: 'Artists', action: showArtists },
{ label: artistName, action: () => showArtistAlbums(artistSlug, artistName) },
{ label: albumName },
])
const result = await dispatch(fetchAlbumTracks(albumSlug))
if (result.meta.requestStatus === 'rejected') return
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
const playAlbumItem = {
key: `album-play:${albumSlug}`,
className: 'file-item',
icon: '▶',
name: 'Play album',
onClick: () => {
void addAlbumToQueue(albumSlug, true)
},
}
const trackItems = tracks.map((t) => {
const num = t.track_number ? `${t.track_number}. ` : ''
const dur = t.duration_secs ? fmt(t.duration_secs) : ''
return {
key: `track:${t.slug}`,
className: 'file-item',
icon: '🎵',
name: `${num}${t.title}`,
detail: dur,
onClick: () => {
addTrackToQueue(
{
slug: t.slug,
title: t.title,
artist: t.artist_name,
album_slug: albumSlug,
duration: t.duration_secs,
},
true,
)
},
}
})
setLibraryItems([playAlbumItem, ...trackItems])
}
function setBreadcrumb(parts: Crumb[]) {
setBreadcrumbs(parts)
}
function addTrackToQueue(
track: {
slug: string
title: string
artist: string
album_slug: string | null
duration: number | null
},
playNow?: boolean,
) {
const prevIdx = store.getState().queue.currentIndex
dispatch(addTrack({ track, playNow }))
const q = store.getState().queue
if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) {
playIndex(q.currentIndex)
}
}
async function addAlbumToQueue(albumSlug: string, playFirst?: boolean) {
const result = await dispatch(fetchAlbumTracks(albumSlug))
if (result.meta.requestStatus === 'rejected') return
const { tracks } = result.payload as { albumSlug: string; tracks: Track[] }
if (!tracks || !tracks.length) return
const list = tracks.map((t) => ({
slug: t.slug,
title: t.title,
artist: t.artist_name,
album_slug: t.album_slug,
duration: t.duration_secs,
}))
const prevIdx = store.getState().queue.currentIndex
dispatch(addTracksBatch({ tracks: list, playFirst }))
const q = store.getState().queue
if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) {
playIndex(q.currentIndex)
}
showToast(`Added ${list.length} tracks`)
}
async function playAllArtistTracks(artistSlug: string) {
const result = await dispatch(fetchArtistTracks(artistSlug))
if (result.meta.requestStatus === 'rejected') return
const { tracks } = result.payload as { artistSlug: string; tracks: Track[] }
if (!tracks || !tracks.length) return
const list = tracks.map((t) => ({
slug: t.slug,
title: t.title,
artist: t.artist_name,
album_slug: t.album_slug,
duration: t.duration_secs,
}))
dispatch(replaceQueue({ items: list, playFromIndex: 0 }))
playIndex(0)
showToast(`Added ${list.length} tracks`)
}
const playback = attachAudioPlayback(audio, {
onEnded: nextTrack,
onErrorSkip: nextTrack,
onToast: showToast,
})
function playIndex(i: number) {
const q = store.getState().queue
if (i < 0 || i >= q.items.length) return
dispatch(playAtIndex(i))
const track = store.getState().queue.items[i]
void playback.loadStreamForTrack(track.slug)
if (window.history && window.history.replaceState) {
const url = new URL(window.location.href)
url.searchParams.set('t', track.slug)
window.history.replaceState(null, '', url.toString())
}
}
function removeFromQueue(idx: number) {
const wasPlaying = store.getState().queue.currentIndex === idx
dispatch(removeFromQueueAt(idx))
if (wasPlaying) playback.pauseAndClearSource()
}
function moveQueueItem(fromPos: number, toPos: number) {
dispatch(moveQueueItemInOrder({ fromPos, toPos }))
}
function clearQueuePlayback() {
dispatch(clearQueue())
playback.pauseAndClearSource()
}
function nextTrack() {
const q = store.getState().queue
if (!q.items.length) return
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)
})
}
queueActionsRef.current = {
playIndex,
removeFromQueue,
moveQueueItem,
clearQueue: clearQueuePlayback,
}
function onSearch(q: string) {
if (searchTimer) {
window.clearTimeout(searchTimer)
}
if (q.length < 2) {
closeSearch()
return
}
searchTimer = window.setTimeout(async () => {
const results = await searchTracks(q)
if (!results || !(results as any[]).length) {
closeSearch()
return
}
setSearchResults(results as any[])
setSearchOpen(true)
}, 250)
}
function closeSearch() {
setSearchOpen(false)
setSearchResults([])
}
function onSearchSelect(type: string, slug: string) {
closeSearch()
if (type === 'artist') void showArtistAlbums(slug, '')
else if (type === 'album') void addAlbumToQueue(slug, true)
else if (type === 'track') {
addTrackToQueue(
{ slug, title: '', artist: '', album_slug: null, duration: null },
true,
)
void preloadStream(slug)
}
}
searchSelectRef.current = onSearchSelect
function toggleSidebar() {
const sidebar = document.getElementById('sidebar')
const overlay = document.getElementById('sidebarOverlay')
sidebar?.classList.toggle('open')
overlay?.classList.toggle('show')
}
const onMediaSeekTo = (d: { seekTime?: number }) => {
if (typeof d.seekTime === 'number') {
playback.seekToTime(d.seekTime)
}
}
if ('mediaSession' in navigator) {
try {
navigator.mediaSession.setActionHandler('play', togglePlay)
navigator.mediaSession.setActionHandler('pause', togglePlay)
navigator.mediaSession.setActionHandler('previoustrack', prevTrack)
navigator.mediaSession.setActionHandler('nexttrack', nextTrack)
navigator.mediaSession.setActionHandler('seekto', onMediaSeekTo as (d: any) => void)
} catch {
// ignore
}
}
const onMenuClick = () => toggleSidebar()
const btnMenu = document.querySelector('.btn-menu')
btnMenu?.addEventListener('click', onMenuClick)
const onSidebarOverlayClick = () => toggleSidebar()
const sidebarOverlay = document.getElementById('sidebarOverlay')
sidebarOverlay?.addEventListener('click', onSidebarOverlayClick)
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) {
searchInput.addEventListener('input', onSearchInput)
searchInput.addEventListener('keydown', onSearchKeydown)
}
const onPrevClick = () => prevTrack()
const onPlayClick = () => togglePlay()
const onNextClick = () => nextTrack()
const btnPrev = document.getElementById('btnPrev')
btnPrev?.addEventListener('click', onPrevClick)
const btnPlay = document.getElementById('btnPlayPause')
btnPlay?.addEventListener('click', onPlayClick)
const btnNext = document.getElementById('btnNext')
btnNext?.addEventListener('click', onNextClick)
; (async () => {
const url = new URL(window.location.href)
const urlSlug = url.searchParams.get('t')
if (urlSlug) {
try {
const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap()
addTrackToQueue(
{
slug: detail.slug,
title: detail.title,
artist: detail.artist_name,
album_slug: detail.album_slug,
duration: detail.duration_secs,
},
true,
)
} catch {
// fetchTrackDetail rejected — track not found or error
}
}
void showArtists()
})()
return () => {
queueActionsRef.current = null
playback.dispose()
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
}
}
}
}, [dispatch])
const libraryLoading =
breadcrumbs.length === 1
? artistsLoading
: breadcrumbs.length === 2
? albumsLoading
: albumTracksLoading
const libraryError =
breadcrumbs.length === 1
? artistsError
: breadcrumbs.length === 2
? albumsError
: albumTracksError
return (
<div className="furumi-root">
<Header
searchOpen={searchOpen}
searchResults={searchResults}
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
/>
<MainPanel
breadcrumbs={breadcrumbs}
libraryLoading={libraryLoading}
libraryError={libraryError}
libraryItems={libraryItems}
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
onQueueRemove={(origIdx) =>
queueActionsRef.current?.removeFromQueue(origIdx)
}
onQueueMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
}
onClearQueue={() => queueActionsRef.current?.clearQueue()}
/>
<PlayerBar
track={nowPlayingTrack}
queue={queueItemsView}
order={queueOrderView}
playingOrigIdx={queuePlayingOrigIdxView}
scrollSignal={queueScrollSignal}
onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)}
onQueueRemove={(origIdx) =>
queueActionsRef.current?.removeFromQueue(origIdx)
}
onQueueMove={(fromPos, toPos) =>
queueActionsRef.current?.moveQueueItem(fromPos, toPos)
}
/>
<div className="toast" id="toast" />
<audio ref={audioRef} />
</div>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,190 @@
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,
}
}
@@ -0,0 +1,30 @@
type Crumb = {
label: string
action?: () => void
}
type BreadcrumbsProps = {
items: Crumb[]
}
export function Breadcrumbs({ items }: BreadcrumbsProps) {
if (!items.length) return null
return (
<div className="breadcrumb">
{items.map((item, index) => {
const isLast = index === items.length - 1
return (
<span key={`${item.label}-${index}`}>
{!isLast && item.action ? (
<span onClick={item.action}>{item.label}</span>
) : (
<span>{item.label}</span>
)}
{!isLast ? ' / ' : ''}
</span>
)
})}
</div>
)
}
@@ -0,0 +1,54 @@
import type { MouseEvent } from 'react'
type LibraryListButton = {
title: string
onClick: (ev: MouseEvent<HTMLButtonElement>) => void
}
type LibraryListItem = {
key: string
className: string
icon: string
name: string
detail?: string
nameClassName?: string
onClick: () => void
button?: LibraryListButton
}
type LibraryListProps = {
loading: boolean
error: string | null
items: LibraryListItem[]
}
export function LibraryList({ loading, error, items }: LibraryListProps) {
if (loading) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</div>
)
}
if (error) {
return <div style={{ padding: '1rem', color: 'var(--danger)' }}>{error}</div>
}
return (
<>
{items.map((item) => (
<div key={item.key} className={item.className} onClick={item.onClick}>
<span className="icon">{item.icon}</span>
<span className={item.nameClassName ?? 'name'}>{item.name}</span>
{item.detail ? <span className="detail">{item.detail}</span> : null}
{item.button ? (
<button className="add-btn" title={item.button.title} onClick={item.button.onClick}>
&#10133;
</button>
) : null}
</div>
))}
</>
)
}
@@ -0,0 +1,107 @@
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>
)
}
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import { API_ROOT } from '../furumiApi'
import type { QueueItem } from './QueueList'
function Cover({ src }: { src: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
}
export function NowPlaying({ track }: { track: QueueItem | null }) {
if (!track) {
return (
<div className="np-info">
<div className="np-cover" id="npCover">
&#127925;
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
Nothing playing
</div>
<div className="np-artist" id="npArtist">
&mdash;
</div>
</div>
</div>
)
}
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
return (
<div className="np-info">
<div className="np-cover" id="npCover">
<Cover src={coverUrl} />
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
{track.title}
</div>
<div className="np-artist" id="npArtist">
{track.artist || '—'}
</div>
</div>
</div>
)
}
@@ -0,0 +1,77 @@
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>
)
}
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from 'react'
import { API_ROOT } from '../furumiApi'
export type QueueItem = {
slug: string
title: string
artist: string
album_slug: string | null
duration: number | null
}
type QueueListProps = {
queue: QueueItem[]
order: number[]
playingOrigIdx: number
scrollSignal: number
onPlay: (origIdx: number) => void
onRemove: (origIdx: number) => void
onMove: (fromPos: number, toPos: number) => void
}
function pad(n: number) {
return String(n).padStart(2, '0')
}
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 Cover({ src }: { src: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
}
export function QueueList({
queue,
order,
playingOrigIdx,
scrollSignal,
onPlay,
onRemove,
onMove,
}: QueueListProps) {
const playingRef = useRef<HTMLDivElement | null>(null)
const [draggingPos, setDraggingPos] = useState<number | null>(null)
const [dragOverPos, setDragOverPos] = useState<number | null>(null)
useEffect(() => {
if (playingRef.current) {
playingRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, [playingOrigIdx, scrollSignal])
if (!queue.length) {
return (
<div className="queue-empty">
<div className="empty-icon">&#127925;</div>
<div>Select an album to start</div>
</div>
)
}
return (
<>
{order.map((origIdx, pos) => {
const t = queue[origIdx]
if (!t) return null
const isPlaying = origIdx === playingOrigIdx
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
const dur = t.duration ? fmt(t.duration) : ''
const isDragging = draggingPos === pos
const isDragOver = dragOverPos === pos
return (
<div
key={`${t.slug}:${pos}`}
ref={isPlaying ? playingRef : null}
className={`queue-item${isPlaying ? ' playing' : ''}${isDragging ? ' dragging' : ''}${
isDragOver ? ' drag-over' : ''
}`}
draggable
onClick={() => onPlay(origIdx)}
onDragStart={(e) => {
setDraggingPos(pos)
e.dataTransfer?.setData('text/plain', String(pos))
}}
onDragEnd={() => {
setDraggingPos(null)
setDragOverPos(null)
}}
onDragOver={(e) => {
e.preventDefault()
}}
onDragEnter={() => {
setDragOverPos(pos)
}}
onDragLeave={() => {
setDragOverPos((cur) => (cur === pos ? null : cur))
}}
onDrop={(e) => {
e.preventDefault()
setDragOverPos(null)
const from = parseInt(e.dataTransfer?.getData('text/plain') ?? '', 10)
if (!Number.isNaN(from)) onMove(from, pos)
setDraggingPos(null)
}}
>
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
<div className="qi-cover">
{coverSrc ? <Cover src={coverSrc} /> : <>&#127925;</>}
</div>
<div className="qi-info">
<div className="qi-title">{t.title}</div>
<div className="qi-artist">{t.artist || ''}</div>
</div>
<span className="qi-dur">{dur}</span>
<button
className="qi-remove"
onClick={(e) => {
e.stopPropagation()
onRemove(origIdx)
}}
>
&#10005;
</button>
</div>
)
})}
</>
)
}
@@ -0,0 +1,30 @@
type SearchResultItem = {
result_type: string
slug: string
name: string
detail?: string
}
type SearchDropdownProps = {
isOpen: boolean
results: SearchResultItem[]
onSelect: (type: string, slug: string) => void
}
export function SearchDropdown({ isOpen, results, onSelect }: SearchDropdownProps) {
return (
<div className={`search-dropdown${isOpen ? ' open' : ''}`}>
{results.map((r) => (
<div
key={`${r.result_type}:${r.slug}`}
className="search-result"
onClick={() => onSelect(r.result_type, r.slug)}
>
<span className="sr-type">{r.result_type}</span>
{r.name}
{r.detail ? <span className="sr-detail">{r.detail}</span> : null}
</div>
))}
</div>
)
}
@@ -0,0 +1,46 @@
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>
)
}
@@ -0,0 +1,35 @@
.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;
}
@@ -0,0 +1 @@
export * from './Header'
@@ -0,0 +1 @@
export * from './queue-popover'
@@ -0,0 +1,68 @@
.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;
}
@@ -0,0 +1,86 @@
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>
)
}
@@ -0,0 +1,725 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
.furumi-root,
.furumi-root * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.furumi-root {
height: 100%;
display: flex;
flex-direction: column;
font-family: 'Inter', system-ui, sans-serif;
}
:root {
--bg-base: #0a0c12;
--bg-panel: #111520;
--bg-card: #161d2e;
--bg-hover: #1e2740;
--bg-active: #252f4a;
--border: #1f2c45;
--accent: #7c6af7;
--accent-dim: #5a4fcf;
--accent-glow: rgba(124, 106, 247, 0.3);
--text: #e2e8f0;
--text-muted: #64748b;
--text-dim: #94a3b8;
--success: #34d399;
--danger: #f87171;
}
.btn-menu {
display: none;
background: none;
border: none;
color: var(--text);
font-size: 1.2rem;
cursor: pointer;
padding: 0.1rem 0.5rem;
margin-right: 0.2rem;
border-radius: 4px;
}
.search-wrap {
position: relative;
}
.search-wrap input {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px 6px 30px;
color: var(--text);
font-size: 13px;
width: 220px;
font-family: inherit;
}
.search-wrap::before {
content: '🔍';
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
}
.search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0 0 6px 6px;
max-height: 300px;
overflow-y: auto;
z-index: 50;
display: none;
}
.search-dropdown.open {
display: block;
}
.search-result {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
border-bottom: 1px solid var(--border);
}
.search-result:hover {
background: var(--bg-hover);
}
.search-result .sr-type {
font-size: 10px;
color: var(--text-muted);
text-transform: uppercase;
margin-right: 6px;
}
.search-result .sr-detail {
font-size: 11px;
color: var(--text-muted);
margin-left: 4px;
}
.main {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
background: var(--bg-base);
color: var(--text);
}
.sidebar-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 20;
}
.sidebar-overlay.show {
display: block;
}
.sidebar {
width: 280px;
min-width: 200px;
max-width: 400px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
border-right: 1px solid var(--border);
overflow: hidden;
resize: horizontal;
}
.sidebar-header {
padding: 0.85rem 1rem 0.6rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.breadcrumb {
padding: 0.5rem 1rem;
font-size: 0.78rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.breadcrumb span {
color: var(--accent);
cursor: pointer;
}
.breadcrumb span:hover {
text-decoration: underline;
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 0.3rem 0;
}
.file-list::-webkit-scrollbar {
width: 4px;
}
.file-list::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.file-item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 1rem;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-dim);
user-select: none;
transition: background 0.12s;
}
.file-item:hover {
background: var(--bg-hover);
color: var(--text);
}
.file-item.dir {
color: var(--accent);
}
.file-item .icon {
font-size: 0.95rem;
flex-shrink: 0;
opacity: 0.8;
}
.file-item .name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-item .detail {
font-size: 0.7rem;
color: var(--text-muted);
flex-shrink: 0;
}
.file-item .add-btn {
opacity: 0;
font-size: 0.75rem;
background: var(--bg-hover);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.2rem 0.4rem;
cursor: pointer;
flex-shrink: 0;
}
.file-item:hover .add-btn {
opacity: 1;
}
.file-item .add-btn:hover {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.queue-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-base);
}
.queue-header {
padding: 0.85rem 1.25rem 0.6rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
.queue-actions {
display: flex;
gap: 0.5rem;
}
.queue-btn {
font-size: 0.7rem;
padding: 0.2rem 0.55rem;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-muted);
cursor: pointer;
}
.queue-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.queue-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.queue-list {
flex: 1;
overflow-y: auto;
padding: 0.3rem 0;
}
.queue-list::-webkit-scrollbar {
width: 4px;
}
.queue-list::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 4px;
}
.queue-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.55rem 1.25rem;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.12s;
}
.queue-item:hover {
background: var(--bg-hover);
}
.queue-item.playing {
background: var(--bg-active);
border-left-color: var(--accent);
}
.queue-item.playing .qi-title {
color: var(--accent);
}
.qi-title {
color: #ffffff;
}
.queue-item .qi-index {
font-size: 0.75rem;
color: var(--text-muted);
width: 1.5rem;
text-align: right;
flex-shrink: 0;
}
.queue-item.playing .qi-index::before {
content: '▶';
font-size: 0.6rem;
color: var(--accent);
}
.queue-item .qi-cover {
width: 36px;
height: 36px;
border-radius: 5px;
background: var(--bg-card);
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.queue-item .qi-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.queue-item .qi-info {
flex: 1;
overflow: hidden;
}
.queue-item .qi-title {
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-item .qi-artist {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.queue-item .qi-dur {
font-size: 0.75rem;
color: var(--text-muted);
margin-left: auto;
margin-right: 0.5rem;
}
.qi-remove {
background: none;
border: none;
font-size: 0.9rem;
color: var(--text-muted);
cursor: pointer;
padding: 0.3rem;
border-radius: 4px;
opacity: 0;
}
.queue-item:hover .qi-remove {
opacity: 1;
}
.qi-remove:hover {
background: rgba(248, 113, 113, 0.15);
color: var(--danger);
}
.queue-item.dragging {
opacity: 0.5;
}
.queue-item.drag-over {
border-top: 2px solid var(--accent);
margin-top: -2px;
}
.queue-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: 0.875rem;
gap: 0.5rem;
padding: 2rem;
}
.queue-empty .empty-icon {
font-size: 2.5rem;
opacity: 0.3;
}
.player-bar {
background: var(--bg-panel);
border-top: 1px solid var(--border);
padding: 0.9rem 1.5rem;
flex-shrink: 0;
display: grid;
grid-template-columns: 1fr 2fr 1fr;
align-items: center;
gap: 1rem;
}
.np-info {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.np-cover {
width: 44px;
height: 44px;
border-radius: 6px;
background: var(--bg-card);
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.np-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.np-text {
min-width: 0;
}
.np-title {
font-size: 0.875rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #ffffff;
}
.np-artist {
font-size: 0.75rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.ctrl-btns {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ctrl-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 0.35rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.ctrl-btn:hover {
color: var(--text);
background: var(--bg-hover);
}
.ctrl-btn.active {
color: var(--accent);
}
.ctrl-btn-main {
width: 38px;
height: 38px;
background: var(--accent);
color: #fff !important;
font-size: 1.1rem;
box-shadow: 0 0 14px var(--accent-glow);
}
.ctrl-btn-main:hover {
background: var(--accent-dim) !important;
}
.progress-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
}
.time {
font-size: 0.7rem;
color: var(--text-muted);
flex-shrink: 0;
font-variant-numeric: tabular-nums;
min-width: 2.5rem;
text-align: center;
}
.progress-bar {
flex: 1;
height: 4px;
background: var(--bg-hover);
border-radius: 2px;
cursor: pointer;
position: relative;
}
.progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
pointer-events: none;
}
.progress-fill::after {
content: '';
position: absolute;
right: -5px;
top: 50%;
transform: translateY(-50%);
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
opacity: 0;
transition: opacity 0.15s;
}
.progress-bar:hover .progress-fill::after {
opacity: 1;
}
.volume-row {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.vol-icon {
font-size: 0.9rem;
color: var(--text-muted);
cursor: pointer;
}
.volume-slider {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
border-radius: 2px;
background: var(--bg-hover);
cursor: pointer;
outline: none;
}
.volume-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
cursor: pointer;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.toast {
position: fixed;
bottom: 90px;
right: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 1rem;
font-size: 0.8rem;
color: var(--text-dim);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
opacity: 0;
transform: translateY(8px);
transition: all 0.25s;
pointer-events: none;
z-index: 100;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.btn-menu {
display: inline-block;
}
.header {
padding: 0.75rem 1rem;
}
.sidebar {
position: absolute;
top: 0;
bottom: 0;
left: -100%;
width: 85%;
max-width: 320px;
z-index: 30;
transition: left 0.3s;
box-shadow: 4px 0 20px rgba(0, 0, 0, 0.6);
}
.sidebar.open {
left: 0;
}
.player-bar {
grid-template-columns: 1fr;
gap: 0.75rem;
padding: 0.75rem 1rem;
}
.volume-row {
display: none;
}
.search-wrap input {
width: 140px;
}
}
@@ -0,0 +1,54 @@
import axios from 'axios'
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? ''
export const API_ROOT = `${FURUMI_API_BASE}/api`
export const furumiApi = axios.create({
baseURL: API_ROOT,
})
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)
}
+24
View File
@@ -0,0 +1,24 @@
html,
body {
height: 100%;
}
body {
margin: 0;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
color: #0f172a;
background-color: #f3f6fb;
}
#root {
height: 100%;
}
* {
box-sizing: border-box;
}
h1 {
margin-top: 0;
margin-bottom: 10px;
}
+14
View File
@@ -0,0 +1,14 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { Provider } from 'react-redux'
import { store } from './store'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
)
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,54 @@
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
@@ -0,0 +1,274 @@
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
@@ -0,0 +1,57 @@
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
@@ -0,0 +1,42 @@
// 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
@@ -0,0 +1,14 @@
function pad(n: number) {
return String(n).padStart(2, '0')
}
export function fmt(secs: number) {
if (!secs || Number.isNaN(secs)) return '0:00'
const s = Math.floor(secs)
const m = Math.floor(s / 60)
const h = Math.floor(m / 60)
if (h > 0) {
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
}
return `${m}:${pad(s % 60)}`
}
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/auth': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/callback': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})
+12
View File
@@ -0,0 +1,12 @@
PORT=3001
BASE_URL=http://localhost:3001
FRONTEND_ORIGIN=http://localhost:5173
SESSION_SECRET=super-long-random-secret
# Если true/1/on/yes — сервер стартует без OIDC и не требует авторизации.
DISABLE_AUTH=false
OIDC_ISSUER_BASE_URL=https://your-issuer.example.com
OIDC_CLIENT_ID=your-client-id
OIDC_CLIENT_SECRET=your-client-secret
OIDC_SCOPE="openid profile email"
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "server",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-openid-connect": "^2.19.4"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^25.5.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}
+135
View File
@@ -0,0 +1,135 @@
import 'dotenv/config';
import path from 'path';
import cors from 'cors';
import express from 'express';
import { auth } from 'express-openid-connect';
const app = express();
const port = Number(process.env.PORT ?? 3001);
const frontendOrigin = process.env.FRONTEND_ORIGIN ?? 'http://localhost:5173';
const disableAuth = ['1', 'true', 'yes', 'on'].includes(
String(process.env.DISABLE_AUTH ?? '').trim().toLowerCase(),
);
const oidcConfig = {
authRequired: false,
auth0Logout: false,
secret: process.env.SESSION_SECRET ?? 'change-me-in-env',
baseURL: process.env.BASE_URL ?? `http://localhost:${port}`,
clientID: process.env.OIDC_CLIENT_ID ?? '',
issuerBaseURL: process.env.OIDC_ISSUER_BASE_URL ?? '',
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
authorizationParams: {
response_type: 'code',
scope: process.env.OIDC_SCOPE ?? 'openid profile email',
},
};
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
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)',
);
}
app.use(
cors({
origin: frontendOrigin,
credentials: true,
}),
);
app.use(express.json());
if (!disableAuth) {
app.use(auth(oidcConfig));
}
app.get('/auth/health', (_req, res) => {
res.json({ ok: true });
});
app.get('/auth/me', (req, res) => {
if (disableAuth) {
res.json({
authenticated: false,
bypassAuth: true,
user: {
sub: 'noauth',
name: 'No Auth',
},
});
return;
}
if (!req.oidc.isAuthenticated()) {
res.status(401).json({ authenticated: false });
return;
}
res.json({
authenticated: true,
user: req.oidc.user,
});
});
app.get('/auth/token', (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) {
res.status(204).end();
return;
}
res.oidc.login({
returnTo: frontendOrigin,
});
});
app.get('/auth/logout', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
}
res.oidc.logout({
returnTo: frontendOrigin,
});
});
// 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, () => {
console.log(
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
);
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}
+3 -1
View File
@@ -18,10 +18,12 @@ 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"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
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"] }
+6 -1
View File
@@ -92,6 +92,7 @@ pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Er
FROM artists ar FROM artists ar
LEFT JOIN albums al ON al.artist_id = ar.id LEFT JOIN albums al ON al.artist_id = ar.id
LEFT JOIN tracks t ON t.artist_id = ar.id LEFT JOIN tracks t ON t.artist_id = ar.id
WHERE NOT ar.hidden
GROUP BY ar.id, ar.slug, ar.name GROUP BY ar.id, ar.slug, ar.name
HAVING COUNT(DISTINCT t.id) > 0 HAVING COUNT(DISTINCT t.id) > 0
ORDER BY ar.name"# ORDER BY ar.name"#
@@ -116,8 +117,10 @@ pub async fn list_albums_by_artist(pool: &PgPool, artist_slug: &str) -> Result<V
EXISTS(SELECT 1 FROM album_images ai WHERE ai.album_id = al.id AND ai.image_type = 'cover') AS has_cover EXISTS(SELECT 1 FROM album_images ai WHERE ai.album_id = al.id AND ai.image_type = 'cover') AS has_cover
FROM albums al FROM albums al
JOIN artists ar ON al.artist_id = ar.id JOIN artists ar ON al.artist_id = ar.id
LEFT JOIN tracks t ON t.album_id = al.id LEFT JOIN tracks t ON t.album_id = al.id AND NOT t.hidden
WHERE ar.slug = $1 WHERE ar.slug = $1
AND NOT al.hidden
AND EXISTS (SELECT 1 FROM tracks t2 WHERE t2.album_id = al.id AND NOT t2.hidden)
GROUP BY al.id, al.slug, al.name, al.year GROUP BY al.id, al.slug, al.name, al.year
ORDER BY al.year NULLS LAST, al.name"# ORDER BY al.year NULLS LAST, al.name"#
) )
@@ -135,6 +138,7 @@ pub async fn list_tracks_by_album(pool: &PgPool, album_slug: &str) -> Result<Vec
JOIN artists ar ON t.artist_id = ar.id JOIN artists ar ON t.artist_id = ar.id
LEFT JOIN albums al ON t.album_id = al.id LEFT JOIN albums al ON t.album_id = al.id
WHERE al.slug = $1 WHERE al.slug = $1
AND NOT t.hidden
ORDER BY t.track_number NULLS LAST, t.title"# ORDER BY t.track_number NULLS LAST, t.title"#
) )
.bind(album_slug) .bind(album_slug)
@@ -221,6 +225,7 @@ pub async fn list_all_tracks_by_artist(pool: &PgPool, artist_slug: &str) -> Resu
JOIN artists ar ON t.artist_id = ar.id JOIN artists ar ON t.artist_id = ar.id
LEFT JOIN albums al ON t.album_id = al.id LEFT JOIN albums al ON t.album_id = al.id
WHERE ar.slug = $1 WHERE ar.slug = $1
AND NOT t.hidden
ORDER BY al.year NULLS LAST, al.name, t.track_number NULLS LAST, t.title"# ORDER BY al.year NULLS LAST, al.name, t.track_number NULLS LAST, t.title"#
) )
.bind(artist_slug) .bind(artist_slug)
+1
View File
@@ -39,6 +39,7 @@ 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]
+101 -87
View File
@@ -3,8 +3,9 @@ use axum::{
extract::{Request, State}, extract::{Request, State},
http::{header, HeaderMap, StatusCode}, http::{header, HeaderMap, StatusCode},
middleware::Next, middleware::Next,
response::{Html, IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use openidconnect::{ use openidconnect::{
core::{CoreClient, CoreProviderMetadata, CoreResponseType}, core::{CoreClient, CoreProviderMetadata, CoreResponseType},
reqwest::async_http_client, reqwest::async_http_client,
@@ -16,17 +17,26 @@ 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(
@@ -42,6 +52,9 @@ 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),
@@ -60,12 +73,70 @@ 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());
@@ -92,59 +163,46 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
} }
} }
/// Auth middleware: requires valid SSO session cookie. /// Auth middleware: requires valid Bearer JWT or 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 {
let oidc = match &state.oidc { // 1. Check Bearer token — JWT from OIDC provider
Some(o) => o, if let Some(ref oidc) = state.oidc {
None => return next.run(req).await, // No OIDC configured = no auth if let Some(token) = req
}; .headers()
.get(header::AUTHORIZATION)
let cookies = req .and_then(|v| v.to_str().ok())
.headers() .and_then(|v| v.strip_prefix("Bearer "))
.get(header::COOKIE) {
.and_then(|v| v.to_str().ok()) if let Some(user_id) = validate_bearer_token(oidc, token).await {
.unwrap_or(""); tracing::debug!("Bearer auth OK for user: {}", user_id);
for c in cookies.split(';') {
let c = c.trim();
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await; return next.run(req).await;
} }
} }
} }
let uri = req.uri().to_string(); // 2. Check SSO session cookie (if OIDC configured)
if uri.starts_with("/api/") { if let Some(ref oidc) = state.oidc {
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response() let cookies = req
} else { .headers()
Redirect::to("/login").into_response() .get(header::COOKIE)
} .and_then(|v| v.to_str().ok())
} .unwrap_or("");
/// GET /login — show SSO login page. for c in cookies.split(';') {
pub async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse { let c = c.trim();
if state.oidc.is_none() { if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
return Redirect::to("/").into_response(); if verify_sso_cookie(&oidc.session_secret, val).is_some() {
return next.run(req).await;
}
}
}
} }
Html(LOGIN_HTML).into_response() (StatusCode::UNAUTHORIZED, "Unauthorized").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)]
@@ -319,9 +377,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=Strict; Secure" "SameSite=Lax; Secure"
} else { } else {
"SameSite=Strict" "SameSite=Lax"
}; };
let session_cookie = format!( let session_cookie = format!(
@@ -338,47 +396,3 @@ 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,9 +3,12 @@ pub mod auth;
use std::sync::Arc; use std::sync::Arc;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Duration;
use axum::{Router, routing::get, middleware}; use axum::{Router, routing::get, middleware};
use axum::http::{header, Method};
use sqlx::PgPool; use sqlx::PgPool;
use tower_http::cors::{Any, CorsLayer};
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -28,30 +31,27 @@ 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 authed = Router::new() let api = Router::new()
.route("/", get(player_html))
.nest("/api", library); .nest("/api", library);
let has_oidc = state.oidc.is_some(); let requires_auth = state.oidc.is_some();
let app = if has_oidc { let app = if requires_auth {
authed api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
} else { } else {
authed api
}; };
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)
}