feature/USERS #12

Merged
ab merged 5 commits from feature/USERS into DEV 2026-04-08 16:51:25 +00:00
12 changed files with 115 additions and 60 deletions
+1
View File
@@ -2,3 +2,4 @@
/docker/inbox /docker/inbox
/docker/storage /docker/storage
.env .env
.DS_Store
+2 -1
View File
@@ -1 +1,2 @@
VITE_FURUMI_API_URL=http://localhost:8085 # 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()))
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import './furumi-player.css' import './furumi-player.css'
import { searchTracks, preloadStream, fetchCoverBlob, recordPlay } from './furumiApi' import { furumiApi, searchTracks, recordPlay } from './furumiApi'
import { store, useAppDispatch, useAppSelector } from './store' import { store, useAppDispatch, useAppSelector } from './store'
import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtists } from './store/slices/artistsSlice'
import { fetchArtistAlbums } from './store/slices/albumsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice'
@@ -94,9 +94,11 @@ export function FurumiPlayer({ user }: { user: UserProfile }) {
album: '', album: '',
}) })
navigator.mediaSession.metadata = meta navigator.mediaSession.metadata = meta
fetchCoverBlob(nowPlayingTrack.slug).then((url) => { furumiApi.get(`/tracks/${nowPlayingTrack.slug}/cover`, { responseType: 'blob' })
if (url) meta.artwork = [{ src: url, sizes: '512x512' }] .then((res) => {
meta.artwork = [{ src: URL.createObjectURL(res.data), sizes: '512x512' }]
}) })
.catch(() => {})
} catch { } catch {
// ignore // ignore
} }
@@ -392,7 +394,7 @@ export function FurumiPlayer({ user }: { user: UserProfile }) {
{ slug, title: '', artist: '', album_slug: null, duration: null }, { slug, title: '', artist: '', album_slug: null, duration: null },
true, true,
) )
void preloadStream(slug) void playback.loadStreamForTrack(slug)
} }
} }
searchSelectRef.current = onSearchSelect searchSelectRef.current = onSearchSelect
@@ -1,4 +1,4 @@
import { preloadStream } from './furumiApi' import { furumiApi } from './furumiApi'
import { fmt } from './utils' import { fmt } from './utils'
const MAX_PLAYBACK_ERROR_SKIPS = 5 const MAX_PLAYBACK_ERROR_SKIPS = 5
@@ -109,9 +109,13 @@ export function attachAudioPlayback(
volSlider?.addEventListener('input', onVolInput) volSlider?.addEventListener('input', onVolInput)
async function loadStreamForTrack(slug: string) { async function loadStreamForTrack(slug: string) {
const response = await preloadStream(slug) try {
audio.src = URL.createObjectURL(response?.data) const res = await furumiApi.get(`/stream/${slug}`, { responseType: 'blob' })
audio.src = URL.createObjectURL(res.data)
await audio.play().catch(() => { }) await audio.play().catch(() => { })
} catch {
// stream failed
}
} }
function pauseAndClearSource() { 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,13 +1,14 @@
import { useState } from 'react' import { useState } from 'react'
import { API_ROOT } from '../furumiApi'
import { AuthImg } from './AuthImg'
import type { QueueItem } from './QueueList' import type { QueueItem } from './QueueList'
import { useCoverUrl } from '../hooks/useCoverUrl'
function Cover({ slug }: { slug: string }) { function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false) const [errored, setErrored] = useState(false)
const src = useCoverUrl(slug) const src = `/tracks/${slug}/cover`
if (!src || errored) return <>&#127925;</> 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 }) { export function NowPlaying({ track }: { track: QueueItem | null }) {
@@ -45,4 +46,3 @@ export function NowPlaying({ track }: { track: QueueItem | null }) {
</div> </div>
) )
} }
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useCoverUrl } from '../hooks/useCoverUrl' import { AuthImg } from './AuthImg'
export type QueueItem = { export type QueueItem = {
slug: string slug: string
@@ -34,10 +34,8 @@ function fmt(secs: number) {
function Cover({ slug }: { slug: string }) { function Cover({ slug }: { slug: string }) {
const [errored, setErrored] = useState(false) const [errored, setErrored] = useState(false)
const src = useCoverUrl(slug) if (errored) return <>&#127925;</>
return <AuthImg src={`/tracks/${slug}/cover`} alt="" onError={() => setErrored(true)} />
if (!src || errored) return <>&#127925;</>
return <img src={src} alt="" onError={() => setErrored(true)} />
} }
export function QueueList({ export function QueueList({
+12 -12
View File
@@ -8,8 +8,20 @@ export const furumiApi = axios.create({
baseURL: API_ROOT, baseURL: API_ROOT,
}) })
function sendTokenToSW(token: string) {
try {
const req = indexedDB.open('furumi-sw', 1)
req.onupgradeneeded = () => req.result.createObjectStore('auth')
req.onsuccess = () => {
const tx = req.result.transaction('auth', 'readwrite')
tx.objectStore('auth').put(token, 'bearer')
}
} catch { /* ignore */ }
}
export function setAuthToken(token: string) { export function setAuthToken(token: string) {
furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}` furumiApi.defaults.headers.common['Authorization'] = `Bearer ${token}`
sendTokenToSW(token)
} }
export function clearAuthToken() { export function clearAuthToken() {
@@ -37,7 +49,6 @@ furumiApi.interceptors.response.use(
const original = error.config const original = error.config
if (error.response?.status === 401 && !original._retried) { if (error.response?.status === 401 && !original._retried) {
original._retried = true original._retried = true
// Deduplicate concurrent refresh attempts
if (!refreshPromise) { if (!refreshPromise) {
refreshPromise = refreshToken().finally(() => { refreshPromise = null }) refreshPromise = refreshToken().finally(() => { refreshPromise = null })
} }
@@ -96,14 +107,3 @@ export async function getRecentPlays(): Promise<RecentPlay[] | null> {
export async function recordPlay(trackSlug: string): Promise<void> { export async function recordPlay(trackSlug: string): Promise<void> {
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
} }
export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
}
export async function fetchCoverBlob(trackSlug: string): Promise<string | null> {
const res = await furumiApi.get(`/tracks/${trackSlug}/cover`, { responseType: 'blob' }).catch(() => null)
if (!res?.data) return null
return URL.createObjectURL(res.data)
}
@@ -1,28 +0,0 @@
import { useEffect, useState } from 'react'
import { fetchCoverBlob } from '../furumiApi'
export function useCoverUrl(trackSlug: string | undefined): string | null {
const [url, setUrl] = useState<string | null>(null)
useEffect(() => {
if (!trackSlug) {
setUrl(null)
return
}
let revoke: string | null = null
fetchCoverBlob(trackSlug).then((blobUrl) => {
if (blobUrl) {
revoke = blobUrl
setUrl(blobUrl)
}
})
return () => {
if (revoke) URL.revokeObjectURL(revoke)
}
}, [trackSlug])
return url
}
+4
View File
@@ -5,6 +5,10 @@ import { store } from './store'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
}
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<Provider store={store}> <Provider store={store}>
+4
View File
@@ -14,6 +14,10 @@ export default defineConfig({
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },
'/api': {
target: 'http://localhost:8085',
changeOrigin: true,
},
}, },
}, },
}) })