Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 761306eb9d | |||
| e85ed32b7b | |||
| e34440498c | |||
| f873542d02 | |||
| 3f2013e9d5 | |||
| 7ede23ff94 |
@@ -1,32 +0,0 @@
|
|||||||
---
|
|
||||||
description: REST API запросы — создавать функции в furumiApi.ts (только furumi-node-player/client)
|
|
||||||
globs: furumi-node-player/client/**/*.{ts,tsx}
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# REST API в furumi-node-player
|
|
||||||
|
|
||||||
**Область действия:** правило применяется только к проекту `furumi-node-player/client/`. В остальных частях репозитория можно использовать другой подход.
|
|
||||||
|
|
||||||
Чтобы выполнить REST API запрос, нужно создать функцию, которая принимает необходимые параметры и вызывает `furumiApi`. Все такие функции должны находиться в файле `furumi-node-player/client/src/furumiApi.ts`.
|
|
||||||
|
|
||||||
## Правила
|
|
||||||
|
|
||||||
1. **Не вызывать `furumiApi` напрямую** из компонентов или других модулей.
|
|
||||||
2. **Добавлять новую функцию** в `furumiApi.ts` для каждого эндпоинта.
|
|
||||||
3. Функция принимает нужные параметры и возвращает `Promise` с данными (или `null` при ошибке).
|
|
||||||
|
|
||||||
## Пример
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// furumiApi.ts — добавлять сюда
|
|
||||||
export async function getSomething(id: string) {
|
|
||||||
const res = await furumiApi.get(`/something/${id}`).catch(() => null)
|
|
||||||
return res?.data ?? null
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// FurumiPlayer.tsx — использовать функцию, не furumiApi напрямую
|
|
||||||
const data = await getSomething(id)
|
|
||||||
```
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
description: Новые React-компоненты — папка kebab-case, TSX + CSS module, index.ts (furumi-node-player/client/src)
|
|
||||||
globs: furumi-node-player/client/src/**/*
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Структура новых компонентов (furumi-node-player/client)
|
|
||||||
|
|
||||||
**Область:** при добавлении **нового** компонента в `furumi-node-player/client/src/` используйте расположение и файлы ниже.
|
|
||||||
|
|
||||||
## Расположение
|
|
||||||
|
|
||||||
- Базовая папка: `furumi-node-player/client/src/components/`
|
|
||||||
- Для каждого компонента — **отдельная подпапка** с именем в **kebab-case** (например `play-button`, `search-dropdown`).
|
|
||||||
|
|
||||||
## Файлы внутри папки компонента
|
|
||||||
|
|
||||||
1. **Компонент:** `components/<имя-kebab>/<имя-kebab>.tsx` — реализация компонента; к нему подключён CSS-модуль (`import styles from './<имя-kebab>.module.css'`).
|
|
||||||
2. **Стили:** `components/<имя-kebab>/<имя-kebab>.module.css` — модульные стили для этого компонента.
|
|
||||||
3. **Баррель:** `components/<имя-kebab>/index.ts` — **реэкспорт всего** из файла компонента: `export * from './<имя-kebab>'`.
|
|
||||||
|
|
||||||
Импорт снаружи: `import { MyWidget } from './components/my-widget'` (относительный путь к папке), без указания `index.ts`.
|
|
||||||
|
|
||||||
## Пример (`my-widget`)
|
|
||||||
|
|
||||||
```
|
|
||||||
components/my-widget/
|
|
||||||
my-widget.tsx
|
|
||||||
my-widget.module.css
|
|
||||||
index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// my-widget.tsx
|
|
||||||
import styles from './my-widget.module.css'
|
|
||||||
|
|
||||||
export function MyWidget() {
|
|
||||||
return <div className={styles.root}>…</div>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// index.ts
|
|
||||||
export * from './my-widget'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Примечание
|
|
||||||
|
|
||||||
Существующие файлы-компоненты в корне `components/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше.
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
name: Publish Node Player Image (dev)
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- DEV
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/Dockerfile.node-player
|
|
||||||
push: true
|
|
||||||
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
name: Publish Node Player Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
- '!DEV'
|
|
||||||
tags:
|
|
||||||
- 'v*.*.*'
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: docker.io
|
|
||||||
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Determine version and tags
|
|
||||||
id: info
|
|
||||||
run: |
|
|
||||||
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
|
||||||
SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)"
|
|
||||||
|
|
||||||
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
|
||||||
TAG="${{ github.ref_name }}"
|
|
||||||
VERSION="${TAG#v}"
|
|
||||||
echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: docker/Dockerfile.node-player
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.info.outputs.tags }}
|
|
||||||
cache-from: type=gha
|
|
||||||
cache-to: type=gha,mode=max
|
|
||||||
+2
-3
@@ -1,5 +1,4 @@
|
|||||||
/target
|
/target
|
||||||
/docker/inbox
|
/inbox
|
||||||
/docker/storage
|
/storage
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
|
||||||
|
|||||||
Generated
+1
-18
@@ -1140,7 +1140,7 @@ dependencies = [
|
|||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hmac",
|
"hmac",
|
||||||
"jsonwebtoken 10.3.0",
|
"jsonwebtoken",
|
||||||
"libc",
|
"libc",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"ogg",
|
"ogg",
|
||||||
@@ -1176,10 +1176,8 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
|
||||||
"clap",
|
"clap",
|
||||||
"hmac",
|
"hmac",
|
||||||
"jsonwebtoken 9.3.1",
|
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"openidconnect",
|
"openidconnect",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
@@ -1904,21 +1902,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jsonwebtoken"
|
|
||||||
version = "9.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
|
||||||
dependencies = [
|
|
||||||
"base64 0.22.1",
|
|
||||||
"js-sys",
|
|
||||||
"pem",
|
|
||||||
"ring",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"simple_asn1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonwebtoken"
|
name = "jsonwebtoken"
|
||||||
version = "10.3.0"
|
version = "10.3.0"
|
||||||
|
|||||||
@@ -8,35 +8,8 @@ RUN apt-get update && apt-get install -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
|
|
||||||
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
|
|
||||||
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
|
|
||||||
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
|
|
||||||
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
|
|
||||||
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
|
|
||||||
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
|
|
||||||
|
|
||||||
# 2. Create dummy sources so cargo can resolve and build dependencies
|
|
||||||
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
|
|
||||||
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
|
|
||||||
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
|
|
||||||
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
|
|
||||||
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
|
|
||||||
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
|
|
||||||
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
|
|
||||||
|
|
||||||
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
|
|
||||||
RUN cargo build --release --bin furumi-agent 2>/dev/null || true
|
|
||||||
|
|
||||||
# 4. Copy real source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
|
|
||||||
RUN touch furumi-common/src/lib.rs furumi-agent/src/main.rs
|
|
||||||
|
|
||||||
ARG FURUMI_VERSION=dev
|
ARG FURUMI_VERSION=dev
|
||||||
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
|
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
FROM node:22-alpine AS build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# 1. Install server dependencies (cached layer)
|
|
||||||
COPY furumi-node-player/server/package.json furumi-node-player/server/package-lock.json ./server/
|
|
||||||
RUN cd server && npm ci
|
|
||||||
|
|
||||||
# 2. Install client dependencies (cached layer)
|
|
||||||
COPY furumi-node-player/client/package.json furumi-node-player/client/package-lock.json ./client/
|
|
||||||
RUN cd client && npm ci
|
|
||||||
|
|
||||||
# 3. Build server
|
|
||||||
COPY furumi-node-player/server/ ./server/
|
|
||||||
RUN cd server && npm run build
|
|
||||||
|
|
||||||
# 4. Build client (VITE_FURUMI_API_URL empty = relative /api on same origin)
|
|
||||||
COPY furumi-node-player/client/ ./client/
|
|
||||||
RUN cd client && npm run build
|
|
||||||
|
|
||||||
FROM node:22-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Server runtime
|
|
||||||
COPY --from=build /app/server/dist ./server/dist
|
|
||||||
COPY --from=build /app/server/node_modules ./server/node_modules
|
|
||||||
COPY --from=build /app/server/package.json ./server/
|
|
||||||
|
|
||||||
# Client static files
|
|
||||||
COPY --from=build /app/client/dist ./client/dist
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3001
|
|
||||||
|
|
||||||
EXPOSE 3001
|
|
||||||
|
|
||||||
CMD ["node", "server/dist/index.js"]
|
|
||||||
@@ -8,35 +8,8 @@ RUN apt-get update && apt-get install -y \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# 1. Copy workspace manifests and lock file (changes rarely → cached layer)
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY furumi-common/Cargo.toml furumi-common/Cargo.toml
|
|
||||||
COPY furumi-server/Cargo.toml furumi-server/Cargo.toml
|
|
||||||
COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml
|
|
||||||
COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml
|
|
||||||
COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml
|
|
||||||
COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml
|
|
||||||
COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml
|
|
||||||
|
|
||||||
# 2. Create dummy sources so cargo can resolve and build dependencies
|
|
||||||
RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \
|
|
||||||
&& mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \
|
|
||||||
&& mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \
|
|
||||||
&& mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \
|
|
||||||
&& mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \
|
|
||||||
&& mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \
|
|
||||||
&& mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs
|
|
||||||
|
|
||||||
# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change)
|
|
||||||
RUN cargo build --release --bin furumi-web-player 2>/dev/null || true
|
|
||||||
|
|
||||||
# 4. Copy real source code
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps)
|
|
||||||
RUN touch furumi-common/src/lib.rs furumi-web-player/src/main.rs
|
|
||||||
|
|
||||||
ARG FURUMI_VERSION=dev
|
ARG FURUMI_VERSION=dev
|
||||||
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
|
RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ services:
|
|||||||
|
|
||||||
agent:
|
agent:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: .
|
||||||
dockerfile: docker/Dockerfile.agent
|
dockerfile: Dockerfile.agent
|
||||||
container_name: furumi-agent
|
container_name: furumi-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -25,12 +25,10 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8090:8090"
|
- "8090:8090"
|
||||||
environment:
|
environment:
|
||||||
RUST_LOG: info
|
|
||||||
FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
||||||
FURUMI_AGENT_INBOX_DIR: "/inbox"
|
FURUMI_AGENT_INBOX_DIR: "/inbox"
|
||||||
FURUMI_AGENT_STORAGE_DIR: "/storage"
|
FURUMI_AGENT_STORAGE_DIR: "/storage"
|
||||||
FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}"
|
||||||
FURUMI_AGENT_OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}"
|
|
||||||
FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}"
|
FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}"
|
||||||
FURUMI_PLAYER_BIND: "0.0.0.0:8090"
|
FURUMI_PLAYER_BIND: "0.0.0.0:8090"
|
||||||
FURUMI_AGENT_POLL_INTERVAL_SECS: 5
|
FURUMI_AGENT_POLL_INTERVAL_SECS: 5
|
||||||
@@ -43,8 +41,8 @@ services:
|
|||||||
|
|
||||||
web-player:
|
web-player:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: .
|
||||||
dockerfile: docker/Dockerfile.web-player
|
dockerfile: Dockerfile.web-player
|
||||||
container_name: furumi-web-player
|
container_name: furumi-web-player
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -55,11 +53,6 @@ services:
|
|||||||
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}"
|
||||||
FURUMI_PLAYER_STORAGE_DIR: "/storage"
|
FURUMI_PLAYER_STORAGE_DIR: "/storage"
|
||||||
FURUMI_PLAYER_BIND: "0.0.0.0:8085"
|
FURUMI_PLAYER_BIND: "0.0.0.0:8085"
|
||||||
FURUMI_PLAYER_OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}"
|
|
||||||
FURUMI_PLAYER_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
|
|
||||||
FURUMI_PLAYER_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
|
|
||||||
FURUMI_PLAYER_OIDC_REDIRECT_URL: "${OIDC_REDIRECT_URL}"
|
|
||||||
FURUMI_PLAYER_OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/storage
|
- ./storage:/storage
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada
|
|||||||
|
|
||||||
10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations.
|
10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations.
|
||||||
|
|
||||||
11. **Confidence**: MUST be a decimal number between 0.0 and 1.0 (e.g., 0.95, 0.7, 0.3). NEVER use words like "high", "medium", "low" — only a numeric float value.
|
11. **Confidence**: Rate your confidence from 0.0 to 1.0.
|
||||||
- 1.0: All fields are clear and unambiguous.
|
- 1.0: All fields are clear and unambiguous.
|
||||||
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
|
- 0.8+: Minor inferences made (e.g., year from path), but high certainty.
|
||||||
- 0.5-0.8: Some guesswork involved, human review recommended.
|
- 0.5-0.8: Some guesswork involved, human review recommended.
|
||||||
|
|||||||
@@ -25,37 +25,16 @@ pub async fn normalize(
|
|||||||
) -> anyhow::Result<NormalizedFields> {
|
) -> anyhow::Result<NormalizedFields> {
|
||||||
let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx);
|
let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx);
|
||||||
|
|
||||||
let schema = normalize_schema();
|
|
||||||
let response = call_ollama(
|
let response = call_ollama(
|
||||||
&state.config.ollama_url,
|
&state.config.ollama_url,
|
||||||
&state.config.ollama_model,
|
&state.config.ollama_model,
|
||||||
&state.system_prompt,
|
&state.system_prompt,
|
||||||
&user_message,
|
&user_message,
|
||||||
state.config.ollama_auth.as_deref(),
|
state.config.ollama_auth.as_deref(),
|
||||||
0.5,
|
|
||||||
512,
|
|
||||||
Some(("normalized_metadata", schema.clone())),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
match parse_response(&response) {
|
parse_response(&response)
|
||||||
Ok(fields) => Ok(fields),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(error = %e, "LLM parse failed, retrying with higher frequency_penalty");
|
|
||||||
let response2 = call_ollama(
|
|
||||||
&state.config.ollama_url,
|
|
||||||
&state.config.ollama_model,
|
|
||||||
&state.system_prompt,
|
|
||||||
&user_message,
|
|
||||||
state.config.ollama_auth.as_deref(),
|
|
||||||
1.5,
|
|
||||||
512,
|
|
||||||
Some(("normalized_metadata", schema)),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
parse_response(&response2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_user_message(
|
fn build_user_message(
|
||||||
@@ -137,12 +116,9 @@ fn build_user_message(
|
|||||||
struct ChatRequest {
|
struct ChatRequest {
|
||||||
model: String,
|
model: String,
|
||||||
messages: Vec<ChatMessage>,
|
messages: Vec<ChatMessage>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
response_format: ChatResponseFormat,
|
||||||
response_format: Option<ChatResponseFormat>,
|
|
||||||
stream: bool,
|
stream: bool,
|
||||||
temperature: f64,
|
temperature: f64,
|
||||||
max_tokens: u32,
|
|
||||||
frequency_penalty: f64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -155,14 +131,6 @@ struct ChatMessage {
|
|||||||
struct ChatResponseFormat {
|
struct ChatResponseFormat {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
kind: String,
|
kind: String,
|
||||||
json_schema: JsonSchemaWrapper,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct JsonSchemaWrapper {
|
|
||||||
name: String,
|
|
||||||
strict: bool,
|
|
||||||
schema: serde_json::Value,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -186,19 +154,11 @@ pub async fn call_ollama(
|
|||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
auth: Option<&str>,
|
auth: Option<&str>,
|
||||||
frequency_penalty: f64,
|
|
||||||
max_tokens: u32,
|
|
||||||
schema: Option<(&str, serde_json::Value)>,
|
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(120))
|
.timeout(std::time::Duration::from_secs(120))
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let response_format = schema.map(|(name, schema)| ChatResponseFormat {
|
|
||||||
kind: "json_schema".to_owned(),
|
|
||||||
json_schema: JsonSchemaWrapper { name: name.to_owned(), strict: true, schema },
|
|
||||||
});
|
|
||||||
|
|
||||||
let request = ChatRequest {
|
let request = ChatRequest {
|
||||||
model: model.to_owned(),
|
model: model.to_owned(),
|
||||||
messages: vec![
|
messages: vec![
|
||||||
@@ -211,11 +171,9 @@ pub async fn call_ollama(
|
|||||||
content: user_message.to_owned(),
|
content: user_message.to_owned(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
response_format,
|
response_format: ChatResponseFormat { kind: "json_object".to_owned() },
|
||||||
stream: false,
|
stream: false,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
max_tokens,
|
|
||||||
frequency_penalty,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
||||||
@@ -253,26 +211,6 @@ pub async fn call_ollama(
|
|||||||
Ok(content)
|
Ok(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_schema() -> serde_json::Value {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"artist": { "type": ["string", "null"] },
|
|
||||||
"album": { "type": ["string", "null"] },
|
|
||||||
"title": { "type": ["string", "null"] },
|
|
||||||
"year": { "type": ["integer", "null"] },
|
|
||||||
"track_number": { "type": ["integer", "null"] },
|
|
||||||
"genre": { "type": ["string", "null"] },
|
|
||||||
"featured_artists": { "type": "array", "items": { "type": "string" } },
|
|
||||||
"release_kind": { "type": ["string", "null"] },
|
|
||||||
"confidence": { "type": ["number", "null"] },
|
|
||||||
"notes": { "type": ["string", "null"] }
|
|
||||||
},
|
|
||||||
"required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_kind", "confidence", "notes"],
|
|
||||||
"additionalProperties": false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the LLM JSON response into NormalizedFields.
|
/// Parse the LLM JSON response into NormalizedFields.
|
||||||
/// Handles both clean JSON and JSON wrapped in markdown code fences.
|
/// Handles both clean JSON and JSON wrapped in markdown code fences.
|
||||||
fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
|
fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
|
||||||
@@ -297,7 +235,6 @@ fn parse_response(response: &str) -> anyhow::Result<NormalizedFields> {
|
|||||||
genre: Option<String>,
|
genre: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
featured_artists: Vec<String>,
|
featured_artists: Vec<String>,
|
||||||
#[serde(rename = "release_kind")]
|
|
||||||
release_type: Option<String>,
|
release_type: Option<String>,
|
||||||
confidence: Option<f64>,
|
confidence: Option<f64>,
|
||||||
notes: Option<String>,
|
notes: Option<String>,
|
||||||
|
|||||||
@@ -35,39 +35,12 @@ pub async fn propose_merge(state: &Arc<AppState>, merge_id: Uuid) -> anyhow::Res
|
|||||||
|
|
||||||
let user_message = build_merge_message(&artists_data);
|
let user_message = build_merge_message(&artists_data);
|
||||||
|
|
||||||
let schema = serde_json::json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"canonical_artist_name": { "type": "string" },
|
|
||||||
"winner_artist_id": { "type": "integer" },
|
|
||||||
"album_mappings": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"source_album_id": { "type": "integer" },
|
|
||||||
"canonical_name": { "type": "string" },
|
|
||||||
"merge_into_album_id": { "type": ["integer", "null"] }
|
|
||||||
},
|
|
||||||
"required": ["source_album_id", "canonical_name", "merge_into_album_id"],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"notes": { "type": "string" }
|
|
||||||
},
|
|
||||||
"required": ["canonical_artist_name", "winner_artist_id", "album_mappings", "notes"],
|
|
||||||
"additionalProperties": false
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = call_ollama(
|
let response = call_ollama(
|
||||||
&state.config.ollama_url,
|
&state.config.ollama_url,
|
||||||
&state.config.ollama_model,
|
&state.config.ollama_model,
|
||||||
&state.merge_prompt,
|
&state.merge_prompt,
|
||||||
&user_message,
|
&user_message,
|
||||||
state.config.ollama_auth.as_deref(),
|
state.config.ollama_auth.as_deref(),
|
||||||
0.5,
|
|
||||||
4096,
|
|
||||||
Some(("artist_merge", schema)),
|
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
let proposal = parse_merge_response(&response)?;
|
let proposal = parse_merge_response(&response)?;
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# Leave empty — vite proxy handles /api in dev, same-origin in production
|
|
||||||
VITE_FURUMI_API_URL=
|
|
||||||
+3
-392
@@ -8,11 +8,8 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4"
|
||||||
"react-redux": "^9.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
@@ -589,32 +586,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reduxjs/toolkit": {
|
|
||||||
"version": "2.11.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
|
||||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.0.0",
|
|
||||||
"@standard-schema/utils": "^0.3.0",
|
|
||||||
"immer": "^11.0.0",
|
|
||||||
"redux": "^5.0.1",
|
|
||||||
"redux-thunk": "^3.1.0",
|
|
||||||
"reselect": "^5.1.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
|
||||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"react-redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.10",
|
"version": "1.0.0-rc.10",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
|
||||||
@@ -877,18 +848,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/utils": {
|
|
||||||
"version": "0.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||||
@@ -928,7 +887,7 @@
|
|||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@@ -944,12 +903,6 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
|
||||||
"version": "0.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
|
||||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.57.1",
|
"version": "8.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
|
||||||
@@ -1334,23 +1287,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/asynckit": {
|
|
||||||
"version": "0.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
|
||||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/axios": {
|
|
||||||
"version": "1.13.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
|
||||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"follow-redirects": "^1.15.11",
|
|
||||||
"form-data": "^4.0.5",
|
|
||||||
"proxy-from-env": "^1.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -1416,19 +1352,6 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/callsites": {
|
"node_modules/callsites": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||||
@@ -1497,18 +1420,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/combined-stream": {
|
|
||||||
"version": "1.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
|
||||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"delayed-stream": "~1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -1542,7 +1453,7 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -1570,15 +1481,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/delayed-stream": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1589,20 +1491,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dunder-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.321",
|
"version": "1.5.321",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
|
||||||
@@ -1610,51 +1498,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/es-define-property": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-errors": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-object-atoms": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/es-set-tostringtag": {
|
|
||||||
"version": "2.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
|
||||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-intrinsic": "^1.2.6",
|
|
||||||
"has-tostringtag": "^1.0.2",
|
|
||||||
"hasown": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1952,42 +1795,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
|
||||||
"version": "1.15.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"debug": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/form-data": {
|
|
||||||
"version": "4.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"asynckit": "^0.4.0",
|
|
||||||
"combined-stream": "^1.0.8",
|
|
||||||
"es-set-tostringtag": "^2.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"mime-types": "^2.1.12"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2003,15 +1810,6 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/function-bind": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/gensync": {
|
"node_modules/gensync": {
|
||||||
"version": "1.0.0-beta.2",
|
"version": "1.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||||
@@ -2022,43 +1820,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-intrinsic": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
|
||||||
"es-define-property": "^1.0.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"es-object-atoms": "^1.1.1",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-symbols": "^1.1.0",
|
|
||||||
"hasown": "^2.0.2",
|
|
||||||
"math-intrinsics": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/get-proto": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"dunder-proto": "^1.0.1",
|
|
||||||
"es-object-atoms": "^1.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -2085,18 +1846,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/gopd": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -2107,45 +1856,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/has-symbols": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-tostringtag": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"has-symbols": "^1.0.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hasown": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"function-bind": "^1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/hermes-estree": {
|
"node_modules/hermes-estree": {
|
||||||
"version": "0.25.1",
|
"version": "0.25.1",
|
||||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||||
@@ -2173,16 +1883,6 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/immer": {
|
|
||||||
"version": "11.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
|
||||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/immer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/import-fresh": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -2625,36 +2325,6 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/math-intrinsics": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -2850,12 +2520,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-from-env": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/punycode": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -2887,50 +2551,6 @@
|
|||||||
"react": "^19.2.4"
|
"react": "^19.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-redux": {
|
|
||||||
"version": "9.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
|
||||||
"use-sync-external-store": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "^18.2.25 || ^19",
|
|
||||||
"react": "^18.0 || ^19",
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"redux": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/redux": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/redux-thunk": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"redux": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reselect": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||||
@@ -3194,15 +2814,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
|
||||||
|
|||||||
@@ -10,11 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reduxjs/toolkit": "^2.11.2",
|
|
||||||
"axios": "^1.7.9",
|
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4"
|
||||||
"react-redux": "^9.2.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
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,102 +1,71 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
.page {
|
||||||
|
|
||||||
.auth-page {
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: #0a0c12;
|
|
||||||
font-family: 'Inter', system-ui, sans-serif;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- loading spinner ---------- */
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
.auth-loading {
|
.subtitle {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #5a6475;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #e6eaf2;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 20px;
|
gap: 10px;
|
||||||
}
|
color: #0f172a;
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border: 3px solid #1f2c45;
|
|
||||||
border-top-color: #7c6af7;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: #5a6475;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
background: #2251ff;
|
||||||
transition: background 0.2s;
|
color: #ffffff;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card .btn-login:hover {
|
.btn.ghost {
|
||||||
background: #6b58e8;
|
background: #edf1ff;
|
||||||
|
color: #1e3fc4;
|
||||||
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card .error {
|
.profile p {
|
||||||
color: #f87171;
|
margin: 8px 0;
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #cc1e1e;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { FurumiPlayer } from './FurumiPlayer'
|
import { FurumiPlayer } from './FurumiPlayer'
|
||||||
import { setAuthToken, clearAuthToken } from './furumiApi'
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
|
||||||
type UserProfile = {
|
type UserProfile = {
|
||||||
@@ -9,19 +8,38 @@ type UserProfile = {
|
|||||||
email?: string
|
email?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [user, setUser] = useState<UserProfile | null>(null)
|
const [user, setUser] = useState<UserProfile | null>(null)
|
||||||
const [error, setError] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
|
if (runWithoutAuth) {
|
||||||
|
setError(null)
|
||||||
|
setUser({ sub: 'noauth', name: 'No Auth' })
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const loadMe = async () => {
|
const loadMe = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/auth/me', { credentials: 'include' })
|
const response = await fetch(`${apiBase}/api/me`, {
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
setUser(null)
|
setUser(null)
|
||||||
clearAuthToken()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,20 +49,6 @@ function App() {
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setUser(data.user ?? null)
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load session')
|
setError(err instanceof Error ? err.message : 'Failed to load session')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -53,40 +57,85 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void loadMe()
|
void loadMe()
|
||||||
}, [])
|
}, [apiBase, runWithoutAuth])
|
||||||
|
|
||||||
// Authenticated — render player immediately
|
const loginUrl = `${apiBase}/api/login`
|
||||||
if (!loading && user) {
|
const logoutUrl = `${apiBase}/api/logout`
|
||||||
return <FurumiPlayer user={user} />
|
const playerApiRoot = `${apiBase}/api`
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
<main className="auth-page">
|
<>
|
||||||
<section className="auth-card">
|
{!loading && (user || runWithoutAuth) ? (
|
||||||
<div className="logo">Furumi</div>
|
<FurumiPlayer apiRoot={playerApiRoot} />
|
||||||
<p className="subtitle">Sign in to continue</p>
|
) : (
|
||||||
|
<main className="page">
|
||||||
|
<section className="card">
|
||||||
|
<h1>OIDC Login</h1>
|
||||||
|
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
|
||||||
|
|
||||||
{error && <p className="error">{error}</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>
|
||||||
|
|
||||||
<a className="btn-login" href="/auth/login">
|
{loading && <p>Проверяю сессию...</p>}
|
||||||
Sign in with SSO
|
{error && <p className="error">Ошибка: {error}</p>}
|
||||||
</a>
|
|
||||||
</section>
|
{!loading && runWithoutAuth && (
|
||||||
</main>
|
<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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,194 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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} />
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import type { MouseEvent as ReactMouseEvent } from 'react'
|
|
||||||
import { useAppDispatch, useAppSelector } from '../store'
|
|
||||||
import {
|
|
||||||
selectPlayingOrigIdx,
|
|
||||||
selectQueueItems,
|
|
||||||
selectQueueOrder,
|
|
||||||
selectQueueScrollSignal,
|
|
||||||
selectRepeatAll,
|
|
||||||
selectShuffle,
|
|
||||||
toggleRepeat,
|
|
||||||
toggleShuffle,
|
|
||||||
} from '../store/slices/queueSlice'
|
|
||||||
import { Breadcrumbs } from './Breadcrumbs'
|
|
||||||
import { LibraryList } from './LibraryList'
|
|
||||||
import { QueueList } from './QueueList'
|
|
||||||
|
|
||||||
export type Crumb = { label: string; action?: () => void }
|
|
||||||
|
|
||||||
export type LibraryListItem = {
|
|
||||||
key: string
|
|
||||||
className: string
|
|
||||||
icon: string
|
|
||||||
name: string
|
|
||||||
detail?: string
|
|
||||||
nameClassName?: string
|
|
||||||
onClick: () => void
|
|
||||||
button?: { title: string; onClick: (ev: ReactMouseEvent<HTMLButtonElement>) => void }
|
|
||||||
}
|
|
||||||
|
|
||||||
type MainPanelProps = {
|
|
||||||
breadcrumbs: Crumb[]
|
|
||||||
libraryLoading: boolean
|
|
||||||
libraryError: string | null
|
|
||||||
libraryItems: LibraryListItem[]
|
|
||||||
onQueuePlay: (origIdx: number) => void
|
|
||||||
onQueueRemove: (origIdx: number) => void
|
|
||||||
onQueueMove: (fromPos: number, toPos: number) => void
|
|
||||||
onClearQueue: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MainPanel({
|
|
||||||
breadcrumbs,
|
|
||||||
libraryLoading,
|
|
||||||
libraryError,
|
|
||||||
libraryItems,
|
|
||||||
onQueuePlay,
|
|
||||||
onQueueRemove,
|
|
||||||
onQueueMove,
|
|
||||||
onClearQueue,
|
|
||||||
}: MainPanelProps) {
|
|
||||||
const dispatch = useAppDispatch()
|
|
||||||
const queueItemsView = useAppSelector(selectQueueItems)
|
|
||||||
const queueOrderView = useAppSelector(selectQueueOrder)
|
|
||||||
const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx)
|
|
||||||
const queueScrollSignal = useAppSelector(selectQueueScrollSignal)
|
|
||||||
const shuffle = useAppSelector(selectShuffle)
|
|
||||||
const repeatAll = useAppSelector(selectRepeatAll)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="main">
|
|
||||||
<div className="sidebar-overlay" id="sidebarOverlay" />
|
|
||||||
<aside className="sidebar" id="sidebar">
|
|
||||||
<div className="sidebar-header">Library</div>
|
|
||||||
<Breadcrumbs items={breadcrumbs} />
|
|
||||||
<div className="file-list" id="fileList">
|
|
||||||
<LibraryList loading={libraryLoading} error={libraryError} items={libraryItems} />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<section className="queue-panel">
|
|
||||||
<div className="queue-header">
|
|
||||||
<span>Queue</span>
|
|
||||||
<div className="queue-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`queue-btn${shuffle ? ' active' : ''}`}
|
|
||||||
onClick={() => dispatch(toggleShuffle())}
|
|
||||||
>
|
|
||||||
Shuffle
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`queue-btn${repeatAll ? ' active' : ''}`}
|
|
||||||
onClick={() => dispatch(toggleRepeat())}
|
|
||||||
>
|
|
||||||
Repeat
|
|
||||||
</button>
|
|
||||||
<button type="button" className="queue-btn" onClick={onClearQueue}>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="queue-list" id="queueList">
|
|
||||||
<QueueList
|
|
||||||
queue={queueItemsView}
|
|
||||||
order={queueOrderView}
|
|
||||||
playingOrigIdx={queuePlayingOrigIdxView}
|
|
||||||
scrollSignal={queueScrollSignal}
|
|
||||||
onPlay={onQueuePlay}
|
|
||||||
onRemove={onQueueRemove}
|
|
||||||
onMove={onQueueMove}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { AuthImg } from './AuthImg'
|
|
||||||
import type { QueueItem } from './QueueList'
|
import type { QueueItem } from './QueueList'
|
||||||
|
|
||||||
function Cover({ slug }: { slug: string }) {
|
function Cover({ src }: { src: string }) {
|
||||||
const [errored, setErrored] = useState(false)
|
const [errored, setErrored] = useState(false)
|
||||||
const src = `/tracks/${slug}/cover`
|
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
if (errored) return <>🎵</>
|
if (errored) return <>🎵</>
|
||||||
return <AuthImg src={src} alt="" onError={() => setErrored(true)} />
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NowPlaying({ track }: { track: QueueItem | null }) {
|
export function NowPlaying({ apiRoot, track }: { apiRoot: string; track: QueueItem | null }) {
|
||||||
if (!track) {
|
if (!track) {
|
||||||
return (
|
return (
|
||||||
<div className="np-info">
|
<div className="np-info">
|
||||||
@@ -29,10 +31,12 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const coverUrl = `${apiRoot}/tracks/${track.slug}/cover`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="np-info">
|
<div className="np-info">
|
||||||
<div className="np-cover" id="npCover">
|
<div className="np-cover" id="npCover">
|
||||||
<Cover slug={track.slug} />
|
<Cover src={coverUrl} />
|
||||||
</div>
|
</div>
|
||||||
<div className="np-text">
|
<div className="np-text">
|
||||||
<div className="np-title" id="npTitle">
|
<div className="np-title" id="npTitle">
|
||||||
@@ -45,3 +49,4 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { NowPlaying } from './NowPlaying'
|
|
||||||
import { QueuePopover } from './queue-popover'
|
|
||||||
import type { QueueItem } from './QueueList'
|
|
||||||
|
|
||||||
type PlayerBarProps = {
|
|
||||||
track: QueueItem | null
|
|
||||||
queue: QueueItem[]
|
|
||||||
order: number[]
|
|
||||||
playingOrigIdx: number
|
|
||||||
scrollSignal: number
|
|
||||||
onQueuePlay: (origIdx: number) => void
|
|
||||||
onQueueRemove: (origIdx: number) => void
|
|
||||||
onQueueMove: (fromPos: number, toPos: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlayerBar({
|
|
||||||
track,
|
|
||||||
queue,
|
|
||||||
order,
|
|
||||||
playingOrigIdx,
|
|
||||||
scrollSignal,
|
|
||||||
onQueuePlay,
|
|
||||||
onQueueRemove,
|
|
||||||
onQueueMove,
|
|
||||||
}: PlayerBarProps) {
|
|
||||||
return (
|
|
||||||
<div className="player-bar">
|
|
||||||
<NowPlaying track={track} />
|
|
||||||
<div className="controls">
|
|
||||||
<div className="ctrl-btns">
|
|
||||||
<button className="ctrl-btn" id="btnPrev">
|
|
||||||
⏮
|
|
||||||
</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,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { AuthImg } from './AuthImg'
|
|
||||||
|
|
||||||
export type QueueItem = {
|
export type QueueItem = {
|
||||||
slug: string
|
slug: string
|
||||||
@@ -10,6 +9,7 @@ export type QueueItem = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type QueueListProps = {
|
type QueueListProps = {
|
||||||
|
apiRoot: string
|
||||||
queue: QueueItem[]
|
queue: QueueItem[]
|
||||||
order: number[]
|
order: number[]
|
||||||
playingOrigIdx: number
|
playingOrigIdx: number
|
||||||
@@ -32,13 +32,18 @@ function fmt(secs: number) {
|
|||||||
return `${m}:${pad(s % 60)}`
|
return `${m}:${pad(s % 60)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function Cover({ slug }: { slug: string }) {
|
function Cover({ src }: { src: string }) {
|
||||||
const [errored, setErrored] = useState(false)
|
const [errored, setErrored] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
setErrored(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
if (errored) return <>🎵</>
|
if (errored) return <>🎵</>
|
||||||
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
|
return <img src={src} alt="" onError={() => setErrored(true)} />
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QueueList({
|
export function QueueList({
|
||||||
|
apiRoot,
|
||||||
queue,
|
queue,
|
||||||
order,
|
order,
|
||||||
playingOrigIdx,
|
playingOrigIdx,
|
||||||
@@ -73,7 +78,7 @@ export function QueueList({
|
|||||||
if (!t) return null
|
if (!t) return null
|
||||||
|
|
||||||
const isPlaying = origIdx === playingOrigIdx
|
const isPlaying = origIdx === playingOrigIdx
|
||||||
const hasAlbum = !!t.album_slug
|
const coverSrc = t.album_slug ? `${apiRoot}/tracks/${t.slug}/cover` : ''
|
||||||
const dur = t.duration ? fmt(t.duration) : ''
|
const dur = t.duration ? fmt(t.duration) : ''
|
||||||
const isDragging = draggingPos === pos
|
const isDragging = draggingPos === pos
|
||||||
const isDragOver = dragOverPos === pos
|
const isDragOver = dragOverPos === pos
|
||||||
@@ -114,7 +119,7 @@ export function QueueList({
|
|||||||
>
|
>
|
||||||
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
|
||||||
<div className="qi-cover">
|
<div className="qi-cover">
|
||||||
{hasAlbum ? <Cover slug={t.slug} /> : <>🎵</>}
|
{coverSrc ? <Cover src={coverSrc} /> : <>🎵</>}
|
||||||
</div>
|
</div>
|
||||||
<div className="qi-info">
|
<div className="qi-info">
|
||||||
<div className="qi-title">{t.title}</div>
|
<div className="qi-title">{t.title}</div>
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: var(--bg-panel);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
flex-shrink: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerLogo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerLogo svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.headerVersion {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
background: rgba(255, 255, 255, 0.05);
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
font-weight: 500;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './Header'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './queue-popover'
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
.root {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.35rem;
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
cursor: pointer;
|
|
||||||
font: inherit;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trigger:hover {
|
|
||||||
color: var(--text);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.triggerIcon {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popover {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 0.5rem);
|
|
||||||
right: 0;
|
|
||||||
z-index: 60;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: min(100vw - 2rem, 320px);
|
|
||||||
max-width: min(100vw - 2rem, 360px);
|
|
||||||
max-height: min(50vh, 360px);
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--bg-card);
|
|
||||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body :global(.queue-empty) {
|
|
||||||
padding: 1.25rem 0.75rem;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { useEffect, useId, useRef, useState } from 'react'
|
|
||||||
import { QueueList, type QueueItem } from '../QueueList'
|
|
||||||
import styles from './queue-popover.module.css'
|
|
||||||
|
|
||||||
export type QueuePopoverProps = {
|
|
||||||
queue: QueueItem[]
|
|
||||||
order: number[]
|
|
||||||
playingOrigIdx: number
|
|
||||||
scrollSignal: number
|
|
||||||
onPlay: (origIdx: number) => void
|
|
||||||
onRemove: (origIdx: number) => void
|
|
||||||
onMove: (fromPos: number, toPos: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QueuePopover({
|
|
||||||
queue,
|
|
||||||
order,
|
|
||||||
playingOrigIdx,
|
|
||||||
scrollSignal,
|
|
||||||
onPlay,
|
|
||||||
onRemove,
|
|
||||||
onMove,
|
|
||||||
}: QueuePopoverProps) {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
const rootRef = useRef<HTMLDivElement>(null)
|
|
||||||
const titleId = useId()
|
|
||||||
const panelId = useId()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
function onDocMouseDown(e: MouseEvent) {
|
|
||||||
const el = rootRef.current
|
|
||||||
if (el && !el.contains(e.target as Node)) setOpen(false)
|
|
||||||
}
|
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Escape') setOpen(false)
|
|
||||||
}
|
|
||||||
document.addEventListener('mousedown', onDocMouseDown)
|
|
||||||
document.addEventListener('keydown', onKey)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', onDocMouseDown)
|
|
||||||
document.removeEventListener('keydown', onKey)
|
|
||||||
}
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.root} ref={rootRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.trigger}
|
|
||||||
title="Playback queue"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-controls={open ? panelId : undefined}
|
|
||||||
onClick={() => setOpen((v) => !v)}
|
|
||||||
>
|
|
||||||
<span className={styles.triggerIcon} aria-hidden>
|
|
||||||
☰
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div
|
|
||||||
id={panelId}
|
|
||||||
className={styles.popover}
|
|
||||||
role="dialog"
|
|
||||||
aria-labelledby={titleId}
|
|
||||||
>
|
|
||||||
<div className={styles.header} id={titleId}>
|
|
||||||
Queue
|
|
||||||
</div>
|
|
||||||
<div className={styles.body}>
|
|
||||||
<QueueList
|
|
||||||
queue={queue}
|
|
||||||
order={order}
|
|
||||||
playingOrigIdx={playingOrigIdx}
|
|
||||||
scrollSignal={scrollSignal}
|
|
||||||
onPlay={onPlay}
|
|
||||||
onRemove={onRemove}
|
|
||||||
onMove={onMove}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -31,6 +31,40 @@
|
|||||||
--danger: #f87171;
|
--danger: #f87171;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-logo svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-version {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-menu {
|
.btn-menu {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -344,10 +378,6 @@
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qi-title {
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.queue-item .qi-index {
|
.queue-item .qi-index {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -501,7 +531,6 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: #ffffff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.np-artist {
|
.np-artist {
|
||||||
|
|||||||
@@ -1,109 +1,12 @@
|
|||||||
import axios from 'axios'
|
export type FurumiApiClient = (path: string) => Promise<unknown | null>
|
||||||
import type { Album, Artist, SearchResult, Track, TrackDetail } from './types'
|
|
||||||
|
|
||||||
const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? ''
|
export function createFurumiApiClient(apiRoot: string): FurumiApiClient {
|
||||||
export const API_ROOT = `${FURUMI_API_BASE}/api`
|
const API = apiRoot
|
||||||
|
|
||||||
export const furumiApi = axios.create({
|
return async function api(path: string) {
|
||||||
baseURL: API_ROOT,
|
const r = await fetch(API + path)
|
||||||
})
|
if (!r.ok) return null
|
||||||
|
return r.json()
|
||||||
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,8 +1,3 @@
|
|||||||
html,
|
|
||||||
body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
@@ -10,10 +5,6 @@ body {
|
|||||||
background-color: #f3f6fb;
|
background-color: #f3f6fb;
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { Provider } from 'react-redux'
|
|
||||||
import { store } from './store'
|
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.register('/sw.js')
|
|
||||||
}
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<Provider store={store}>
|
<App />
|
||||||
<App />
|
|
||||||
</Provider>
|
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit'
|
|
||||||
import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux'
|
|
||||||
import artistsReducer from './slices/artistsSlice'
|
|
||||||
import albumsReducer from './slices/albumsSlice'
|
|
||||||
import albumTracksReducer from './slices/albumTracksSlice'
|
|
||||||
import artistTracksReducer from './slices/artistTracksSlice'
|
|
||||||
import trackDetailReducer from './slices/trackDetailSlice'
|
|
||||||
import queueReducer from './slices/queueSlice'
|
|
||||||
|
|
||||||
export const store = configureStore({
|
|
||||||
reducer: {
|
|
||||||
artists: artistsReducer,
|
|
||||||
albums: albumsReducer,
|
|
||||||
albumTracks: albumTracksReducer,
|
|
||||||
artistTracks: artistTracksReducer,
|
|
||||||
trackDetail: trackDetailReducer,
|
|
||||||
queue: queueReducer,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export type RootState = ReturnType<typeof store.getState>
|
|
||||||
export type AppDispatch = typeof store.dispatch
|
|
||||||
|
|
||||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
|
||||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
|
||||||
import type { Track } from '../../types'
|
|
||||||
import { getAlbumTracks } from '../../furumiApi'
|
|
||||||
|
|
||||||
export const fetchAlbumTracks = createAsyncThunk(
|
|
||||||
'albumTracks/fetch',
|
|
||||||
async (albumSlug: string, { rejectWithValue }) => {
|
|
||||||
const data = await getAlbumTracks(albumSlug)
|
|
||||||
if (data === null) return rejectWithValue('Failed to fetch album tracks')
|
|
||||||
return { albumSlug, tracks: data }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
interface AlbumTracksState {
|
|
||||||
byAlbum: Record<string, Track[]>
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: AlbumTracksState = {
|
|
||||||
byAlbum: {},
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumTracksSlice = createSlice({
|
|
||||||
name: 'albumTracks',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
clearAlbumTracks(state) {
|
|
||||||
state.byAlbum = {}
|
|
||||||
state.error = null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder
|
|
||||||
.addCase(fetchAlbumTracks.pending, (state) => {
|
|
||||||
state.loading = true
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchAlbumTracks.fulfilled, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.byAlbum[action.payload.albumSlug] = action.payload.tracks
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchAlbumTracks.rejected, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.error = action.payload as string ?? 'Unknown error'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const { clearAlbumTracks } = albumTracksSlice.actions
|
|
||||||
export default albumTracksSlice.reducer
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
|
||||||
import type { Album } from '../../types'
|
|
||||||
import { getArtistAlbums } from '../../furumiApi'
|
|
||||||
|
|
||||||
export const fetchArtistAlbums = createAsyncThunk(
|
|
||||||
'albums/fetchByArtist',
|
|
||||||
async (artistSlug: string, { rejectWithValue }) => {
|
|
||||||
const data = await getArtistAlbums(artistSlug)
|
|
||||||
if (data === null) return rejectWithValue('Failed to fetch albums')
|
|
||||||
return { artistSlug, albums: data }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
interface AlbumsState {
|
|
||||||
byArtist: Record<string, Album[]>
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: AlbumsState = {
|
|
||||||
byArtist: {},
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const albumsSlice = createSlice({
|
|
||||||
name: 'albums',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
clearAlbums(state) {
|
|
||||||
state.byArtist = {}
|
|
||||||
state.error = null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder
|
|
||||||
.addCase(fetchArtistAlbums.pending, (state) => {
|
|
||||||
state.loading = true
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchArtistAlbums.fulfilled, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.byArtist[action.payload.artistSlug] = action.payload.albums
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchArtistAlbums.rejected, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.error = action.payload as string ?? 'Unknown error'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const { clearAlbums } = albumsSlice.actions
|
|
||||||
export default albumsSlice.reducer
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
|
||||||
import type { Track } from '../../types'
|
|
||||||
import { getArtistTracks } from '../../furumiApi'
|
|
||||||
|
|
||||||
export const fetchArtistTracks = createAsyncThunk(
|
|
||||||
'artistTracks/fetch',
|
|
||||||
async (artistSlug: string, { rejectWithValue }) => {
|
|
||||||
const data = await getArtistTracks(artistSlug)
|
|
||||||
if (data === null) return rejectWithValue('Failed to fetch artist tracks')
|
|
||||||
return { artistSlug, tracks: data }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ArtistTracksState {
|
|
||||||
byArtist: Record<string, Track[]>
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ArtistTracksState = {
|
|
||||||
byArtist: {},
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const artistTracksSlice = createSlice({
|
|
||||||
name: 'artistTracks',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
clearArtistTracks(state) {
|
|
||||||
state.byArtist = {}
|
|
||||||
state.error = null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder
|
|
||||||
.addCase(fetchArtistTracks.pending, (state) => {
|
|
||||||
state.loading = true
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchArtistTracks.fulfilled, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.byArtist[action.payload.artistSlug] = action.payload.tracks
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchArtistTracks.rejected, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.error = action.payload as string ?? 'Unknown error'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const { clearArtistTracks } = artistTracksSlice.actions
|
|
||||||
export default artistTracksSlice.reducer
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
|
||||||
import type { Artist } from '../../types'
|
|
||||||
import { getArtists } from '../../furumiApi'
|
|
||||||
|
|
||||||
export const fetchArtists = createAsyncThunk(
|
|
||||||
'artists/fetch',
|
|
||||||
async (_, { rejectWithValue }) => {
|
|
||||||
const data = await getArtists()
|
|
||||||
if (data === null) return rejectWithValue('Failed to fetch artists')
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ArtistsState {
|
|
||||||
items: Artist[]
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ArtistsState = {
|
|
||||||
items: [],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const artistsSlice = createSlice({
|
|
||||||
name: 'artists',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
clearArtists(state) {
|
|
||||||
state.items = []
|
|
||||||
state.error = null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder
|
|
||||||
.addCase(fetchArtists.pending, (state) => {
|
|
||||||
state.loading = true
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchArtists.fulfilled, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.items = action.payload
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchArtists.rejected, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.error = action.payload as string ?? 'Unknown error'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const { clearArtists } = artistsSlice.actions
|
|
||||||
export default artistsSlice.reducer
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
|
||||||
import type { QueueItem } from '../../components/QueueList'
|
|
||||||
|
|
||||||
export interface QueueState {
|
|
||||||
items: QueueItem[]
|
|
||||||
currentIndex: number
|
|
||||||
shuffle: boolean
|
|
||||||
repeatAll: boolean
|
|
||||||
shuffleOrder: number[]
|
|
||||||
scrollSignal: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function readShufflePref(): boolean {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem('furumi_shuffle') === '1'
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRepeatPref(): boolean {
|
|
||||||
try {
|
|
||||||
return window.localStorage.getItem('furumi_repeat') !== '0'
|
|
||||||
} catch {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildShuffleOrder(state: QueueState) {
|
|
||||||
const n = state.items.length
|
|
||||||
if (n === 0) {
|
|
||||||
state.shuffleOrder = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const order = [...Array(n).keys()]
|
|
||||||
for (let i = order.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1))
|
|
||||||
;[order[i], order[j]] = [order[j], order[i]]
|
|
||||||
}
|
|
||||||
if (state.currentIndex !== -1) {
|
|
||||||
const ci = order.indexOf(state.currentIndex)
|
|
||||||
if (ci > 0) {
|
|
||||||
order.splice(ci, 1)
|
|
||||||
order.unshift(state.currentIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.shuffleOrder = order
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureShuffleOrder(state: QueueState) {
|
|
||||||
if (!state.shuffle) return
|
|
||||||
if (state.shuffleOrder.length !== state.items.length) {
|
|
||||||
buildShuffleOrder(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: QueueState = {
|
|
||||||
items: [],
|
|
||||||
currentIndex: -1,
|
|
||||||
shuffle: typeof window !== 'undefined' ? readShufflePref() : false,
|
|
||||||
repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true,
|
|
||||||
shuffleOrder: [],
|
|
||||||
scrollSignal: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const queueSlice = createSlice({
|
|
||||||
name: 'queue',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
addTrack(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
track: QueueItem
|
|
||||||
playNow?: boolean
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const { track, playNow } = action.payload
|
|
||||||
const existing = state.items.findIndex((t) => t.slug === track.slug)
|
|
||||||
if (existing !== -1) {
|
|
||||||
if (playNow) {
|
|
||||||
state.currentIndex = existing
|
|
||||||
state.scrollSignal += 1
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const oldLen = state.items.length
|
|
||||||
const idle = state.currentIndex === -1
|
|
||||||
state.items.push(track)
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
if (playNow || (oldLen === 0 && idle)) {
|
|
||||||
state.currentIndex = state.items.length - 1
|
|
||||||
state.scrollSignal += 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
addTracksBatch(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
tracks: QueueItem[]
|
|
||||||
playFirst?: boolean
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const { tracks, playFirst } = action.payload
|
|
||||||
let firstNewIdx: number | null = null
|
|
||||||
for (const t of tracks) {
|
|
||||||
if (state.items.some((q) => q.slug === t.slug)) continue
|
|
||||||
if (firstNewIdx === null) firstNewIdx = state.items.length
|
|
||||||
state.items.push(t)
|
|
||||||
}
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
if (firstNewIdx === null) return
|
|
||||||
if (playFirst || state.currentIndex === -1) {
|
|
||||||
state.currentIndex = firstNewIdx
|
|
||||||
state.scrollSignal += 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
replaceQueue(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{
|
|
||||||
items: QueueItem[]
|
|
||||||
playFromIndex?: number
|
|
||||||
}>,
|
|
||||||
) {
|
|
||||||
const { items, playFromIndex = 0 } = action.payload
|
|
||||||
state.items = items
|
|
||||||
state.currentIndex =
|
|
||||||
items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1
|
|
||||||
state.shuffleOrder = []
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
},
|
|
||||||
|
|
||||||
clearQueue(state) {
|
|
||||||
state.items = []
|
|
||||||
state.currentIndex = -1
|
|
||||||
state.shuffleOrder = []
|
|
||||||
state.scrollSignal += 1
|
|
||||||
},
|
|
||||||
|
|
||||||
playAtIndex(state, action: PayloadAction<number>) {
|
|
||||||
const i = action.payload
|
|
||||||
if (i < 0 || i >= state.items.length) return
|
|
||||||
state.currentIndex = i
|
|
||||||
state.scrollSignal += 1
|
|
||||||
},
|
|
||||||
|
|
||||||
removeFromQueueAt(state, action: PayloadAction<number>) {
|
|
||||||
const idx = action.payload
|
|
||||||
if (idx < 0 || idx >= state.items.length) return
|
|
||||||
|
|
||||||
if (idx === state.currentIndex) {
|
|
||||||
state.currentIndex = -1
|
|
||||||
} else if (state.currentIndex > idx) {
|
|
||||||
state.currentIndex -= 1
|
|
||||||
}
|
|
||||||
|
|
||||||
state.items.splice(idx, 1)
|
|
||||||
|
|
||||||
if (state.shuffle) {
|
|
||||||
const si = state.shuffleOrder.indexOf(idx)
|
|
||||||
if (si !== -1) state.shuffleOrder.splice(si, 1)
|
|
||||||
for (let i = 0; i < state.shuffleOrder.length; i++) {
|
|
||||||
if (state.shuffleOrder[i] > idx) state.shuffleOrder[i] -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureShuffleOrder(state)
|
|
||||||
},
|
|
||||||
|
|
||||||
moveQueueItemInOrder(
|
|
||||||
state,
|
|
||||||
action: PayloadAction<{ fromPos: number; toPos: number }>,
|
|
||||||
) {
|
|
||||||
const { fromPos, toPos } = action.payload
|
|
||||||
if (fromPos === toPos) return
|
|
||||||
|
|
||||||
if (state.shuffle) {
|
|
||||||
const order = state.shuffleOrder
|
|
||||||
if (fromPos < 0 || fromPos >= order.length) return
|
|
||||||
if (toPos < 0 || toPos >= order.length) return
|
|
||||||
const item = order.splice(fromPos, 1)[0]
|
|
||||||
order.splice(toPos, 0, item)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = state.items
|
|
||||||
if (fromPos < 0 || fromPos >= items.length) return
|
|
||||||
if (toPos < 0 || toPos >= items.length) return
|
|
||||||
const qIdx = state.currentIndex
|
|
||||||
const item = items.splice(fromPos, 1)[0]
|
|
||||||
items.splice(toPos, 0, item)
|
|
||||||
if (qIdx === fromPos) state.currentIndex = toPos
|
|
||||||
else if (fromPos < qIdx && toPos >= qIdx) state.currentIndex -= 1
|
|
||||||
else if (fromPos > qIdx && toPos <= qIdx) state.currentIndex += 1
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleShuffle(state) {
|
|
||||||
state.shuffle = !state.shuffle
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem('furumi_shuffle', state.shuffle ? '1' : '0')
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
if (state.shuffle) buildShuffleOrder(state)
|
|
||||||
else state.shuffleOrder = []
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleRepeat(state) {
|
|
||||||
state.repeatAll = !state.repeatAll
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem('furumi_repeat', state.repeatAll ? '1' : '0')
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
rebuildShuffleOrder(state) {
|
|
||||||
if (state.shuffle) buildShuffleOrder(state)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const {
|
|
||||||
addTrack,
|
|
||||||
addTracksBatch,
|
|
||||||
replaceQueue,
|
|
||||||
clearQueue,
|
|
||||||
playAtIndex,
|
|
||||||
removeFromQueueAt,
|
|
||||||
moveQueueItemInOrder,
|
|
||||||
toggleShuffle,
|
|
||||||
toggleRepeat,
|
|
||||||
rebuildShuffleOrder,
|
|
||||||
} = queueSlice.actions
|
|
||||||
|
|
||||||
type QueueSliceRoot = { queue: QueueState }
|
|
||||||
|
|
||||||
export function selectQueueItems(state: QueueSliceRoot) {
|
|
||||||
return state.queue.items
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: toggle shuffle should rebuild the shuffle order
|
|
||||||
export function selectQueueOrder(state: QueueSliceRoot): number[] {
|
|
||||||
const q = state.queue
|
|
||||||
if (!q.shuffle) return q.items.map((_, i) => i)
|
|
||||||
if (q.shuffleOrder.length !== q.items.length) {
|
|
||||||
return q.items.map((_, i) => i)
|
|
||||||
}
|
|
||||||
return q.shuffleOrder
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectPlayingOrigIdx(state: QueueSliceRoot) {
|
|
||||||
return state.queue.currentIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectQueueScrollSignal(state: QueueSliceRoot) {
|
|
||||||
return state.queue.scrollSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectNowPlayingTrack(state: QueueSliceRoot): QueueItem | null {
|
|
||||||
const q = state.queue
|
|
||||||
if (q.currentIndex < 0 || q.currentIndex >= q.items.length) return null
|
|
||||||
return q.items[q.currentIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectShuffle(state: QueueSliceRoot) {
|
|
||||||
return state.queue.shuffle
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectRepeatAll(state: QueueSliceRoot) {
|
|
||||||
return state.queue.repeatAll
|
|
||||||
}
|
|
||||||
|
|
||||||
export default queueSlice.reducer
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
|
|
||||||
import type { TrackDetail } from '../../types'
|
|
||||||
import { getTrackInfo } from '../../furumiApi'
|
|
||||||
|
|
||||||
export const fetchTrackDetail = createAsyncThunk(
|
|
||||||
'trackDetail/fetch',
|
|
||||||
async (trackSlug: string, { rejectWithValue }) => {
|
|
||||||
const data = await getTrackInfo(trackSlug)
|
|
||||||
if (data === null) return rejectWithValue('Failed to fetch track detail')
|
|
||||||
return { trackSlug, detail: data }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
interface TrackDetailState {
|
|
||||||
bySlug: Record<string, TrackDetail>
|
|
||||||
loading: boolean
|
|
||||||
error: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TrackDetailState = {
|
|
||||||
bySlug: {},
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackDetailSlice = createSlice({
|
|
||||||
name: 'trackDetail',
|
|
||||||
initialState,
|
|
||||||
reducers: {
|
|
||||||
clearTrackDetail(state) {
|
|
||||||
state.bySlug = {}
|
|
||||||
state.error = null
|
|
||||||
},
|
|
||||||
removeTrackDetail(state, action: { payload: string }) {
|
|
||||||
delete state.bySlug[action.payload]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
extraReducers: (builder) => {
|
|
||||||
builder
|
|
||||||
.addCase(fetchTrackDetail.pending, (state) => {
|
|
||||||
state.loading = true
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchTrackDetail.fulfilled, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.bySlug[action.payload.trackSlug] = action.payload.detail
|
|
||||||
state.error = null
|
|
||||||
})
|
|
||||||
.addCase(fetchTrackDetail.rejected, (state, action) => {
|
|
||||||
state.loading = false
|
|
||||||
state.error = action.payload as string ?? 'Unknown error'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions
|
|
||||||
export default trackDetailSlice.reducer
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
// API entity types (see PLAYER-API.md)
|
|
||||||
|
|
||||||
export interface Artist {
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
album_count: number
|
|
||||||
track_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Album {
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
year: number | null
|
|
||||||
track_count: number
|
|
||||||
has_cover: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Track {
|
|
||||||
slug: string
|
|
||||||
title: string
|
|
||||||
track_number: number | null
|
|
||||||
duration_secs: number
|
|
||||||
artist_name: string
|
|
||||||
album_name: string | null
|
|
||||||
album_slug: string | null
|
|
||||||
genre: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TrackDetail extends Track {
|
|
||||||
storage_path: string
|
|
||||||
artist_slug: string
|
|
||||||
album_year: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SearchResultType = 'artist' | 'album' | 'track'
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
result_type: SearchResultType
|
|
||||||
slug: string
|
|
||||||
name: string
|
|
||||||
detail: string | null
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
function pad(n: number) {
|
|
||||||
return String(n).padStart(2, '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fmt(secs: number) {
|
|
||||||
if (!secs || Number.isNaN(secs)) return '0:00'
|
|
||||||
const s = Math.floor(secs)
|
|
||||||
const m = Math.floor(s / 60)
|
|
||||||
const h = Math.floor(m / 60)
|
|
||||||
if (h > 0) {
|
|
||||||
return `${h}:${pad(m % 60)}:${pad(s % 60)}`
|
|
||||||
}
|
|
||||||
return `${m}:${pad(s % 60)}`
|
|
||||||
}
|
|
||||||
@@ -6,16 +6,8 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/auth': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/callback': {
|
|
||||||
target: 'http://localhost:3001',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8085',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import path from 'path';
|
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { auth } from 'express-openid-connect';
|
import { auth } from 'express-openid-connect';
|
||||||
@@ -24,11 +23,12 @@ const oidcConfig = {
|
|||||||
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
|
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
|
||||||
authorizationParams: {
|
authorizationParams: {
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: process.env.OIDC_SCOPE ?? 'openid profile email offline_access',
|
scope: process.env.OIDC_SCOPE ?? 'openid profile email',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
|
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
|
||||||
|
// Keep a clear startup failure if OIDC is not configured.
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)',
|
'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)',
|
||||||
);
|
);
|
||||||
@@ -46,11 +46,11 @@ if (!disableAuth) {
|
|||||||
app.use(auth(oidcConfig));
|
app.use(auth(oidcConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/auth/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/auth/me', (req, res) => {
|
app.get('/api/me', (req, res) => {
|
||||||
if (disableAuth) {
|
if (disableAuth) {
|
||||||
res.json({
|
res.json({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
@@ -74,42 +74,7 @@ app.get('/auth/me', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/auth/token', async (req, res) => {
|
app.get('/api/login', (req, res) => {
|
||||||
if (disableAuth) {
|
|
||||||
res.status(204).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.oidc.isAuthenticated()) {
|
|
||||||
res.status(401).json({ authenticated: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
if (disableAuth) {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
return;
|
return;
|
||||||
@@ -120,7 +85,7 @@ app.get('/auth/login', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/auth/logout', (req, res) => {
|
app.get('/api/logout', (req, res) => {
|
||||||
if (disableAuth) {
|
if (disableAuth) {
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
return;
|
return;
|
||||||
@@ -131,13 +96,6 @@ app.get('/auth/logout', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Production: serve Vite-built client as static files
|
|
||||||
const clientDist = path.resolve(import.meta.dirname, '../../client/dist');
|
|
||||||
app.use(express.static(clientDist));
|
|
||||||
app.get('/{*path}', (_req, res) => {
|
|
||||||
res.sendFile(path.join(clientDist, 'index.html'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(
|
console.log(
|
||||||
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
|
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ axum = { version = "0.7", features = ["tokio", "macros"] }
|
|||||||
clap = { version = "4.5", features = ["derive", "env"] }
|
clap = { version = "4.5", features = ["derive", "env"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
|
||||||
tokio = { version = "1.50", features = ["full"] }
|
tokio = { version = "1.50", features = ["full"] }
|
||||||
tower = { version = "0.4", features = ["util"] }
|
tower = { version = "0.4", features = ["util"] }
|
||||||
@@ -19,12 +18,10 @@ mime_guess = "2.0"
|
|||||||
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
|
symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] }
|
||||||
tokio-util = { version = "0.7", features = ["io"] }
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
openidconnect = "3.4"
|
openidconnect = "3.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||||
jsonwebtoken = "9"
|
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hmac = "0.12"
|
hmac = "0.12"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
urlencoding = "2.1.3"
|
urlencoding = "2.1.3"
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
tower-http = { version = "0.6", features = ["cors"] }
|
|
||||||
|
|||||||
@@ -82,82 +82,6 @@ pub struct SearchResult {
|
|||||||
pub detail: Option<String>, // artist name for albums/tracks
|
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 ---
|
// --- Queries ---
|
||||||
|
|
||||||
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ struct Args {
|
|||||||
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
|
/// OIDC Session Secret (32+ chars, for HMAC). Random if not provided.
|
||||||
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
|
#[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")]
|
||||||
oidc_session_secret: Option<String>,
|
oidc_session_secret: Option<String>,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|||||||
@@ -9,10 +9,8 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
use axum::Extension;
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
use super::auth::AuthUser;
|
|
||||||
|
|
||||||
type S = Arc<AppState>;
|
type S = Arc<AppState>;
|
||||||
|
|
||||||
@@ -293,30 +291,6 @@ 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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
fn error_json(status: StatusCode, message: &str) -> Response {
|
fn error_json(status: StatusCode, message: &str) -> Response {
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ use axum::{
|
|||||||
extract::{Request, State},
|
extract::{Request, State},
|
||||||
http::{header, HeaderMap, StatusCode},
|
http::{header, HeaderMap, StatusCode},
|
||||||
middleware::Next,
|
middleware::Next,
|
||||||
response::{IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
core::{CoreClient, CoreProviderMetadata, CoreResponseType},
|
||||||
reqwest::async_http_client,
|
reqwest::async_http_client,
|
||||||
@@ -17,26 +16,17 @@ use serde::Deserialize;
|
|||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation as JwtValidation};
|
|
||||||
use jsonwebtoken::jwk::JwkSet;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
const SESSION_COOKIE: &str = "furumi_session";
|
const SESSION_COOKIE: &str = "furumi_session";
|
||||||
const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
|
||||||
|
|
||||||
type HmacSha256 = Hmac<sha2::Sha256>;
|
type HmacSha256 = Hmac<sha2::Sha256>;
|
||||||
|
|
||||||
pub struct OidcState {
|
pub struct OidcState {
|
||||||
pub client: CoreClient,
|
pub client: CoreClient,
|
||||||
pub session_secret: Vec<u8>,
|
pub session_secret: Vec<u8>,
|
||||||
jwks_uri: String,
|
|
||||||
issuer_url: String,
|
|
||||||
jwks_cache: RwLock<Option<(JwkSet, Instant)>>,
|
|
||||||
http_client: reqwest::Client,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn oidc_init(
|
pub async fn oidc_init(
|
||||||
@@ -52,9 +42,6 @@ pub async fn oidc_init(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let jwks_uri = provider_metadata.jwks_uri().to_string();
|
|
||||||
let issuer_url = provider_metadata.issuer().to_string();
|
|
||||||
|
|
||||||
let client = CoreClient::from_provider_metadata(
|
let client = CoreClient::from_provider_metadata(
|
||||||
provider_metadata,
|
provider_metadata,
|
||||||
ClientId::new(client_id),
|
ClientId::new(client_id),
|
||||||
@@ -73,84 +60,9 @@ pub async fn oidc_init(
|
|||||||
b
|
b
|
||||||
};
|
};
|
||||||
|
|
||||||
let http_client = reqwest::Client::new();
|
|
||||||
|
|
||||||
tracing::info!("JWKS URI: {}", jwks_uri);
|
|
||||||
|
|
||||||
Ok(OidcState {
|
Ok(OidcState {
|
||||||
client,
|
client,
|
||||||
session_secret,
|
session_secret,
|
||||||
jwks_uri,
|
|
||||||
issuer_url,
|
|
||||||
jwks_cache: RwLock::new(None),
|
|
||||||
http_client,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
impl OidcState {
|
|
||||||
async fn get_jwks(&self) -> anyhow::Result<JwkSet> {
|
|
||||||
{
|
|
||||||
let cache = self.jwks_cache.read().await;
|
|
||||||
if let Some((ref jwks, fetched_at)) = *cache {
|
|
||||||
if fetched_at.elapsed() < JWKS_CACHE_TTL {
|
|
||||||
return Ok(jwks.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.refresh_jwks().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn refresh_jwks(&self) -> anyhow::Result<JwkSet> {
|
|
||||||
tracing::debug!("Fetching JWKS from {}", self.jwks_uri);
|
|
||||||
let jwks: JwkSet = self.http_client.get(&self.jwks_uri).send().await?.json().await?;
|
|
||||||
let mut cache = self.jwks_cache.write().await;
|
|
||||||
*cache = Some((jwks.clone(), Instant::now()));
|
|
||||||
Ok(jwks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, 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,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,73 +92,61 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Auth middleware: requires valid Bearer JWT or SSO session cookie.
|
/// Auth middleware: requires valid SSO session cookie.
|
||||||
/// Inserts AuthUser into request extensions and upserts user in DB.
|
|
||||||
pub async fn require_auth(
|
pub async fn require_auth(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
mut req: Request,
|
req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let mut auth_user: Option<AuthUser> = None;
|
let oidc = match &state.oidc {
|
||||||
|
Some(o) => o,
|
||||||
|
None => return next.run(req).await, // No OIDC configured = no auth
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Check Bearer token — JWT from OIDC provider
|
let cookies = req
|
||||||
if let Some(ref oidc) = state.oidc {
|
.headers()
|
||||||
if let Some(token) = req
|
.get(header::COOKIE)
|
||||||
.headers()
|
.and_then(|v| v.to_str().ok())
|
||||||
.get(header::AUTHORIZATION)
|
.unwrap_or("");
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
|
||||||
{
|
|
||||||
auth_user = validate_bearer_token(oidc, token).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check SSO session cookie (if OIDC configured)
|
for c in cookies.split(';') {
|
||||||
if auth_user.is_none() {
|
let c = c.trim();
|
||||||
if let Some(ref oidc) = state.oidc {
|
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||||
let cookies = req
|
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
|
||||||
.headers()
|
return next.run(req).await;
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match auth_user {
|
let uri = req.uri().to_string();
|
||||||
Some(user) => {
|
if uri.starts_with("/api/") {
|
||||||
tracing::debug!("Auth OK for user: {}", user.username);
|
(StatusCode::UNAUTHORIZED, "Unauthorized").into_response()
|
||||||
// Upsert user in background
|
} else {
|
||||||
let pool = state.pool.clone();
|
Redirect::to("/login").into_response()
|
||||||
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct LoginQuery {
|
pub struct LoginQuery {
|
||||||
pub next: Option<String>,
|
pub next: Option<String>,
|
||||||
@@ -419,9 +319,9 @@ pub async fn oidc_callback(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let session_attrs = if is_https {
|
let session_attrs = if is_https {
|
||||||
"SameSite=Lax; Secure"
|
"SameSite=Strict; Secure"
|
||||||
} else {
|
} else {
|
||||||
"SameSite=Lax"
|
"SameSite=Strict"
|
||||||
};
|
};
|
||||||
|
|
||||||
let session_cookie = format!(
|
let session_cookie = format!(
|
||||||
@@ -438,3 +338,47 @@ pub async fn oidc_callback(
|
|||||||
|
|
||||||
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
(StatusCode::FOUND, headers, Body::empty()).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LOGIN_HTML: &str = r#"<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Furumi Player — Login</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #0d0f14;
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #161b27;
|
||||||
|
border: 1px solid #2a3347;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2.5rem 3rem;
|
||||||
|
width: 360px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.logo { font-size: 1.8rem; font-weight: 700; color: #7c6af7; margin-bottom: 0.25rem; }
|
||||||
|
.subtitle { font-size: 0.85rem; color: #64748b; margin-bottom: 2rem; }
|
||||||
|
.btn-sso {
|
||||||
|
display: block; width: 100%; padding: 0.75rem; text-align: center;
|
||||||
|
background: #7c6af7; border: none; border-radius: 8px;
|
||||||
|
color: #fff; font-size: 0.95rem; font-weight: 600; text-decoration: none;
|
||||||
|
cursor: pointer; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.btn-sso:hover { background: #6b58e8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<div class="logo">Furumi</div>
|
||||||
|
<div class="subtitle">Sign in to continue</div>
|
||||||
|
<a href="/auth/login" class="btn-sso">SSO Login</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>"#;
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ pub mod auth;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use axum::{Router, routing::{get, post}, middleware};
|
use axum::{Router, routing::get, middleware};
|
||||||
use axum::http::{header, Method};
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -29,31 +26,32 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/tracks/:slug", get(api::get_track_detail))
|
.route("/tracks/:slug", get(api::get_track_detail))
|
||||||
.route("/tracks/:slug/cover", get(api::track_cover))
|
.route("/tracks/:slug/cover", get(api::track_cover))
|
||||||
.route("/stream/:slug", get(api::stream_track))
|
.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 api = Router::new()
|
let authed = Router::new()
|
||||||
|
.route("/", get(player_html))
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
|
|
||||||
let requires_auth = state.oidc.is_some();
|
let has_oidc = state.oidc.is_some();
|
||||||
|
|
||||||
let app = if requires_auth {
|
let app = if has_oidc {
|
||||||
api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
authed
|
||||||
|
.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth))
|
||||||
} else {
|
} else {
|
||||||
api
|
authed
|
||||||
};
|
};
|
||||||
|
|
||||||
let cors = CorsLayer::new()
|
|
||||||
.allow_origin(Any)
|
|
||||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD])
|
|
||||||
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
|
|
||||||
.max_age(Duration::from_secs(600));
|
|
||||||
|
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/login", get(auth::login_page))
|
||||||
|
.route("/logout", get(auth::logout))
|
||||||
.route("/auth/login", get(auth::oidc_login))
|
.route("/auth/login", get(auth::oidc_login))
|
||||||
.route("/auth/callback", get(auth::oidc_callback))
|
.route("/auth/callback", get(auth::oidc_callback))
|
||||||
.merge(app)
|
.merge(app)
|
||||||
.layer(cors)
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn player_html() -> axum::response::Html<String> {
|
||||||
|
let html = include_str!("player.html")
|
||||||
|
.replace("<!-- VERSION_PLACEHOLDER -->", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")));
|
||||||
|
axum::response::Html(html)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user