Merge branch 'DEV' into feature/node-app
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m5s
Publish Node Player Image / build-and-push-image (push) Failing after 26s
Publish Web Player Image / build-and-push-image (push) Successful in 1m43s

This commit is contained in:
Boris Cherepanov
2026-04-19 21:11:31 +03:00
36 changed files with 1421 additions and 366 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
VITE_API_BASE_URL=http://localhost:8085
VITE_API_KEY=
# Leave empty — vite proxy handles /api in dev, same-origin in production
VITE_FURUMI_API_URL=
+45
View File
@@ -0,0 +1,45 @@
const DB_NAME = 'furumi-sw'
const STORE = 'auth'
const KEY = 'bearer'
function openDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1)
req.onupgradeneeded = () => req.result.createObjectStore(STORE)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
async function getToken() {
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE, 'readonly')
const req = tx.objectStore(STORE).get(KEY)
req.onsuccess = () => resolve(req.result || null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url)
if (url.origin !== self.location.origin || !url.pathname.startsWith('/api/')) return
e.respondWith(
(async () => {
const token = await getToken()
if (!token) return fetch(e.request)
const headers = new Headers(e.request.headers)
headers.set('Authorization', `Bearer ${token}`)
return fetch(new Request(e.request, { headers }))
})()
)
})
self.addEventListener('install', () => self.skipWaiting())
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()))
+79 -48
View File
@@ -1,71 +1,102 @@
.page {
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
.auth-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #0a0c12;
font-family: 'Inter', system-ui, sans-serif;
color: #e2e8f0;
}
.card {
width: min(520px, 100%);
border: 1px solid #d8dde6;
border-radius: 14px;
padding: 24px;
background-color: #ffffff;
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.08);
}
/* ---------- loading spinner ---------- */
.subtitle {
margin-top: 0;
margin-bottom: 20px;
color: #5a6475;
}
.settings {
margin-bottom: 16px;
padding: 12px;
border: 1px solid #e6eaf2;
border-radius: 10px;
background: #f8fafc;
}
.toggle {
.auth-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: #0f172a;
font-weight: 600;
gap: 20px;
}
.toggle input {
width: 18px;
height: 18px;
.spinner {
width: 36px;
height: 36px;
border: 3px solid #1f2c45;
border-top-color: #7c6af7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.hint {
margin: 10px 0 0;
color: #5a6475;
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn {
display: inline-block;
text-decoration: none;
background: #2251ff;
color: #ffffff;
padding: 10px 16px;
.auth-loading .logo {
font-size: 1.6rem;
font-weight: 700;
color: #7c6af7;
}
.auth-loading p {
color: #64748b;
font-size: 0.85rem;
}
/* ---------- login card ---------- */
.auth-card {
width: min(380px, 100%);
background: #111520;
border: 1px solid #1f2c45;
border-radius: 16px;
padding: 2.5rem 2rem;
text-align: center;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.auth-card .logo {
font-size: 1.8rem;
font-weight: 700;
color: #7c6af7;
margin-bottom: 4px;
}
.auth-card .subtitle {
font-size: 0.85rem;
color: #64748b;
margin-bottom: 2rem;
}
.auth-card .btn-login {
display: block;
width: 100%;
padding: 0.75rem;
text-align: center;
background: #7c6af7;
border: none;
border-radius: 8px;
color: #fff;
font-size: 0.95rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background 0.2s;
}
.btn.ghost {
background: #edf1ff;
color: #1e3fc4;
margin-top: 10px;
.auth-card .btn-login:hover {
background: #6b58e8;
}
.profile p {
margin: 8px 0;
.auth-card .error {
color: #f87171;
font-size: 0.85rem;
margin-bottom: 1rem;
}
.error {
color: #cc1e1e;
}
+46 -90
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { FurumiPlayer } from './FurumiPlayer'
import { setAuthToken, clearAuthToken } from './furumiApi'
import './App.css'
type UserProfile = {
@@ -8,30 +9,12 @@ type UserProfile = {
email?: string
}
const NO_AUTH_STORAGE_KEY = 'furumiNodePlayer.runWithoutAuth'
function App() {
const [loading, setLoading] = useState(true)
const [user, setUser] = useState<UserProfile | null>(null)
const [error, setError] = useState<string | null>(null)
const [runWithoutAuth, setRunWithoutAuth] = useState(() => {
try {
return window.localStorage.getItem(NO_AUTH_STORAGE_KEY) === '1'
} catch {
return false
}
})
const apiBase = useMemo(() => import.meta.env.VITE_API_BASE_URL ?? '', [])
useEffect(() => {
if (runWithoutAuth) {
setError(null)
setUser({ sub: 'noauth', name: 'No Auth' })
setLoading(false)
return
}
const loadMe = async () => {
try {
const response = await fetch(`/api/me`, {
@@ -40,6 +23,7 @@ function App() {
if (response.status === 401) {
setUser(null)
clearAuthToken()
return
}
@@ -49,6 +33,20 @@ function App() {
const data = await response.json()
setUser(data.user ?? null)
if (data.user) {
try {
const tokenRes = await fetch('/auth/token', { credentials: 'include' })
if (tokenRes.ok) {
const tokenData = await tokenRes.json()
if (tokenData.access_token) {
setAuthToken(tokenData.access_token)
}
}
} catch {
// Token fetch failed
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load session')
} finally {
@@ -57,84 +55,42 @@ function App() {
}
void loadMe()
}, [apiBase, runWithoutAuth])
}, [])
const loginUrl = `/api/login`
const logoutUrl = `/api/logout`
// Authenticated — render player immediately
if (!loading && user) {
return <FurumiPlayer user={user} />
}
// Loading — show spinner (no login form flash)
if (loading) {
return (
<main className="auth-page">
<div className="auth-loading">
<div className="logo">Furumi</div>
<div className="spinner" />
<p>Loading...</p>
</div>
</main>
)
}
// Not authenticated — show login
return (
<>
{!loading && (user || runWithoutAuth) ? (
<FurumiPlayer />
) : (
<main className="page">
<section className="card">
<h1>OIDC Login</h1>
<p className="subtitle">Авторизация обрабатывается на Express сервере.</p>
<main className="auth-page">
<section className="auth-card">
<div className="logo">Furumi</div>
<p className="subtitle">Sign in to continue</p>
<div className="settings">
<label className="toggle">
<input
type="checkbox"
checked={runWithoutAuth}
onChange={(e) => {
const next = e.target.checked
setRunWithoutAuth(next)
try {
if (next) window.localStorage.setItem(NO_AUTH_STORAGE_KEY, '1')
else window.localStorage.removeItem(NO_AUTH_STORAGE_KEY)
} catch {
// ignore
}
setLoading(true)
setUser(null)
}}
/>
<span>Запускать без авторизации</span>
</label>
</div>
{error && <p className="error">{error}</p>}
{loading && <p>Проверяю сессию...</p>}
{error && <p className="error">Ошибка: {error}</p>}
{!loading && runWithoutAuth && (
<p className="hint">
Режим без авторизации включён. Для входа отключи настройку выше.
</p>
)}
{!loading && !user && (
<a className="btn" href={loginUrl}>
Войти через OIDC
</a>
)}
{!loading && user && (
<div className="profile">
<p>
<strong>ID:</strong> {user.sub}
</p>
{user.name && (
<p>
<strong>Имя:</strong> {user.name}
</p>
)}
{user.email && (
<p>
<strong>Email:</strong> {user.email}
</p>
)}
{!runWithoutAuth && (
<a className="btn ghost" href={logoutUrl}>
Выйти
</a>
)}
</div>
)}
</section>
</main>
)}
</>
<a className="btn-login" href="/auth/login">
Sign in with SSO
</a>
</section>
</main>
)
}
+19 -7
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css'
import { API_ROOT, searchTracks, preloadStream } from './furumiApi'
import { furumiApi, searchTracks, recordPlay } from './furumiApi'
import { store, useAppDispatch, useAppSelector } from './store'
import { fetchArtists } from './store/slices/artistsSlice'
import { fetchArtistAlbums } from './store/slices/albumsSlice'
@@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel'
import { PlayerBar } from './components/PlayerBar'
import type { Track } from './types'
export function FurumiPlayer() {
export type UserProfile = {
sub: string
name?: string
email?: string
}
export function FurumiPlayer({ user }: { user: UserProfile }) {
const dispatch = useAppDispatch()
const artistsLoading = useAppSelector((s) => s.artists.loading)
const artistsError = useAppSelector((s) => s.artists.error)
@@ -80,16 +86,19 @@ export function FurumiPlayer() {
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({
const meta = new window.MediaMetadata({
title: nowPlayingTrack.title,
artist: nowPlayingTrack.artist || '',
album: '',
artwork: [{ src: coverUrl, sizes: '512x512' }],
})
navigator.mediaSession.metadata = meta
furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' })
.then((res) => {
meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }]
})
.catch(() => {})
} catch {
// ignore
}
@@ -292,6 +301,7 @@ export function FurumiPlayer() {
dispatch(playAtIndex(i))
const track = store.getState().queue.items[i]
void playback.loadStreamForTrack(track.slug)
void recordPlay(track.slug)
if (window.history && window.history.replaceState) {
const url = new URL(window.location.href)
url.searchParams.set('t', track.slug)
@@ -384,7 +394,7 @@ export function FurumiPlayer() {
{ slug, title: '', artist: '', album_slug: null, duration: null },
true,
)
void preloadStream(slug)
void playback.loadStreamForTrack(slug)
}
}
searchSelectRef.current = onSearchSelect
@@ -512,6 +522,8 @@ export function FurumiPlayer() {
searchOpen={searchOpen}
searchResults={searchResults}
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
onPlayTrack={(slug) => searchSelectRef.current('track', slug)}
user={user}
/>
<MainPanel
@@ -1,4 +1,4 @@
import { preloadStream } from './furumiApi'
import { furumiApi } from './furumiApi'
import { fmt } from './utils'
const MAX_PLAYBACK_ERROR_SKIPS = 5
@@ -109,9 +109,13 @@ export function attachAudioPlayback(
volSlider?.addEventListener('input', onVolInput)
async function loadStreamForTrack(slug: string) {
const response = await preloadStream(slug)
audio.src = URL.createObjectURL(response?.data)
await audio.play().catch(() => { })
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() {
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react'
import { furumiApi } from '../furumiApi'
export function AuthImg({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
const [blobUrl, setBlobUrl] = useState<string | null>(null)
useEffect(() => {
if (!src) return
let revoked = false
furumiApi.get(src, { responseType: 'blob' })
.then((res) => {
if (!revoked) setBlobUrl(URL.createObjectURL(res.data))
})
.catch(() => {})
return () => {
revoked = true
if (blobUrl) URL.revokeObjectURL(blobUrl)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src])
if (!blobUrl) return null
return <img src={blobUrl} alt={alt ?? ''} {...props} />
}
@@ -1,16 +1,13 @@
import { useEffect, useState } from 'react'
import { API_ROOT } from '../furumiApi'
import { useState } from 'react'
import { AuthImg } from './AuthImg'
import type { QueueItem } from './QueueList'
function Cover({ src }: { src: string }) {
function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
const src = `/tracks/${slug}/cover`
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
return <AuthImg src={src} alt="" onError={() => setErrored(true)} />
}
export function NowPlaying({ track }: { track: QueueItem | null }) {
@@ -32,12 +29,10 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
)
}
const coverUrl = `${API_ROOT}/tracks/${track.slug}/cover`
return (
<div className="np-info">
<div className="np-cover" id="npCover">
<Cover src={coverUrl} />
<Cover slug={track.slug} />
</div>
<div className="np-text">
<div className="np-title" id="npTitle">
@@ -50,4 +45,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
</div>
)
}
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { API_ROOT } from '../furumiApi'
import { AuthImg } from './AuthImg'
export type QueueItem = {
slug: string
@@ -32,14 +32,10 @@ function fmt(secs: number) {
return `${m}:${pad(s % 60)}`
}
function Cover({ src }: { src: string }) {
function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false)
useEffect(() => {
setErrored(false)
}, [src])
if (errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
}
export function QueueList({
@@ -77,7 +73,7 @@ export function QueueList({
if (!t) return null
const isPlaying = origIdx === playingOrigIdx
const coverSrc = t.album_slug ? `${API_ROOT}/tracks/${t.slug}/cover` : ''
const hasAlbum = !!t.album_slug
const dur = t.duration ? fmt(t.duration) : ''
const isDragging = draggingPos === pos
const isDragOver = dragOverPos === pos
@@ -118,7 +114,7 @@ export function QueueList({
>
<span className="qi-index">{isPlaying ? '' : pos + 1}</span>
<div className="qi-cover">
{coverSrc ? <Cover src={coverSrc} /> : <>&#127925;</>}
{hasAlbum ? <Cover slug={t.slug} /> : <>&#127925;</>}
</div>
<div className="qi-info">
<div className="qi-title">{t.title}</div>
@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { SearchDropdown } from '../SearchDropdown'
import { RecentPlays } from './RecentPlays'
import styles from './header.module.css'
type SearchResultItem = {
@@ -8,39 +10,100 @@ type SearchResultItem = {
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">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className={styles.headerVersion}>v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={onSearchSelect}
/>
<>
<header className={styles.header}>
<div className={styles.headerLogo}>
<button className="btn-menu">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className={styles.headerVersion}>v</span>
</div>
</div>
</header>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={onSearchSelect}
/>
</div>
<UserMenu user={user} onShowRecent={() => setShowRecent(true)} />
</div>
</header>
{showRecent && (
<RecentPlays
onClose={() => setShowRecent(false)}
onPlay={onPlayTrack}
/>
)}
</>
)
}
@@ -0,0 +1,64 @@
import { useEffect, useState } from 'react'
import { getRecentPlays, type RecentPlay } from '../../furumiApi'
import styles from './header.module.css'
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
return `${days}d ago`
}
export function RecentPlays({ onClose, onPlay }: { onClose: () => void; onPlay: (slug: string) => void }) {
const [plays, setPlays] = useState<RecentPlay[] | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
getRecentPlays().then((data) => {
setPlays(data)
setLoading(false)
})
}, [])
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className={styles.recentOverlay} onClick={onClose}>
<div className={styles.recentPanel} onClick={(e) => e.stopPropagation()}>
<div className={styles.recentHeader}>
<h2>Recent plays</h2>
<button className={styles.recentClose} onClick={onClose}>&#10005;</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>
)
}
@@ -32,4 +32,203 @@
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;
}
+67 -7
View File
@@ -1,16 +1,64 @@
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 } : {},
})
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
@@ -43,7 +91,19 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
return res?.data ?? null
}
export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => 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)
}
+4
View File
@@ -5,6 +5,10 @@ import { store } from './store'
import './index.css'
import App from './App.tsx'
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider store={store}>
+9 -1
View File
@@ -6,10 +6,18 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
'/auth': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/callback': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/api': {
target: 'http://localhost:8085',
changeOrigin: true,
},
},
},
})
+48 -6
View File
@@ -1,5 +1,6 @@
import 'dotenv/config';
import path from 'path';
import cors from 'cors';
import express from 'express';
import { auth } from 'express-openid-connect';
@@ -23,12 +24,11 @@ const oidcConfig = {
clientSecret: process.env.OIDC_CLIENT_SECRET ?? '',
authorizationParams: {
response_type: 'code',
scope: process.env.OIDC_SCOPE ?? 'openid profile email',
scope: process.env.OIDC_SCOPE ?? 'openid profile email offline_access',
},
};
if (!disableAuth && (!oidcConfig.clientID || !oidcConfig.issuerBaseURL || !oidcConfig.clientSecret)) {
// Keep a clear startup failure if OIDC is not configured.
throw new Error(
'OIDC config is missing. Set OIDC_ISSUER_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET in server/.env (or set DISABLE_AUTH=true)',
);
@@ -46,11 +46,11 @@ if (!disableAuth) {
app.use(auth(oidcConfig));
}
app.get('/api/health', (_req, res) => {
app.get('/auth/health', (_req, res) => {
res.json({ ok: true });
});
app.get('/api/me', (req, res) => {
app.get('/auth/me', (req, res) => {
if (disableAuth) {
res.json({
authenticated: false,
@@ -74,7 +74,42 @@ app.get('/api/me', (req, res) => {
});
});
app.get('/api/login', (req, res) => {
app.get('/auth/token', async (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
}
if (!req.oidc.isAuthenticated()) {
res.status(401).json({ authenticated: false });
return;
}
let accessToken = req.oidc.accessToken;
if (!accessToken?.access_token) {
res.status(500).json({ error: 'no access token in session' });
return;
}
// Refresh if expired
if (accessToken.isExpired()) {
try {
accessToken = await accessToken.refresh();
} catch (e) {
console.error('Token refresh failed:', e);
res.status(401).json({ error: 'token refresh failed' });
return;
}
}
res.json({
access_token: accessToken.access_token,
token_type: 'Bearer',
expires_in: accessToken.expires_in,
});
});
app.get('/auth/login', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
@@ -85,7 +120,7 @@ app.get('/api/login', (req, res) => {
});
});
app.get('/api/logout', (req, res) => {
app.get('/auth/logout', (req, res) => {
if (disableAuth) {
res.status(204).end();
return;
@@ -96,6 +131,13 @@ app.get('/api/logout', (req, res) => {
});
});
// Production: serve Vite-built client as static files
const clientDist = path.resolve(import.meta.dirname, '../../client/dist');
app.use(express.static(clientDist));
app.get('/{*path}', (_req, res) => {
res.sendFile(path.join(clientDist, 'index.html'));
});
app.listen(port, () => {
console.log(
`${disableAuth ? 'NO-AUTH' : 'OIDC auth'} server listening on http://localhost:${port}`,