diff --git a/.cursor/rules/furumi-node-player-client-components.mdc b/.cursor/rules/furumi-node-player-client-components.mdc new file mode 100644 index 0000000..86b9c2b --- /dev/null +++ b/.cursor/rules/furumi-node-player-client-components.mdc @@ -0,0 +1,49 @@ +--- +description: Новые React-компоненты — папка kebab-case, TSX + CSS module, index.ts (furumi-node-player/client/src) +globs: furumi-node-player/client/src/**/* +alwaysApply: false +--- + +# Структура новых компонентов (furumi-node-player/client) + +**Область:** при добавлении **нового** компонента в `furumi-node-player/client/src/` используйте расположение и файлы ниже. + +## Расположение + +- Базовая папка: `furumi-node-player/client/src/components/` +- Для каждого компонента — **отдельная подпапка** с именем в **kebab-case** (например `play-button`, `search-dropdown`). + +## Файлы внутри папки компонента + +1. **Компонент:** `components/<имя-kebab>/<имя-kebab>.tsx` — реализация компонента; к нему подключён CSS-модуль (`import styles from './<имя-kebab>.module.css'`). +2. **Стили:** `components/<имя-kebab>/<имя-kebab>.module.css` — модульные стили для этого компонента. +3. **Баррель:** `components/<имя-kebab>/index.ts` — **реэкспорт всего** из файла компонента: `export * from './<имя-kebab>'`. + +Импорт снаружи: `import { MyWidget } from './components/my-widget'` (относительный путь к папке), без указания `index.ts`. + +## Пример (`my-widget`) + +``` +components/my-widget/ + my-widget.tsx + my-widget.module.css + index.ts +``` + +```typescript +// my-widget.tsx +import styles from './my-widget.module.css' + +export function MyWidget() { + return
+} +``` + +```typescript +// index.ts +export * from './my-widget' +``` + +## Примечание + +Существующие файлы-компоненты в корне `components/` (один файл без папки) — наследие; **новые** компоненты добавляйте только по структуре выше. diff --git a/.github/workflows/docker-publish-node-player-dev.yml b/.github/workflows/docker-publish-node-player-dev.yml new file mode 100644 index 0000000..d0ec036 --- /dev/null +++ b/.github/workflows/docker-publish-node-player-dev.yml @@ -0,0 +1,40 @@ +name: Publish Node Player Image (dev) + +on: + push: + branches: + - DEV + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.node-player + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:dev + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-publish-node-player.yml b/.github/workflows/docker-publish-node-player.yml new file mode 100644 index 0000000..41d2693 --- /dev/null +++ b/.github/workflows/docker-publish-node-player.yml @@ -0,0 +1,57 @@ +name: Publish Node Player Image + +on: + push: + branches: + - '**' + - '!DEV' + tags: + - 'v*.*.*' + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/furumi-node-player + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine version and tags + id: info + run: | + IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" + SHORT_SHA="$(echo '${{ github.sha }}' | cut -c1-7)" + + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then + TAG="${{ github.ref_name }}" + VERSION="${TAG#v}" + echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> "$GITHUB_OUTPUT" + else + echo "tags=${IMAGE}:trunk,${IMAGE}:${SHORT_SHA}" >> "$GITHUB_OUTPUT" + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.node-player + push: true + tags: ${{ steps.info.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index eeb9a57..2f3c66b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ /target -/inbox -/storage +/docker/inbox +/docker/storage .env diff --git a/Cargo.lock b/Cargo.lock index d733eb8..0e209f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1140,7 +1140,7 @@ dependencies = [ "futures-core", "futures-util", "hmac", - "jsonwebtoken", + "jsonwebtoken 10.3.0", "libc", "mime_guess", "ogg", @@ -1178,6 +1178,7 @@ dependencies = [ "base64 0.22.1", "clap", "hmac", + "jsonwebtoken 9.3.1", "mime_guess", "openidconnect", "rand 0.8.5", @@ -1902,6 +1903,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index d0c2da4..ed676b9 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app + +# 1. Copy workspace manifests and lock file (changes rarely → cached layer) +COPY Cargo.toml Cargo.lock ./ +COPY furumi-common/Cargo.toml furumi-common/Cargo.toml +COPY furumi-server/Cargo.toml furumi-server/Cargo.toml +COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml +COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml +COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml +COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml +COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml + +# 2. Create dummy sources so cargo can resolve and build dependencies +RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \ + && mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \ + && mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \ + && mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \ + && mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \ + && mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \ + && mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs + +# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change) +RUN cargo build --release --bin furumi-agent 2>/dev/null || true + +# 4. Copy real source code COPY . . +# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps) +RUN touch furumi-common/src/lib.rs furumi-agent/src/main.rs + ARG FURUMI_VERSION=dev RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-agent diff --git a/docker/Dockerfile.node-player b/docker/Dockerfile.node-player new file mode 100644 index 0000000..0ad2d3d --- /dev/null +++ b/docker/Dockerfile.node-player @@ -0,0 +1,38 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +# 1. Install server dependencies (cached layer) +COPY furumi-node-player/server/package.json furumi-node-player/server/package-lock.json ./server/ +RUN cd server && npm ci + +# 2. Install client dependencies (cached layer) +COPY furumi-node-player/client/package.json furumi-node-player/client/package-lock.json ./client/ +RUN cd client && npm ci + +# 3. Build server +COPY furumi-node-player/server/ ./server/ +RUN cd server && npm run build + +# 4. Build client (VITE_FURUMI_API_URL empty = relative /api on same origin) +COPY furumi-node-player/client/ ./client/ +RUN cd client && npm run build + +FROM node:22-alpine + +WORKDIR /app + +# Server runtime +COPY --from=build /app/server/dist ./server/dist +COPY --from=build /app/server/node_modules ./server/node_modules +COPY --from=build /app/server/package.json ./server/ + +# Client static files +COPY --from=build /app/client/dist ./client/dist + +ENV NODE_ENV=production +ENV PORT=3001 + +EXPOSE 3001 + +CMD ["node", "server/dist/index.js"] diff --git a/docker/Dockerfile.web-player b/docker/Dockerfile.web-player index ee6902a..1ae113c 100644 --- a/docker/Dockerfile.web-player +++ b/docker/Dockerfile.web-player @@ -8,8 +8,35 @@ RUN apt-get update && apt-get install -y \ && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app + +# 1. Copy workspace manifests and lock file (changes rarely → cached layer) +COPY Cargo.toml Cargo.lock ./ +COPY furumi-common/Cargo.toml furumi-common/Cargo.toml +COPY furumi-server/Cargo.toml furumi-server/Cargo.toml +COPY furumi-client-core/Cargo.toml furumi-client-core/Cargo.toml +COPY furumi-mount-linux/Cargo.toml furumi-mount-linux/Cargo.toml +COPY furumi-mount-macos/Cargo.toml furumi-mount-macos/Cargo.toml +COPY furumi-agent/Cargo.toml furumi-agent/Cargo.toml +COPY furumi-web-player/Cargo.toml furumi-web-player/Cargo.toml + +# 2. Create dummy sources so cargo can resolve and build dependencies +RUN mkdir -p furumi-common/src && echo "pub fn _dummy(){}" > furumi-common/src/lib.rs \ + && mkdir -p furumi-server/src && echo "fn main(){}" > furumi-server/src/main.rs \ + && mkdir -p furumi-client-core/src && echo "pub fn _dummy(){}" > furumi-client-core/src/lib.rs \ + && mkdir -p furumi-mount-linux/src && echo "fn main(){}" > furumi-mount-linux/src/main.rs \ + && mkdir -p furumi-mount-macos/src && echo "fn main(){}" > furumi-mount-macos/src/main.rs \ + && mkdir -p furumi-agent/src && echo "fn main(){}" > furumi-agent/src/main.rs \ + && mkdir -p furumi-web-player/src && echo "fn main(){}" > furumi-web-player/src/main.rs + +# 3. Build dependencies only (this layer is cached until Cargo.toml/lock change) +RUN cargo build --release --bin furumi-web-player 2>/dev/null || true + +# 4. Copy real source code COPY . . +# 5. Touch sources to invalidate cargo's fingerprint for our crates (not deps) +RUN touch furumi-common/src/lib.rs furumi-web-player/src/main.rs + ARG FURUMI_VERSION=dev RUN FURUMI_VERSION=${FURUMI_VERSION} cargo build --release --bin furumi-web-player diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 100a9a8..438546d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,8 +16,8 @@ services: agent: build: - context: . - dockerfile: Dockerfile.agent + context: .. + dockerfile: docker/Dockerfile.agent container_name: furumi-agent depends_on: db: @@ -25,10 +25,12 @@ services: ports: - "8090:8090" environment: + RUST_LOG: info FURUMI_AGENT_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_AGENT_INBOX_DIR: "/inbox" FURUMI_AGENT_STORAGE_DIR: "/storage" FURUMI_AGENT_OLLAMA_URL: "${OLLAMA_URL:-http://host.docker.internal:11434}" + FURUMI_AGENT_OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:14b}" FURUMI_AGENT_OLLAMA_AUTH: "${OLLAMA_AUTH:-CHANGE-ME}" FURUMI_PLAYER_BIND: "0.0.0.0:8090" FURUMI_AGENT_POLL_INTERVAL_SECS: 5 @@ -41,8 +43,8 @@ services: web-player: build: - context: . - dockerfile: Dockerfile.web-player + context: .. + dockerfile: docker/Dockerfile.web-player container_name: furumi-web-player depends_on: db: @@ -53,7 +55,11 @@ services: FURUMI_PLAYER_DATABASE_URL: "postgres://${POSTGRES_USER:-furumi}:${POSTGRES_PASSWORD:-furumi}@db:5432/${POSTGRES_DB:-furumi}" FURUMI_PLAYER_STORAGE_DIR: "/storage" FURUMI_PLAYER_BIND: "0.0.0.0:8085" - FURUMI_PLAYER_API_KEY: "node-player-api-key" + FURUMI_PLAYER_OIDC_ISSUER_URL: "${OIDC_ISSUER_URL}" + FURUMI_PLAYER_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}" + FURUMI_PLAYER_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}" + FURUMI_PLAYER_OIDC_REDIRECT_URL: "${OIDC_REDIRECT_URL}" + FURUMI_PLAYER_OIDC_SESSION_SECRET: "${OIDC_SESSION_SECRET}" volumes: - ./storage:/storage restart: always diff --git a/furumi-agent/prompts/normalize.txt b/furumi-agent/prompts/normalize.txt index fe8577d..d049bc3 100644 --- a/furumi-agent/prompts/normalize.txt +++ b/furumi-agent/prompts/normalize.txt @@ -64,7 +64,7 @@ You are a music metadata normalization assistant. Your job is to take raw metada 10. **Consistency**: When the database already contains entries for an artist or album, your output MUST match the existing canonical names. Do not introduce new variations. -11. **Confidence**: Rate your confidence from 0.0 to 1.0. +11. **Confidence**: MUST be a decimal number between 0.0 and 1.0 (e.g., 0.95, 0.7, 0.3). NEVER use words like "high", "medium", "low" — only a numeric float value. - 1.0: All fields are clear and unambiguous. - 0.8+: Minor inferences made (e.g., year from path), but high certainty. - 0.5-0.8: Some guesswork involved, human review recommended. diff --git a/furumi-node-player/client/.env.example b/furumi-node-player/client/.env.example index 2312cf7..eeec224 100644 --- a/furumi-node-player/client/.env.example +++ b/furumi-node-player/client/.env.example @@ -1,2 +1 @@ -VITE_API_BASE_URL=http://localhost:8085 -VITE_API_KEY= \ No newline at end of file +VITE_FURUMI_API_URL=http://localhost:8085 diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index 51a08a4..fed1fdb 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -1,5 +1,6 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useState } from 'react' import { FurumiPlayer } from './FurumiPlayer' +import { setAuthToken, clearAuthToken } from './furumiApi' import './App.css' type UserProfile = { @@ -22,7 +23,7 @@ function App() { } }) - const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', []) + const apiBase = '' useEffect(() => { if (runWithoutAuth) { @@ -34,12 +35,13 @@ function App() { const loadMe = async () => { try { - const response = await fetch(`${apiBase}/api/me`, { + const response = await fetch(`${apiBase}/auth/me`, { credentials: 'include', }) if (response.status === 401) { setUser(null) + clearAuthToken() return } @@ -49,6 +51,23 @@ function App() { const data = await response.json() setUser(data.user ?? null) + + // Fetch OIDC access token for Rust API Bearer auth + if (data.user) { + try { + const tokenRes = await fetch(`${apiBase}/auth/token`, { + credentials: 'include', + }) + if (tokenRes.ok) { + const tokenData = await tokenRes.json() + if (tokenData.access_token) { + setAuthToken(tokenData.access_token) + } + } + } catch { + // Token fetch failed — API calls will fall back to other auth methods + } + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load session') } finally { @@ -57,10 +76,10 @@ function App() { } void loadMe() - }, [apiBase, runWithoutAuth]) + }, [runWithoutAuth]) - const loginUrl = `${apiBase}/api/login` - const logoutUrl = `${apiBase}/api/logout` + const loginUrl = `${apiBase}/auth/login` + const logoutUrl = `${apiBase}/auth/logout` return ( <> diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 96c41d2..b17985c 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,21 +1,32 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { - API_ROOT, - searchTracks, - preloadStream, -} from './furumiApi' -import { useAppDispatch, useAppSelector } from './store' +import { API_ROOT, searchTracks, preloadStream } from './furumiApi' +import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' import { fetchArtistTracks } from './store/slices/artistTracksSlice' import { fetchAlbumTracks } from './store/slices/albumTracksSlice' import { fetchTrackDetail } from './store/slices/trackDetailSlice' +import { + addTrack, + addTracksBatch, + replaceQueue, + clearQueue, + playAtIndex, + removeFromQueueAt, + moveQueueItemInOrder, + rebuildShuffleOrder, + selectQueueOrder, + selectPlayingOrigIdx, + selectQueueScrollSignal, + selectNowPlayingTrack, + selectQueueItems, +} from './store/slices/queueSlice' +import { attachAudioPlayback } from './audioPlaybackService' import { fmt } from './utils' -import { Header } from './components/Header' +import { Header } from './components/header' import { MainPanel, type Crumb } from './components/MainPanel' import { PlayerBar } from './components/PlayerBar' -import type { QueueItem } from './components/QueueList' import type { Track } from './types' export function FurumiPlayer() { @@ -26,6 +37,13 @@ export function FurumiPlayer() { const albumsError = useAppSelector((s) => s.albums.error) const albumTracksLoading = useAppSelector((s) => s.albumTracks.loading) const albumTracksError = useAppSelector((s) => s.albumTracks.error) + + const queueItemsView = useAppSelector(selectQueueItems) + const queueOrderView = useAppSelector(selectQueueOrder) + const queuePlayingOrigIdxView = useAppSelector(selectPlayingOrigIdx) + const queueScrollSignal = useAppSelector(selectQueueScrollSignal) + const nowPlayingTrack = useAppSelector(selectNowPlayingTrack) + const [breadcrumbs, setBreadcrumbs] = useState( [], ) @@ -45,81 +63,56 @@ export function FurumiPlayer() { Array<{ result_type: string; slug: string; name: string; detail?: string }> >([]) const [searchOpen, setSearchOpen] = useState(false) - const searchSelectRef = useRef<(type: string, slug: string) => void>(() => {}) - - const [nowPlayingTrack, setNowPlayingTrack] = useState(null) - const [queueItemsView, setQueueItemsView] = useState([]) - const [queueOrderView, setQueueOrderView] = useState([]) - const [queuePlayingOrigIdxView, setQueuePlayingOrigIdxView] = useState(-1) - const [queueScrollSignal, setQueueScrollSignal] = useState(0) - const [queue, setQueue] = useState([]) + const searchSelectRef = useRef<(type: string, slug: string) => void>(() => { }) const queueActionsRef = useRef<{ playIndex: (i: number) => void removeFromQueue: (idx: number) => void moveQueueItem: (fromPos: number, toPos: number) => void + clearQueue: () => void } | null>(null) const audioRef = useRef(null) useEffect(() => { - // --- Original player script adapted for React environment --- + if (!nowPlayingTrack) { + document.title = 'Furumi Player' + return + } + document.title = `${nowPlayingTrack.title} — Furumi` + const coverUrl = `${API_ROOT}/tracks/${nowPlayingTrack.slug}/cover` + if ('mediaSession' in navigator) { + try { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + navigator.mediaSession.metadata = new window.MediaMetadata({ + title: nowPlayingTrack.title, + artist: nowPlayingTrack.artist || '', + album: '', + artwork: [{ src: coverUrl, sizes: '512x512' }], + }) + } catch { + // ignore + } + } + }, [nowPlayingTrack]) + + useEffect(() => { const audioEl = audioRef.current if (!audioEl) return const audio = audioEl - let queueIndex = -1 - let shuffle = false - let repeatAll = true - let shuffleOrder: number[] = [] let searchTimer: number | null = null let toastTimer: number | null = null - let muted = false - // Restore prefs - try { - 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 - } - const btnShuffle = document.getElementById('btnShuffle') - const btnRepeat = document.getElementById('btnRepeat') - shuffle = window.localStorage.getItem('furumi_shuffle') === '1' - repeatAll = window.localStorage.getItem('furumi_repeat') !== '0' - btnShuffle?.classList.toggle('active', shuffle) - btnRepeat?.classList.toggle('active', repeatAll) - } catch { - // ignore + function showToast(msg: string) { + const t = document.getElementById('toast') + if (!t) return + t.textContent = msg + t.classList.add('show') + if (toastTimer) window.clearTimeout(toastTimer) + toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500) } - // --- Audio events --- - audio.addEventListener('timeupdate', () => { - if (audio.duration) { - const fill = document.getElementById('progressFill') - const timeElapsed = document.getElementById('timeElapsed') - const timeDuration = document.getElementById('timeDuration') - if (fill) fill.style.width = `${(audio.currentTime / audio.duration) * 100}%` - if (timeElapsed) timeElapsed.textContent = fmt(audio.currentTime) - if (timeDuration) timeDuration.textContent = fmt(audio.duration) - } - }) - audio.addEventListener('ended', () => nextTrack()) - audio.addEventListener('play', () => { - const btn = document.getElementById('btnPlayPause') - if (btn) btn.innerHTML = '⏸' - }) - audio.addEventListener('pause', () => { - const btn = document.getElementById('btnPlayPause') - if (btn) btn.innerHTML = '▶' - }) - audio.addEventListener('error', () => { - showToast('Playback error') - nextTrack() - }) - - // --- Library navigation --- async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) try { @@ -231,7 +224,6 @@ export function FurumiPlayer() { setBreadcrumbs(parts) } - // --- Queue management --- function addTrackToQueue( track: { slug: string @@ -242,15 +234,11 @@ export function FurumiPlayer() { }, playNow?: boolean, ) { - const existing = queue.findIndex((t) => t.slug === track.slug) - if (existing !== -1) { - if (playNow) playIndex(existing) - return - } - setQueue((q) => [...q, track]); - updateQueueModel() - if (playNow || (queueIndex === -1 && queue.length === 1)) { - playIndex(queue.length - 1) + const prevIdx = store.getState().queue.currentIndex + dispatch(addTrack({ track, playNow })) + const q = store.getState().queue + if (q.currentIndex !== -1 && (playNow || q.currentIndex !== prevIdx)) { + playIndex(q.currentIndex) } } @@ -259,23 +247,19 @@ export function FurumiPlayer() { if (result.meta.requestStatus === 'rejected') return const { tracks } = result.payload as { albumSlug: string; tracks: Track[] } if (!tracks || !tracks.length) return - const list = tracks - let firstIdx = queue.length - list.forEach((t) => { - if (queue.find((q) => q.slug === t.slug)) return - setQueue((q) => [ - ...q, - { - slug: t.slug, - title: t.title, - artist: t.artist_name, - album_slug: t.album_slug, - duration: t.duration_secs, - }, - ]) - }) - updateQueueModel() - if (playFirst || queueIndex === -1) playIndex(firstIdx) + const list = tracks.map((t) => ({ + slug: t.slug, + title: t.title, + artist: t.artist_name, + album_slug: t.album_slug, + duration: t.duration_secs, + })) + const prevIdx = store.getState().queue.currentIndex + dispatch(addTracksBatch({ tracks: list, playFirst })) + const q = store.getState().queue + if (q.currentIndex !== -1 && q.currentIndex !== prevIdx) { + playIndex(q.currentIndex) + } showToast(`Added ${list.length} tracks`) } @@ -284,29 +268,30 @@ export function FurumiPlayer() { if (result.meta.requestStatus === 'rejected') return const { tracks } = result.payload as { artistSlug: string; tracks: Track[] } if (!tracks || !tracks.length) return - const list = tracks - clearQueue() - setQueue(list.map((t) => ({ + const list = tracks.map((t) => ({ slug: t.slug, title: t.title, artist: t.artist_name, album_slug: t.album_slug, duration: t.duration_secs, - }))) - updateQueueModel() + })) + dispatch(replaceQueue({ items: list, playFromIndex: 0 })) playIndex(0) showToast(`Added ${list.length} tracks`) } + const playback = attachAudioPlayback(audio, { + onEnded: nextTrack, + onErrorSkip: nextTrack, + onToast: showToast, + }) + function playIndex(i: number) { - if (i < 0 || i >= queue.length) return - queueIndex = i - const track = queue[i] - audio.src = `${API_ROOT}/stream/${track.slug}` - void audio.play().catch(() => {}) - updateNowPlaying(track) - updateQueueModel() - setQueueScrollSignal((s) => s + 1) + const q = store.getState().queue + if (i < 0 || i >= q.items.length) return + dispatch(playAtIndex(i)) + const track = store.getState().queue.items[i] + void playback.loadStreamForTrack(track.slug) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -314,181 +299,58 @@ export function FurumiPlayer() { } } - function updateNowPlaying(track: QueueItem | null) { - setNowPlayingTrack(track) - if (!track) return - - document.title = `${track.title} — Furumi` - - const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover` - if ('mediaSession' in navigator) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - navigator.mediaSession.metadata = new window.MediaMetadata({ - title: track.title, - artist: track.artist || '', - album: '', - artwork: [{ src: coverUrl, sizes: '512x512' }], - }) - } - } - - function currentOrder() { - if (!shuffle) return [...Array(queue.length).keys()] - if (shuffleOrder.length !== queue.length) buildShuffleOrder() - return shuffleOrder - } - - function buildShuffleOrder() { - shuffleOrder = [...Array(queue.length).keys()] - for (let i = shuffleOrder.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)) - ;[shuffleOrder[i], shuffleOrder[j]] = [shuffleOrder[j], shuffleOrder[i]] - } - if (queueIndex !== -1) { - const ci = shuffleOrder.indexOf(queueIndex) - if (ci > 0) { - shuffleOrder.splice(ci, 1) - shuffleOrder.unshift(queueIndex) - } - } - } - - function updateQueueModel() { - const order = currentOrder() - setQueueItemsView(queue) - setQueueOrderView(order.slice()) - setQueuePlayingOrigIdxView(queueIndex) - } - function removeFromQueue(idx: number) { - if (idx === queueIndex) { - queueIndex = -1 - audio.pause() - audio.src = '' - updateNowPlaying(null) - } else if (queueIndex > idx) { - queueIndex-- - } - - // queue.splice(idx, 1) - setQueue((q) => q.filter((_, i) => i !== idx)); - - if (shuffle) { - const si = shuffleOrder.indexOf(idx) - if (si !== -1) shuffleOrder.splice(si, 1) - for (let i = 0; i < shuffleOrder.length; i++) { - if (shuffleOrder[i] > idx) shuffleOrder[i]-- - } - } - updateQueueModel() + const wasPlaying = store.getState().queue.currentIndex === idx + dispatch(removeFromQueueAt(idx)) + if (wasPlaying) playback.pauseAndClearSource() } - function moveQueueItem(from: number, to: number) { - if (from === to) return - if (shuffle) { - const item = shuffleOrder.splice(from, 1)[0] - shuffleOrder.splice(to, 0, item) - } else { - const item = queue.splice(from, 1)[0] - queue.splice(to, 0, item) - if (queueIndex === from) queueIndex = to - else if (from < queueIndex && to >= queueIndex) queueIndex-- - else if (from > queueIndex && to <= queueIndex) queueIndex++ + function moveQueueItem(fromPos: number, toPos: number) { + dispatch(moveQueueItemInOrder({ fromPos, toPos })) + } + + function clearQueuePlayback() { + dispatch(clearQueue()) + playback.pauseAndClearSource() + } + + function nextTrack() { + const q = store.getState().queue + if (!q.items.length) return + const order = selectQueueOrder(store.getState()) + const pos = order.indexOf(q.currentIndex) + if (pos < order.length - 1) playIndex(order[pos + 1]) + else if (q.repeatAll) { + if (q.shuffle) dispatch(rebuildShuffleOrder()) + const first = selectQueueOrder(store.getState())[0] + if (first !== undefined) playIndex(first) } - updateQueueModel() + } + + function prevTrack() { + const q = store.getState().queue + if (!q.items.length) return + if (playback.rewindCurrentTrackIfPastThreshold()) return + const order = selectQueueOrder(store.getState()) + const pos = order.indexOf(q.currentIndex) + if (pos > 0) playIndex(order[pos - 1]) + else if (q.repeatAll) playIndex(order[order.length - 1]) + } + + function togglePlay() { + const q = store.getState().queue + playback.togglePlay(() => { + playIndex(q.currentIndex === -1 ? 0 : q.currentIndex) + }) } queueActionsRef.current = { playIndex, removeFromQueue, moveQueueItem, + clearQueue: clearQueuePlayback, } - function clearQueue() { - setQueue([]); - queueIndex = -1 - shuffleOrder = [] - audio.pause() - audio.src = '' - updateNowPlaying(null) - document.title = 'Furumi Player' - updateQueueModel() - } - - // --- Playback controls --- - function togglePlay() { - if (!audio.src && queue.length) { - playIndex(queueIndex === -1 ? 0 : queueIndex) - return - } - if (audio.paused) void audio.play() - else audio.pause() - } - - function nextTrack() { - if (!queue.length) return - const order = currentOrder() - const pos = order.indexOf(queueIndex) - if (pos < order.length - 1) playIndex(order[pos + 1]) - else if (repeatAll) { - if (shuffle) buildShuffleOrder() - playIndex(currentOrder()[0]) - } - } - - function prevTrack() { - if (!queue.length) return - if (audio.currentTime > 3) { - audio.currentTime = 0 - return - } - const order = currentOrder() - const pos = order.indexOf(queueIndex) - if (pos > 0) playIndex(order[pos - 1]) - else if (repeatAll) playIndex(order[order.length - 1]) - } - - function toggleShuffle() { - shuffle = !shuffle - if (shuffle) buildShuffleOrder() - const btn = document.getElementById('btnShuffle') - btn?.classList.toggle('active', shuffle) - window.localStorage.setItem('furumi_shuffle', shuffle ? '1' : '0') - updateQueueModel() - } - - function toggleRepeat() { - repeatAll = !repeatAll - const btn = document.getElementById('btnRepeat') - btn?.classList.toggle('active', repeatAll) - window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0') - } - - // --- Seek & Volume --- - function seekTo(e: MouseEvent) { - if (!audio.duration) return - const bar = document.getElementById('progressBar') as HTMLDivElement | null - if (!bar) return - const rect = bar.getBoundingClientRect() - const pct = (e.clientX - rect.left) / rect.width - audio.currentTime = pct * audio.duration - } - - function toggleMute() { - muted = !muted - audio.muted = muted - const volIcon = document.getElementById('volIcon') - if (volIcon) volIcon.innerHTML = muted ? '🔇' : '🔊' - } - - function setVolume(v: number) { - audio.volume = v / 100 - const volIcon = document.getElementById('volIcon') - if (volIcon) volIcon.innerHTML = v === 0 ? '🔇' : '🔊' - window.localStorage.setItem('furumi_vol', String(v)) - } - - // --- Search --- function onSearch(q: string) { if (searchTimer) { window.clearTimeout(searchTimer) @@ -527,16 +389,6 @@ export function FurumiPlayer() { } searchSelectRef.current = onSearchSelect - // --- Helpers --- - function showToast(msg: string) { - const t = document.getElementById('toast') - if (!t) return - t.textContent = msg - t.classList.add('show') - if (toastTimer) window.clearTimeout(toastTimer) - toastTimer = window.setTimeout(() => t.classList.remove('show'), 2500) - } - function toggleSidebar() { const sidebar = document.getElementById('sidebar') const overlay = document.getElementById('sidebarOverlay') @@ -544,100 +396,101 @@ export function FurumiPlayer() { overlay?.classList.toggle('show') } - // --- MediaSession --- + const onMediaSeekTo = (d: { seekTime?: number }) => { + if (typeof d.seekTime === 'number') { + playback.seekToTime(d.seekTime) + } + } + if ('mediaSession' in navigator) { try { navigator.mediaSession.setActionHandler('play', togglePlay) navigator.mediaSession.setActionHandler('pause', togglePlay) navigator.mediaSession.setActionHandler('previoustrack', prevTrack) navigator.mediaSession.setActionHandler('nexttrack', nextTrack) - navigator.mediaSession.setActionHandler('seekto', (d: any) => { - if (typeof d.seekTime === 'number') { - audio.currentTime = d.seekTime - } - }) + navigator.mediaSession.setActionHandler('seekto', onMediaSeekTo as (d: any) => void) } catch { // ignore } } - // --- Wire DOM events that were inline in HTML --- + const onMenuClick = () => toggleSidebar() const btnMenu = document.querySelector('.btn-menu') - btnMenu?.addEventListener('click', () => toggleSidebar()) + btnMenu?.addEventListener('click', onMenuClick) + const onSidebarOverlayClick = () => toggleSidebar() const sidebarOverlay = document.getElementById('sidebarOverlay') - sidebarOverlay?.addEventListener('click', () => toggleSidebar()) + sidebarOverlay?.addEventListener('click', onSidebarOverlayClick) const searchInput = document.getElementById('searchInput') as HTMLInputElement | null + const onSearchInput = (e: Event) => { + onSearch((e.target as HTMLInputElement).value) + } + const onSearchKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeSearch() + } if (searchInput) { - searchInput.addEventListener('input', (e) => { - onSearch((e.target as HTMLInputElement).value) - }) - searchInput.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.key === 'Escape') closeSearch() - }) + searchInput.addEventListener('input', onSearchInput) + searchInput.addEventListener('keydown', onSearchKeydown) } - const btnShuffle = document.getElementById('btnShuffle') - btnShuffle?.addEventListener('click', () => toggleShuffle()) - const btnRepeat = document.getElementById('btnRepeat') - btnRepeat?.addEventListener('click', () => toggleRepeat()) - const btnClear = document.getElementById('btnClearQueue') - btnClear?.addEventListener('click', () => clearQueue()) + const onPrevClick = () => prevTrack() + const onPlayClick = () => togglePlay() + const onNextClick = () => nextTrack() const btnPrev = document.getElementById('btnPrev') - btnPrev?.addEventListener('click', () => prevTrack()) + btnPrev?.addEventListener('click', onPrevClick) const btnPlay = document.getElementById('btnPlayPause') - btnPlay?.addEventListener('click', () => togglePlay()) + btnPlay?.addEventListener('click', onPlayClick) const btnNext = document.getElementById('btnNext') - btnNext?.addEventListener('click', () => nextTrack()) + btnNext?.addEventListener('click', onNextClick) - const progressBar = document.getElementById('progressBar') - progressBar?.addEventListener('click', (e) => seekTo(e as MouseEvent)) - - const volIcon = document.getElementById('volIcon') - volIcon?.addEventListener('click', () => toggleMute()) - const volSlider = document.getElementById('volSlider') as HTMLInputElement | null - if (volSlider) { - volSlider.addEventListener('input', (e) => { - const v = Number((e.target as HTMLInputElement).value) - setVolume(v) - }) - } - - const clearQueueBtn = document.getElementById('btnClearQueue') - clearQueueBtn?.addEventListener('click', () => clearQueue()) - - // --- Init --- - ;(async () => { - const url = new URL(window.location.href) - const urlSlug = url.searchParams.get('t') - if (urlSlug) { - try { - const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap() - addTrackToQueue( - { - slug: detail.slug, - title: detail.title, - artist: detail.artist_name, - album_slug: detail.album_slug, - duration: detail.duration_secs, - }, - true, - ) - } catch { - // fetchTrackDetail rejected — track not found or error + ; (async () => { + const url = new URL(window.location.href) + const urlSlug = url.searchParams.get('t') + if (urlSlug) { + try { + const { detail } = await dispatch(fetchTrackDetail(urlSlug)).unwrap() + addTrackToQueue( + { + slug: detail.slug, + title: detail.title, + artist: detail.artist_name, + album_slug: detail.album_slug, + duration: detail.duration_secs, + }, + true, + ) + } catch { + // fetchTrackDetail rejected — track not found or error + } } - } - void showArtists() - })() + void showArtists() + })() - // Cleanup: best-effort remove listeners on unmount return () => { queueActionsRef.current = null - audio.pause() + playback.dispose() + btnMenu?.removeEventListener('click', onMenuClick) + sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick) + searchInput?.removeEventListener('input', onSearchInput) + searchInput?.removeEventListener('keydown', onSearchKeydown) + btnPrev?.removeEventListener('click', onPrevClick) + btnPlay?.removeEventListener('click', onPlayClick) + btnNext?.removeEventListener('click', onNextClick) + if ('mediaSession' in navigator) { + try { + navigator.mediaSession.setActionHandler('play', null) + navigator.mediaSession.setActionHandler('pause', null) + navigator.mediaSession.setActionHandler('previoustrack', null) + navigator.mediaSession.setActionHandler('nexttrack', null) + navigator.mediaSession.setActionHandler('seekto', null) + } catch { + // ignore + } + } } - }, []) + }, [dispatch]) const libraryLoading = breadcrumbs.length === 1 @@ -666,10 +519,22 @@ export function FurumiPlayer() { libraryLoading={libraryLoading} libraryError={libraryError} libraryItems={libraryItems} - queueItemsView={queueItemsView} - queueOrderView={queueOrderView} - queuePlayingOrigIdxView={queuePlayingOrigIdxView} - queueScrollSignal={queueScrollSignal} + onQueuePlay={(origIdx) => queueActionsRef.current?.playIndex(origIdx)} + onQueueRemove={(origIdx) => + queueActionsRef.current?.removeFromQueue(origIdx) + } + onQueueMove={(fromPos, toPos) => + queueActionsRef.current?.moveQueueItem(fromPos, toPos) + } + onClearQueue={() => queueActionsRef.current?.clearQueue()} + /> + + queueActionsRef.current?.playIndex(origIdx)} onQueueRemove={(origIdx) => queueActionsRef.current?.removeFromQueue(origIdx) @@ -679,11 +544,8 @@ export function FurumiPlayer() { } /> - -
) } - diff --git a/furumi-node-player/client/src/audioPlaybackService.ts b/furumi-node-player/client/src/audioPlaybackService.ts new file mode 100644 index 0000000..e064fbd --- /dev/null +++ b/furumi-node-player/client/src/audioPlaybackService.ts @@ -0,0 +1,190 @@ +import { preloadStream } from './furumiApi' +import { fmt } from './utils' + +const MAX_PLAYBACK_ERROR_SKIPS = 5 + +/** Seconds from track start above which "previous" rewinds current track instead. */ +const PREV_TRACK_REWIND_THRESHOLD_SEC = 3 + +export interface AudioPlaybackCallbacks { + onEnded: () => void + /** Called after a recoverable playback error (to advance queue). */ + onErrorSkip: () => void + onToast: (msg: string) => void +} + +export interface AudioPlaybackHandle { + loadStreamForTrack(slug: string): Promise + 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) { + const response = await preloadStream(slug) + audio.src = URL.createObjectURL(response?.data) + await audio.play().catch(() => { }) + } + + function pauseAndClearSource() { + audio.pause() + audio.src = '' + } + + function togglePlay(whenNoSource: () => void) { + if (!audio.src) { + whenNoSource() + return + } + if (audio.paused) void audio.play() + else audio.pause() + } + + function seekFromProgressBarClick(e: MouseEvent) { + if (!audio.duration) return + const bar = document.getElementById('progressBar') as HTMLDivElement | null + if (!bar) return + const rect = bar.getBoundingClientRect() + const pct = (e.clientX - rect.left) / rect.width + audio.currentTime = pct * audio.duration + } + + function toggleMute() { + muted = !muted + audio.muted = muted + const icon = document.getElementById('volIcon') + if (icon) icon.innerHTML = muted ? '🔇' : '🔊' + } + + 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, + } +} diff --git a/furumi-node-player/client/src/components/MainPanel.tsx b/furumi-node-player/client/src/components/MainPanel.tsx index 7601279..90bf957 100644 --- a/furumi-node-player/client/src/components/MainPanel.tsx +++ b/furumi-node-player/client/src/components/MainPanel.tsx @@ -1,7 +1,18 @@ 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, type QueueItem } from './QueueList' +import { QueueList } from './QueueList' export type Crumb = { label: string; action?: () => void } @@ -21,13 +32,10 @@ type MainPanelProps = { libraryLoading: boolean libraryError: string | null libraryItems: LibraryListItem[] - queueItemsView: QueueItem[] - queueOrderView: number[] - queuePlayingOrigIdxView: number - queueScrollSignal: number onQueuePlay: (origIdx: number) => void onQueueRemove: (origIdx: number) => void onQueueMove: (fromPos: number, toPos: number) => void + onClearQueue: () => void } export function MainPanel({ @@ -35,14 +43,19 @@ export function MainPanel({ libraryLoading, libraryError, libraryItems, - queueItemsView, - queueOrderView, - queuePlayingOrigIdxView, - queueScrollSignal, 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 (
@@ -58,13 +71,21 @@ export function MainPanel({
Queue
- - -
diff --git a/furumi-node-player/client/src/components/PlayerBar.tsx b/furumi-node-player/client/src/components/PlayerBar.tsx index 48fcdff..f0f8a03 100644 --- a/furumi-node-player/client/src/components/PlayerBar.tsx +++ b/furumi-node-player/client/src/components/PlayerBar.tsx @@ -1,7 +1,28 @@ import { NowPlaying } from './NowPlaying' +import { QueuePopover } from './queue-popover' import type { QueueItem } from './QueueList' -export function PlayerBar({ track }: { track: QueueItem | null }) { +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 (
@@ -30,6 +51,15 @@ export function PlayerBar({ track }: { track: QueueItem | null }) {
+ 🔊 diff --git a/furumi-node-player/client/src/components/Header.tsx b/furumi-node-player/client/src/components/header/Header.tsx similarity index 81% rename from furumi-node-player/client/src/components/Header.tsx rename to furumi-node-player/client/src/components/header/Header.tsx index 1cd5708..1a4c4fa 100644 --- a/furumi-node-player/client/src/components/Header.tsx +++ b/furumi-node-player/client/src/components/header/Header.tsx @@ -1,4 +1,5 @@ -import { SearchDropdown } from './SearchDropdown' +import { SearchDropdown } from '../SearchDropdown' +import styles from './header.module.css' type SearchResultItem = { result_type: string @@ -19,8 +20,8 @@ export function Header({ onSearchSelect, }: HeaderProps) { return ( -
-
+
+
@@ -28,7 +29,7 @@ export function Header({ Furumi - v + v
diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css new file mode 100644 index 0000000..3777ab4 --- /dev/null +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -0,0 +1,35 @@ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1.5rem; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + z-index: 10; +} + +.headerLogo { + display: flex; + align-items: center; + gap: 0.75rem; + font-weight: 700; + font-size: 1.1rem; + color: #ffffff; +} + +.headerLogo svg { + width: 22px; + height: 22px; +} + +.headerVersion { + font-size: 0.7rem; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.05); + padding: 0.1rem 0.4rem; + border-radius: 4px; + margin-left: 0.25rem; + font-weight: 500; + text-decoration: none; +} \ No newline at end of file diff --git a/furumi-node-player/client/src/components/header/index.ts b/furumi-node-player/client/src/components/header/index.ts new file mode 100644 index 0000000..220d1b1 --- /dev/null +++ b/furumi-node-player/client/src/components/header/index.ts @@ -0,0 +1 @@ +export * from './Header' \ No newline at end of file diff --git a/furumi-node-player/client/src/components/queue-popover/index.ts b/furumi-node-player/client/src/components/queue-popover/index.ts new file mode 100644 index 0000000..e2aa02a --- /dev/null +++ b/furumi-node-player/client/src/components/queue-popover/index.ts @@ -0,0 +1 @@ +export * from './queue-popover' diff --git a/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css b/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css new file mode 100644 index 0000000..d16d59f --- /dev/null +++ b/furumi-node-player/client/src/components/queue-popover/queue-popover.module.css @@ -0,0 +1,68 @@ +.root { + position: relative; + display: flex; + align-items: center; +} + +.trigger { + display: flex; + align-items: center; + justify-content: center; + padding: 0.35rem; + margin: 0; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font: inherit; + line-height: 1; +} + +.trigger:hover { + color: var(--text); + background: var(--bg-hover); +} + +.triggerIcon { + font-size: 0.95rem; +} + +.popover { + position: absolute; + bottom: calc(100% + 0.5rem); + right: 0; + z-index: 60; + display: flex; + flex-direction: column; + min-width: min(100vw - 2rem, 320px); + max-width: min(100vw - 2rem, 360px); + max-height: min(50vh, 360px); + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-card); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45); +} + +.header { + flex-shrink: 0; + padding: 0.55rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + +.body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 0.35rem 0.5rem; +} + +.body :global(.queue-empty) { + padding: 1.25rem 0.75rem; + font-size: 0.8rem; +} diff --git a/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx b/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx new file mode 100644 index 0000000..40eb69a --- /dev/null +++ b/furumi-node-player/client/src/components/queue-popover/queue-popover.tsx @@ -0,0 +1,86 @@ +import { useEffect, useId, useRef, useState } from 'react' +import { QueueList, type QueueItem } from '../QueueList' +import styles from './queue-popover.module.css' + +export type QueuePopoverProps = { + queue: QueueItem[] + order: number[] + playingOrigIdx: number + scrollSignal: number + onPlay: (origIdx: number) => void + onRemove: (origIdx: number) => void + onMove: (fromPos: number, toPos: number) => void +} + +export function QueuePopover({ + queue, + order, + playingOrigIdx, + scrollSignal, + onPlay, + onRemove, + onMove, +}: QueuePopoverProps) { + const [open, setOpen] = useState(false) + const rootRef = useRef(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 ( +
+ + {open && ( + + )} +
+ ) +} diff --git a/furumi-node-player/client/src/furumi-player.css b/furumi-node-player/client/src/furumi-player.css index 12c80f1..01adf74 100644 --- a/furumi-node-player/client/src/furumi-player.css +++ b/furumi-node-player/client/src/furumi-player.css @@ -31,40 +31,6 @@ --danger: #f87171; } -.header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 1.5rem; - background: var(--bg-panel); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - z-index: 10; -} - -.header-logo { - display: flex; - align-items: center; - gap: 0.75rem; - font-weight: 700; - font-size: 1.1rem; -} - -.header-logo svg { - width: 22px; - height: 22px; -} - -.header-version { - font-size: 0.7rem; - color: var(--text-muted); - background: rgba(255, 255, 255, 0.05); - padding: 0.1rem 0.4rem; - border-radius: 4px; - margin-left: 0.25rem; - font-weight: 500; - text-decoration: none; -} .btn-menu { display: none; @@ -378,6 +344,10 @@ color: var(--accent); } +.qi-title { + color: #ffffff; +} + .queue-item .qi-index { font-size: 0.75rem; color: var(--text-muted); @@ -531,6 +501,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: #ffffff; } .np-artist { diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index e7518ec..176166a 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -1,16 +1,21 @@ import axios from 'axios' import type { Album, Artist, SearchResult, Track, TrackDetail } from './types' -const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '' -export const API_ROOT = `${API_BASE}/api` - -const API_KEY = import.meta.env.VITE_API_KEY +const FURUMI_API_BASE = import.meta.env.VITE_FURUMI_API_URL ?? '' +export const API_ROOT = `${FURUMI_API_BASE}/api` export const furumiApi = axios.create({ baseURL: API_ROOT, - headers: API_KEY ? { 'x-api-key': API_KEY } : {}, }) +export function setAuthToken(token: string) { + furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}` +} + +export function clearAuthToken() { + delete furumiApi.defaults.headers.common['Authorization'] +} + export async function getArtists(): Promise { const res = await furumiApi.get('/artists').catch(() => null) return res?.data ?? null @@ -44,6 +49,6 @@ export async function getTrackInfo(trackSlug: string): Promise null) + return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) } diff --git a/furumi-node-player/client/src/store/index.ts b/furumi-node-player/client/src/store/index.ts index 49b5321..37ff304 100644 --- a/furumi-node-player/client/src/store/index.ts +++ b/furumi-node-player/client/src/store/index.ts @@ -5,6 +5,7 @@ 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: { @@ -13,6 +14,7 @@ export const store = configureStore({ albumTracks: albumTracksReducer, artistTracks: artistTracksReducer, trackDetail: trackDetailReducer, + queue: queueReducer, }, }) diff --git a/furumi-node-player/client/src/store/slices/queueSlice.ts b/furumi-node-player/client/src/store/slices/queueSlice.ts new file mode 100644 index 0000000..09c5b0e --- /dev/null +++ b/furumi-node-player/client/src/store/slices/queueSlice.ts @@ -0,0 +1,274 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import type { QueueItem } from '../../components/QueueList' + +export interface QueueState { + items: QueueItem[] + currentIndex: number + shuffle: boolean + repeatAll: boolean + shuffleOrder: number[] + scrollSignal: number +} + +function readShufflePref(): boolean { + try { + return window.localStorage.getItem('furumi_shuffle') === '1' + } catch { + return false + } +} + +function readRepeatPref(): boolean { + try { + return window.localStorage.getItem('furumi_repeat') !== '0' + } catch { + return true + } +} + +function buildShuffleOrder(state: QueueState) { + const n = state.items.length + if (n === 0) { + state.shuffleOrder = [] + return + } + const order = [...Array(n).keys()] + for (let i = order.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[order[i], order[j]] = [order[j], order[i]] + } + if (state.currentIndex !== -1) { + const ci = order.indexOf(state.currentIndex) + if (ci > 0) { + order.splice(ci, 1) + order.unshift(state.currentIndex) + } + } + state.shuffleOrder = order +} + +function ensureShuffleOrder(state: QueueState) { + if (!state.shuffle) return + if (state.shuffleOrder.length !== state.items.length) { + buildShuffleOrder(state) + } +} + +const initialState: QueueState = { + items: [], + currentIndex: -1, + shuffle: typeof window !== 'undefined' ? readShufflePref() : false, + repeatAll: typeof window !== 'undefined' ? readRepeatPref() : true, + shuffleOrder: [], + scrollSignal: 0, +} + +const queueSlice = createSlice({ + name: 'queue', + initialState, + reducers: { + addTrack( + state, + action: PayloadAction<{ + track: QueueItem + playNow?: boolean + }>, + ) { + const { track, playNow } = action.payload + const existing = state.items.findIndex((t) => t.slug === track.slug) + if (existing !== -1) { + if (playNow) { + state.currentIndex = existing + state.scrollSignal += 1 + } + return + } + const oldLen = state.items.length + const idle = state.currentIndex === -1 + state.items.push(track) + ensureShuffleOrder(state) + if (playNow || (oldLen === 0 && idle)) { + state.currentIndex = state.items.length - 1 + state.scrollSignal += 1 + } + }, + + addTracksBatch( + state, + action: PayloadAction<{ + tracks: QueueItem[] + playFirst?: boolean + }>, + ) { + const { tracks, playFirst } = action.payload + let firstNewIdx: number | null = null + for (const t of tracks) { + if (state.items.some((q) => q.slug === t.slug)) continue + if (firstNewIdx === null) firstNewIdx = state.items.length + state.items.push(t) + } + ensureShuffleOrder(state) + if (firstNewIdx === null) return + if (playFirst || state.currentIndex === -1) { + state.currentIndex = firstNewIdx + state.scrollSignal += 1 + } + }, + + replaceQueue( + state, + action: PayloadAction<{ + items: QueueItem[] + playFromIndex?: number + }>, + ) { + const { items, playFromIndex = 0 } = action.payload + state.items = items + state.currentIndex = + items.length > 0 ? Math.min(playFromIndex, items.length - 1) : -1 + state.shuffleOrder = [] + ensureShuffleOrder(state) + }, + + clearQueue(state) { + state.items = [] + state.currentIndex = -1 + state.shuffleOrder = [] + state.scrollSignal += 1 + }, + + playAtIndex(state, action: PayloadAction) { + const i = action.payload + if (i < 0 || i >= state.items.length) return + state.currentIndex = i + state.scrollSignal += 1 + }, + + removeFromQueueAt(state, action: PayloadAction) { + 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 diff --git a/furumi-node-player/client/vite.config.ts b/furumi-node-player/client/vite.config.ts index ab8ee06..58fa2c9 100644 --- a/furumi-node-player/client/vite.config.ts +++ b/furumi-node-player/client/vite.config.ts @@ -6,7 +6,11 @@ export default defineConfig({ plugins: [react()], server: { proxy: { - '/api': { + '/auth': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/callback': { target: 'http://localhost:3001', changeOrigin: true, }, diff --git a/furumi-node-player/server/src/index.ts b/furumi-node-player/server/src/index.ts index 59622ad..93f7f29 100644 --- a/furumi-node-player/server/src/index.ts +++ b/furumi-node-player/server/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config'; +import path from 'path'; import cors from 'cors'; import express from 'express'; import { auth } from 'express-openid-connect'; @@ -28,7 +29,6 @@ const oidcConfig = { }; if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) { - // Keep a clear startup failure if OIDC is not configured. throw new Error( 'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)', ); @@ -46,11 +46,11 @@ if (!disableAuth) { app.use(auth(oidcConfig)); } -app.get('/api/health', (_req, res) => { +app.get('/auth/health', (_req, res) => { res.json({ ok: true }); }); -app.get('/api/me', (req, res) => { +app.get('/auth/me', (req, res) => { if (disableAuth) { res.json({ authenticated: false, @@ -74,7 +74,32 @@ app.get('/api/me', (req, res) => { }); }); -app.get('/api/login', (req, res) => { +app.get('/auth/token', (req, res) => { + if (disableAuth) { + res.status(204).end(); + return; + } + + if (!req.oidc.isAuthenticated()) { + res.status(401).json({ authenticated: false }); + return; + } + + const accessToken = req.oidc.accessToken?.access_token; + const expiresAt = req.oidc.accessToken?.expires_at; + if (!accessToken) { + res.status(500).json({ error: 'no access token in session' }); + return; + } + + res.json({ + access_token: accessToken, + token_type: 'Bearer', + expires_at: expiresAt, + }); +}); + +app.get('/auth/login', (req, res) => { if (disableAuth) { res.status(204).end(); return; @@ -85,7 +110,7 @@ app.get('/api/login', (req, res) => { }); }); -app.get('/api/logout', (req, res) => { +app.get('/auth/logout', (req, res) => { if (disableAuth) { res.status(204).end(); return; @@ -96,6 +121,13 @@ app.get('/api/logout', (req, res) => { }); }); +// Production: serve Vite-built client as static files +const clientDist = path.resolve(import.meta.dirname, '../../client/dist'); +app.use(express.static(clientDist)); +app.get('*', (_req, res) => { + res.sendFile(path.join(clientDist, 'index.html')); +}); + app.listen(port, () => { console.log( `${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`, diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 822300c..6638f38 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -18,7 +18,8 @@ mime_guess = "2.0" symphonia = { version = "0.5", default-features = false, features = ["mp3", "aac", "flac", "vorbis", "wav", "alac", "adpcm", "pcm", "mpa", "isomp4", "ogg", "aiff", "mkv"] } tokio-util = { version = "0.7", features = ["io"] } openidconnect = "3.4" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +jsonwebtoken = "9" sha2 = "0.10" hmac = "0.12" base64 = "0.22" diff --git a/furumi-web-player/src/main.rs b/furumi-web-player/src/main.rs index b8a8592..f95c39b 100644 --- a/furumi-web-player/src/main.rs +++ b/furumi-web-player/src/main.rs @@ -40,9 +40,6 @@ struct Args { #[arg(long, env = "FURUMI_PLAYER_OIDC_SESSION_SECRET")] oidc_session_secret: Option, - /// API key for x-api-key header auth (alternative to OIDC session) - #[arg(long, env = "FURUMI_PLAYER_API_KEY")] - api_key: Option, } #[tokio::main] @@ -94,15 +91,10 @@ async fn main() -> Result<(), Box> { std::process::exit(1); }); - if args.api_key.is_some() { - tracing::info!("x-api-key auth: enabled"); - } - let state = Arc::new(web::AppState { pool, storage_dir: Arc::new(args.storage_dir), oidc: oidc_state, - api_key: args.api_key, }); tracing::info!("Web player: http://{}", bind_addr); diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index 33f8184..98574f7 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -3,10 +3,9 @@ use axum::{ extract::{Request, State}, http::{header, HeaderMap, StatusCode}, middleware::Next, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect, Response}, }; -const X_API_KEY: &str = "x-api-key"; use openidconnect::{ core::{CoreClient, CoreProviderMetadata, CoreResponseType}, reqwest::async_http_client, @@ -18,17 +17,26 @@ use serde::Deserialize; use base64::Engine; use hmac::{Hmac, Mac}; +use jsonwebtoken::{decode, decode_header, DecodingKey, Validation as JwtValidation}; +use jsonwebtoken::jwk::JwkSet; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; use super::AppState; use std::sync::Arc; const SESSION_COOKIE: &str = "furumi_session"; +const JWKS_CACHE_TTL: Duration = Duration::from_secs(3600); type HmacSha256 = Hmac; pub struct OidcState { pub client: CoreClient, pub session_secret: Vec, + jwks_uri: String, + issuer_url: String, + jwks_cache: RwLock>, + http_client: reqwest::Client, } pub async fn oidc_init( @@ -44,6 +52,9 @@ pub async fn oidc_init( ) .await?; + let jwks_uri = provider_metadata.jwks_uri().to_string(); + let issuer_url = provider_metadata.issuer().to_string(); + let client = CoreClient::from_provider_metadata( provider_metadata, ClientId::new(client_id), @@ -62,12 +73,70 @@ pub async fn oidc_init( b }; + let http_client = reqwest::Client::new(); + + tracing::info!("JWKS URI: {}", jwks_uri); + Ok(OidcState { client, session_secret, + jwks_uri, + issuer_url, + jwks_cache: RwLock::new(None), + http_client, }) } +impl OidcState { + async fn get_jwks(&self) -> anyhow::Result { + { + 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 { + tracing::debug!("Fetching JWKS from {}", self.jwks_uri); + let jwks: JwkSet = self.http_client.get(&self.jwks_uri).send().await?.json().await?; + let mut cache = self.jwks_cache.write().await; + *cache = Some((jwks.clone(), Instant::now())); + Ok(jwks) + } +} + +#[derive(Debug, serde::Deserialize)] +struct BearerClaims { + sub: String, +} + +async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { + 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::(token, &key, &validation).ok()?; + Some(data.claims.sub) +} + fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String { let mut mac = HmacSha256::new_from_slice(secret).unwrap(); mac.update(user_id.as_bytes()); @@ -94,20 +163,22 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option { } } -/// Auth middleware: requires valid SSO session cookie or x-api-key header. +/// Auth middleware: requires valid Bearer JWT or SSO session cookie. pub async fn require_auth( State(state): State>, req: Request, next: Next, ) -> Response { - // 1. Check x-api-key header (if configured) - if let Some(ref expected) = state.api_key { - if let Some(val) = req + // 1. Check Bearer token — JWT from OIDC provider + if let Some(ref oidc) = state.oidc { + if let Some(token) = req .headers() - .get(X_API_KEY) + .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) { - if val == expected { + if let Some(user_id) = validate_bearer_token(oidc, token).await { + tracing::debug!("Bearer auth OK for user: {}", user_id); return next.run(req).await; } } @@ -131,36 +202,7 @@ pub async fn require_auth( } } - let uri = req.uri().to_string(); - if uri.starts_with("/api/") { - (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } else if state.oidc.is_some() { - Redirect::to("/login").into_response() - } else { - // Only API key configured — no web login available - (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() - } -} - -/// GET /login — show SSO login page. -pub async fn login_page(State(state): State>) -> 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() + (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() } #[derive(Deserialize)] @@ -335,9 +377,9 @@ pub async fn oidc_callback( .unwrap_or(false); let session_attrs = if is_https { - "SameSite=Strict; Secure" + "SameSite=Lax; Secure" } else { - "SameSite=Strict" + "SameSite=Lax" }; let session_cookie = format!( @@ -354,47 +396,3 @@ pub async fn oidc_callback( (StatusCode::FOUND, headers, Body::empty()).into_response() } - -const LOGIN_HTML: &str = r#" - - - - -Furumi Player — Login - - - -
- -
Sign in to continue
- SSO Login -
- -"#; diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index 355b4bb..c8d95b9 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -16,7 +16,6 @@ pub struct AppState { #[allow(dead_code)] pub storage_dir: Arc, pub oidc: Option>, - pub api_key: Option, } pub fn build_router(state: Arc) -> Router { @@ -32,37 +31,27 @@ pub fn build_router(state: Arc) -> Router { .route("/stream/:slug", get(api::stream_track)) .route("/search", get(api::search)); - let authed = Router::new() - .route("/", get(player_html)) + let api = Router::new() .nest("/api", library); let requires_auth = state.oidc.is_some(); let app = if requires_auth { - authed - .route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) + api.route_layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)) } else { - authed + api }; let cors = CorsLayer::new() .allow_origin(Any) .allow_methods([Method::GET, Method::OPTIONS, Method::HEAD]) - .allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::HeaderName::from_static("x-api-key")]) + .allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION]) .max_age(Duration::from_secs(600)); Router::new() - .route("/login", get(auth::login_page)) - .route("/logout", get(auth::logout)) .route("/auth/login", get(auth::oidc_login)) .route("/auth/callback", get(auth::oidc_callback)) .merge(app) .layer(cors) .with_state(state) } - -async fn player_html() -> axum::response::Html { - let html = include_str!("player.html") - .replace("", option_env!("FURUMI_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))); - axum::response::Html(html) -}