From 88b6cbfa5bc37d89310f625bc854649a683694f4 Mon Sep 17 00:00:00 2001 From: Music Player Bot Date: Tue, 28 Jul 2026 19:26:35 +0800 Subject: [PATCH 1/5] perf: optimize file streaming and status polling --- internal/api/handler.go | 11 +++ internal/api/middleware.go | 14 +++ internal/api/response_writer_test.go | 142 +++++++++++++++++++++++++++ web/src/App.vue | 2 +- web/src/stores/auth.test.ts | 56 +++++++++-- web/src/stores/auth.ts | 29 ++++-- web/src/stores/library.test.ts | 128 +++++++++++++++++++++++- web/src/stores/library.ts | 71 +++++++++++--- 8 files changed, 421 insertions(+), 32 deletions(-) create mode 100644 internal/api/response_writer_test.go diff --git a/internal/api/handler.go b/internal/api/handler.go index f2f684c..010db05 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "log" "net/http" "os" @@ -228,6 +229,16 @@ type commitTrackingWriter struct { func (w *commitTrackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } +func (w *commitTrackingWriter) ReadFrom(r io.Reader) (int64, error) { + if !w.committed { + w.WriteHeader(http.StatusOK) + } + if readerFrom, ok := w.ResponseWriter.(io.ReaderFrom); ok { + return readerFrom.ReadFrom(r) + } + return io.Copy(writerOnly{Writer: w}, r) +} + func (w *commitTrackingWriter) Flush() { if !w.committed { w.WriteHeader(http.StatusOK) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 6d2ef0e..85114d3 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -1,6 +1,7 @@ package api import ( + "io" "log" "net" "net/http" @@ -87,6 +88,13 @@ type loggingWriter struct { func (lw *loggingWriter) Unwrap() http.ResponseWriter { return lw.ResponseWriter } +func (lw *loggingWriter) ReadFrom(r io.Reader) (int64, error) { + if readerFrom, ok := lw.ResponseWriter.(io.ReaderFrom); ok { + return readerFrom.ReadFrom(r) + } + return io.Copy(writerOnly{Writer: lw}, r) +} + func (lw *loggingWriter) Flush() { if flusher, ok := lw.ResponseWriter.(http.Flusher); ok { flusher.Flush() @@ -97,3 +105,9 @@ func (lw *loggingWriter) WriteHeader(code int) { lw.statusCode = code lw.ResponseWriter.WriteHeader(code) } + +// writerOnly hides optional interfaces such as io.ReaderFrom so fallback copies +// cannot recurse back into a response writer's ReadFrom method. +type writerOnly struct { + io.Writer +} diff --git a/internal/api/response_writer_test.go b/internal/api/response_writer_test.go new file mode 100644 index 0000000..dd4dea8 --- /dev/null +++ b/internal/api/response_writer_test.go @@ -0,0 +1,142 @@ +package api + +import ( + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" +) + +type basicResponseWriter struct { + header http.Header + body bytes.Buffer + statusCodes []int +} + +func (w *basicResponseWriter) Header() http.Header { + if w.header == nil { + w.header = make(http.Header) + } + return w.header +} + +func (w *basicResponseWriter) WriteHeader(status int) { + if len(w.statusCodes) != 0 { + return + } + w.statusCodes = append(w.statusCodes, status) +} + +func (w *basicResponseWriter) Write(p []byte) (int, error) { + if len(w.statusCodes) == 0 { + w.WriteHeader(http.StatusOK) + } + return w.body.Write(p) +} + +type readerFromResponseWriter struct { + basicResponseWriter + readFromCalls int + readFromErr error +} + +func (w *readerFromResponseWriter) ReadFrom(r io.Reader) (int64, error) { + w.readFromCalls++ + if len(w.statusCodes) == 0 { + w.WriteHeader(http.StatusOK) + } + n, err := io.Copy(&w.body, r) + if err != nil { + return n, err + } + return n, w.readFromErr +} + +type readerOnly struct { + io.Reader +} + +func TestResponseWritersDelegateReaderFrom(t *testing.T) { + payload := "original audio bytes" + delegatedErr := errors.New("delegated read error") + + for _, status := range []int{http.StatusOK, http.StatusPartialContent} { + t.Run(http.StatusText(status), func(t *testing.T) { + base := &readerFromResponseWriter{readFromErr: delegatedErr} + logged := &loggingWriter{ + ResponseWriter: base, + statusCode: http.StatusOK, + } + tracked := &commitTrackingWriter{ResponseWriter: logged} + if status != http.StatusOK { + tracked.WriteHeader(status) + } + + n, err := io.Copy(tracked, readerOnly{Reader: strings.NewReader(payload)}) + if !errors.Is(err, delegatedErr) { + t.Fatalf("ReadFrom error = %v, want %v", err, delegatedErr) + } + if n != int64(len(payload)) { + t.Fatalf("copied bytes = %d, want %d", n, len(payload)) + } + if got := base.body.String(); got != payload { + t.Fatalf("response body = %q, want %q", got, payload) + } + if base.readFromCalls != 1 { + t.Fatalf("underlying ReadFrom calls = %d, want 1", base.readFromCalls) + } + if !tracked.committed { + t.Fatal("commitTrackingWriter did not record the response commit") + } + if logged.statusCode != status { + t.Fatalf("logged status = %d, want %d", logged.statusCode, status) + } + if len(base.statusCodes) != 1 || base.statusCodes[0] != status { + t.Fatalf("response commits = %v, want [%d]", base.statusCodes, status) + } + }) + } +} + +func TestLoggingWriterReaderFromFallback(t *testing.T) { + payload := "fallback logging bytes" + base := &basicResponseWriter{} + logged := &loggingWriter{ + ResponseWriter: base, + statusCode: http.StatusOK, + } + logged.WriteHeader(http.StatusPartialContent) + + n, err := io.Copy(logged, readerOnly{Reader: strings.NewReader(payload)}) + if err != nil { + t.Fatalf("fallback copy: %v", err) + } + if n != int64(len(payload)) || base.body.String() != payload { + t.Fatalf("fallback response = %d/%q, want %d/%q", n, base.body.String(), len(payload), payload) + } + if logged.statusCode != http.StatusPartialContent { + t.Fatalf("logged status = %d, want %d", logged.statusCode, http.StatusPartialContent) + } +} + +func TestCommitTrackingWriterReaderFromFallback(t *testing.T) { + payload := "fallback tracked bytes" + base := &basicResponseWriter{} + tracked := &commitTrackingWriter{ResponseWriter: base} + + n, err := io.Copy(tracked, readerOnly{Reader: strings.NewReader(payload)}) + if err != nil { + t.Fatalf("fallback copy: %v", err) + } + if n != int64(len(payload)) || base.body.String() != payload { + t.Fatalf("fallback response = %d/%q, want %d/%q", n, base.body.String(), len(payload), payload) + } + if !tracked.committed { + t.Fatal("commitTrackingWriter did not record the response commit") + } + if len(base.statusCodes) != 1 || base.statusCodes[0] != http.StatusOK { + t.Fatalf("response commits = %v, want [%d]", base.statusCodes, http.StatusOK) + } +} diff --git a/web/src/App.vue b/web/src/App.vue index 7ce7e73..4e9b96f 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -106,7 +106,7 @@ watch(() => route.fullPath, async () => { }) watch(() => auth.isLoggedIn, loggedIn => { - if (loggedIn) void library.start() + if (loggedIn) void library.start(auth.statusSnapshot ?? undefined) else library.stop() }, { immediate: true }) diff --git a/web/src/stores/auth.test.ts b/web/src/stores/auth.test.ts index cc75cf7..f9621d9 100644 --- a/web/src/stores/auth.test.ts +++ b/web/src/stores/auth.test.ts @@ -11,11 +11,12 @@ vi.mock('../api', () => ({ describe('auth store', () => { beforeEach(() => { + vi.resetAllMocks() setActivePinia(createPinia()) }) - it('initializes from server status only once', async () => { - vi.mocked(api.getStatus).mockResolvedValue({ + it('initializes from one shared status request and returns the full snapshot', async () => { + const snapshot: api.Status = { fileCount: 3, libraryReady: true, libraryGeneration: 1, @@ -25,18 +26,58 @@ describe('auth store', () => { scanError: '', authRequired: true, authenticated: false, - }) + } + vi.mocked(api.getStatus).mockResolvedValue(snapshot) const auth = useAuthStore() - await Promise.all([auth.checkAuth(), auth.checkAuth()]) - await auth.checkAuth() + const [first, second] = await Promise.all([auth.checkAuth(), auth.checkAuth()]) + const cached = await auth.checkAuth() expect(api.getStatus).toHaveBeenCalledTimes(1) + expect(first).toBe(snapshot) + expect(second).toBe(snapshot) + expect(cached).toEqual(snapshot) + expect(auth.statusSnapshot).toEqual(snapshot) expect(auth.initialized).toBe(true) expect(auth.authRequired).toBe(true) expect(auth.isLoggedIn).toBe(false) }) + it('clears stale status snapshots on expiry, login, and logout', async () => { + const anonymous: api.Status = { + authRequired: true, + authenticated: false, + } + const authenticated: api.Status = { + fileCount: 3, + libraryReady: true, + libraryGeneration: 1, + scanStatus: 'idle', + authRequired: true, + authenticated: true, + } + vi.mocked(api.getStatus) + .mockResolvedValueOnce(anonymous) + .mockResolvedValueOnce(authenticated) + vi.mocked(api.login).mockResolvedValue({ status: 'logged in' }) + vi.mocked(api.logout).mockResolvedValue(undefined) + const auth = useAuthStore() + + await auth.checkAuth() + expect(auth.statusSnapshot).toEqual(anonymous) + auth.expire() + expect(auth.statusSnapshot).toBeNull() + + await auth.login('secret', true) + expect(auth.statusSnapshot).toBeNull() + await auth.checkAuth(true) + expect(auth.statusSnapshot).toEqual(authenticated) + + await auth.logout() + expect(auth.statusSnapshot).toBeNull() + expect(auth.state).toBe('anonymous') + }) + it('updates authentication state after login and logout', async () => { vi.mocked(api.login).mockResolvedValue({ status: 'logged in' }) vi.mocked(api.logout).mockResolvedValue(undefined) @@ -68,11 +109,12 @@ describe('auth store', () => { .mockResolvedValueOnce({ authRequired: false, authenticated: true }) const auth = useAuthStore() - await auth.checkAuth() + await expect(auth.checkAuth()).resolves.toBeNull() expect(auth.state).toBe('unavailable') expect(auth.isLoggedIn).toBe(false) + expect(auth.statusSnapshot).toBeNull() - await auth.checkAuth(true) + await expect(auth.checkAuth(true)).resolves.toEqual({ authRequired: false, authenticated: true }) expect(auth.state).toBe('authenticated') expect(auth.isLoggedIn).toBe(true) }) diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 1e0c588..90463c0 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -7,43 +7,51 @@ export type AuthState = 'checking' | 'authenticated' | 'anonymous' | 'unavailabl export const useAuthStore = defineStore('auth', () => { const state = ref('checking') const authRequired = ref(false) - let checking: Promise | null = null + const statusSnapshot = ref(null) + let checking: Promise | null = null const isLoggedIn = computed(() => state.value === 'authenticated') const initialized = computed(() => state.value !== 'checking') async function login(password: string, remember: boolean) { await api.login(password, remember) + statusSnapshot.value = null authRequired.value = true state.value = 'authenticated' } async function logout() { - try { - await api.logout() - } catch { - // Logging out locally must remain available when the server is unreachable. - } finally { - state.value = 'anonymous' - } + try { + await api.logout() + } catch { + // Logging out locally must remain available when the server is unreachable. + } finally { + statusSnapshot.value = null + state.value = 'anonymous' + } } function expire() { + statusSnapshot.value = null authRequired.value = true state.value = 'anonymous' } - async function checkAuth(force = false) { - if (!force && initialized.value) return + async function checkAuth(force = false): Promise { + if (!force && initialized.value) return statusSnapshot.value if (checking) return checking state.value = 'checking' checking = (async () => { try { const status = await api.getStatus() + statusSnapshot.value = status authRequired.value = status.authRequired state.value = !status.authRequired || status.authenticated ? 'authenticated' : 'anonymous' + return status } catch { + statusSnapshot.value = null state.value = 'unavailable' + return null } finally { checking = null } @@ -55,6 +63,7 @@ export const useAuthStore = defineStore('auth', () => { state, isLoggedIn, authRequired, + statusSnapshot, initialized, login, logout, diff --git a/web/src/stores/library.test.ts b/web/src/stores/library.test.ts index 97fe09f..508f183 100644 --- a/web/src/stores/library.test.ts +++ b/web/src/stores/library.test.ts @@ -1,7 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { useLibraryStore } from './library' import { usePlayerStore } from './player' +import { useAuthStore } from './auth' import * as api from '../api' vi.mock('../api', () => ({ @@ -35,12 +36,135 @@ function deferred() { return { promise, resolve } } +function setVisibility(state: DocumentVisibilityState, notify = true) { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: state, + }) + if (notify) document.dispatchEvent(new Event('visibilitychange')) +} + describe('library store', () => { beforeEach(() => { - vi.clearAllMocks() + vi.useFakeTimers() + vi.resetAllMocks() + setVisibility('visible', false) setActivePinia(createPinia()) }) + afterEach(() => { + useLibraryStore().stop() + setVisibility('visible', false) + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it('reuses the auth status snapshot and waits 30 seconds while idle', async () => { + const initial = status() + vi.mocked(api.getStatus).mockResolvedValue(initial) + const auth = useAuthStore() + const library = useLibraryStore() + + const snapshot = await auth.checkAuth() + await library.start(snapshot ?? undefined) + + expect(library.libraryReady).toBe(true) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(29999) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(2) + }) + + it('reschedules between the 30 second idle and 2 second scanning intervals', async () => { + vi.mocked(api.getStatus) + .mockResolvedValueOnce(status()) + .mockResolvedValueOnce(status({ scanStatus: 'scanning' })) + .mockResolvedValueOnce(status()) + .mockResolvedValue(status()) + const library = useLibraryStore() + + await library.start() + expect(api.getStatus).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(29999) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(2) + expect(library.scanActive).toBe(true) + + await vi.advanceTimersByTimeAsync(1999) + expect(api.getStatus).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(3) + expect(library.scanActive).toBe(false) + + await vi.advanceTimersByTimeAsync(29999) + expect(api.getStatus).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(4) + }) + + it('pauses while hidden and refreshes immediately when visible again', async () => { + vi.mocked(api.getStatus) + .mockResolvedValueOnce(status()) + .mockResolvedValue(status({ scanStatus: 'scanning' })) + const library = useLibraryStore() + + await library.start() + setVisibility('hidden') + await vi.advanceTimersByTimeAsync(60000) + expect(api.getStatus).toHaveBeenCalledTimes(1) + + setVisibility('visible') + await vi.advanceTimersByTimeAsync(0) + expect(api.getStatus).toHaveBeenCalledTimes(2) + expect(library.scanActive).toBe(true) + + await vi.advanceTimersByTimeAsync(1999) + expect(api.getStatus).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(3) + }) + + it('cancels the idle timer when a rescan starts and enters the 2 second cycle', async () => { + vi.mocked(api.rescan).mockResolvedValue(undefined) + vi.mocked(api.getStatus).mockResolvedValue(status({ scanStatus: 'scanning' })) + const library = useLibraryStore() + + await library.start(status()) + await vi.advanceTimersByTimeAsync(5000) + await library.requestRescan() + + expect(api.rescan).toHaveBeenCalledTimes(1) + expect(api.getStatus).toHaveBeenCalledTimes(1) + expect(vi.getTimerCount()).toBe(1) + await vi.advanceTimersByTimeAsync(1999) + expect(api.getStatus).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(api.getStatus).toHaveBeenCalledTimes(2) + }) + + it('removes its visibility listener and leaves no polling behind after stop', async () => { + const addListener = vi.spyOn(document, 'addEventListener') + const removeListener = vi.spyOn(document, 'removeEventListener') + vi.mocked(api.getStatus).mockResolvedValue(status()) + const library = useLibraryStore() + + await library.start(status()) + const registration = addListener.mock.calls.find(([event]) => event === 'visibilitychange') + expect(registration).toBeDefined() + + library.stop() + + expect(removeListener).toHaveBeenCalledWith('visibilitychange', registration?.[1]) + await vi.advanceTimersByTimeAsync(60000) + setVisibility('hidden') + setVisibility('visible') + await vi.advanceTimersByTimeAsync(0) + expect(api.getStatus).not.toHaveBeenCalled() + }) + it('tracks initialization and becomes ready after a later status update', async () => { vi.mocked(api.getStatus) .mockResolvedValueOnce(status({ diff --git a/web/src/stores/library.ts b/web/src/stores/library.ts index a2f659c..dda5d84 100644 --- a/web/src/stores/library.ts +++ b/web/src/stores/library.ts @@ -3,7 +3,8 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' import * as api from '../api' -const STATUS_POLL_MS = 2000 +const ACTIVE_STATUS_POLL_MS = 2000 +const IDLE_STATUS_POLL_MS = 30000 export const useLibraryStore = defineStore('library', () => { const status = ref(null) @@ -23,12 +24,34 @@ export const useLibraryStore = defineStore('library', () => { const scanActive = computed(() => scanStatus.value === 'initializing' || scanStatus.value === 'scanning') const canRescan = computed(() => !loading.value && !scanActive.value) + function pageIsHidden() { + return typeof document !== 'undefined' && document.visibilityState === 'hidden' + } + + function clearPollTimer() { + if (pollTimer) clearTimeout(pollTimer) + pollTimer = null + } + function schedulePoll() { - if (!running || pollTimer) return + if (!running || loading.value || pollTimer || pageIsHidden()) return + const delay = scanActive.value ? ACTIVE_STATUS_POLL_MS : IDLE_STATUS_POLL_MS pollTimer = setTimeout(() => { pollTimer = null - void refreshStatus().finally(schedulePoll) - }, STATUS_POLL_MS) + const epoch = lifecycleEpoch + void refreshStatus().finally(() => { + if (epoch === lifecycleEpoch) schedulePoll() + }) + }, delay) + } + + function handleVisibilityChange() { + clearPollTimer() + if (!running || pageIsHidden()) return + const epoch = lifecycleEpoch + void refreshStatus().finally(() => { + if (epoch === lifecycleEpoch) schedulePoll() + }) } async function refreshStatus() { @@ -57,18 +80,36 @@ export const useLibraryStore = defineStore('library', () => { return request.promise } - async function start() { - if (running) return refreshStatus() + async function start(initialStatus?: api.Status) { + if (running) { + if (initialStatus && !status.value) { + status.value = initialStatus + statusError.value = null + } + await statusRequest?.promise + return + } running = true - await refreshStatus() - schedulePoll() + const epoch = lifecycleEpoch + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', handleVisibilityChange) + } + if (initialStatus) { + status.value = initialStatus + statusError.value = null + } else if (!pageIsHidden()) { + await refreshStatus() + } + if (epoch === lifecycleEpoch) schedulePoll() } function stop() { running = false lifecycleEpoch += 1 - if (pollTimer) clearTimeout(pollTimer) - pollTimer = null + clearPollTimer() + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', handleVisibilityChange) + } statusRequest?.controller.abort() statusRequest = null rescanController?.abort() @@ -81,6 +122,7 @@ export const useLibraryStore = defineStore('library', () => { async function requestRescan() { if (!canRescan.value) return + clearPollTimer() const epoch = lifecycleEpoch const controller = new AbortController() rescanController = controller @@ -89,6 +131,9 @@ export const useLibraryStore = defineStore('library', () => { try { await api.rescan(controller.signal) if (epoch !== lifecycleEpoch) return + const pendingStatus = statusRequest?.epoch === epoch ? statusRequest.promise : null + if (pendingStatus) await pendingStatus + if (epoch !== lifecycleEpoch) return if (status.value) { status.value = { ...status.value, @@ -97,7 +142,6 @@ export const useLibraryStore = defineStore('library', () => { } } await refreshStatus() - if (epoch === lifecycleEpoch) schedulePoll() } catch (error) { if (epoch !== lifecycleEpoch || axios.isCancel(error)) return if (axios.isAxiosError(error)) { @@ -108,7 +152,10 @@ export const useLibraryStore = defineStore('library', () => { } } finally { if (rescanController === controller) rescanController = null - if (epoch === lifecycleEpoch) loading.value = false + if (epoch === lifecycleEpoch) { + loading.value = false + schedulePoll() + } } } From 7121ba9dd157de313a7544455198e4b79f91ccf1 Mon Sep 17 00:00:00 2001 From: Music Player Bot Date: Wed, 29 Jul 2026 10:07:57 +0800 Subject: [PATCH 2/5] fix: prevent stream mode from covering next control --- web/src/components/NowPlayingBar.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/NowPlayingBar.vue b/web/src/components/NowPlayingBar.vue index 21eb4dd..913f085 100644 --- a/web/src/components/NowPlayingBar.vue +++ b/web/src/components/NowPlayingBar.vue @@ -500,7 +500,7 @@ async function toggleFavorite() { } } -@media (min-width: 961px) and (max-width: 1240px) { +@media (min-width: 761px) and (max-width: 1360px) { .now-playing-bar { grid-template-columns: minmax(0, 1fr) auto auto; column-gap: 0.5rem; From c472ed81882847b35e881dcc768a85be95dcd8d8 Mon Sep 17 00:00:00 2001 From: Music Player Bot Date: Wed, 29 Jul 2026 15:07:27 +0800 Subject: [PATCH 3/5] feat: improve browse queue and playback startup --- internal/api/browse.go | 11 +- internal/api/browse_test.go | 14 + internal/api/handler.go | 2 + internal/api/queue.go | 112 ++++++++ internal/api/queue_test.go | 110 ++++++++ internal/playqueue/manager.go | 130 +++++++++ internal/playqueue/manager_test.go | 54 ++++ web/src/api/index.ts | 14 + web/src/stores/player.test.ts | 365 +++++++++++++++++++++++- web/src/stores/player.ts | 434 +++++++++++++++++++++++++++-- web/src/views/BrowseView.test.ts | 110 +++++++- web/src/views/BrowseView.vue | 118 ++++++-- web/src/views/HomeView.test.ts | 20 +- web/src/views/HomeView.vue | 55 ++-- 14 files changed, 1465 insertions(+), 84 deletions(-) diff --git a/internal/api/browse.go b/internal/api/browse.go index 881a3ba..eac3f13 100644 --- a/internal/api/browse.go +++ b/internal/api/browse.go @@ -140,6 +140,8 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { initialCapacity := min(retainedLimit, 4096) retainedCandidates := make(browseCandidateHeap, 0, initialCapacity) + idx, generation := a.currentSnapshot(r) + audioCount := 0 total := 0 for { if r.Context().Err() != nil { @@ -157,6 +159,13 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { candidate.isDirectory = true case isRegular: // Files sort after directories. + if index.AudioExtensions[strings.ToLower(filepath.Ext(entry.Name()))] { + relPath := browseRelativePath(dir, entry.Name()) + audio := idx.ByID[index.GenerateID(relPath)] + if audio != nil && filepath.Clean(audio.Filepath) == filepath.Clean(relPath) { + audioCount++ + } + } default: continue } @@ -181,7 +190,6 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { directories := make([]browseDirectory, 0, len(pageCandidates)) files := make([]browseFile, 0, len(pageCandidates)) - idx, generation := a.currentSnapshot(r) for _, candidate := range pageCandidates { if r.Context().Err() != nil { return @@ -204,6 +212,7 @@ func (a *API) handleBrowse(w http.ResponseWriter, r *http.Request) { "directories": directories, "files": files, "total": total, + "audioCount": audioCount, "page": page, "generation": generation, }) diff --git a/internal/api/browse_test.go b/internal/api/browse_test.go index 3d9a06b..f8ab43c 100644 --- a/internal/api/browse_test.go +++ b/internal/api/browse_test.go @@ -54,6 +54,7 @@ func TestBrowseListsMeaningfulFilesAndDirectories(t *testing.T) { Directories []browseDirectory `json:"directories"` Files []browseFile `json:"files"` Total int `json:"total"` + AudioCount int `json:"audioCount"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { t.Fatal(err) @@ -61,6 +62,9 @@ func TestBrowseListsMeaningfulFilesAndDirectories(t *testing.T) { if len(result.Directories) != 1 || result.Directories[0].Path != "artist1/Scans" { t.Fatalf("unexpected directories: %+v", result.Directories) } + if result.AudioCount != 3 { + t.Fatalf("audio count = %d, want 3 direct indexed files", result.AudioCount) + } names := make(map[string]browseFile) for _, file := range result.Files { names[file.Name] = file @@ -708,6 +712,16 @@ func TestBrowseAndMetadataRequireAuthentication(t *testing.T) { t.Errorf("GET %s status = %d, want 401", path, resp.StatusCode) } } + + resp, body := postJSON( + t, + env.server.URL+"/api/queues/forged/prepend-directory", + `{"dir":"artist1"}`, + nil, + ) + if resp.StatusCode != http.StatusUnauthorized || body["code"] != "UNAUTHORIZED" { + t.Errorf("POST prepend directory = %d/%v, want 401/UNAUTHORIZED", resp.StatusCode, body) + } } func mustWriteBrowseFile(t *testing.T, path string, content []byte) { diff --git a/internal/api/handler.go b/internal/api/handler.go index 010db05..2b331e0 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -131,6 +131,7 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("POST /api/queues", a.withLibraryReady(a.handleCreateQueue)) mux.HandleFunc("GET /api/queues/{id}/items", a.withLibraryReady(a.handleQueueItems)) mux.HandleFunc("POST /api/queues/{id}/select", a.withLibraryReady(a.handleQueueSelect)) + mux.HandleFunc("POST /api/queues/{id}/prepend-directory", a.withLibraryReady(a.handleQueuePrependDirectory)) mux.HandleFunc("DELETE /api/queues/{id}", a.handleDeleteQueue) mux.HandleFunc("GET /api/files/{id}/metadata", a.withLibraryReady(a.handleFileMetadata)) mux.HandleFunc("GET /api/files/{id}/cover", a.withLibraryReady(a.handleFileCover)) @@ -169,6 +170,7 @@ func (a *API) RoutesWithAuth() http.Handler { mux.HandleFunc("POST /api/queues", a.withAuth(a.withLibraryReady(a.handleCreateQueue))) mux.HandleFunc("GET /api/queues/{id}/items", a.withAuth(a.withLibraryReady(a.handleQueueItems))) mux.HandleFunc("POST /api/queues/{id}/select", a.withAuth(a.withLibraryReady(a.handleQueueSelect))) + mux.HandleFunc("POST /api/queues/{id}/prepend-directory", a.withAuth(a.withLibraryReady(a.handleQueuePrependDirectory))) mux.HandleFunc("DELETE /api/queues/{id}", a.withAuth(a.handleDeleteQueue)) mux.HandleFunc("GET /api/files/{id}/metadata", a.withAuth(a.withLibraryReady(a.handleFileMetadata))) mux.HandleFunc("GET /api/files/{id}/cover", a.withAuth(a.withLibraryReady(a.handleFileCover))) diff --git a/internal/api/queue.go b/internal/api/queue.go index 1ce8bdc..cc59c09 100644 --- a/internal/api/queue.go +++ b/internal/api/queue.go @@ -3,8 +3,14 @@ package api import ( "context" "errors" + "io" "net/http" + "os" + "path/filepath" + "sort" + "strings" + "github.com/ColderCoder/ShuffleMuse/internal/index" "github.com/ColderCoder/ShuffleMuse/internal/playqueue" ) @@ -68,6 +74,110 @@ func (a *API) handleQueueSelect(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } +func (a *API) handleQueuePrependDirectory(w http.ResponseWriter, r *http.Request) { + var body struct { + Dir string `json:"dir"` + } + if !decodeStrictJSON(w, r, &body) { + return + } + if body.Dir == "" || len(body.Dir) > 4096 { + writeError(w, http.StatusBadRequest, "INVALID_DIRECTORY", "dir is required and must not exceed 4096 bytes") + return + } + dir, err := cleanLibraryPath(body.Dir, true) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_DIRECTORY", err.Error()) + return + } + idx, generation := a.currentSnapshot(r) + entries, found, err := a.directAudioEntries(r.Context(), dir, idx) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return + } + writeError(w, http.StatusInternalServerError, "BROWSE_ERROR", "failed to read directory") + return + } + if !found { + writeError(w, http.StatusNotFound, "NOT_FOUND", "directory not found") + return + } + result, err := a.Queues.Prepend(r.Context(), r.PathValue("id"), entries, idx, generation) + if err != nil { + writeQueueError(w, err) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (a *API) directAudioEntries( + ctx context.Context, + dir string, + idx *index.Index, +) ([]index.FileEntry, bool, error) { + resolver, err := index.NewRootResolver(a.Config.MusicDir) + if err != nil { + return nil, false, err + } + absDir, stat, err := resolver.Stat(dir) + if err != nil || !stat.IsDir() { + return nil, false, nil + } + directory, err := os.Open(absDir) + if err != nil { + return nil, true, err + } + defer directory.Close() + + entries := make([]index.FileEntry, 0) + for { + if err := ctx.Err(); err != nil { + return nil, true, err + } + batch, readErr := directory.ReadDir(256) + for _, entry := range batch { + if isSystemBrowseEntry(entry.Name()) { + continue + } + _, regular := classifyBrowseEntry(resolver, entry, dir) + if !regular { + continue + } + relPath := browseRelativePath(dir, entry.Name()) + _, info, statErr := resolver.Stat(relPath) + if statErr != nil || !info.Mode().IsRegular() { + continue + } + audio := idx.ByID[index.GenerateID(relPath)] + if audio == nil || filepath.Clean(audio.Filepath) != filepath.Clean(relPath) { + continue + } + entries = append(entries, *audio) + } + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, true, readErr + } + } + sort.Slice(entries, func(i, j int) bool { + left := filepath.Base(entries[i].Filepath) + right := filepath.Base(entries[j].Filepath) + leftSort := strings.ToLower(left) + rightSort := strings.ToLower(right) + if leftSort != rightSort { + return leftSort < rightSort + } + if left != right { + return left < right + } + return entries[i].ID < entries[j].ID + }) + return entries, true, nil +} + func (a *API) handleDeleteQueue(w http.ResponseWriter, r *http.Request) { a.Queues.Delete(r.PathValue("id")) w.WriteHeader(http.StatusNoContent) @@ -79,6 +189,8 @@ func writeQueueError(w http.ResponseWriter, err error) { writeError(w, http.StatusNotFound, "QUEUE_NOT_FOUND", "queue not found") case errors.Is(err, playqueue.ErrFileNotFound): writeError(w, http.StatusNotFound, "FILE_NOT_FOUND", "file not found") + case errors.Is(err, playqueue.ErrNoAudioFiles): + writeError(w, http.StatusUnprocessableEntity, "NO_AUDIO_FILES", "directory contains no audio files") case errors.Is(err, playqueue.ErrBusy): writeError(w, http.StatusServiceUnavailable, "QUEUE_BUSY", "queue builder is busy") case errors.Is(err, playqueue.ErrCapacity): diff --git a/internal/api/queue_test.go b/internal/api/queue_test.go index 4310187..c1ec1d4 100644 --- a/internal/api/queue_test.go +++ b/internal/api/queue_test.go @@ -4,9 +4,12 @@ import ( "encoding/json" "io" "net/http" + "os" + "path/filepath" "strings" "testing" + "github.com/ColderCoder/ShuffleMuse/internal/index" "github.com/ColderCoder/ShuffleMuse/internal/playqueue" ) @@ -101,6 +104,113 @@ func TestQueueAPIPinTagReplaceAndStableErrors(t *testing.T) { } } +func TestQueueAPIPrependsDirectDirectoryAudioInFilenameOrder(t *testing.T) { + env := setupTestEnv(t) + defer env.teardown() + nestedDir := filepath.Join(env.tmpDir, "music", "artist1", "Disc 2") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(nestedDir, "nested.flac"), []byte("fake audio data"), 0o644); err != nil { + t.Fatal(err) + } + next, err := index.Scan(filepath.Join(env.tmpDir, "music")) + if err != nil { + t.Fatal(err) + } + env.api.UpdateIndex(next) + env.idx = next + + directTagged := next.ByID[index.GenerateID(filepath.Join("artist1", "track2.flac"))] + if directTagged == nil { + t.Fatal("direct tagged track not indexed") + } + if err := env.tagStore.AddTag(directTagged.Filepath, "focus"); err != nil { + t.Fatal(err) + } + createdResponse, created := postJSON(t, env.server.URL+"/api/queues", `{"tag":"focus"}`, nil) + if createdResponse.StatusCode != http.StatusCreated { + t.Fatalf("create = %d/%v", createdResponse.StatusCode, created) + } + oldID := created["queue"].(map[string]interface{})["id"].(string) + + response, result := postJSON( + t, + env.server.URL+"/api/queues/"+oldID+"/prepend-directory", + `{"dir":"artist1"}`, + nil, + ) + if response.StatusCode != http.StatusOK { + t.Fatalf("prepend = %d/%v", response.StatusCode, result) + } + queue := result["queue"].(map[string]interface{}) + if queue["id"] == oldID || queue["tag"] != "focus" || int(queue["total"].(float64)) != 3 { + t.Fatalf("replacement queue = %v", queue) + } + if int(result["directoryTrackCount"].(float64)) != 3 { + t.Fatalf("directory track count = %v", result["directoryTrackCount"]) + } + items := result["items"].([]interface{}) + if len(items) != 3 { + t.Fatalf("items = %v", items) + } + wantNames := []string{"track1", "track2", "track3"} + seen := make(map[string]bool) + for index, raw := range items { + item := raw.(map[string]interface{}) + if item["name"] != wantNames[index] || int(item["queueIndex"].(float64)) != index { + t.Fatalf("item %d = %v, want %q", index, item, wantNames[index]) + } + id := item["id"].(string) + if seen[id] { + t.Fatalf("duplicate directory item %q", id) + } + seen[id] = true + } + oldResponse, old := getJSON(t, env.server.URL+"/api/queues/"+oldID+"/items?page=1", nil) + if oldResponse.StatusCode != http.StatusNotFound || old["code"] != "QUEUE_NOT_FOUND" { + t.Fatalf("old queue after prepend = %d/%v", oldResponse.StatusCode, old) + } +} + +func TestQueueAPIPrependDirectoryValidationAndEmptyRoot(t *testing.T) { + env := setupTestEnv(t) + defer env.teardown() + createdResponse, created := postJSON(t, env.server.URL+"/api/queues", `{}`, nil) + if createdResponse.StatusCode != http.StatusCreated { + t.Fatalf("create = %d/%v", createdResponse.StatusCode, created) + } + queueID := created["queue"].(map[string]interface{})["id"].(string) + + empty, emptyBody := postJSON( + t, + env.server.URL+"/api/queues/"+queueID+"/prepend-directory", + `{"dir":"."}`, + nil, + ) + if empty.StatusCode != http.StatusUnprocessableEntity || emptyBody["code"] != "NO_AUDIO_FILES" { + t.Fatalf("empty root = %d/%v", empty.StatusCode, emptyBody) + } + invalid, invalidBody := postJSON( + t, + env.server.URL+"/api/queues/"+queueID+"/prepend-directory", + `{"dir":"../outside"}`, + nil, + ) + if invalid.StatusCode != http.StatusBadRequest || invalidBody["code"] != "INVALID_DIRECTORY" { + t.Fatalf("invalid dir = %d/%v", invalid.StatusCode, invalidBody) + } + missing, missingBody := postJSON( + t, + env.server.URL+"/api/queues/"+queueID+"/prepend-directory", + `{"dir":"missing"}`, + nil, + ) + if missing.StatusCode != http.StatusNotFound || missingBody["code"] != "NOT_FOUND" { + t.Fatalf("missing dir = %d/%v", missing.StatusCode, missingBody) + } +} + func TestQueueAPIStrictJSONLimit(t *testing.T) { env := setupTestEnv(t) defer env.teardown() diff --git a/internal/playqueue/manager.go b/internal/playqueue/manager.go index 64a334e..2523ac8 100644 --- a/internal/playqueue/manager.go +++ b/internal/playqueue/manager.go @@ -29,6 +29,7 @@ const ( var ( ErrNotFound = errors.New("queue not found") ErrFileNotFound = errors.New("file not found") + ErrNoAudioFiles = errors.New("directory contains no audio files") ErrBusy = errors.New("queue builder is busy") ErrCapacity = errors.New("queue cache capacity exceeded") ) @@ -91,6 +92,11 @@ type SelectResult struct { QueueIndex int `json:"queueIndex"` } +type PrependResult struct { + Page + DirectoryTrackCount int `json:"directoryTrackCount"` +} + type snapshotRef struct { generation uint64 index *index.Index @@ -385,6 +391,130 @@ func (m *Manager) Select(ctx context.Context, token, fileID string, current *ind return SelectResult{Page: m.pageLocked(replacement, newToken, 1, current, generation), QueueIndex: 0}, nil } +func (m *Manager) Prepend( + ctx context.Context, + token string, + entries []index.FileEntry, + current *index.Index, + generation uint64, +) (PrependResult, error) { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + digest, ok := decodeToken(token) + if !ok { + return PrependResult{}, ErrNotFound + } + if current == nil { + return PrependResult{}, ErrFileNotFound + } + if err := m.builds.acquire(ctx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return PrependResult{}, err + } + return PrependResult{}, ErrBusy + } + defer m.builds.release() + + now := m.now() + m.mu.Lock() + q := m.queues[digest] + if q == nil || now.Sub(q.lastAccess) >= m.config.Idle { + if q != nil { + m.removeLocked(q) + } + m.mu.Unlock() + return PrependResult{}, ErrNotFound + } + q.lastAccess = now + m.mu.Unlock() + + selected := make(map[string]struct{}, len(entries)) + prefix := make([]index.FileEntry, 0, len(entries)+len(q.prefix)) + for i := range entries { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + } + entry := entries[i] + online := current.ByID[entry.ID] + if online == nil || online.Filepath != entry.Filepath { + continue + } + if _, exists := selected[entry.ID]; exists { + continue + } + selected[entry.ID] = struct{}{} + prefix = append(prefix, entry) + } + if len(prefix) == 0 { + return PrependResult{}, ErrNoAudioFiles + } + directoryTrackCount := len(prefix) + for i := range q.prefix { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + } + if _, exists := selected[q.prefix[i].ID]; !exists { + prefix = append(prefix, q.prefix[i]) + } + } + order := make([]uint32, 0, len(q.order)) + for i, position := range q.order { + if i&1023 == 0 { + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + } + if int(position) >= len(q.snapshot.index.Files) { + continue + } + if _, exists := selected[q.snapshot.index.Files[position].ID]; !exists { + order = append(order, position) + } + } + + newToken, newDigest, err := m.newToken() + if err != nil { + return PrependResult{}, err + } + replacement := &queue{ + digest: newDigest, tag: q.tag, createdGeneration: q.createdGeneration, + snapshot: q.snapshot, order: order, prefix: prefix, + createdAt: now, lastAccess: now, + } + replacement.bytes = estimateQueue(replacement) + + m.mu.Lock() + defer m.mu.Unlock() + if err := ctx.Err(); err != nil { + return PrependResult{}, err + } + if m.queues[digest] != q { + return PrependResult{}, ErrNotFound + } + if _, exists := m.queues[newDigest]; exists { + return PrependResult{}, errors.New("queue token collision") + } + if saturatedAdd(q.snapshot.bytes, replacement.bytes) > m.config.MaxBytes { + return PrependResult{}, ErrCapacity + } + // Transfer the shared snapshot to the immutable replacement. The old queue + // may still be referenced by a concurrent read-only scan, so leave it intact. + m.totalBytes -= q.bytes + delete(m.queues, q.digest) + m.queues[newDigest] = replacement + m.totalBytes = saturatedAdd(m.totalBytes, replacement.bytes) + m.evictLocked(newDigest) + return PrependResult{ + Page: m.pageLocked(replacement, newToken, 1, current, generation), + DirectoryTrackCount: directoryTrackCount, + }, nil +} + func (m *Manager) Delete(token string) { digest, ok := decodeToken(token) if !ok { diff --git a/internal/playqueue/manager_test.go b/internal/playqueue/manager_test.go index fdb7a03..63df631 100644 --- a/internal/playqueue/manager_test.go +++ b/internal/playqueue/manager_test.go @@ -279,6 +279,60 @@ func TestSelectExistingOrAtomicallyReplacesQueue(t *testing.T) { } } +func TestPrependMovesEntriesToFrontWithoutDuplicates(t *testing.T) { + idx := testIndex(5) + tagged := []string{idx.Files[1].Filepath, idx.Files[2].Filepath} + m := NewManager(Config{}, staticTags{"focus": tagged}, Options{Random: randomBytes(8)}) + created, err := m.Create(context.Background(), idx, 3, CreateRequest{Tag: "focus"}) + if err != nil { + t.Fatal(err) + } + + prepended, err := m.Prepend(context.Background(), created.Queue.ID, []index.FileEntry{ + idx.Files[4], idx.Files[2], idx.Files[0], idx.Files[2], + }, idx, 4) + if err != nil { + t.Fatal(err) + } + if prepended.DirectoryTrackCount != 3 || prepended.Queue.Tag != "focus" || prepended.Queue.Total != 4 { + t.Fatalf("unexpected prepend result: %+v", prepended) + } + assertIDs(t, prepended.Items, []string{ + idx.Files[4].ID, idx.Files[2].ID, idx.Files[0].ID, idx.Files[1].ID, + }) + seen := make(map[string]bool) + for _, item := range prepended.Items { + if seen[item.ID] { + t.Fatalf("duplicate item after prepend: %+v", prepended.Items) + } + seen[item.ID] = true + } + if _, err := m.Page(created.Queue.ID, 1, idx, 4); !errors.Is(err, ErrNotFound) { + t.Fatalf("old queue remained after prepend: %v", err) + } +} + +func TestPrependRejectsEmptyOrCanceledWorkWithoutReplacingQueue(t *testing.T) { + idx := testIndex(3) + m := NewManager(Config{}, nil, Options{Random: randomBytes(4)}) + created, err := m.Create(context.Background(), idx, 1, CreateRequest{}) + if err != nil { + t.Fatal(err) + } + + if _, err := m.Prepend(context.Background(), created.Queue.ID, nil, idx, 1); !errors.Is(err, ErrNoAudioFiles) { + t.Fatalf("empty prepend error = %v", err) + } + canceled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := m.Prepend(canceled, created.Queue.ID, []index.FileEntry{idx.Files[0]}, idx, 1); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled prepend error = %v", err) + } + if _, err := m.Page(created.Queue.ID, 1, idx, 1); err != nil { + t.Fatalf("failed prepend replaced original queue: %v", err) + } +} + func TestSelectLargeQueueHonorsCancellationWithoutRemovingQueue(t *testing.T) { idx := testIndex(5_000) tagged := make([]string, 0, len(idx.Files)-1) diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 67f3461..907fa2f 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -59,6 +59,10 @@ export interface SelectQueueResponse extends QueuePage { queueIndex: number } +export interface PrependDirectoryResponse extends QueuePage { + directoryTrackCount: number +} + export interface DirectoryEntry { name: string path: string @@ -85,6 +89,7 @@ export interface BrowseResponse { directories: DirectoryEntry[] files: BrowseFileEntry[] total: number + audioCount: number page: number generation?: number } @@ -213,6 +218,15 @@ export async function selectQueueItem(id: string, fileId: string, signal?: Abort return res.data } +export async function prependQueueDirectory(id: string, dir: string, signal?: AbortSignal) { + const res = await http.post( + `/queues/${encodeURIComponent(id)}/prepend-directory`, + { dir }, + { signal }, + ) + return res.data +} + export async function deleteQueue(id: string, signal?: AbortSignal) { await http.delete(`/queues/${encodeURIComponent(id)}`, { signal }) } diff --git a/web/src/stores/player.test.ts b/web/src/stores/player.test.ts index 9b0c346..fca2ab5 100644 --- a/web/src/stores/player.test.ts +++ b/web/src/stores/player.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { usePlayerStore } from './player' import * as api from '../api' @@ -7,6 +7,7 @@ vi.mock('../api', () => ({ createQueue: vi.fn(), getQueuePage: vi.fn(), selectQueueItem: vi.fn(), + prependQueueDirectory: vi.fn(), deleteQueue: vi.fn(), getFileMetadata: vi.fn(), getFiles: vi.fn(), @@ -21,6 +22,7 @@ class FakeAudio extends EventTarget { currentTime = 0 duration = 240 readyState = 1 + playCalls = 0 constructor() { super() @@ -33,6 +35,7 @@ class FakeAudio extends EventTarget { } async play() { + this.playCalls += 1 this.paused = false this.dispatchEvent(new Event('playing')) } @@ -47,6 +50,97 @@ class FakeAudio extends EventTarget { } } +class FakeConstantSource { + offset = { value: 1 } + startCalls = 0 + stopCalls = 0 + connectCalls = 0 + disconnectCalls = 0 + + connect() { + this.connectCalls += 1 + } + + disconnect() { + this.disconnectCalls += 1 + } + + start() { + this.startCalls += 1 + } + + stop() { + this.stopCalls += 1 + } +} + +class FakeGain { + gain = { value: 1 } + connectCalls = 0 + disconnectCalls = 0 + + connect() { + this.connectCalls += 1 + } + + disconnect() { + this.disconnectCalls += 1 + } +} + +class FakeAudioContext { + static instances: FakeAudioContext[] = [] + static rejectResume = false + static failConstruction = false + + state: AudioContextState = 'suspended' + destination = {} + source = new FakeConstantSource() + gain = new FakeGain() + resumeCalls = 0 + suspendCalls = 0 + closeCalls = 0 + + constructor() { + if (FakeAudioContext.failConstruction) throw new Error('context unavailable') + FakeAudioContext.instances.push(this) + } + + createConstantSource() { + return this.source + } + + createGain() { + return this.gain + } + + async resume() { + this.resumeCalls += 1 + if (FakeAudioContext.rejectResume) throw new Error('resume rejected') + this.state = 'running' + } + + async suspend() { + this.suspendCalls += 1 + this.state = 'suspended' + } + + async close() { + this.closeCalls += 1 + this.state = 'closed' + } + + static reset() { + FakeAudioContext.instances = [] + FakeAudioContext.rejectResume = false + FakeAudioContext.failConstruction = false + } +} + +function enableWebAudio() { + vi.stubGlobal('AudioContext', FakeAudioContext) +} + function item(index: number, id = `track-${index}`): api.QueueItem { return { id, @@ -89,6 +183,9 @@ describe('player store server queues', () => { vi.clearAllMocks() localStorage.clear() FakeAudio.instances = [] + FakeAudioContext.reset() + vi.stubGlobal('AudioContext', undefined) + vi.stubGlobal('webkitAudioContext', undefined) vi.stubGlobal('Audio', FakeAudio) setActivePinia(createPinia()) vi.mocked(api.createQueue).mockResolvedValue(page()) @@ -101,6 +198,11 @@ describe('player store server queues', () => { vi.mocked(api.deleteQueue).mockResolvedValue(undefined) }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + it('sanitizes corrupt and out-of-range stored volume before creating audio', () => { localStorage.setItem('shufflemuse-volume', 'not-a-number') let player = usePlayerStore() @@ -197,6 +299,182 @@ describe('player store server queues', () => { expect(player.isPlaying).toBe(true) }) + it('warms a cold audio endpoint for 1.5 seconds before starting media', async () => { + vi.useFakeTimers() + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + + const playback = player.playAt(0) + await vi.advanceTimersByTimeAsync(0) + const audio = FakeAudio.instances[0] + expect(FakeAudioContext.instances).toHaveLength(1) + expect(audio.src).toContain('/api/stream/one') + expect(audio.playCalls).toBe(0) + expect(player.isBuffering).toBe(true) + + await vi.advanceTimersByTimeAsync(1499) + expect(audio.playCalls).toBe(0) + await vi.advanceTimersByTimeAsync(1) + await playback + + expect(audio.playCalls).toBe(1) + expect(player.isPlaying).toBe(true) + expect(player.isBuffering).toBe(false) + player.reset() + }) + + it('reuses a warm endpoint for track changes, mode changes, and Opus seeks', async () => { + vi.useFakeTimers() + enableWebAudio() + vi.mocked(api.createQueue).mockResolvedValue(page( + 'two-tracks', 1, 2, [item(0, 'one'), item(1, 'two')], + )) + const player = usePlayerStore() + await player.preparePlaylist() + + const first = player.playAt(0) + await vi.advanceTimersByTimeAsync(1500) + await first + const context = FakeAudioContext.instances[0] + const audio = FakeAudio.instances[0] + + await player.playAt(1) + await player.setStreamMode('opus') + await player.seek(45) + + expect(player.currentTrack?.id).toBe('two') + expect(audio.src).toContain('mode=opus') + expect(audio.src).toContain('start=45.000') + expect(audio.playCalls).toBe(4) + expect(FakeAudioContext.instances).toHaveLength(1) + expect(context.resumeCalls).toBe(1) + expect(context.source.startCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) + player.reset() + }) + + it('keeps the endpoint warm for 15 seconds after pause, then warms it again', async () => { + vi.useFakeTimers() + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + const first = player.playAt(0) + await vi.advanceTimersByTimeAsync(1500) + await first + const context = FakeAudioContext.instances[0] + const audio = FakeAudio.instances[0] + + player.pause() + await vi.advanceTimersByTimeAsync(14999) + await player.resume() + expect(audio.playCalls).toBe(2) + expect(context.suspendCalls).toBe(0) + + player.pause() + await vi.advanceTimersByTimeAsync(15000) + expect(context.suspendCalls).toBe(1) + expect(context.state).toBe('suspended') + + const resumed = player.resume() + await vi.advanceTimersByTimeAsync(1499) + expect(audio.playCalls).toBe(2) + await vi.advanceTimersByTimeAsync(1) + await resumed + expect(audio.playCalls).toBe(3) + expect(context.resumeCalls).toBe(2) + player.reset() + }) + + it('lets pause cancel media playback while the endpoint is warming', async () => { + vi.useFakeTimers() + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + + const playback = player.playAt(0) + await vi.advanceTimersByTimeAsync(500) + expect(player.isBuffering).toBe(true) + await player.togglePlay() + expect(player.isBuffering).toBe(false) + + await vi.advanceTimersByTimeAsync(1000) + await playback + expect(FakeAudio.instances[0].playCalls).toBe(0) + expect(player.isPlaying).toBe(false) + player.reset() + }) + + it('only starts the latest track selected during endpoint warmup', async () => { + vi.useFakeTimers() + enableWebAudio() + vi.mocked(api.createQueue).mockResolvedValue(page( + 'two-tracks', 1, 2, [item(0, 'one'), item(1, 'two')], + )) + const player = usePlayerStore() + await player.preparePlaylist() + + const first = player.playAt(0) + await vi.advanceTimersByTimeAsync(500) + const second = player.playAt(1) + await vi.advanceTimersByTimeAsync(1000) + await Promise.all([first, second]) + + const audio = FakeAudio.instances[0] + expect(player.currentTrack?.id).toBe('two') + expect(audio.src).toContain('/api/stream/two') + expect(audio.playCalls).toBe(1) + expect(FakeAudioContext.instances).toHaveLength(1) + player.reset() + }) + + it('cancels warmup playback and releases endpoint resources on reset', async () => { + vi.useFakeTimers() + enableWebAudio() + const player = usePlayerStore() + await player.preparePlaylist() + + const playback = player.playAt(0) + await vi.advanceTimersByTimeAsync(0) + const context = FakeAudioContext.instances[0] + const audio = FakeAudio.instances[0] + player.pause() + player.pause() + expect(vi.getTimerCount()).toBe(2) + + player.reset() + expect(context.source.stopCalls).toBe(1) + expect(context.source.disconnectCalls).toBe(1) + expect(context.gain.disconnectCalls).toBe(1) + expect(context.closeCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) + + await vi.advanceTimersByTimeAsync(1500) + await playback + expect(audio.playCalls).toBe(0) + }) + + it('falls back immediately when Web Audio is unsupported or cannot start', async () => { + const unsupported = usePlayerStore() + await unsupported.preparePlaylist() + await unsupported.playAt(0) + expect(FakeAudio.instances[0].playCalls).toBe(1) + unsupported.reset() + + setActivePinia(createPinia()) + enableWebAudio() + FakeAudioContext.rejectResume = true + const rejected = usePlayerStore() + await rejected.preparePlaylist() + await rejected.playAt(0) + + expect(FakeAudio.instances[1].playCalls).toBe(1) + expect(FakeAudioContext.instances).toHaveLength(1) + expect(FakeAudioContext.instances[0].closeCalls).toBe(1) + expect(rejected.isPlaying).toBe(true) + rejected.reset() + }) + it('ignores a stale aborted queue creation', async () => { const old = deferred() vi.mocked(api.createQueue) @@ -318,6 +596,91 @@ describe('player store server queues', () => { expect(player.playlistError).toBe('Failed to prepare playlist') }) + it('moves a directory to the queue front and immediately plays its first track', async () => { + const player = usePlayerStore() + await player.preparePlaylist() + player.selectedTag = 'focus' + vi.mocked(api.prependQueueDirectory).mockResolvedValueOnce({ + ...page('folder-queue', 1, 3, [ + item(0, 'folder-a'), + item(1, 'folder-b'), + item(2, 'one'), + ]), + queue: { + id: 'folder-queue', + tag: 'focus', + createdGeneration: 1, + total: 3, + pageSize: 200, + }, + directoryTrackCount: 2, + }) + + const count = await player.prependDirectory('Album') + + expect(api.prependQueueDirectory).toHaveBeenCalledWith('queue-1', 'Album', expect.any(AbortSignal)) + expect(count).toBe(2) + expect(player.queue?.id).toBe('folder-queue') + expect(player.selectedTag).toBe('focus') + expect(player.activeIndex).toBe(0) + expect(player.sidebarPage).toBe(1) + expect(player.currentTrack?.id).toBe('folder-a') + expect(player.currentTime).toBe(0) + expect(player.isPlaying).toBe(true) + expect(FakeAudio.instances[0].src).toContain('/api/stream/folder-a') + }) + + it('rebuilds an expired queue once before prepending the directory', async () => { + const player = usePlayerStore() + await player.preparePlaylist() + player.selectedTag = 'focus' + vi.mocked(api.prependQueueDirectory) + .mockRejectedValueOnce(Object.assign(new Error('expired'), { + isAxiosError: true, + response: { data: { code: 'QUEUE_NOT_FOUND' } }, + })) + .mockResolvedValueOnce({ + ...page('folder-queue', 1, 2, [item(0, 'folder-a'), item(1, 'tagged')]), + queue: { + id: 'folder-queue', + tag: 'focus', + createdGeneration: 1, + total: 2, + pageSize: 200, + }, + directoryTrackCount: 1, + }) + vi.mocked(api.createQueue).mockResolvedValueOnce(page( + 'recovered-base', 1, 1, [item(0, 'tagged')], + )) + + const count = await player.prependDirectory('Album') + + expect(api.createQueue).toHaveBeenLastCalledWith({ tag: 'focus' }, expect.any(AbortSignal)) + expect(api.prependQueueDirectory).toHaveBeenNthCalledWith( + 2, 'recovered-base', 'Album', expect.any(AbortSignal), + ) + expect(count).toBe(1) + expect(player.currentTrack?.id).toBe('folder-a') + }) + + it('keeps the current queue and track when directory prepending fails', async () => { + const player = usePlayerStore() + await player.preparePlaylist() + await player.playAt(0) + const audio = FakeAudio.instances[0] + const source = audio.src + vi.mocked(api.prependQueueDirectory).mockRejectedValueOnce(new Error('busy')) + + await expect(player.prependDirectory('Album')).rejects.toThrow('busy') + + expect(player.queue?.id).toBe('queue-1') + expect(player.currentTrack?.id).toBe('one') + expect(player.isPlaying).toBe(true) + expect(audio.src).toBe(source) + expect(player.playlistLoading).toBe(false) + }) + it('creates a new independent queue after the last track ends', async () => { vi.mocked(api.createQueue) .mockResolvedValueOnce(page('cycle-one', 1, 1, [item(0, 'last')])) diff --git a/web/src/stores/player.ts b/web/src/stores/player.ts index b1ab1d8..cfa0ead 100644 --- a/web/src/stores/player.ts +++ b/web/src/stores/player.ts @@ -7,6 +7,8 @@ const STORAGE_KEY_VOLUME = 'shufflemuse-volume' const STORAGE_KEY_MUTED = 'shufflemuse-muted' const STORAGE_KEY_STREAM_MODE = 'shufflemuse-stream-mode' const MAX_CACHED_PAGES = 5 +const ENDPOINT_WARMUP_MS = 1500 +const ENDPOINT_KEEPALIVE_MS = 15000 export type StreamMode = 'original' | 'opus' @@ -23,6 +25,11 @@ interface CachedPage { libraryGeneration: number } +interface PlaybackIntent { + id: number + endpointReady: Promise +} + function loadVolume(): number { try { const saved = localStorage.getItem(STORAGE_KEY_VOLUME) @@ -90,10 +97,23 @@ export const usePlayerStore = defineStore('player', () => { let audio: HTMLAudioElement | null = null let playEpoch = 0 + let playbackIntent = 0 + let activeMediaPlaybackIntent = 0 + let pendingMediaPlaybackIntent = 0 let sourceRequest = 0 let sourceOffset = 0 let loadedTrackID: string | null = null let loadedMode: StreamMode | null = null + let endpointContext: AudioContext | null = null + let endpointSource: ConstantSourceNode | null = null + let endpointGain: GainNode | null = null + let endpointWarm = false + let endpointDisabled = false + let endpointGeneration = 0 + let endpointWarmupTimer: ReturnType | null = null + let endpointWarmupPromise: Promise | null = null + let resolveEndpointWarmup: (() => void) | null = null + let endpointSuspendTimer: ReturnType | null = null let queueController: AbortController | null = null let selectController: AbortController | null = null let metadataController: AbortController | null = null @@ -113,6 +133,190 @@ export const usePlayerStore = defineStore('player', () => { const playlist = computed(() => sidebarItems.value) const cachedPageCount = computed(() => pages.value.size) + function clearEndpointSuspendTimer() { + if (endpointSuspendTimer === null) return + clearTimeout(endpointSuspendTimer) + endpointSuspendTimer = null + } + + function settleEndpointWarmup() { + if (endpointWarmupTimer !== null) { + clearTimeout(endpointWarmupTimer) + endpointWarmupTimer = null + } + const resolve = resolveEndpointWarmup + resolveEndpointWarmup = null + endpointWarmupPromise = null + resolve?.() + } + + function closeEndpoint(disable: boolean) { + endpointGeneration += 1 + clearEndpointSuspendTimer() + settleEndpointWarmup() + + const context = endpointContext + const source = endpointSource + const gain = endpointGain + endpointContext = null + endpointSource = null + endpointGain = null + endpointWarm = false + endpointDisabled = disable + + try { source?.stop() } catch { /* already stopped */ } + try { source?.disconnect() } catch { /* already disconnected */ } + try { gain?.disconnect() } catch { /* already disconnected */ } + if (context && context.state !== 'closed') { + try { void context.close().catch(() => {}) } catch { /* already closed */ } + } + } + + function createEndpointContext(): AudioContext | null { + const scope = globalThis as unknown as { + AudioContext?: new () => AudioContext + webkitAudioContext?: new () => AudioContext + } + const AudioContextConstructor = scope.AudioContext ?? scope.webkitAudioContext + if (!AudioContextConstructor) { + endpointDisabled = true + return null + } + + let context: AudioContext | null = null + let source: ConstantSourceNode | null = null + let gain: GainNode | null = null + try { + context = new AudioContextConstructor() + source = context.createConstantSource() + gain = context.createGain() + source.offset.value = 0 + gain.gain.value = 0 + source.connect(gain) + gain.connect(context.destination) + source.start() + endpointContext = context + endpointSource = source + endpointGain = gain + return context + } catch { + try { source?.stop() } catch { /* not started */ } + try { source?.disconnect() } catch { /* not connected */ } + try { gain?.disconnect() } catch { /* not connected */ } + if (context && context.state !== 'closed') { + try { void context.close().catch(() => {}) } catch { /* ignore */ } + } + endpointDisabled = true + return null + } + } + + function warmEndpoint(): Promise { + endpointGeneration += 1 + clearEndpointSuspendTimer() + if (endpointDisabled) return Promise.resolve() + if (endpointWarmupPromise) return endpointWarmupPromise + + if (endpointContext?.state === 'closed') closeEndpoint(false) + if (endpointContext && endpointWarm && endpointContext.state === 'running') { + return Promise.resolve() + } + + const context = endpointContext ?? createEndpointContext() + if (!context) return Promise.resolve() + endpointWarm = false + + endpointWarmupPromise = new Promise(resolve => { + resolveEndpointWarmup = resolve + }) + const ready = endpointWarmupPromise + endpointWarmupTimer = setTimeout(() => { + if (endpointContext === context) endpointWarm = context.state === 'running' + settleEndpointWarmup() + }, ENDPOINT_WARMUP_MS) + + let resumed: Promise + try { + resumed = context.state === 'running' ? Promise.resolve() : context.resume() + } catch { + closeEndpoint(true) + return ready + } + void resumed.then( + () => { + if (endpointContext === context && endpointWarmupPromise === null) { + endpointWarm = context.state === 'running' + } + }, + () => { + if (endpointContext === context) closeEndpoint(true) + }, + ) + return ready + } + + function scheduleEndpointSuspend() { + clearEndpointSuspendTimer() + const context = endpointContext + if (!context || context.state === 'closed') return + const generation = ++endpointGeneration + endpointSuspendTimer = setTimeout(() => { + endpointSuspendTimer = null + if (endpointContext !== context || endpointGeneration !== generation) return + endpointWarm = false + try { + void context.suspend().then( + () => { + if (endpointContext !== context || endpointGeneration === generation) return + try { + void context.resume().then(() => { + if (endpointContext === context && endpointWarmupPromise === null) { + endpointWarm = context.state === 'running' + } + }, () => {}) + } catch { /* playback still falls back after the warmup deadline */ } + }, + () => { + if (endpointContext === context && endpointGeneration === generation) { + endpointWarm = context.state === 'running' + } + }, + ) + } catch { + endpointWarm = context.state === 'running' + } + }, ENDPOINT_KEEPALIVE_MS) + } + + function beginPlaybackIntent(): PlaybackIntent { + const id = ++playbackIntent + if (audio && !audio.paused && isPlaying.value) { + activeMediaPlaybackIntent = id + } else if (!isPlaying.value) { + pendingMediaPlaybackIntent = id + isBuffering.value = true + } + return { id, endpointReady: warmEndpoint() } + } + + function isCurrentPlaybackIntent(intent: PlaybackIntent): boolean { + return intent.id === playbackIntent + } + + function clearPendingPlayback(intent: PlaybackIntent) { + if (pendingMediaPlaybackIntent !== intent.id) return + pendingMediaPlaybackIntent = 0 + if (!isPlaying.value) isBuffering.value = false + } + + function abandonPlayback(intent: PlaybackIntent | null) { + if (!intent || !isCurrentPlaybackIntent(intent)) return + playbackIntent += 1 + activeMediaPlaybackIntent = audio && !audio.paused && isPlaying.value ? playbackIntent : 0 + clearPendingPlayback(intent) + if (!isPlaying.value) scheduleEndpointSuspend() + } + function apiQueuePageSize(): number { return queue.value?.pageSize ?? 200 } @@ -131,6 +335,9 @@ export const usePlayerStore = defineStore('player', () => { void next() }) audio.addEventListener('playing', () => { + if (activeMediaPlaybackIntent !== playbackIntent || audio?.paused) return + clearEndpointSuspendTimer() + pendingMediaPlaybackIntent = 0 isBuffering.value = false isPlaying.value = true }) @@ -141,8 +348,12 @@ export const usePlayerStore = defineStore('player', () => { audio.addEventListener('waiting', () => { if (!audio?.paused) isBuffering.value = true }) - audio.addEventListener('canplay', () => { isBuffering.value = false }) - audio.addEventListener('seeked', () => { isBuffering.value = false }) + audio.addEventListener('canplay', () => { + if (pendingMediaPlaybackIntent !== playbackIntent) isBuffering.value = false + }) + audio.addEventListener('seeked', () => { + if (pendingMediaPlaybackIntent !== playbackIntent) isBuffering.value = false + }) audio.addEventListener('timeupdate', () => { if (!audio) return const absoluteTime = sourceOffset + audio.currentTime @@ -155,9 +366,11 @@ export const usePlayerStore = defineStore('player', () => { if (Number.isFinite(audio.duration) && audio.duration > 0) duration.value = audio.duration }) audio.addEventListener('error', () => { + pendingMediaPlaybackIntent = 0 error.value = 'Playback error' isBuffering.value = false isPlaying.value = false + scheduleEndpointSuspend() }) } return audio @@ -274,8 +487,15 @@ export const usePlayerStore = defineStore('player', () => { }) } - async function loadCurrentSource(position: number, autoplay: boolean) { + async function loadCurrentSource( + position: number, + autoplay: boolean, + suppliedIntent: PlaybackIntent | null = null, + ) { if (!currentTrack.value) return + const intent = autoplay ? suppliedIntent ?? beginPlaybackIntent() : null + if (intent && !isCurrentPlaybackIntent(intent)) return + const epoch = playEpoch const trackID = currentTrack.value.id const mode = streamMode.value const requestID = ++sourceRequest @@ -289,6 +509,7 @@ export const usePlayerStore = defineStore('player', () => { loadedTrackID = trackID loadedMode = mode isBuffering.value = autoplay + pendingMediaPlaybackIntent = intent?.id ?? 0 error.value = null element.src = url element.load() @@ -299,18 +520,44 @@ export const usePlayerStore = defineStore('player', () => { element.currentTime = target } if (autoplay) { + if (!intent) return + await intent.endpointReady + if (requestID !== sourceRequest || epoch !== playEpoch || !isCurrentPlaybackIntent(intent)) { + clearPendingPlayback(intent) + return + } + activeMediaPlaybackIntent = intent.id await element.play() - if (requestID !== sourceRequest) return + if (requestID !== sourceRequest || epoch !== playEpoch || !isCurrentPlaybackIntent(intent)) { + if (activeMediaPlaybackIntent === intent.id) { + activeMediaPlaybackIntent = 0 + element.pause() + } + clearPendingPlayback(intent) + return + } + pendingMediaPlaybackIntent = 0 isPlaying.value = true + isBuffering.value = false } else { + pendingMediaPlaybackIntent = 0 + activeMediaPlaybackIntent = 0 isPlaying.value = false isBuffering.value = false + if (endpointSuspendTimer === null) scheduleEndpointSuspend() } } catch { - if (requestID !== sourceRequest) return + if ( + requestID !== sourceRequest + || epoch !== playEpoch + || (intent && !isCurrentPlaybackIntent(intent)) + ) return + pendingMediaPlaybackIntent = 0 + activeMediaPlaybackIntent = 0 isPlaying.value = false isBuffering.value = false error.value = 'Failed to load track' + scheduleEndpointSuspend() } } @@ -319,6 +566,7 @@ export const usePlayerStore = defineStore('player', () => { epoch: number, preserveCurrent: boolean, autoplay: boolean, + playback: PlaybackIntent | null, ) { if (epoch !== playEpoch) return queue.value = response.queue @@ -335,6 +583,7 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 } + abandonPlayback(playback) playlistError.value = selectedTag.value ? `No tracks tagged ${selectedTag.value}` : 'No tracks available' return } @@ -346,6 +595,7 @@ export const usePlayerStore = defineStore('player', () => { } const first = response.items.find(item => item.available) if (!first) { + abandonPlayback(playback) playlistError.value = 'No tracks available' return } @@ -354,14 +604,20 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 void refreshMetadata(first.id, epoch) - if (autoplay) await loadCurrentSource(0, true) - else if (audio) audio.pause() + if (autoplay) await loadCurrentSource(0, true, playback) + else if (audio) { + audio.pause() + if (!isPlaying.value && endpointSuspendTimer === null) scheduleEndpointSuspend() + } } async function createReplacement( tag: string, options: { pinCurrent: boolean; preserveCurrent: boolean; autoplay: boolean }, + suppliedPlayback: PlaybackIntent | null = null, ) { + const playback = options.autoplay ? suppliedPlayback ?? beginPlaybackIntent() : null + if (playback && !isPlaying.value) isBuffering.value = true const epoch = ++playEpoch abortQueueWork() if (!options.preserveCurrent) metadataController?.abort() @@ -386,12 +642,13 @@ export const usePlayerStore = defineStore('player', () => { if (epoch !== playEpoch || controller.signal.aborted) return selectedTag.value = tag recoveryUsed = false - await applyCreatedQueue(response, epoch, options.preserveCurrent, options.autoplay) + await applyCreatedQueue(response, epoch, options.preserveCurrent, options.autoplay, playback) } catch (requestError) { if (epoch !== playEpoch || axios.isCancel(requestError)) return selectedTag.value = previousTag playlistError.value = 'Failed to prepare playlist' if (currentTrack.value) void refreshMetadata(currentTrack.value.id, epoch, false) + if (!isPlaying.value) abandonPlayback(playback) } finally { if (queueController === controller) queueController = null if (epoch === playEpoch) playlistLoading.value = false @@ -418,6 +675,71 @@ export const usePlayerStore = defineStore('player', () => { }) } + async function prependDirectory(dir: string): Promise { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true + const epoch = ++playEpoch + abortQueueWork() + metadataController?.abort() + const controller = new AbortController() + queueController = controller + playlistLoading.value = true + playlistError.value = null + let temporaryQueueID: string | null = null + try { + let response: api.PrependDirectoryResponse | null = null + const existingQueueID = queue.value?.id + if (existingQueueID) { + try { + response = await api.prependQueueDirectory(existingQueueID, dir, controller.signal) + } catch (requestError) { + if (errorCode(requestError) !== 'QUEUE_NOT_FOUND' || controller.signal.aborted) throw requestError + } + } + if (!response) { + const request = selectedTag.value ? { tag: selectedTag.value } : {} + const created = await api.createQueue(request, controller.signal) + if (epoch !== playEpoch || controller.signal.aborted) { + void api.deleteQueue(created.queue.id) + return null + } + temporaryQueueID = created.queue.id + response = await api.prependQueueDirectory(temporaryQueueID, dir, controller.signal) + temporaryQueueID = null + } + if (epoch !== playEpoch || controller.signal.aborted) { + void api.deleteQueue(response.queue.id) + return null + } + + queue.value = response.queue + resetPageCache(response) + activeIndex.value = 0 + sidebarPage.value = 1 + evictPages() + recoveryUsed = false + const first = response.items.find(item => item.queueIndex === 0 && item.available) + if (!first) throw new Error('Directory queue did not return an available first track') + syncCurrentTrack(first) + currentTime.value = 0 + duration.value = 0 + void refreshMetadata(first.id, epoch) + await loadCurrentSource(0, true, playback) + return response.directoryTrackCount + } catch (requestError) { + if (temporaryQueueID) { + try { await api.deleteQueue(temporaryQueueID) } catch { /* best effort */ } + } + if (epoch !== playEpoch || axios.isCancel(requestError)) return null + if (currentTrack.value) void refreshMetadata(currentTrack.value.id, epoch, false) + if (!isPlaying.value) abandonPlayback(playback) + throw requestError + } finally { + if (queueController === controller) queueController = null + if (epoch === playEpoch) playlistLoading.value = false + } + } + async function recoverQueue(epoch: number): Promise { if (recoveryUsed || epoch !== playEpoch) return false recoveryUsed = true @@ -430,7 +752,7 @@ export const usePlayerStore = defineStore('player', () => { try { const response = await api.createQueue(request, controller.signal) if (epoch !== playEpoch || controller.signal.aborted) return false - await applyCreatedQueue(response, epoch, true, false) + await applyCreatedQueue(response, epoch, true, false, null) return true } catch (requestError) { if (!axios.isCancel(requestError) && epoch === playEpoch) playlistError.value = 'Playlist expired and could not be restored' @@ -482,18 +804,23 @@ export const usePlayerStore = defineStore('player', () => { } async function playAt(index: number) { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true if (!queue.value) await preparePlaylist() + if (!isCurrentPlaybackIntent(playback)) return if (!queue.value) { error.value = 'No tracks available' + abandonPlayback(playback) return } const epoch = ++playEpoch selectController?.abort() metadataController?.abort() const item = await itemAt(index, epoch) - if (epoch !== playEpoch) return + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return if (!item || !item.available) { error.value = 'Track is unavailable' + abandonPlayback(playback) return } activeIndex.value = item.queueIndex @@ -502,7 +829,7 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 void refreshMetadata(item.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) } async function reconcileSelection(fileID: string, epoch: number) { @@ -545,6 +872,8 @@ export const usePlayerStore = defineStore('player', () => { } async function playTrack(file: api.FileEntry) { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true const epoch = ++playEpoch selectController?.abort() metadataController?.abort() @@ -552,44 +881,78 @@ export const usePlayerStore = defineStore('player', () => { currentTime.value = 0 duration.value = 0 void refreshMetadata(file.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) if (epoch === playEpoch) void reconcileSelection(file.id, epoch) } function pause() { - getAudio().pause() + playbackIntent += 1 + activeMediaPlaybackIntent = 0 + pendingMediaPlaybackIntent = 0 + audio?.pause() isBuffering.value = false isPlaying.value = false + scheduleEndpointSuspend() } async function resume() { + const playback = beginPlaybackIntent() + isBuffering.value = true if (!currentTrack.value) { if (!queue.value) await preparePlaylist() - if (!queue.value || queue.value.total === 0) return + if (!isCurrentPlaybackIntent(playback)) return + if (!queue.value || queue.value.total === 0) { + abandonPlayback(playback) + return + } const item = await itemAt(activeIndex.value, playEpoch) + if (!isCurrentPlaybackIntent(playback)) return if (item?.available) syncCurrentTrack(item) } - if (!currentTrack.value) return + if (!currentTrack.value) { + abandonPlayback(playback) + return + } if (loadedTrackID !== currentTrack.value.id || loadedMode !== streamMode.value) { - await loadCurrentSource(currentTime.value, true) + await loadCurrentSource(currentTime.value, true, playback) return } const element = getAudio() - isBuffering.value = true + const epoch = playEpoch try { + pendingMediaPlaybackIntent = playback.id + await playback.endpointReady + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) { + clearPendingPlayback(playback) + return + } + activeMediaPlaybackIntent = playback.id await element.play() + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) { + if (activeMediaPlaybackIntent === playback.id) { + activeMediaPlaybackIntent = 0 + element.pause() + } + clearPendingPlayback(playback) + return + } + pendingMediaPlaybackIntent = 0 isPlaying.value = true isBuffering.value = false error.value = null } catch { + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return + pendingMediaPlaybackIntent = 0 + activeMediaPlaybackIntent = 0 isPlaying.value = false isBuffering.value = false error.value = 'Failed to resume playback' + scheduleEndpointSuspend() } } async function togglePlay() { - if (isPlaying.value) pause() + if (isPlaying.value || isBuffering.value) pause() else await resume() } @@ -605,21 +968,29 @@ export const usePlayerStore = defineStore('player', () => { } async function next() { + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true if (!queue.value) await preparePlaylist() + if (!isCurrentPlaybackIntent(playback)) return if (!queue.value || queue.value.total === 0) { error.value = 'No tracks available' + abandonPlayback(playback) return } const epoch = ++playEpoch const originalQueue = queue.value.id const item = await findAvailable(activeIndex.value + 1, 1, epoch) - if (epoch !== playEpoch) return + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return if (queue.value?.id !== originalQueue) { await next() return } if (!item) { - await createReplacement(selectedTag.value, { pinCurrent: false, preserveCurrent: false, autoplay: true }) + await createReplacement( + selectedTag.value, + { pinCurrent: false, preserveCurrent: false, autoplay: true }, + playback, + ) return } activeIndex.value = item.queueIndex @@ -628,7 +999,7 @@ export const usePlayerStore = defineStore('player', () => { duration.value = 0 evictPages() void refreshMetadata(item.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) } async function previous() { @@ -636,15 +1007,18 @@ export const usePlayerStore = defineStore('player', () => { await seek(0) return } + const playback = beginPlaybackIntent() + if (!isPlaying.value) isBuffering.value = true const epoch = ++playEpoch const originalQueue = queue.value.id const item = await findAvailable(activeIndex.value - 1, -1, epoch) - if (epoch !== playEpoch) return + if (epoch !== playEpoch || !isCurrentPlaybackIntent(playback)) return if (queue.value?.id !== originalQueue) { await previous() return } if (!item) { + abandonPlayback(playback) await seek(0) return } @@ -654,7 +1028,7 @@ export const usePlayerStore = defineStore('player', () => { duration.value = 0 evictPages() void refreshMetadata(item.id, epoch) - await loadCurrentSource(0, true) + await loadCurrentSource(0, true, playback) } async function showQueuePage(page: number) { @@ -685,7 +1059,8 @@ export const usePlayerStore = defineStore('player', () => { const target = duration.value > 0 ? clamp(seconds, 0, duration.value) : Math.max(seconds, 0) const shouldResume = isPlaying.value if (streamMode.value === 'opus') { - await loadCurrentSource(target, shouldResume) + const playback = shouldResume ? beginPlaybackIntent() : null + await loadCurrentSource(target, shouldResume, playback) return } const element = getAudio() @@ -694,17 +1069,19 @@ export const usePlayerStore = defineStore('player', () => { try { element.currentTime = target } catch { - await loadCurrentSource(target, shouldResume) + const playback = shouldResume ? beginPlaybackIntent() : null + await loadCurrentSource(target, shouldResume, playback) } } async function setStreamMode(mode: StreamMode) { if (streamMode.value === mode) return const shouldResume = isPlaying.value + const playback = shouldResume ? beginPlaybackIntent() : null const position = currentTime.value streamMode.value = mode try { localStorage.setItem(STORAGE_KEY_STREAM_MODE, mode) } catch { /* ignore */ } - if (currentTrack.value) await loadCurrentSource(position, shouldResume) + if (currentTrack.value) await loadCurrentSource(position, shouldResume, playback) } function setVolume(value: number) { @@ -726,7 +1103,11 @@ export const usePlayerStore = defineStore('player', () => { function reset() { playEpoch += 1 + playbackIntent += 1 + activeMediaPlaybackIntent = 0 + pendingMediaPlaybackIntent = 0 sourceRequest += 1 + closeEndpoint(false) abortQueueWork() metadataController?.abort() metadataController = null @@ -794,6 +1175,7 @@ export const usePlayerStore = defineStore('player', () => { preparePlaylist, filterPlaylistByTag, randomizePlaylist, + prependDirectory, playAt, playTrack, pause, diff --git a/web/src/views/BrowseView.test.ts b/web/src/views/BrowseView.test.ts index 22e0486..d52f4f7 100644 --- a/web/src/views/BrowseView.test.ts +++ b/web/src/views/BrowseView.test.ts @@ -5,6 +5,7 @@ import { createMemoryHistory, createRouter } from 'vue-router' import BrowseView from './BrowseView.vue' import * as api from '../api' import { useLibraryStore } from '../stores/library' +import { usePlayerStore } from '../stores/player' function deferred() { let resolve!: (value: T) => void @@ -34,6 +35,7 @@ describe('BrowseView', () => { .mockResolvedValueOnce({ files: [], total: 0, + audioCount: 0, page: 1, directories: [{ name: 'Artist', path: 'Artist' }], }) @@ -53,6 +55,7 @@ describe('BrowseView', () => { trackName: 'Track', }], total: 1, + audioCount: 1, page: 1, directories: [], }) @@ -108,6 +111,7 @@ describe('BrowseView', () => { audioId: 'new', }], total: 1, + audioCount: 1, page: 1, directories: [], }) @@ -129,6 +133,7 @@ describe('BrowseView', () => { audioId: 'old', }], total: 1, + audioCount: 1, page: 1, directories: [], }) @@ -138,7 +143,7 @@ describe('BrowseView', () => { expect(wrapper.text()).not.toContain('old.flac') }) - it('starts a rescan from the browse toolbar', async () => { + it('starts a rescan from the browse toolbar', async () => { const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/browse', name: 'browse', component: BrowseView }], @@ -170,16 +175,115 @@ describe('BrowseView', () => { expect(library.scanStatus).toBe('scanning') }) + it('plays the current folder from the queue front', async () => { + vi.mocked(api.getBrowse).mockReset() + vi.mocked(api.getBrowse).mockResolvedValue({ + files: [{ + id: 'one', + name: 'track.flac', + path: 'Artist/track.flac', + dir: 'Artist', + kind: 'audio', + mimeType: 'audio/flac', + size: 1, + modified: '', + previewable: false, + playable: true, + audioId: 'one', + }], + directories: [], + total: 1, + audioCount: 1, + page: 1, + }) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/browse', name: 'browse', component: BrowseView }], + }) + await router.push({ name: 'browse', query: { dir: 'Artist' } }) + await router.isReady() + const pinia = createPinia() + const player = usePlayerStore(pinia) + player.queue = { id: 'queue', tag: '', createdGeneration: 1, total: 1, pageSize: 200 } + const prepend = vi.spyOn(player, 'prependDirectory').mockResolvedValue(1) + const wrapper = mount(BrowseView, { global: { plugins: [pinia, router] } }) + await flushPromises() + + const button = wrapper.get('.folder-play-button') + expect(button.text()).toContain('Play folder') + expect(button.attributes('disabled')).toBeUndefined() + await button.trigger('click') + await flushPromises() + + expect(prepend).toHaveBeenCalledWith('Artist') + expect(wrapper.get('.queue-message').text()).toBe('Playing 1 track from this folder') + }) + + it('disables empty folders and reports a directory queue failure', async () => { + vi.mocked(api.getBrowse).mockReset() + vi.mocked(api.getBrowse) + .mockResolvedValueOnce({ + files: [], + directories: [], + total: 0, + audioCount: 0, + page: 1, + }) + .mockResolvedValueOnce({ + files: [{ + id: 'one', + name: 'track.flac', + path: 'Artist/track.flac', + dir: 'Artist', + kind: 'audio', + mimeType: 'audio/flac', + size: 1, + modified: '', + previewable: false, + playable: true, + audioId: 'one', + }], + directories: [], + total: 1, + audioCount: 1, + page: 1, + }) + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/browse', name: 'browse', component: BrowseView }], + }) + await router.push('/browse') + await router.isReady() + const pinia = createPinia() + const player = usePlayerStore(pinia) + player.queue = { id: 'queue', tag: '', createdGeneration: 1, total: 1, pageSize: 200 } + const prepend = vi.spyOn(player, 'prependDirectory').mockRejectedValue(new Error('queue failed')) + const wrapper = mount(BrowseView, { global: { plugins: [pinia, router] } }) + await flushPromises() + + expect(wrapper.get('.folder-play-button').attributes('disabled')).toBeDefined() + + await router.push({ name: 'browse', query: { dir: 'Artist' } }) + await flushPromises() + const button = wrapper.get('.folder-play-button') + expect(button.attributes('disabled')).toBeUndefined() + await button.trigger('click') + await flushPromises() + + expect(prepend).toHaveBeenCalledWith('Artist') + expect(wrapper.get('[role="alert"]').text()).toBe('Failed to add this folder to the playlist') + }) + it('replaces the current page instead of appending results', async () => { vi.mocked(api.getBrowse).mockReset() vi.mocked(api.getBrowse) .mockResolvedValueOnce({ files: [{ id: 'first', name: 'first.flac', path: 'first.flac', dir: '.', kind: 'audio', mimeType: 'audio/flac', size: 1, modified: '', previewable: false, playable: true, audioId: 'first' }], - directories: [], total: 51, page: 1, + directories: [], total: 51, audioCount: 51, page: 1, }) .mockResolvedValueOnce({ files: [{ id: 'last', name: 'last.flac', path: 'last.flac', dir: '.', kind: 'audio', mimeType: 'audio/flac', size: 1, modified: '', previewable: false, playable: true, audioId: 'last' }], - directories: [], total: 51, page: 2, + directories: [], total: 51, audioCount: 51, page: 2, }) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/browse', name: 'browse', component: BrowseView }] }) await router.push('/browse') diff --git a/web/src/views/BrowseView.vue b/web/src/views/BrowseView.vue index 18909aa..e68374c 100644 --- a/web/src/views/BrowseView.vue +++ b/web/src/views/BrowseView.vue @@ -2,7 +2,7 @@ import axios from 'axios' import { computed, onUnmounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { ChevronRight, Folder, RefreshCw } from 'lucide-vue-next' +import { ChevronRight, Folder, ListPlus, RefreshCw } from 'lucide-vue-next' import * as api from '../api' import { usePlayerStore } from '../stores/player' import { useTagsStore } from '../stores/tags' @@ -18,13 +18,24 @@ const router = useRouter() const currentDir = ref('.') const directories = ref([]) const files = ref([]) +const audioCount = ref(0) const previewFile = ref(null) const loading = ref(false) const error = ref(null) +const queueMessage = ref(null) +const queueError = ref(null) +const prependingDirectory = ref(false) const page = ref(1) const total = ref(0) const pageSize = 50 const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) +const canPlayDirectory = computed(() => ( + audioCount.value > 0 + && !loading.value + && !prependingDirectory.value + && !player.playlistLoading + && player.queue !== null +)) const breadcrumbs = ref<{ label: string; path: string }[]>([]) let browseRequest = 0 let browseController: AbortController | null = null @@ -45,11 +56,16 @@ async function loadFiles(dir: string, pageNum = 1) { browseController?.abort() const controller = new AbortController() browseController = controller + if (dir !== currentDir.value) { + queueMessage.value = null + queueError.value = null + } currentDir.value = dir page.value = pageNum directories.value = [] files.value = [] total.value = 0 + audioCount.value = 0 breadcrumbs.value = buildBreadcrumbs(dir) loading.value = true error.value = null @@ -59,6 +75,7 @@ async function loadFiles(dir: string, pageNum = 1) { files.value = result.files directories.value = result.directories total.value = result.total + audioCount.value = result.audioCount page.value = pageNum } catch (caught) { if (axios.isCancel(caught)) return @@ -74,6 +91,8 @@ async function loadFiles(dir: string, pageNum = 1) { function navigate(path: string) { previewFile.value = null + queueMessage.value = null + queueError.value = null const query = path === '.' ? {} : { dir: path } void router.push({ name: 'browse', query }) } @@ -93,6 +112,26 @@ function handlePlay(file: api.BrowseFileEntry) { }) } +async function handlePlayDirectory() { + if (!canPlayDirectory.value) return + const dir = currentDir.value + prependingDirectory.value = true + queueMessage.value = null + queueError.value = null + try { + const count = await player.prependDirectory(dir) + if (count !== null && currentDir.value === dir) { + queueMessage.value = `Playing ${count} track${count === 1 ? '' : 's'} from this folder` + } + } catch (caught) { + if (!axios.isCancel(caught) && currentDir.value === dir) { + queueError.value = 'Failed to add this folder to the playlist' + } + } finally { + prependingDirectory.value = false + } +} + watch(() => route.query.dir, value => { const dir = typeof value === 'string' && value !== '' ? value : '.' previewFile.value = null @@ -134,19 +173,32 @@ onUnmounted(() => { {{ library.scanError || 'Library scan failed' }}

- + +

{{ queueMessage }}

+
Loading...
{{ error }}
@@ -229,16 +281,37 @@ onUnmounted(() => { to { transform: rotate(360deg); } } -.breadcrumb { +.breadcrumb-toolbar { display: flex; align-items: center; - flex-wrap: wrap; - gap: 0.25rem; + gap: 0.75rem; min-height: 32px; padding-bottom: 0.5rem; border-bottom: 1px solid var(--border); } +.breadcrumb { + display: flex; + flex: 1 1 auto; + align-items: center; + flex-wrap: wrap; + gap: 0.25rem; + min-width: 0; +} + +.folder-play-button { + flex: 0 0 auto; +} + +.queue-message { + padding: 0.75rem 1rem; + border: 1px solid rgb(74 158 255 / 28%); + border-radius: var(--radius-md); + background: rgb(74 158 255 / 8%); + color: var(--text-secondary); + font-size: 0.8125rem; +} + .breadcrumb-link { padding: 0.25rem; border: 0; @@ -350,4 +423,15 @@ onUnmounted(() => { color: var(--text-muted); font-size: 0.8125rem; } + +@media (max-width: 600px) { + .breadcrumb-toolbar { + align-items: stretch; + flex-direction: column; + } + + .folder-play-button { + width: 100%; + } +} diff --git a/web/src/views/HomeView.test.ts b/web/src/views/HomeView.test.ts index 2d95dbb..2f09171 100644 --- a/web/src/views/HomeView.test.ts +++ b/web/src/views/HomeView.test.ts @@ -34,11 +34,27 @@ describe('HomeView', () => { vi.useRealTimers() }) - it('keeps tag filtering without a large Randomize action', async () => { - const wrapper = await mountHome() + it('places tag filtering below the cover without the welcome copy', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const player = usePlayerStore() + player.currentTrack = { + id: 'one', + name: 'Track One', + dir: 'Album', + filepath: 'Album/one.flac', + streamUrl: '/api/stream/one', + } + const wrapper = await mountHome(pinia) await flushPromises() expect(wrapper.find('#tag-filter').exists()).toBe(true) + expect(wrapper.get('label[for="tag-filter"]').text()).toBe('Filter by tag') + expect(wrapper.text()).not.toContain('Welcome to ShuffleMuse') + expect(wrapper.text()).not.toContain('Your personal music library') + const cover = wrapper.get('.now-playing-cover').element + const filter = wrapper.get('.tag-filter').element + expect(cover.compareDocumentPosition(filter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() expect(wrapper.findAll('button').some(button => button.text().includes('Randomize'))).toBe(false) }) diff --git a/web/src/views/HomeView.vue b/web/src/views/HomeView.vue index b4efccf..4f0715a 100644 --- a/web/src/views/HomeView.vue +++ b/web/src/views/HomeView.vue @@ -114,9 +114,6 @@ onBeforeUnmount(() => {