From 83a145d0a8d71fbdb96189f4fd323a19af31a837 Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Thu, 2 Apr 2026 00:13:30 +0300 Subject: [PATCH 1/9] feat: work with order in state --- .../client/src/FurumiPlayer.tsx | 303 +++++++----------- furumi-node-player/client/src/furumiApi.ts | 2 +- furumi-node-player/client/src/store/index.ts | 2 + .../client/src/store/slices/queueSlice.ts | 273 ++++++++++++++++ 4 files changed, 397 insertions(+), 183 deletions(-) create mode 100644 furumi-node-player/client/src/store/slices/queueSlice.ts diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 96c41d2..c960250 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -5,17 +5,33 @@ import { searchTracks, preloadStream, } from './furumiApi' -import { useAppDispatch, useAppSelector } from './store' +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, + toggleShuffle, + toggleRepeat, + rebuildShuffleOrder, + selectQueueOrder, + selectPlayingOrigIdx, + selectQueueScrollSignal, + selectNowPlayingTrack, + selectQueueItems, +} from './store/slices/queueSlice' import { fmt } from './utils' 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 +42,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( [], ) @@ -47,13 +70,6 @@ export function FurumiPlayer() { 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 queueActionsRef = useRef<{ playIndex: (i: number) => void removeFromQueue: (idx: number) => void @@ -63,20 +79,48 @@ export function FurumiPlayer() { 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]) + + const shuffle = useAppSelector((s) => s.queue.shuffle) + const repeatAll = useAppSelector((s) => s.queue.repeatAll) + + useEffect(() => { + const btnShuffle = document.getElementById('btnShuffle') + const btnRepeat = document.getElementById('btnRepeat') + btnShuffle?.classList.toggle('active', shuffle) + btnRepeat?.classList.toggle('active', repeatAll) + }, [shuffle, repeatAll]) + + 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 + let playbackErrorSkips = 0 + const MAX_PLAYBACK_ERROR_SKIPS = 5 - // Restore prefs try { const v = window.localStorage.getItem('furumi_vol') const volSlider = document.getElementById('volSlider') as HTMLInputElement | null @@ -84,17 +128,10 @@ export function FurumiPlayer() { 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 } - // --- Audio events --- audio.addEventListener('timeupdate', () => { if (audio.duration) { const fill = document.getElementById('progressFill') @@ -106,6 +143,9 @@ export function FurumiPlayer() { } }) audio.addEventListener('ended', () => nextTrack()) + audio.addEventListener('playing', () => { + playbackErrorSkips = 0 + }) audio.addEventListener('play', () => { const btn = document.getElementById('btnPlayPause') if (btn) btn.innerHTML = '⏸' @@ -116,10 +156,11 @@ export function FurumiPlayer() { }) audio.addEventListener('error', () => { showToast('Playback error') + if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return + playbackErrorSkips += 1 nextTrack() }) - // --- Library navigation --- async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) try { @@ -231,7 +272,6 @@ export function FurumiPlayer() { setBreadcrumbs(parts) } - // --- Queue management --- function addTrackToQueue( track: { slug: string @@ -242,15 +282,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 +295,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 +316,25 @@ 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`) } function playIndex(i: number) { - if (i < 0 || i >= queue.length) return - queueIndex = i - const track = queue[i] + const q = store.getState().queue + if (i < 0 || i >= q.items.length) return + dispatch(playAtIndex(i)) + const track = store.getState().queue.items[i] audio.src = `${API_ROOT}/stream/${track.slug}` void audio.play().catch(() => {}) - updateNowPlaying(track) - updateQueueModel() - setQueueScrollSignal((s) => s + 1) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -314,88 +342,17 @@ 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 + const wasPlaying = store.getState().queue.currentIndex === idx + dispatch(removeFromQueueAt(idx)) + if (wasPlaying) { 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() } - 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++ - } - updateQueueModel() + function moveQueueItem(fromPos: number, toPos: number) { + dispatch(moveQueueItemInOrder({ fromPos, toPos })) } queueActionsRef.current = { @@ -404,21 +361,16 @@ export function FurumiPlayer() { moveQueueItem, } - function clearQueue() { - setQueue([]); - queueIndex = -1 - shuffleOrder = [] + function clearQueuePlayback() { + dispatch(clearQueue()) 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) + const q = store.getState().queue + if (!audio.src && q.items.length) { + playIndex(q.currentIndex === -1 ? 0 : q.currentIndex) return } if (audio.paused) void audio.play() @@ -426,45 +378,39 @@ export function FurumiPlayer() { } function nextTrack() { - if (!queue.length) return - const order = currentOrder() - const pos = order.indexOf(queueIndex) + 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 (repeatAll) { - if (shuffle) buildShuffleOrder() - playIndex(currentOrder()[0]) + else if (q.repeatAll) { + if (q.shuffle) dispatch(rebuildShuffleOrder()) + const first = selectQueueOrder(store.getState())[0] + if (first !== undefined) playIndex(first) } } function prevTrack() { - if (!queue.length) return + const q = store.getState().queue + if (!q.items.length) return if (audio.currentTime > 3) { audio.currentTime = 0 return } - const order = currentOrder() - const pos = order.indexOf(queueIndex) + const order = selectQueueOrder(store.getState()) + const pos = order.indexOf(q.currentIndex) if (pos > 0) playIndex(order[pos - 1]) - else if (repeatAll) playIndex(order[order.length - 1]) + else if (q.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 onToggleShuffle() { + dispatch(toggleShuffle()) } - function toggleRepeat() { - repeatAll = !repeatAll - const btn = document.getElementById('btnRepeat') - btn?.classList.toggle('active', repeatAll) - window.localStorage.setItem('furumi_repeat', repeatAll ? '1' : '0') + function onToggleRepeat() { + dispatch(toggleRepeat()) } - // --- Seek & Volume --- function seekTo(e: MouseEvent) { if (!audio.duration) return const bar = document.getElementById('progressBar') as HTMLDivElement | null @@ -488,7 +434,6 @@ export function FurumiPlayer() { window.localStorage.setItem('furumi_vol', String(v)) } - // --- Search --- function onSearch(q: string) { if (searchTimer) { window.clearTimeout(searchTimer) @@ -527,7 +472,6 @@ export function FurumiPlayer() { } searchSelectRef.current = onSearchSelect - // --- Helpers --- function showToast(msg: string) { const t = document.getElementById('toast') if (!t) return @@ -544,7 +488,6 @@ export function FurumiPlayer() { overlay?.classList.toggle('show') } - // --- MediaSession --- if ('mediaSession' in navigator) { try { navigator.mediaSession.setActionHandler('play', togglePlay) @@ -561,7 +504,6 @@ export function FurumiPlayer() { } } - // --- Wire DOM events that were inline in HTML --- const btnMenu = document.querySelector('.btn-menu') btnMenu?.addEventListener('click', () => toggleSidebar()) @@ -579,11 +521,11 @@ export function FurumiPlayer() { } const btnShuffle = document.getElementById('btnShuffle') - btnShuffle?.addEventListener('click', () => toggleShuffle()) + btnShuffle?.addEventListener('click', () => onToggleShuffle()) const btnRepeat = document.getElementById('btnRepeat') - btnRepeat?.addEventListener('click', () => toggleRepeat()) + btnRepeat?.addEventListener('click', () => onToggleRepeat()) const btnClear = document.getElementById('btnClearQueue') - btnClear?.addEventListener('click', () => clearQueue()) + btnClear?.addEventListener('click', () => clearQueuePlayback()) const btnPrev = document.getElementById('btnPrev') btnPrev?.addEventListener('click', () => prevTrack()) @@ -606,9 +548,8 @@ export function FurumiPlayer() { } const clearQueueBtn = document.getElementById('btnClearQueue') - clearQueueBtn?.addEventListener('click', () => clearQueue()) + clearQueueBtn?.addEventListener('click', () => clearQueuePlayback()) - // --- Init --- ;(async () => { const url = new URL(window.location.href) const urlSlug = url.searchParams.get('t') @@ -632,12 +573,11 @@ export function FurumiPlayer() { void showArtists() })() - // Cleanup: best-effort remove listeners on unmount return () => { queueActionsRef.current = null audio.pause() } - }, []) + }, [dispatch]) const libraryLoading = breadcrumbs.length === 1 @@ -686,4 +626,3 @@ export function FurumiPlayer() { ) } - diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index e7518ec..135e573 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -44,6 +44,6 @@ export async function getTrackInfo(trackSlug: string): Promise null) + await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'arraybuffer' }).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..50e479f --- /dev/null +++ b/furumi-node-player/client/src/store/slices/queueSlice.ts @@ -0,0 +1,273 @@ +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 +} + +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 From 480880f2921a9d71fe3fdd180607d02f32612c75 Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Thu, 2 Apr 2026 00:29:21 +0300 Subject: [PATCH 2/9] fix: load audio thmb --- .../client/src/FurumiPlayer.tsx | 58 +++++++++++-------- furumi-node-player/client/src/furumiApi.ts | 2 +- 2 files changed, 35 insertions(+), 25 deletions(-) diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index c960250..7a38ac5 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -68,7 +68,7 @@ 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 searchSelectRef = useRef<(type: string, slug: string) => void>(() => { }) const queueActionsRef = useRef<{ playIndex: (i: number) => void @@ -333,8 +333,18 @@ export function FurumiPlayer() { if (i < 0 || i >= q.items.length) return dispatch(playAtIndex(i)) const track = store.getState().queue.items[i] - audio.src = `${API_ROOT}/stream/${track.slug}` - void audio.play().catch(() => {}) + // TODO remove after auth refactor + preloadStream(track.slug).then(response => { + console.log('response', response) + audio.src = URL.createObjectURL(response?.data) + void audio.play().catch(() => { }) + // Optionally revoke old object URL if needed to avoid memory leaks + // if (oldSrc?.startsWith('blob:')) { + // URL.revokeObjectURL(oldSrc) + // } + }) + // audio.src = `${API_ROOT}/stream/${track.slug}` + // void audio.play().catch(() => {}) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -550,28 +560,28 @@ export function FurumiPlayer() { const clearQueueBtn = document.getElementById('btnClearQueue') clearQueueBtn?.addEventListener('click', () => clearQueuePlayback()) - ;(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() + })() return () => { queueActionsRef.current = null diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index 135e573..bdf4114 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -44,6 +44,6 @@ export async function getTrackInfo(trackSlug: string): Promise null) + return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) } From 30c6400354c8189e6076463cf829bc22ab942b0a Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Thu, 2 Apr 2026 00:38:30 +0300 Subject: [PATCH 3/9] feat: create playback service --- .../client/src/FurumiPlayer.tsx | 168 ++++------------ .../client/src/audioPlaybackService.ts | 190 ++++++++++++++++++ 2 files changed, 224 insertions(+), 134 deletions(-) create mode 100644 furumi-node-player/client/src/audioPlaybackService.ts diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 7a38ac5..513f29e 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,10 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { - API_ROOT, - searchTracks, - preloadStream, -} from './furumiApi' +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' @@ -28,6 +24,7 @@ import { selectNowPlayingTrack, selectQueueItems, } from './store/slices/queueSlice' +import { attachAudioPlayback } from './audioPlaybackService' import { fmt } from './utils' import { Header } from './components/Header' import { MainPanel, type Crumb } from './components/MainPanel' @@ -117,50 +114,16 @@ export function FurumiPlayer() { let searchTimer: number | null = null let toastTimer: number | null = null - let muted = false - let playbackErrorSkips = 0 - const MAX_PLAYBACK_ERROR_SKIPS = 5 - 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 + 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.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('playing', () => { - playbackErrorSkips = 0 - }) - 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') - if (playbackErrorSkips >= MAX_PLAYBACK_ERROR_SKIPS) return - playbackErrorSkips += 1 - nextTrack() - }) - async function showArtists() { setBreadcrumb([{ label: 'Artists', action: showArtists }]) try { @@ -328,23 +291,18 @@ export function FurumiPlayer() { showToast(`Added ${list.length} tracks`) } + const playback = attachAudioPlayback(audio, { + onEnded: nextTrack, + onErrorSkip: nextTrack, + onToast: showToast, + }) + function playIndex(i: number) { const q = store.getState().queue if (i < 0 || i >= q.items.length) return dispatch(playAtIndex(i)) const track = store.getState().queue.items[i] - // TODO remove after auth refactor - preloadStream(track.slug).then(response => { - console.log('response', response) - audio.src = URL.createObjectURL(response?.data) - void audio.play().catch(() => { }) - // Optionally revoke old object URL if needed to avoid memory leaks - // if (oldSrc?.startsWith('blob:')) { - // URL.revokeObjectURL(oldSrc) - // } - }) - // audio.src = `${API_ROOT}/stream/${track.slug}` - // void audio.play().catch(() => {}) + void playback.loadStreamForTrack(track.slug) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -355,36 +313,16 @@ export function FurumiPlayer() { function removeFromQueue(idx: number) { const wasPlaying = store.getState().queue.currentIndex === idx dispatch(removeFromQueueAt(idx)) - if (wasPlaying) { - audio.pause() - audio.src = '' - } + if (wasPlaying) playback.pauseAndClearSource() } function moveQueueItem(fromPos: number, toPos: number) { dispatch(moveQueueItemInOrder({ fromPos, toPos })) } - queueActionsRef.current = { - playIndex, - removeFromQueue, - moveQueueItem, - } - function clearQueuePlayback() { dispatch(clearQueue()) - audio.pause() - audio.src = '' - } - - function togglePlay() { - const q = store.getState().queue - if (!audio.src && q.items.length) { - playIndex(q.currentIndex === -1 ? 0 : q.currentIndex) - return - } - if (audio.paused) void audio.play() - else audio.pause() + playback.pauseAndClearSource() } function nextTrack() { @@ -403,16 +341,26 @@ export function FurumiPlayer() { function prevTrack() { const q = store.getState().queue if (!q.items.length) return - if (audio.currentTime > 3) { - audio.currentTime = 0 - 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, + } + function onToggleShuffle() { dispatch(toggleShuffle()) } @@ -421,29 +369,6 @@ export function FurumiPlayer() { dispatch(toggleRepeat()) } - 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)) - } - function onSearch(q: string) { if (searchTimer) { window.clearTimeout(searchTimer) @@ -482,15 +407,6 @@ export function FurumiPlayer() { } searchSelectRef.current = onSearchSelect - 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') @@ -506,7 +422,7 @@ export function FurumiPlayer() { navigator.mediaSession.setActionHandler('nexttrack', nextTrack) navigator.mediaSession.setActionHandler('seekto', (d: any) => { if (typeof d.seekTime === 'number') { - audio.currentTime = d.seekTime + playback.seekToTime(d.seekTime) } }) } catch { @@ -544,22 +460,6 @@ export function FurumiPlayer() { const btnNext = document.getElementById('btnNext') btnNext?.addEventListener('click', () => nextTrack()) - 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', () => clearQueuePlayback()) - ; (async () => { const url = new URL(window.location.href) const urlSlug = url.searchParams.get('t') @@ -585,7 +485,7 @@ export function FurumiPlayer() { return () => { queueActionsRef.current = null - audio.pause() + playback.dispose() } }, [dispatch]) 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, + } +} From e42566f44e076fd94ff1857fcc9db67e0e22bbec Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Thu, 2 Apr 2026 00:48:57 +0300 Subject: [PATCH 4/9] fix: correct behavior click of buttons --- .../client/src/FurumiPlayer.tsx | 72 ++++++++++++++----- 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 513f29e..d3c6268 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -414,51 +414,64 @@ export function FurumiPlayer() { overlay?.classList.toggle('show') } + 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') { - playback.seekToTime(d.seekTime) - } - }) + navigator.mediaSession.setActionHandler('seekto', onMediaSeekTo as (d: any) => void) } catch { // ignore } } + 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 onShuffleClick = () => onToggleShuffle() + const onRepeatClick = () => onToggleRepeat() + const onClearClick = () => clearQueuePlayback() + const onPrevClick = () => prevTrack() + const onPlayClick = () => togglePlay() + const onNextClick = () => nextTrack() + const btnShuffle = document.getElementById('btnShuffle') - btnShuffle?.addEventListener('click', () => onToggleShuffle()) + btnShuffle?.addEventListener('click', onShuffleClick) const btnRepeat = document.getElementById('btnRepeat') - btnRepeat?.addEventListener('click', () => onToggleRepeat()) + btnRepeat?.addEventListener('click', onRepeatClick) const btnClear = document.getElementById('btnClearQueue') - btnClear?.addEventListener('click', () => clearQueuePlayback()) + btnClear?.addEventListener('click', onClearClick) 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) ; (async () => { const url = new URL(window.location.href) @@ -486,6 +499,27 @@ export function FurumiPlayer() { return () => { queueActionsRef.current = null playback.dispose() + btnMenu?.removeEventListener('click', onMenuClick) + sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick) + searchInput?.removeEventListener('input', onSearchInput) + searchInput?.removeEventListener('keydown', onSearchKeydown) + btnShuffle?.removeEventListener('click', onShuffleClick) + btnRepeat?.removeEventListener('click', onRepeatClick) + btnClear?.removeEventListener('click', onClearClick) + 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]) From daaa3b08141006c5fe1bf8eeb93272e6e408a394 Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Sat, 4 Apr 2026 18:33:45 +0300 Subject: [PATCH 5/9] feat: update styles --- .../client/src/FurumiPlayer.tsx | 2 +- .../src/components/{ => header}/Header.tsx | 9 ++--- .../src/components/header/header.module.css | 35 +++++++++++++++++++ .../client/src/components/header/index.ts | 1 + .../client/src/furumi-player.css | 34 ------------------ 5 files changed, 42 insertions(+), 39 deletions(-) rename furumi-node-player/client/src/components/{ => header}/Header.tsx (81%) create mode 100644 furumi-node-player/client/src/components/header/header.module.css create mode 100644 furumi-node-player/client/src/components/header/index.ts diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index d3c6268..b7393d8 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -26,7 +26,7 @@ import { } 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 { Track } from './types' 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/furumi-player.css b/furumi-node-player/client/src/furumi-player.css index 12c80f1..25907e1 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; From 3199c12af540f540a53b8e6e5f3c7cc857e3d7eb Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Sat, 4 Apr 2026 18:49:29 +0300 Subject: [PATCH 6/9] feat: added alternative queue display --- .../furumi-node-player-client-components.mdc | 49 +++++++++++ .../client/src/FurumiPlayer.tsx | 15 +++- .../client/src/components/PlayerBar.tsx | 32 ++++++- .../src/components/queue-popover/index.ts | 1 + .../queue-popover/queue-popover.module.css | 68 +++++++++++++++ .../queue-popover/queue-popover.tsx | 86 +++++++++++++++++++ 6 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 .cursor/rules/furumi-node-player-client-components.mdc create mode 100644 furumi-node-player/client/src/components/queue-popover/index.ts create mode 100644 furumi-node-player/client/src/components/queue-popover/queue-popover.module.css create mode 100644 furumi-node-player/client/src/components/queue-popover/queue-popover.tsx 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/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index b7393d8..ba53105 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -563,7 +563,20 @@ export function FurumiPlayer() { } /> - + queueActionsRef.current?.playIndex(origIdx)} + onQueueRemove={(origIdx) => + queueActionsRef.current?.removeFromQueue(origIdx) + } + onQueueMove={(fromPos, toPos) => + queueActionsRef.current?.moveQueueItem(fromPos, toPos) + } + />
+ 🔊 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 && ( + + )} +
+ ) +} From 0b6f518b724b417be1e5cafba1116b91815fd50c Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Sat, 4 Apr 2026 19:17:33 +0300 Subject: [PATCH 7/9] feat: refactoring --- .../client/src/FurumiPlayer.tsx | 40 ++--------------- .../client/src/components/MainPanel.tsx | 45 ++++++++++++++----- .../client/src/store/slices/queueSlice.ts | 1 + 3 files changed, 37 insertions(+), 49 deletions(-) diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index ba53105..b17985c 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -15,8 +15,6 @@ import { playAtIndex, removeFromQueueAt, moveQueueItemInOrder, - toggleShuffle, - toggleRepeat, rebuildShuffleOrder, selectQueueOrder, selectPlayingOrigIdx, @@ -71,6 +69,7 @@ export function FurumiPlayer() { playIndex: (i: number) => void removeFromQueue: (idx: number) => void moveQueueItem: (fromPos: number, toPos: number) => void + clearQueue: () => void } | null>(null) const audioRef = useRef(null) @@ -97,16 +96,6 @@ export function FurumiPlayer() { } }, [nowPlayingTrack]) - const shuffle = useAppSelector((s) => s.queue.shuffle) - const repeatAll = useAppSelector((s) => s.queue.repeatAll) - - useEffect(() => { - const btnShuffle = document.getElementById('btnShuffle') - const btnRepeat = document.getElementById('btnRepeat') - btnShuffle?.classList.toggle('active', shuffle) - btnRepeat?.classList.toggle('active', repeatAll) - }, [shuffle, repeatAll]) - useEffect(() => { const audioEl = audioRef.current if (!audioEl) return @@ -359,14 +348,7 @@ export function FurumiPlayer() { playIndex, removeFromQueue, moveQueueItem, - } - - function onToggleShuffle() { - dispatch(toggleShuffle()) - } - - function onToggleRepeat() { - dispatch(toggleRepeat()) + clearQueue: clearQueuePlayback, } function onSearch(q: string) { @@ -452,20 +434,10 @@ export function FurumiPlayer() { searchInput.addEventListener('keydown', onSearchKeydown) } - const onShuffleClick = () => onToggleShuffle() - const onRepeatClick = () => onToggleRepeat() - const onClearClick = () => clearQueuePlayback() const onPrevClick = () => prevTrack() const onPlayClick = () => togglePlay() const onNextClick = () => nextTrack() - const btnShuffle = document.getElementById('btnShuffle') - btnShuffle?.addEventListener('click', onShuffleClick) - const btnRepeat = document.getElementById('btnRepeat') - btnRepeat?.addEventListener('click', onRepeatClick) - const btnClear = document.getElementById('btnClearQueue') - btnClear?.addEventListener('click', onClearClick) - const btnPrev = document.getElementById('btnPrev') btnPrev?.addEventListener('click', onPrevClick) const btnPlay = document.getElementById('btnPlayPause') @@ -503,9 +475,6 @@ export function FurumiPlayer() { sidebarOverlay?.removeEventListener('click', onSidebarOverlayClick) searchInput?.removeEventListener('input', onSearchInput) searchInput?.removeEventListener('keydown', onSearchKeydown) - btnShuffle?.removeEventListener('click', onShuffleClick) - btnRepeat?.removeEventListener('click', onRepeatClick) - btnClear?.removeEventListener('click', onClearClick) btnPrev?.removeEventListener('click', onPrevClick) btnPlay?.removeEventListener('click', onPlayClick) btnNext?.removeEventListener('click', onNextClick) @@ -550,10 +519,6 @@ 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) @@ -561,6 +526,7 @@ export function FurumiPlayer() { onQueueMove={(fromPos, toPos) => queueActionsRef.current?.moveQueueItem(fromPos, toPos) } + onClearQueue={() => queueActionsRef.current?.clearQueue()} /> 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/store/slices/queueSlice.ts b/furumi-node-player/client/src/store/slices/queueSlice.ts index 50e479f..09c5b0e 100644 --- a/furumi-node-player/client/src/store/slices/queueSlice.ts +++ b/furumi-node-player/client/src/store/slices/queueSlice.ts @@ -239,6 +239,7 @@ 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) From 94d14e8fc8ea15982eca0e959cf407adfd2d1f4b Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Sat, 4 Apr 2026 19:34:20 +0300 Subject: [PATCH 8/9] feat: update styles --- furumi-node-player/client/src/furumi-player.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/furumi-node-player/client/src/furumi-player.css b/furumi-node-player/client/src/furumi-player.css index 25907e1..01adf74 100644 --- a/furumi-node-player/client/src/furumi-player.css +++ b/furumi-node-player/client/src/furumi-player.css @@ -344,6 +344,10 @@ color: var(--accent); } +.qi-title { + color: #ffffff; +} + .queue-item .qi-index { font-size: 0.75rem; color: var(--text-muted); @@ -497,6 +501,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + color: #ffffff; } .np-artist { From e99cacae8b4be31a85ed941611780fb48ef27651 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 14:51:52 +0100 Subject: [PATCH 9/9] feat(auth): replace cookie/api-key auth with JWT Bearer tokens, separate UI from API - Add JWT Bearer token validation to Rust API via OIDC provider JWKS with automatic key rotation and 1-hour cache - Remove x-api-key auth support and built-in web UI from furumi-web-player, leaving it as a pure API server - Add /auth/token endpoint to Node player server to expose OIDC access tokens to the frontend - Move Node player auth endpoints from /api/* to /auth/* to avoid path conflicts with Rust API - Add static file serving to Node Express server for production single-container deployment - Fix SameSite=Strict cookie issue breaking OIDC redirect flow (use Lax) - Add Dockerfile.node-player with multi-stage Node.js build - Add CI workflows for node-player Docker image (dev + release) - Optimize Rust Dockerfiles with dependency caching layer - Update docker-compose with OIDC env vars and OLLAMA_MODEL support - Cherry-pick agent LLM client fixes from DEV branch Co-Authored-By: Claude Opus 4.6 (1M context) --- .../docker-publish-node-player-dev.yml | 40 +++++ .../workflows/docker-publish-node-player.yml | 57 ++++++ .gitignore | 4 +- Cargo.lock | 19 +- docker/Dockerfile.agent | 27 +++ docker/Dockerfile.node-player | 38 ++++ docker/Dockerfile.web-player | 27 +++ docker/docker-compose.yml | 16 +- furumi-agent/prompts/normalize.txt | 2 +- furumi-agent/src/ingest/normalize.rs | 126 ++++++++++--- furumi-agent/src/merge.rs | 27 +++ furumi-node-player/client/.env.example | 3 +- furumi-node-player/client/src/App.tsx | 31 +++- furumi-node-player/client/src/furumiApi.ts | 15 +- furumi-node-player/client/vite.config.ts | 6 +- furumi-node-player/server/src/index.ts | 42 ++++- furumi-web-player/Cargo.toml | 3 +- furumi-web-player/src/main.rs | 8 - furumi-web-player/src/web/auth.rs | 166 +++++++++--------- furumi-web-player/src/web/mod.rs | 19 +- 20 files changed, 515 insertions(+), 161 deletions(-) create mode 100644 .github/workflows/docker-publish-node-player-dev.yml create mode 100644 .github/workflows/docker-publish-node-player.yml create mode 100644 docker/Dockerfile.node-player 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 9cb8bb9..87c6251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1114,7 +1114,7 @@ dependencies = [ "futures-core", "futures-util", "hmac", - "jsonwebtoken", + "jsonwebtoken 10.3.0", "libc", "mime_guess", "ogg", @@ -1152,6 +1152,7 @@ dependencies = [ "base64 0.22.1", "clap", "hmac", + "jsonwebtoken 9.3.1", "mime_guess", "openidconnect", "rand 0.8.5", @@ -1165,6 +1166,7 @@ dependencies = [ "tokio", "tokio-util", "tower 0.4.13", + "tower-http", "tracing", "tracing-subscriber", "urlencoding", @@ -1864,6 +1866,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-agent/src/ingest/normalize.rs b/furumi-agent/src/ingest/normalize.rs index bd8020c..cc19bce 100644 --- a/furumi-agent/src/ingest/normalize.rs +++ b/furumi-agent/src/ingest/normalize.rs @@ -25,16 +25,37 @@ pub async fn normalize( ) -> anyhow::Result { let user_message = build_user_message(raw, hints, similar_artists, similar_albums, folder_ctx); + let schema = normalize_schema(); let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.system_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + 512, + Some(("normalized_metadata", schema.clone())), ) .await?; - parse_response(&response) + match parse_response(&response) { + Ok(fields) => Ok(fields), + Err(e) => { + tracing::warn!(error = %e, "LLM parse failed, retrying with higher frequency_penalty"); + let response2 = call_ollama( + &state.config.ollama_url, + &state.config.ollama_model, + &state.system_prompt, + &user_message, + state.config.ollama_auth.as_deref(), + 1.5, + 512, + Some(("normalized_metadata", schema)), + ) + .await?; + parse_response(&response2) + } + } } fn build_user_message( @@ -113,32 +134,49 @@ fn build_user_message( } #[derive(Serialize)] -struct OllamaRequest { +struct ChatRequest { model: String, - messages: Vec, - format: String, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + response_format: Option, stream: bool, - options: OllamaOptions, + temperature: f64, + max_tokens: u32, + frequency_penalty: f64, } #[derive(Serialize)] -struct OllamaMessage { +struct ChatMessage { role: String, content: String, } #[derive(Serialize)] -struct OllamaOptions { - temperature: f64, +struct ChatResponseFormat { + #[serde(rename = "type")] + kind: String, + json_schema: JsonSchemaWrapper, +} + +#[derive(Serialize)] +struct JsonSchemaWrapper { + name: String, + strict: bool, + schema: serde_json::Value, } #[derive(Deserialize)] -struct OllamaResponse { - message: OllamaResponseMessage, +struct ChatResponse { + choices: Vec, } #[derive(Deserialize)] -struct OllamaResponseMessage { +struct ChatChoice { + message: ChatResponseMessage, +} + +#[derive(Deserialize)] +struct ChatResponseMessage { content: String, } @@ -148,30 +186,40 @@ pub async fn call_ollama( system_prompt: &str, user_message: &str, auth: Option<&str>, + frequency_penalty: f64, + max_tokens: u32, + schema: Option<(&str, serde_json::Value)>, ) -> anyhow::Result { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(120)) .build()?; - let request = OllamaRequest { + let response_format = schema.map(|(name, schema)| ChatResponseFormat { + kind: "json_schema".to_owned(), + json_schema: JsonSchemaWrapper { name: name.to_owned(), strict: true, schema }, + }); + + let request = ChatRequest { model: model.to_owned(), messages: vec![ - OllamaMessage { + ChatMessage { role: "system".to_owned(), content: system_prompt.to_owned(), }, - OllamaMessage { + ChatMessage { role: "user".to_owned(), content: user_message.to_owned(), }, ], - format: "json".to_owned(), + response_format, stream: false, - options: OllamaOptions { temperature: 0.1 }, + temperature: 0.1, + max_tokens, + frequency_penalty, }; - let url = format!("{}/api/chat", base_url.trim_end_matches('/')); - tracing::info!(%url, model, prompt_len = user_message.len(), "Calling Ollama API..."); + let url = format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); + tracing::info!(%url, model, prompt_len = user_message.len(), "Calling LLM API..."); let start = std::time::Instant::now(); let mut req = client.post(&url).json(&request); @@ -184,18 +232,45 @@ pub async fn call_ollama( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); - tracing::error!(%status, body = &body[..body.len().min(500)], "Ollama API error"); - anyhow::bail!("Ollama returned {}: {}", status, body); + tracing::error!(%status, body = &body[..body.len().min(500)], "LLM API error"); + anyhow::bail!("LLM returned {}: {}", status, body); } - let ollama_resp: OllamaResponse = resp.json().await?; + let chat_resp: ChatResponse = resp.json().await?; + let content = chat_resp + .choices + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("LLM returned empty choices"))? + .message + .content; tracing::info!( elapsed_ms = elapsed.as_millis() as u64, - response_len = ollama_resp.message.content.len(), - "Ollama response received" + response_len = content.len(), + "LLM response received" ); - tracing::debug!(raw_response = %ollama_resp.message.content, "LLM raw output"); - Ok(ollama_resp.message.content) + tracing::debug!(raw_response = %content, "LLM raw output"); + Ok(content) +} + +fn normalize_schema() -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "artist": { "type": ["string", "null"] }, + "album": { "type": ["string", "null"] }, + "title": { "type": ["string", "null"] }, + "year": { "type": ["integer", "null"] }, + "track_number": { "type": ["integer", "null"] }, + "genre": { "type": ["string", "null"] }, + "featured_artists": { "type": "array", "items": { "type": "string" } }, + "release_kind": { "type": ["string", "null"] }, + "confidence": { "type": ["number", "null"] }, + "notes": { "type": ["string", "null"] } + }, + "required": ["artist", "album", "title", "year", "track_number", "genre", "featured_artists", "release_kind", "confidence", "notes"], + "additionalProperties": false + }) } /// Parse the LLM JSON response into NormalizedFields. @@ -222,6 +297,7 @@ fn parse_response(response: &str) -> anyhow::Result { genre: Option, #[serde(default)] featured_artists: Vec, + #[serde(rename = "release_kind")] release_type: Option, confidence: Option, notes: Option, diff --git a/furumi-agent/src/merge.rs b/furumi-agent/src/merge.rs index 25f6e5b..8c7f83c 100644 --- a/furumi-agent/src/merge.rs +++ b/furumi-agent/src/merge.rs @@ -35,12 +35,39 @@ pub async fn propose_merge(state: &Arc, merge_id: Uuid) -> anyhow::Res let user_message = build_merge_message(&artists_data); + let schema = serde_json::json!({ + "type": "object", + "properties": { + "canonical_artist_name": { "type": "string" }, + "winner_artist_id": { "type": "integer" }, + "album_mappings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source_album_id": { "type": "integer" }, + "canonical_name": { "type": "string" }, + "merge_into_album_id": { "type": ["integer", "null"] } + }, + "required": ["source_album_id", "canonical_name", "merge_into_album_id"], + "additionalProperties": false + } + }, + "notes": { "type": "string" } + }, + "required": ["canonical_artist_name", "winner_artist_id", "album_mappings", "notes"], + "additionalProperties": false + }); + let response = call_ollama( &state.config.ollama_url, &state.config.ollama_model, &state.merge_prompt, &user_message, state.config.ollama_auth.as_deref(), + 0.5, + 4096, + Some(("artist_merge", schema)), ).await?; let proposal = parse_merge_response(&response)?; 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/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index bdf4114..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 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) -}