Compare commits
51 Commits
03f95cfd05
...
DEV
| Author | SHA1 | Date | |
|---|---|---|---|
| 9467737a7c | |||
| 2edc293ee0 | |||
| 5c7f940b7e | |||
| 4a39d44211 | |||
| d3aba1152c | |||
| c11b71a0ef | |||
| ea2fc53faf | |||
| d6dd046fad | |||
| 3fa79423bd | |||
| 6b1aa6b5d5 | |||
| 4fdd56dae4 | |||
| 5bc2b55ffd | |||
| ed918b9373 | |||
| 1ea5f66ea3 | |||
| 7bc7de44cf | |||
| befba57374 | |||
| a9a8ee81b8 | |||
| 1df10fb0b7 | |||
| b1f75b3ee2 | |||
| 5a5c9967e1 | |||
| f3392eff9f | |||
| e920059125 | |||
| e99cacae8b | |||
| 48c473de56 | |||
| 1e75644abb | |||
| 2d7ac3d8ce | |||
| 70a947a8c1 | |||
| 94d14e8fc8 | |||
| 0b6f518b72 | |||
| 3199c12af5 | |||
| daaa3b0814 | |||
| e42566f44e | |||
| 30c6400354 | |||
| 480880f292 | |||
| 83a145d0a8 | |||
| aea4aef4b2 | |||
| 8ceee6028a | |||
| 3491c52793 | |||
| f0e1bbc7f8 | |||
| 8f38e27eb0 | |||
| 8cac2d1160 | |||
| 5a5dab85d0 | |||
| 310f0061d3 | |||
| f26135ca25 | |||
| 71d5a38f21 | |||
| 8d70a5133a | |||
| 56760be586 | |||
| 108c374c6d | |||
| 2129dc8007 | |||
| cc3ef04cbe | |||
| a730ab568c |
@@ -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/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.agent
|
||||
file: docker/Dockerfile.agent
|
||||
push: true
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
build-args: |
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.agent
|
||||
file: docker/Dockerfile.agent
|
||||
push: true
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
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
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web-player
|
||||
file: docker/Dockerfile.web-player
|
||||
push: true
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
build-args: |
|
||||
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web-player
|
||||
file: docker/Dockerfile.web-player
|
||||
push: true
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
build-args: |
|
||||
|
||||
@@ -51,6 +51,7 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.info.outputs.tags }}
|
||||
build-args: |
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
/inbox
|
||||
/storage
|
||||
/docker/inbox
|
||||
/docker/storage
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
Generated
+72
-1
@@ -2,6 +2,12 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -572,6 +578,15 @@ version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.15"
|
||||
@@ -969,6 +984,16 @@ version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1017,6 +1042,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"encoding_rs",
|
||||
"id3",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1114,7 +1140,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"hmac",
|
||||
"jsonwebtoken",
|
||||
"jsonwebtoken 10.3.0",
|
||||
"libc",
|
||||
"mime_guess",
|
||||
"ogg",
|
||||
@@ -1150,8 +1176,10 @@ dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"clap",
|
||||
"hmac",
|
||||
"jsonwebtoken 9.3.1",
|
||||
"mime_guess",
|
||||
"openidconnect",
|
||||
"rand 0.8.5",
|
||||
@@ -1165,6 +1193,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
@@ -1748,6 +1777,17 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "id3"
|
||||
version = "1.16.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"byteorder",
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
@@ -1864,6 +1904,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "jsonwebtoken"
|
||||
version = "10.3.0"
|
||||
@@ -2021,6 +2076,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
@@ -3412,6 +3477,12 @@ dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.agent
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.agent
|
||||
container_name: furumi-agent
|
||||
depends_on:
|
||||
db:
|
||||
@@ -25,10 +25,12 @@ services:
|
||||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
RUST_LOG: info
|
||||
FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
||||
FURUMI_AGENT_INBOX_DIR: "/inbox"
|
||||
FURUMI_AGENT_STORAGE_DIR: "/storage"
|
||||
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_PLAYER_BIND: "0.0.0.0:8090"
|
||||
FURUMI_AGENT_POLL_INTERVAL_SECS: 5
|
||||
@@ -41,8 +43,8 @@ services:
|
||||
|
||||
web-player:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.web-player
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.web-player
|
||||
container_name: furumi-web-player
|
||||
depends_on:
|
||||
db:
|
||||
@@ -53,6 +55,11 @@ services:
|
||||
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
||||
FURUMI_PLAYER_STORAGE_DIR: "/storage"
|
||||
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:
|
||||
- ./storage:/storage
|
||||
restart: always
|
||||
@@ -14,6 +14,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
|
||||
id3 = "1"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.50", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
display_name TEXT,
|
||||
email TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE play_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
track_id BIGINT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
played_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_play_events_user_id ON play_events(user_id);
|
||||
CREATE INDEX idx_play_events_track_id ON play_events(track_id);
|
||||
CREATE INDEX idx_play_events_user_track ON play_events(user_id, track_id);
|
||||
CREATE INDEX idx_play_events_played_at ON play_events(played_at DESC);
|
||||
@@ -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:
|
||||
|
||||
### Artist ID 42: "pink floyd"
|
||||
Album ID 10: "the wall" (1979)
|
||||
- 01. "In the Flesh?" [track_id=100]
|
||||
- 02. "The Thin Ice" [track_id=101]
|
||||
### Artist ID 42: "deep purple"
|
||||
Album ID 10: "machine head" (1972)
|
||||
- 01. "Highway Star" [track_id=100]
|
||||
- 02. "Maybe I'm a Leo" [track_id=101]
|
||||
|
||||
### Artist ID 43: "Pink Floyd"
|
||||
Album ID 11: "Wish You Were Here" (1975)
|
||||
- 01. "Shine On You Crazy Diamond (Parts I-V)" [track_id=200]
|
||||
### Artist ID 43: "Deep Purple"
|
||||
Album ID 11: "Burn" (1974)
|
||||
- 01. "Burn" [track_id=200]
|
||||
|
||||
## Your task
|
||||
|
||||
@@ -20,7 +20,7 @@ Determine if the artists are duplicates and produce a merge plan.
|
||||
## Rules
|
||||
|
||||
### 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 one artist has clearly more tracks or albums, their name spelling may be more authoritative.
|
||||
- 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:
|
||||
|
||||
{"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.
|
||||
- `winner_artist_id`: the integer ID of the artist whose record survives (must be one of the IDs provided).
|
||||
|
||||
@@ -3,10 +3,10 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
||||
## Rules
|
||||
|
||||
1. **Artist names** must use correct capitalization and canonical spelling. Examples:
|
||||
- "pink floyd" → "Pink Floyd"
|
||||
- "deep purple" → "Deep Purple"
|
||||
- "AC DC" → "AC/DC"
|
||||
- "Guns n roses" → "Guns N' Roses"
|
||||
- "Led zepplin" → "Led Zeppelin" (fix common misspellings)
|
||||
- "guns n roses" → "Guns N' Roses"
|
||||
- "led zepplin" → "Led Zeppelin" (fix common misspellings)
|
||||
- "саша скул" → "Саша Скул" (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 "Саша Скул".
|
||||
- **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.
|
||||
- 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).
|
||||
- **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.
|
||||
- Use title case for 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.
|
||||
- If all tracks in the same album follow a naming pattern (e.g., numbered names like "Part 1", "Part 2"), preserve that pattern consistently. Do not simplify or truncate individual track names.
|
||||
|
||||
@@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
||||
|
||||
10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations.
|
||||
|
||||
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.
|
||||
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
|
||||
- 0.5-0.8: Some guesswork involved, human review recommended.
|
||||
|
||||
+29
-2
@@ -334,18 +334,31 @@ pub async fn approve_and_finalize(
|
||||
.fetch_one(pool)
|
||||
.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")
|
||||
.bind(&pt.file_hash)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if let Some((track_id,)) = existing {
|
||||
// Already finalized — just mark pending as approved
|
||||
update_pending_status(pool, pending_id, "approved", None).await?;
|
||||
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_id = upsert_artist(pool, artist_name).await?;
|
||||
|
||||
@@ -837,6 +850,12 @@ pub struct AlbumTrackRow {
|
||||
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> {
|
||||
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"
|
||||
@@ -873,6 +892,14 @@ pub async fn get_album_cover(pool: &PgPool, album_id: i64) -> Result<Option<(Str
|
||||
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
|
||||
|
||||
@@ -19,9 +19,25 @@ pub struct RawMetadata {
|
||||
pub duration_secs: Option<f64>,
|
||||
}
|
||||
|
||||
/// Extract metadata from an audio file using Symphonia.
|
||||
/// Extract metadata from an audio file.
|
||||
/// For MP3, falls back to the `id3` crate when Symphonia cannot probe the file
|
||||
/// (e.g., ID3 tag with large embedded cover art exceeds Symphonia's 1 MB probe limit).
|
||||
/// Must be called from a blocking context (spawn_blocking).
|
||||
pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
match extract_via_symphonia(path) {
|
||||
Ok(meta) => return Ok(meta),
|
||||
Err(e) => {
|
||||
let is_mp3 = path.extension().and_then(|e| e.to_str()).map(|e| e.eq_ignore_ascii_case("mp3")).unwrap_or(false);
|
||||
if is_mp3 {
|
||||
tracing::debug!(error = %e, "Symphonia failed on MP3, falling back to id3 crate");
|
||||
return extract_mp3_via_id3(path);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_via_symphonia(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let mss = MediaSourceStream::new(Box::new(file), Default::default());
|
||||
|
||||
@@ -66,6 +82,25 @@ pub fn extract(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
/// Read MP3 tags via the `id3` crate. Duration is not available this way.
|
||||
fn extract_mp3_via_id3(path: &Path) -> anyhow::Result<RawMetadata> {
|
||||
use id3::TagLike;
|
||||
|
||||
let tag = id3::Tag::read_from_path(path)
|
||||
.map_err(|e| anyhow::anyhow!("id3 read failed: {}", e))?;
|
||||
|
||||
let mut meta = RawMetadata::default();
|
||||
meta.title = tag.title().map(|s| fix_encoding(s.to_owned()));
|
||||
meta.artist = tag.artist().map(|s| fix_encoding(s.to_owned()));
|
||||
meta.album = tag.album().map(|s| fix_encoding(s.to_owned()));
|
||||
meta.year = tag.year().and_then(|y| u32::try_from(y).ok());
|
||||
meta.track_number = tag.track();
|
||||
meta.genre = tag.genre().map(|s: &str| fix_encoding(s.to_owned()));
|
||||
// duration_secs remains None — acceptable for large-cover files
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
fn extract_tags(tags: &[symphonia::core::meta::Tag], meta: &mut RawMetadata) {
|
||||
for tag in tags {
|
||||
let value = fix_encoding(tag.value.to_string());
|
||||
|
||||
@@ -188,8 +188,20 @@ async fn reprocess_pending(state: &Arc<AppState>) -> anyhow::Result<usize> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
|
||||
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
|
||||
// Source file is gone — check if already in library by hash
|
||||
let in_library: (bool,) = sqlx::query_as(
|
||||
"SELECT EXISTS(SELECT 1 FROM tracks WHERE file_hash = $1)"
|
||||
)
|
||||
.bind(&pt.file_hash)
|
||||
.fetch_one(&state.pool).await.unwrap_or((false,));
|
||||
|
||||
if in_library.0 {
|
||||
tracing::info!(id = %pt.id, "Source missing but track already in library — merging");
|
||||
db::update_pending_status(&state.pool, pt.id, "merged", None).await?;
|
||||
} else {
|
||||
tracing::error!(id = %pt.id, "Source file missing: {:?}", source);
|
||||
db::update_pending_status(&state.pool, pt.id, "error", Some("Source file missing")).await?;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
|
||||
@@ -25,16 +25,37 @@ pub async fn normalize(
|
||||
) -> anyhow::Result<NormalizedFields> {
|
||||
let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx);
|
||||
|
||||
let schema = normalize_schema();
|
||||
let response = call_ollama(
|
||||
&state.config.ollama_url,
|
||||
&state.config.ollama_model,
|
||||
&state.system_prompt,
|
||||
&user_message,
|
||||
state.config.ollama_auth.as_deref(),
|
||||
0.5,
|
||||
512,
|
||||
Some(("normalized_metadata", schema.clone())),
|
||||
)
|
||||
.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(
|
||||
@@ -113,32 +134,49 @@ fn build_user_message(
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OllamaRequest {
|
||||
struct ChatRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaMessage>,
|
||||
format: String,
|
||||
messages: Vec<ChatMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
response_format: Option<ChatResponseFormat>,
|
||||
stream: bool,
|
||||
options: OllamaOptions,
|
||||
temperature: f64,
|
||||
max_tokens: u32,
|
||||
frequency_penalty: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OllamaMessage {
|
||||
struct ChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OllamaOptions {
|
||||
temperature: f64,
|
||||
struct ChatResponseFormat {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
json_schema: JsonSchemaWrapper,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct JsonSchemaWrapper {
|
||||
name: String,
|
||||
strict: bool,
|
||||
schema: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaResponse {
|
||||
message: OllamaResponseMessage,
|
||||
struct ChatResponse {
|
||||
choices: Vec<ChatChoice>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OllamaResponseMessage {
|
||||
struct ChatChoice {
|
||||
message: ChatResponseMessage,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChatResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
@@ -148,30 +186,40 @@ pub async fn call_ollama(
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
auth: Option<&str>,
|
||||
frequency_penalty: f64,
|
||||
max_tokens: u32,
|
||||
schema: Option<(&str, serde_json::Value)>,
|
||||
) -> anyhow::Result<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.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(),
|
||||
messages: vec![
|
||||
OllamaMessage {
|
||||
ChatMessage {
|
||||
role: "system".to_owned(),
|
||||
content: system_prompt.to_owned(),
|
||||
},
|
||||
OllamaMessage {
|
||||
ChatMessage {
|
||||
role: "user".to_owned(),
|
||||
content: user_message.to_owned(),
|
||||
},
|
||||
],
|
||||
format: "json".to_owned(),
|
||||
response_format,
|
||||
stream: false,
|
||||
options: OllamaOptions { temperature: 0.1 },
|
||||
temperature: 0.1,
|
||||
max_tokens,
|
||||
frequency_penalty,
|
||||
};
|
||||
|
||||
let url = format!("{}/api/chat", base_url.trim_end_matches('/'));
|
||||
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API...");
|
||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||
tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API...");
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let mut req = client.post(&url).json(&request);
|
||||
@@ -184,18 +232,45 @@ pub async fn call_ollama(
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error");
|
||||
anyhow::bail!("Ollama returned {}: {}", status, body);
|
||||
tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error");
|
||||
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!(
|
||||
elapsed_ms = elapsed.as_millis() as u64,
|
||||
response_len = ollama_resp.message.content.len(),
|
||||
"Ollama response received"
|
||||
response_len = content.len(),
|
||||
"LLM response received"
|
||||
);
|
||||
tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output");
|
||||
Ok(ollama_resp.message.content)
|
||||
tracing::debug!(raw_response = %content, "LLM raw output");
|
||||
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.
|
||||
@@ -222,6 +297,7 @@ fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
|
||||
genre: Option<String>,
|
||||
#[serde(default)]
|
||||
featured_artists: Vec<String>,
|
||||
#[serde(rename = "release_kind")]
|
||||
release_type: Option<String>,
|
||||
confidence: Option<f64>,
|
||||
notes: Option<String>,
|
||||
|
||||
@@ -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 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(
|
||||
&state.config.ollama_url,
|
||||
&state.config.ollama_model,
|
||||
&state.merge_prompt,
|
||||
&user_message,
|
||||
state.config.ollama_auth.as_deref(),
|
||||
0.5,
|
||||
4096,
|
||||
Some(("artist_merge", schema)),
|
||||
).await?;
|
||||
|
||||
let proposal = parse_merge_response(&response)?;
|
||||
|
||||
@@ -295,13 +295,14 @@ function renderFilterBar(s) {
|
||||
`;
|
||||
}
|
||||
|
||||
function showTab(tab, btn) {
|
||||
function showTab(tab, btn, noHash) {
|
||||
currentTab = tab;
|
||||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
clearSelection();
|
||||
const pag = document.getElementById('lib-pagination');
|
||||
if (pag) pag.style.display = 'none';
|
||||
if (!noHash) location.hash = tab;
|
||||
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 === '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(); }
|
||||
@@ -312,7 +313,11 @@ function showTab(tab, btn) {
|
||||
// --- Queue ---
|
||||
async function loadQueue(status, keepSelection) {
|
||||
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 raw = await api(`/queue${qs}`) || [];
|
||||
const hasMore = raw.length > queuePageSize;
|
||||
@@ -359,6 +364,9 @@ function renderQueue(hasMore) {
|
||||
const artist = it.norm_artist || it.raw_artist || '-';
|
||||
const title = it.norm_title || it.raw_title || '-';
|
||||
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 tnum = it.norm_track_number || it.raw_track_number || '';
|
||||
const canApprove = it.status === 'review';
|
||||
@@ -368,7 +376,7 @@ function renderQueue(hasMore) {
|
||||
<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_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>${tnum}</td>
|
||||
<td>${conf}</td>
|
||||
@@ -1163,6 +1171,37 @@ 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 ---
|
||||
function openModal() { document.getElementById('modalOverlay').classList.add('visible'); }
|
||||
function closeModal() { document.getElementById('modalOverlay').classList.remove('visible'); }
|
||||
@@ -1495,7 +1534,20 @@ async function openArtistForm(id) {
|
||||
<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('');
|
||||
body.innerHTML = tracks;
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1586,9 +1638,82 @@ async function removeAppearance(artistId, trackId, btn) {
|
||||
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 ---
|
||||
(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();
|
||||
loadQueue();
|
||||
if (currentTab === 'queue' && !location.hash.slice(1)) loadQueue();
|
||||
setInterval(loadStats, 5000);
|
||||
// Auto-refresh queue when on queue tab
|
||||
setInterval(() => { if (currentTab === 'queue') loadQueue(currentFilter, true); }, 5000);
|
||||
|
||||
+88
-11
@@ -528,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)]
|
||||
pub struct ReorderBody {
|
||||
pub orders: Vec<(i64, i32)>,
|
||||
@@ -544,19 +558,82 @@ pub async fn reorder_album_tracks(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
||||
let cover = match db::get_album_cover(&state.pool, id).await {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
|
||||
Err(e) => return error_response(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
/// Cover by artist+album name — used for queue items that may not have an album_id yet.
|
||||
#[derive(Deserialize)]
|
||||
pub struct CoverByNameQuery {
|
||||
#[serde(default)] pub artist: String,
|
||||
#[serde(default)] pub name: String,
|
||||
}
|
||||
pub async fn album_cover_by_name(State(state): State<S>, Query(q): Query<CoverByNameQuery>) -> impl IntoResponse {
|
||||
let album_id = match db::find_album_id(&state.pool, &q.artist, &q.name).await {
|
||||
Ok(Some(id)) => id,
|
||||
_ => return StatusCode::NOT_FOUND.into_response(),
|
||||
};
|
||||
match tokio::fs::read(&cover.0).await {
|
||||
Ok(bytes) => (
|
||||
[(axum::http::header::CONTENT_TYPE, cover.1)],
|
||||
bytes,
|
||||
).into_response(),
|
||||
Err(_) => StatusCode::NOT_FOUND.into_response(),
|
||||
album_cover_by_id(&state, album_id).await
|
||||
}
|
||||
|
||||
pub async fn album_cover(State(state): State<S>, Path(id): Path<i64>) -> impl IntoResponse {
|
||||
album_cover_by_id(&state, id).await
|
||||
}
|
||||
|
||||
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)]
|
||||
|
||||
@@ -41,10 +41,12 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.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/cover-by-name", get(api::album_cover_by_name))
|
||||
.route("/albums/:id/cover", get(api::album_cover))
|
||||
.route("/albums/:id/full", get(api::get_album_full))
|
||||
.route("/albums/:id/reorder", put(api::reorder_album_tracks))
|
||||
.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))
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Leave empty — vite proxy handles /api in dev, same-origin in production
|
||||
VITE_FURUMI_API_URL=
|
||||
+392
-3
@@ -8,8 +8,11 @@
|
||||
"name": "client",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"axios": "^1.7.9",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -586,6 +589,32 @@
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.10",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||
@@ -848,6 +877,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -887,7 +928,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -903,6 +944,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||
@@ -1287,6 +1334,23 @@
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1352,6 +1416,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -1420,6 +1497,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1453,7 +1542,7 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
@@ -1481,6 +1570,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
@@ -1491,6 +1589,20 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.321",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
||||
@@ -1498,6 +1610,51 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@@ -1795,6 +1952,42 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1810,6 +2003,15 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
@@ -1820,6 +2022,43 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@@ -1846,6 +2085,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -1856,6 +2107,45 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@@ -1883,6 +2173,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -2325,6 +2625,36 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -2520,6 +2850,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -2551,6 +2887,50 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -2814,6 +3194,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||
|
||||
@@ -10,8 +10,11 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"axios": "^1.7.9",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react-dom": "^19.2.4",
|
||||
"react-redux": "^9.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const DB_NAME = 'furumi-sw'
|
||||
const STORE = 'auth'
|
||||
const KEY = 'bearer'
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, 1)
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE)
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
try {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve) => {
|
||||
const tx = db.transaction(STORE, 'readonly')
|
||||
const req = tx.objectStore(STORE).get(KEY)
|
||||
req.onsuccess = () => resolve(req.result || null)
|
||||
req.onerror = () => resolve(null)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
const url = new URL(e.request.url)
|
||||
if (url.origin !== self.location.origin || !url.pathname.startsWith('/api/')) return
|
||||
|
||||
e.respondWith(
|
||||
(async () => {
|
||||
const token = await getToken()
|
||||
if (!token) return fetch(e.request)
|
||||
|
||||
const headers = new Headers(e.request.headers)
|
||||
headers.set('Authorization', `Bearer ${token}`)
|
||||
return fetch(new Request(e.request, { headers }))
|
||||
})()
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting())
|
||||
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
|
||||
@@ -1,71 +1,102 @@
|
||||
.page {
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #0a0c12;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
/* ---------- loading spinner ---------- */
|
||||
|
||||
.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 {
|
||||
.auth-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid #1f2c45;
|
||||
border-top-color: #7c6af7;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 10px 0 0;
|
||||
color: #5a6475;
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
background: #2251ff;
|
||||
color: #ffffff;
|
||||
padding: 10px 16px;
|
||||
.auth-loading .logo {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #7c6af7;
|
||||
}
|
||||
|
||||
.auth-loading p {
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------- login card ---------- */
|
||||
|
||||
.auth-card {
|
||||
width: min(380px, 100%);
|
||||
background: #111520;
|
||||
border: 1px solid #1f2c45;
|
||||
border-radius: 16px;
|
||||
padding: 2.5rem 2rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.auth-card .logo {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #7c6af7;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-card .subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.auth-card .btn-login {
|
||||
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.ghost {
|
||||
background: #edf1ff;
|
||||
color: #1e3fc4;
|
||||
margin-top: 10px;
|
||||
.auth-card .btn-login:hover {
|
||||
background: #6b58e8;
|
||||
}
|
||||
|
||||
.profile p {
|
||||
margin: 8px 0;
|
||||
.auth-card .error {
|
||||
color: #f87171;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #cc1e1e;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FurumiPlayer } from './FurumiPlayer'
|
||||
import { setAuthToken, clearAuthToken } from './furumiApi'
|
||||
import './App.css'
|
||||
|
||||
type UserProfile = {
|
||||
@@ -8,38 +9,19 @@ type UserProfile = {
|
||||
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 = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', [])
|
||||
|
||||
useEffect(() => {
|
||||
if (runWithoutAuth) {
|
||||
setError(null)
|
||||
setUser({ sub: 'noauth', name: 'No Auth' })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const loadMe = async () => {
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/api/me`, {
|
||||
credentials: 'include',
|
||||
})
|
||||
const response = await fetch('/auth/me', { credentials: 'include' })
|
||||
|
||||
if (response.status === 401) {
|
||||
setUser(null)
|
||||
clearAuthToken()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,6 +31,20 @@ function App() {
|
||||
|
||||
const data = await response.json()
|
||||
setUser(data.user ?? null)
|
||||
|
||||
if (data.user) {
|
||||
try {
|
||||
const tokenRes = await fetch('/auth/token', { credentials: 'include' })
|
||||
if (tokenRes.ok) {
|
||||
const tokenData = await tokenRes.json()
|
||||
if (tokenData.access_token) {
|
||||
setAuthToken(tokenData.access_token)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Token fetch failed
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load session')
|
||||
} finally {
|
||||
@@ -57,85 +53,40 @@ function App() {
|
||||
}
|
||||
|
||||
void loadMe()
|
||||
}, [apiBase, runWithoutAuth])
|
||||
}, [])
|
||||
|
||||
const loginUrl = `${apiBase}/api/login`
|
||||
const logoutUrl = `${apiBase}/api/logout`
|
||||
const playerApiRoot = `${apiBase}/api`
|
||||
// Authenticated — render player immediately
|
||||
if (!loading && user) {
|
||||
return <FurumiPlayer user={user} />
|
||||
}
|
||||
|
||||
// Loading — show spinner (no login form flash)
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<div className="auth-loading">
|
||||
<div className="logo">Furumi</div>
|
||||
<div className="spinner" />
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// Not authenticated — show login
|
||||
return (
|
||||
<>
|
||||
{!loading && (user || runWithoutAuth) ? (
|
||||
<FurumiPlayer apiRoot={playerApiRoot} />
|
||||
) : (
|
||||
<main className="page">
|
||||
<section className="card">
|
||||
<h1>OIDC Login</h1>
|
||||
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
||||
<main className="auth-page">
|
||||
<section className="auth-card">
|
||||
<div className="logo">Furumi</div>
|
||||
<p className="subtitle">Sign in to continue</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>
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
<a className="btn-login" href="/auth/login">
|
||||
Sign in with SSO
|
||||
</a>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,194 @@
|
||||
import { furumiApi } 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 ? '⏸' : '▶'
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' })
|
||||
audio.src = URL.createObjectURL(res.data)
|
||||
await audio.play().catch(() => { })
|
||||
} catch {
|
||||
// stream failed
|
||||
}
|
||||
}
|
||||
|
||||
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 ? '🔇' : '🔊'
|
||||
}
|
||||
|
||||
function setVolume(percent: number) {
|
||||
audio.volume = percent / 100
|
||||
const icon = document.getElementById('volIcon')
|
||||
if (icon) icon.innerHTML = percent === 0 ? '🔇' : '🔊'
|
||||
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,24 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { furumiApi } from '../furumiApi'
|
||||
|
||||
export function AuthImg({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) return
|
||||
let revoked = false
|
||||
furumiApi.get(src, { responseType: 'blob' })
|
||||
.then((res) => {
|
||||
if (!revoked) setBlobUrl(URL.createObjectURL(res.data))
|
||||
})
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
revoked = true
|
||||
if (blobUrl) URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [src])
|
||||
|
||||
if (!blobUrl) return null
|
||||
return <img src={blobUrl} alt={alt ?? ''} {...props} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { AuthImg } from './AuthImg'
|
||||
import type { QueueItem } from './QueueList'
|
||||
|
||||
function Cover({ src }: { src: string }) {
|
||||
function Cover({ slug }: { slug: string }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
const src = `/tracks/${slug}/cover`
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
return <AuthImg src={src} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
|
||||
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
||||
if (!track) {
|
||||
return (
|
||||
<div className="np-info">
|
||||
@@ -31,12 +29,10 @@ export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueIt
|
||||
)
|
||||
}
|
||||
|
||||
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
|
||||
|
||||
return (
|
||||
<div className="np-info">
|
||||
<div className="np-cover" id="npCover">
|
||||
<Cover src={coverUrl} />
|
||||
<Cover slug={track.slug} />
|
||||
</div>
|
||||
<div className="np-text">
|
||||
<div className="np-title" id="npTitle">
|
||||
@@ -49,4 +45,3 @@ export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueIt
|
||||
</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">
|
||||
⏮
|
||||
</button>
|
||||
<button className="ctrl-btn ctrl-btn-main" id="btnPlayPause">
|
||||
▶
|
||||
</button>
|
||||
<button className="ctrl-btn" id="btnNext">
|
||||
⏭
|
||||
</button>
|
||||
</div>
|
||||
<div className="progress-row">
|
||||
<span className="time" id="timeElapsed">
|
||||
0:00
|
||||
</span>
|
||||
<div className="progress-bar" id="progressBar">
|
||||
<div className="progress-fill" id="progressFill" style={{ width: '0%' }} />
|
||||
</div>
|
||||
<span className="time" id="timeDuration">
|
||||
0:00
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="volume-row">
|
||||
<QueuePopover
|
||||
queue={queue}
|
||||
order={order}
|
||||
playingOrigIdx={playingOrigIdx}
|
||||
scrollSignal={scrollSignal}
|
||||
onPlay={onQueuePlay}
|
||||
onRemove={onQueueRemove}
|
||||
onMove={onQueueMove}
|
||||
/>
|
||||
<span className="vol-icon" id="volIcon">
|
||||
🔊
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
className="volume-slider"
|
||||
id="volSlider"
|
||||
min={0}
|
||||
max={100}
|
||||
defaultValue={80}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { AuthImg } from './AuthImg'
|
||||
|
||||
export type QueueItem = {
|
||||
slug: string
|
||||
@@ -9,7 +10,6 @@ export type QueueItem = {
|
||||
}
|
||||
|
||||
type QueueListProps = {
|
||||
apiRoot: string
|
||||
queue: QueueItem[]
|
||||
order: number[]
|
||||
playingOrigIdx: number
|
||||
@@ -32,18 +32,13 @@ function fmt(secs: number) {
|
||||
return `${m}:${pad(s % 60)}`
|
||||
}
|
||||
|
||||
function Cover({ src }: { src: string }) {
|
||||
function Cover({ slug }: { slug: string }) {
|
||||
const [errored, setErrored] = useState(false)
|
||||
useEffect(() => {
|
||||
setErrored(false)
|
||||
}, [src])
|
||||
|
||||
if (errored) return <>🎵</>
|
||||
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
|
||||
}
|
||||
|
||||
export function QueueList({
|
||||
apiRoot,
|
||||
queue,
|
||||
order,
|
||||
playingOrigIdx,
|
||||
@@ -78,7 +73,7 @@ export function QueueList({
|
||||
if (!t) return null
|
||||
|
||||
const isPlaying = origIdx === playingOrigIdx
|
||||
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
|
||||
const hasAlbum = !!t.album_slug
|
||||
const dur = t.duration ? fmt(t.duration) : ''
|
||||
const isDragging = draggingPos === pos
|
||||
const isDragOver = dragOverPos === pos
|
||||
@@ -119,7 +114,7 @@ export function QueueList({
|
||||
>
|
||||
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||
<div className="qi-cover">
|
||||
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||
{hasAlbum ? <Cover slug={t.slug} /> : <>🎵</>}
|
||||
</div>
|
||||
<div className="qi-info">
|
||||
<div className="qi-title">{t.title}</div>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { SearchDropdown } from '../SearchDropdown'
|
||||
import { RecentPlays } from './RecentPlays'
|
||||
import styles from './header.module.css'
|
||||
|
||||
type SearchResultItem = {
|
||||
result_type: string
|
||||
slug: string
|
||||
name: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
type UserInfo = {
|
||||
sub: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
searchOpen: boolean
|
||||
searchResults: SearchResultItem[]
|
||||
onSearchSelect: (type: string, slug: string) => void
|
||||
onPlayTrack: (slug: string) => void
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
const initials = (user.name ?? user.sub)
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
|
||||
return (
|
||||
<div className={styles.userMenu} ref={ref}>
|
||||
<button className={styles.userAvatar} onClick={() => setOpen(!open)} title={user.name ?? user.sub}>
|
||||
{initials}
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.userDropdown}>
|
||||
<div className={styles.userInfo}>
|
||||
<div className={styles.userName}>{user.name ?? user.sub}</div>
|
||||
{user.email && <div className={styles.userEmail}>{user.email}</div>}
|
||||
</div>
|
||||
<button className={styles.userAction} onClick={() => { setOpen(false); onShowRecent() }}>
|
||||
Recent plays
|
||||
</button>
|
||||
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header({
|
||||
searchOpen,
|
||||
searchResults,
|
||||
onSearchSelect,
|
||||
onPlayTrack,
|
||||
user,
|
||||
}: HeaderProps) {
|
||||
const [showRecent, setShowRecent] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerLogo}>
|
||||
<button className="btn-menu">☰</button>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="9" cy="18" r="3" />
|
||||
<circle cx="18" cy="15" r="3" />
|
||||
<path d="M12 18V6l9-3v3" />
|
||||
</svg>
|
||||
Furumi
|
||||
<span className={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>
|
||||
<UserMenu user={user} onShowRecent={() => setShowRecent(true)} />
|
||||
</div>
|
||||
</header>
|
||||
{showRecent && (
|
||||
<RecentPlays
|
||||
onClose={() => setShowRecent(false)}
|
||||
onPlay={onPlayTrack}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getRecentPlays, type RecentPlay } from '../../furumiApi'
|
||||
import styles from './header.module.css'
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `${hrs}h ago`
|
||||
const days = Math.floor(hrs / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
|
||||
export function RecentPlays({ onClose, onPlay }: { onClose: () => void; onPlay: (slug: string) => void }) {
|
||||
const [plays, setPlays] = useState<RecentPlay[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
getRecentPlays().then((data) => {
|
||||
setPlays(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div className={styles.recentOverlay} onClick={onClose}>
|
||||
<div className={styles.recentPanel} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.recentHeader}>
|
||||
<h2>Recent plays</h2>
|
||||
<button className={styles.recentClose} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className={styles.recentList}>
|
||||
{loading && <p className={styles.recentEmpty}>Loading...</p>}
|
||||
{!loading && (!plays || plays.length === 0) && (
|
||||
<p className={styles.recentEmpty}>No play history yet</p>
|
||||
)}
|
||||
{plays?.map((p, i) => (
|
||||
<div
|
||||
key={`${p.track_slug}-${i}`}
|
||||
className={styles.recentItem}
|
||||
onClick={() => { onPlay(p.track_slug); onClose() }}
|
||||
>
|
||||
<div className={styles.recentTrack}>
|
||||
<div className={styles.recentTitle}>{p.track_title}</div>
|
||||
<div className={styles.recentArtist}>{p.artist_name}</div>
|
||||
</div>
|
||||
<div className={styles.recentTime}>{timeAgo(p.played_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
/* User menu */
|
||||
|
||||
.userMenu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.userAvatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.userAvatar:hover {
|
||||
background: var(--accent-dim);
|
||||
}
|
||||
|
||||
.userDropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
min-width: 200px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.userName {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.userEmail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.userLogout {
|
||||
display: block;
|
||||
padding: 10px 16px;
|
||||
color: var(--danger);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.userLogout:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.userAction {
|
||||
display: block;
|
||||
padding: 10px 16px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.userAction:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Recent plays overlay */
|
||||
|
||||
.recentOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.recentPanel {
|
||||
width: min(480px, 90vw);
|
||||
max-height: 70vh;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.recentHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.recentHeader h2 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.recentClose {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.recentClose:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.recentList {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recentItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.recentItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.recentTrack {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recentTitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recentArtist {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.recentTime {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.recentEmpty {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -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>
|
||||
☰
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
id={panelId}
|
||||
className={styles.popover}
|
||||
role="dialog"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<div className={styles.header} id={titleId}>
|
||||
Queue
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<QueueList
|
||||
queue={queue}
|
||||
order={order}
|
||||
playingOrigIdx={playingOrigIdx}
|
||||
scrollSignal={scrollSignal}
|
||||
onPlay={onPlay}
|
||||
onRemove={onRemove}
|
||||
onMove={onMove}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -31,40 +31,6 @@
|
||||
--danger: #f87171;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--bg-panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.header-logo svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.header-version {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
margin-left: 0.25rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-menu {
|
||||
display: none;
|
||||
@@ -378,6 +344,10 @@
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.qi-title {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.queue-item .qi-index {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
@@ -531,6 +501,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.np-artist {
|
||||
|
||||
@@ -1,12 +1,109 @@
|
||||
export type FurumiApiClient = (path: string) => Promise<unknown | null>
|
||||
import axios from 'axios'
|
||||
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
|
||||
|
||||
export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
|
||||
const API = apiRoot
|
||||
const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? ''
|
||||
export const API_ROOT = `${FURUMI_API_BASE}/api`
|
||||
|
||||
return async function api(path: string) {
|
||||
const r = await fetch(API + path)
|
||||
if (!r.ok) return null
|
||||
return r.json()
|
||||
}
|
||||
export const furumiApi = axios.create({
|
||||
baseURL: API_ROOT,
|
||||
})
|
||||
|
||||
function sendTokenToSW(token: string) {
|
||||
try {
|
||||
const req = indexedDB.open('furumi-sw', 1)
|
||||
req.onupgradeneeded = () => req.result.createObjectStore('auth')
|
||||
req.onsuccess = () => {
|
||||
const tx = req.result.transaction('auth', 'readwrite')
|
||||
tx.objectStore('auth').put(token, 'bearer')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string) {
|
||||
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
sendTokenToSW(token)
|
||||
}
|
||||
|
||||
export function clearAuthToken() {
|
||||
delete furumiApi.defaults.headers.common['Authorization']
|
||||
}
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/auth/token', { credentials: 'include' })
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
if (data.access_token) {
|
||||
setAuthToken(data.access_token)
|
||||
return true
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<boolean> | null = null
|
||||
|
||||
furumiApi.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const original = error.config
|
||||
if (error.response?.status === 401 && !original._retried) {
|
||||
original._retried = true
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = refreshToken().finally(() => { refreshPromise = null })
|
||||
}
|
||||
const ok = await refreshPromise
|
||||
if (ok) return furumiApi(original)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
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 type RecentPlay = {
|
||||
track_slug: string
|
||||
track_title: string
|
||||
artist_name: string
|
||||
album_slug: string | null
|
||||
played_at: string
|
||||
}
|
||||
|
||||
export async function getRecentPlays(): Promise<RecentPlay[] | null> {
|
||||
const res = await furumiApi.get<RecentPlay[]>('/me/recent').catch(() => null)
|
||||
return res?.data ?? null
|
||||
}
|
||||
|
||||
export async function recordPlay(trackSlug: string): Promise<void> {
|
||||
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
@@ -5,6 +10,10 @@ body {
|
||||
background-color: #f3f6fb;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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'
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -6,10 +6,18 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
'/auth': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/callback': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8085',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
import path from 'path';
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import { auth } from 'express-openid-connect';
|
||||
@@ -23,12 +24,11 @@ const oidcConfig = {
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
|
||||
authorizationParams: {
|
||||
response_type: 'code',
|
||||
scope: process.env.OIDC_SCOPE ?? 'openid profile email',
|
||||
scope: process.env.OIDC_SCOPE ?? 'openid profile email offline_access',
|
||||
},
|
||||
};
|
||||
|
||||
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
|
||||
// Keep a clear startup failure if OIDC is not configured.
|
||||
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)',
|
||||
);
|
||||
@@ -46,11 +46,11 @@ if (!disableAuth) {
|
||||
app.use(auth(oidcConfig));
|
||||
}
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
app.get('/auth/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/me', (req, res) => {
|
||||
app.get('/auth/me', (req, res) => {
|
||||
if (disableAuth) {
|
||||
res.json({
|
||||
authenticated: false,
|
||||
@@ -74,7 +74,42 @@ app.get('/api/me', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/login', (req, res) => {
|
||||
app.get('/auth/token', async (req, res) => {
|
||||
if (disableAuth) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.oidc.isAuthenticated()) {
|
||||
res.status(401).json({ authenticated: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let accessToken = req.oidc.accessToken;
|
||||
if (!accessToken?.access_token) {
|
||||
res.status(500).json({ error: 'no access token in session' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh if expired
|
||||
if (accessToken.isExpired()) {
|
||||
try {
|
||||
accessToken = await accessToken.refresh();
|
||||
} catch (e) {
|
||||
console.error('Token refresh failed:', e);
|
||||
res.status(401).json({ error: 'token refresh failed' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
access_token: accessToken.access_token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: accessToken.expires_in,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/auth/login', (req, res) => {
|
||||
if (disableAuth) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
@@ -85,7 +120,7 @@ app.get('/api/login', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/logout', (req, res) => {
|
||||
app.get('/auth/logout', (req, res) => {
|
||||
if (disableAuth) {
|
||||
res.status(204).end();
|
||||
return;
|
||||
@@ -96,6 +131,13 @@ app.get('/api/logout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Production: serve Vite-built client as static files
|
||||
const clientDist = path.resolve(import.meta.dirname, '../../client/dist');
|
||||
app.use(express.static(clientDist));
|
||||
app.get('/{*path}', (_req, res) => {
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(
|
||||
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
|
||||
|
||||
@@ -9,6 +9,7 @@ axum = { version = "0.7", features = ["tokio", "macros"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||
tokio = { version = "1.50", features = ["full"] }
|
||||
tower = { version = "0.4", features = ["util"] }
|
||||
@@ -18,10 +19,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"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
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"
|
||||
hmac = "0.12"
|
||||
base64 = "0.22"
|
||||
rand = "0.8"
|
||||
urlencoding = "2.1.3"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
|
||||
@@ -82,6 +82,82 @@ pub struct SearchResult {
|
||||
pub detail: Option<String>, // artist name for albums/tracks
|
||||
}
|
||||
|
||||
// --- User management ---
|
||||
|
||||
pub async fn upsert_user(
|
||||
pool: &PgPool,
|
||||
id: &str,
|
||||
username: &str,
|
||||
display_name: Option<&str>,
|
||||
email: Option<&str>,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO users (id, username, display_name, email, last_seen_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
username = EXCLUDED.username,
|
||||
display_name = EXCLUDED.display_name,
|
||||
email = EXCLUDED.email,
|
||||
last_seen_at = NOW()"#
|
||||
)
|
||||
.bind(id)
|
||||
.bind(username)
|
||||
.bind(display_name)
|
||||
.bind(email)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn record_play_event(
|
||||
pool: &PgPool,
|
||||
user_id: &str,
|
||||
track_slug: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query(
|
||||
r#"INSERT INTO play_events (user_id, track_id, played_at)
|
||||
SELECT $1, t.id, NOW()
|
||||
FROM tracks t WHERE t.slug = $2"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(track_slug)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, sqlx::FromRow)]
|
||||
pub struct RecentPlay {
|
||||
pub track_slug: String,
|
||||
pub track_title: String,
|
||||
pub artist_name: String,
|
||||
pub album_slug: Option<String>,
|
||||
pub played_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub async fn recent_plays(
|
||||
pool: &PgPool,
|
||||
user_id: &str,
|
||||
limit: i32,
|
||||
) -> Result<Vec<RecentPlay>, sqlx::Error> {
|
||||
sqlx::query_as::<_, RecentPlay>(
|
||||
r#"SELECT t.slug AS track_slug, t.title AS track_title,
|
||||
ar.name AS artist_name, al.slug AS album_slug,
|
||||
pe.played_at
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON pe.track_id = t.id
|
||||
JOIN artists ar ON t.artist_id = ar.id
|
||||
LEFT JOIN albums al ON t.album_id = al.id
|
||||
WHERE pe.user_id = $1
|
||||
ORDER BY pe.played_at DESC
|
||||
LIMIT $2"#
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
||||
|
||||
@@ -39,6 +39,7 @@ struct Args {
|
||||
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
|
||||
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
|
||||
oidc_session_secret: Option<String>,
|
||||
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
||||
@@ -9,8 +9,10 @@ use axum::{
|
||||
use serde::Deserialize;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
use axum::Extension;
|
||||
use crate::db;
|
||||
use super::AppState;
|
||||
use super::auth::AuthUser;
|
||||
|
||||
type S = Arc<AppState>;
|
||||
|
||||
@@ -291,6 +293,30 @@ pub async fn search(State(state): State<S>, Query(q): Query<SearchQuery>) -> imp
|
||||
}
|
||||
}
|
||||
|
||||
// --- Play tracking ---
|
||||
|
||||
pub async fn recent_plays(
|
||||
State(state): State<S>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> impl IntoResponse {
|
||||
match db::recent_plays(&state.pool, &user.id, 50).await {
|
||||
Ok(plays) => (StatusCode::OK, Json(serde_json::to_value(plays).unwrap())).into_response(),
|
||||
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn record_play(
|
||||
State(state): State<S>,
|
||||
Path(slug): Path<String>,
|
||||
Extension(user): Extension<AuthUser>,
|
||||
) -> impl IntoResponse {
|
||||
match db::record_play_event(&state.pool, &user.id, &slug).await {
|
||||
Ok(true) => StatusCode::NO_CONTENT.into_response(),
|
||||
Ok(false) => error_json(StatusCode::NOT_FOUND, "track not found"),
|
||||
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn error_json(status: StatusCode, message: &str) -> Response {
|
||||
|
||||
@@ -3,8 +3,9 @@ use axum::{
|
||||
extract::{Request, State},
|
||||
http::{header, HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use openidconnect::{
|
||||
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
||||
reqwest::async_http_client,
|
||||
@@ -16,17 +17,26 @@ use serde::Deserialize;
|
||||
|
||||
use base64::Engine;
|
||||
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 std::sync::Arc;
|
||||
|
||||
const SESSION_COOKIE: &str = "furumi_session";
|
||||
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
|
||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||
|
||||
pub struct OidcState {
|
||||
pub client: CoreClient,
|
||||
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(
|
||||
@@ -42,6 +52,9 @@ pub async fn oidc_init(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let jwks_uri = provider_metadata.jwks_uri().to_string();
|
||||
let issuer_url = provider_metadata.issuer().to_string();
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
ClientId::new(client_id),
|
||||
@@ -60,9 +73,84 @@ pub async fn oidc_init(
|
||||
b
|
||||
};
|
||||
|
||||
let http_client = reqwest::Client::new();
|
||||
|
||||
tracing::info!("JWKS URI: {}", jwks_uri);
|
||||
|
||||
Ok(OidcState {
|
||||
client,
|
||||
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, Clone)]
|
||||
pub struct AuthUser {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct BearerClaims {
|
||||
sub: String,
|
||||
preferred_username: Option<String>,
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
}
|
||||
|
||||
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<AuthUser> {
|
||||
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()?;
|
||||
let c = data.claims;
|
||||
Some(AuthUser {
|
||||
id: c.sub.clone(),
|
||||
username: c.preferred_username.unwrap_or(c.sub),
|
||||
display_name: c.name,
|
||||
email: c.email,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,61 +180,73 @@ 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.
|
||||
/// Inserts AuthUser into request extensions and upserts user in DB.
|
||||
pub async fn require_auth(
|
||||
State(state): State<Arc<AppState>>,
|
||||
req: Request,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let oidc = match &state.oidc {
|
||||
Some(o) => o,
|
||||
None => return next.run(req).await, // No OIDC configured = no auth
|
||||
};
|
||||
let mut auth_user: Option<AuthUser> = None;
|
||||
|
||||
let cookies = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
// 1. Check Bearer token — JWT from OIDC provider
|
||||
if let Some(ref oidc) = state.oidc {
|
||||
if let Some(token) = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
{
|
||||
auth_user = validate_bearer_token(oidc, token).await;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
// 2. Check SSO session cookie (if OIDC configured)
|
||||
if auth_user.is_none() {
|
||||
if let Some(ref oidc) = state.oidc {
|
||||
let cookies = req
|
||||
.headers()
|
||||
.get(header::COOKIE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
|
||||
for c in cookies.split(';') {
|
||||
let c = c.trim();
|
||||
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||
if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) {
|
||||
auth_user = Some(AuthUser {
|
||||
id: user_id.clone(),
|
||||
username: user_id,
|
||||
display_name: None,
|
||||
email: None,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let uri = req.uri().to_string();
|
||||
if uri.starts_with("/api/") {
|
||||
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||
} else {
|
||||
Redirect::to("/login").into_response()
|
||||
match auth_user {
|
||||
Some(user) => {
|
||||
tracing::debug!("Auth OK for user: {}", user.username);
|
||||
// Upsert user in background
|
||||
let pool = state.pool.clone();
|
||||
let u = user.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = crate::db::upsert_user(
|
||||
&pool, &u.id, &u.username, u.display_name.as_deref(), u.email.as_deref(),
|
||||
).await {
|
||||
tracing::warn!("Failed to upsert user: {}", e);
|
||||
}
|
||||
});
|
||||
req.extensions_mut().insert(user);
|
||||
next.run(req).await
|
||||
}
|
||||
None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /login — show SSO login page.
|
||||
pub async fn login_page(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
if state.oidc.is_none() {
|
||||
return Redirect::to("/").into_response();
|
||||
}
|
||||
|
||||
Html(LOGIN_HTML).into_response()
|
||||
}
|
||||
|
||||
/// GET /logout — clear session cookie.
|
||||
pub async fn logout() -> impl IntoResponse {
|
||||
let cookie = format!(
|
||||
"{}=; HttpOnly; SameSite=Strict; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||
SESSION_COOKIE
|
||||
);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::SET_COOKIE, cookie.parse().unwrap());
|
||||
headers.insert(header::LOCATION, "/login".parse().unwrap());
|
||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQuery {
|
||||
pub next: Option<String>,
|
||||
@@ -319,9 +419,9 @@ pub async fn oidc_callback(
|
||||
.unwrap_or(false);
|
||||
|
||||
let session_attrs = if is_https {
|
||||
"SameSite=Strict; Secure"
|
||||
"SameSite=Lax; Secure"
|
||||
} else {
|
||||
"SameSite=Strict"
|
||||
"SameSite=Lax"
|
||||
};
|
||||
|
||||
let session_cookie = format!(
|
||||
@@ -338,47 +438,3 @@ pub async fn oidc_callback(
|
||||
|
||||
(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>"#;
|
||||
|
||||
@@ -3,9 +3,12 @@ pub mod auth;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::{Router, routing::get, middleware};
|
||||
use axum::{Router, routing::{get, post}, middleware};
|
||||
use axum::http::{header, Method};
|
||||
use sqlx::PgPool;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -26,32 +29,31 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/tracks/:slug", get(api::get_track_detail))
|
||||
.route("/tracks/:slug/cover", get(api::track_cover))
|
||||
.route("/stream/:slug", get(api::stream_track))
|
||||
.route("/search", get(api::search));
|
||||
.route("/search", get(api::search))
|
||||
.route("/tracks/:slug/play", post(api::record_play))
|
||||
.route("/me/recent", get(api::recent_plays));
|
||||
|
||||
let authed = Router::new()
|
||||
.route("/", get(player_html))
|
||||
let api = Router::new()
|
||||
.nest("/api", library);
|
||||
|
||||
let has_oidc = state.oidc.is_some();
|
||||
let requires_auth = state.oidc.is_some();
|
||||
|
||||
let app = if has_oidc {
|
||||
authed
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
||||
let app = if requires_auth {
|
||||
api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
||||
} else {
|
||||
authed
|
||||
api
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD])
|
||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||
.max_age(Duration::from_secs(600));
|
||||
|
||||
Router::new()
|
||||
.route("/login", get(auth::login_page))
|
||||
.route("/logout", get(auth::logout))
|
||||
.route("/auth/login", get(auth::oidc_login))
|
||||
.route("/auth/callback", get(auth::oidc_callback))
|
||||
.merge(app)
|
||||
.layer(cors)
|
||||
.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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user