mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(reader): recover audiobook playback after media auth failure
Refresh the web access cookie after a media load failure and resume from the last observed position. Limit retries so unrelated media errors surface promptly. Closes #1536
This commit is contained in:
@@ -83,6 +83,7 @@ const progress = useAudioProgress(props.bookId, { trackingEnabled, manifestRevis
|
||||
|
||||
let queue: ReturnType<typeof useAudioQueue> | null = null
|
||||
let stopQueuePlayingWatch: WatchStopHandle | null = null
|
||||
let stopQueueErrorWatch: WatchStopHandle | null = null
|
||||
const isPlaying = ref(false)
|
||||
const currentPosition = ref(0)
|
||||
const currentFileIndex = ref(0)
|
||||
@@ -124,6 +125,14 @@ function initQueue(startAssetId: string, startPosition: number) {
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
stopQueueErrorWatch?.()
|
||||
stopQueueErrorWatch = watch(
|
||||
queue.loadError,
|
||||
(message) => {
|
||||
if (message && !error.value) error.value = message
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
syncRefs()
|
||||
currentPosition.value = startPosition
|
||||
currentFileIndex.value = queue.currentIndex.value
|
||||
@@ -231,6 +240,8 @@ onUnmounted(() => {
|
||||
releaseMediaSessionArtwork()
|
||||
stopQueuePlayingWatch?.()
|
||||
stopQueuePlayingWatch = null
|
||||
stopQueueErrorWatch?.()
|
||||
stopQueueErrorWatch = null
|
||||
queue?.destroy()
|
||||
stopTicker()
|
||||
progress.flush()
|
||||
|
||||
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
||||
createCoverFillArtworkUrl: vi.fn<(src: string, size?: number) => Promise<string | null>>(),
|
||||
revokeObjectURL: vi.fn<(url: string) => void>(),
|
||||
}))
|
||||
let activeQueueLoadError: ReturnType<typeof ref<string | null>> | null = null
|
||||
|
||||
vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
|
||||
|
||||
@@ -60,7 +61,7 @@ vi.mock('../composables/useAudioQueue', () => ({
|
||||
useAudioQueue: () => ({
|
||||
currentIndex: ref(0),
|
||||
isPlaying: ref(false),
|
||||
loadError: ref<string | null>(null),
|
||||
loadError: (activeQueueLoadError = ref<string | null>(null)),
|
||||
activateIndex: vi.fn<(index: number, positionSeconds: number) => void>(),
|
||||
play: vi.fn<() => void>(),
|
||||
pause: vi.fn<() => void>(),
|
||||
@@ -254,6 +255,7 @@ async function mountView() {
|
||||
describe('AudiobookReaderView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
activeQueueLoadError = null
|
||||
mocks.createCoverFillArtworkUrl.mockResolvedValue(ARTWORK_URL)
|
||||
vi.stubGlobal(
|
||||
'URL',
|
||||
@@ -321,4 +323,14 @@ describe('AudiobookReaderView', () => {
|
||||
expect(placeholder.props()).toMatchObject({ title: 'The Long Orbit', authorLine: 'Ada Vance', isAudio: true })
|
||||
expect(session.metadata).toMatchObject({ title: 'The Long Orbit', artist: 'Ada Vance', artwork: [] })
|
||||
})
|
||||
|
||||
it('shows a queue failure even when playback is idle', async () => {
|
||||
serve(bookDetail())
|
||||
const view = await mountView()
|
||||
activeQueueLoadError!.value = 'Failed to load audio file'
|
||||
await flushPromises()
|
||||
|
||||
expect(view.text()).toContain('Failed to load audio file')
|
||||
expect(view.text()).toContain('reader.audiobook.loadError')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const howls: MockHowl[] = []
|
||||
class MockHowl {
|
||||
readonly options: Record<string, unknown>
|
||||
readonly handlers = new Map<string, () => void>()
|
||||
stateValue = 'unloaded'
|
||||
position = 0
|
||||
loads = 0
|
||||
plays = 0
|
||||
unloaded = false
|
||||
|
||||
constructor(options: Record<string, unknown>) {
|
||||
this.options = options
|
||||
howls.push(this)
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.stateValue
|
||||
}
|
||||
once(event: string, handler: () => void) {
|
||||
this.handlers.set(event, handler)
|
||||
return this
|
||||
}
|
||||
load() {
|
||||
this.loads++
|
||||
this.stateValue = 'loading'
|
||||
return this
|
||||
}
|
||||
emitLoad() {
|
||||
this.stateValue = 'loaded'
|
||||
;(this.options.onload as () => void)()
|
||||
this.handlers.get('load')?.()
|
||||
}
|
||||
emitError() {
|
||||
;(this.options.onloaderror as (id: number, code: number) => void)(1, 4)
|
||||
}
|
||||
play() {
|
||||
this.plays++
|
||||
;(this.options.onplay as () => void)()
|
||||
return 1
|
||||
}
|
||||
pause() {
|
||||
;(this.options.onpause as () => void)()
|
||||
return this
|
||||
}
|
||||
seek(position?: number) {
|
||||
if (position !== undefined) this.position = position
|
||||
return this.position
|
||||
}
|
||||
duration() {
|
||||
return 600
|
||||
}
|
||||
rate() {
|
||||
return this
|
||||
}
|
||||
volume() {
|
||||
return this
|
||||
}
|
||||
stop() {
|
||||
;(this.options.onstop as () => void)()
|
||||
return this
|
||||
}
|
||||
unload() {
|
||||
this.unloaded = true
|
||||
this.stateValue = 'unloaded'
|
||||
return this
|
||||
}
|
||||
}
|
||||
return { refresh: vi.fn<() => Promise<string>>(), howls, MockHowl }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/api', () => ({ refreshAccessToken: mocks.refresh }))
|
||||
vi.mock('howler', () => ({ Howl: mocks.MockHowl }))
|
||||
|
||||
import { useAudioQueue } from './useAudioQueue'
|
||||
|
||||
const file = { assetId: 'aud_example', format: 'mp3', durationMs: 600_000 }
|
||||
const contentUrl = '/api/v1/audiobooks/7/assets/aud_example/content'
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: string) => void
|
||||
let reject!: (reason: Error) => void
|
||||
const promise = new Promise<string>((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('useAudioQueue media error recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
mocks.howls.length = 0
|
||||
mocks.refresh.mockReset()
|
||||
})
|
||||
|
||||
it('renews the access cookie, resumes at the last known position, and preserves settings', async () => {
|
||||
mocks.refresh.mockResolvedValue('fresh-token')
|
||||
const queue = useAudioQueue(7, [file], vi.fn())
|
||||
queue.goToAsset(file.assetId, 120)
|
||||
const original = mocks.howls[0]!
|
||||
original.emitLoad()
|
||||
queue.setSpeed(1.5)
|
||||
queue.setVolume(0.4)
|
||||
queue.play()
|
||||
original.seek(143)
|
||||
expect(queue.position()).toBe(143)
|
||||
|
||||
original.emitError()
|
||||
expect(queue.isPlaying.value).toBe(false)
|
||||
expect(queue.position()).toBe(143)
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.refresh).toHaveBeenCalledOnce()
|
||||
expect(original.unloaded).toBe(true)
|
||||
const retry = mocks.howls[1]!
|
||||
expect(retry.options).toMatchObject({ src: [contentUrl], rate: 1.5, volume: 0.4 })
|
||||
expect(retry.loads).toBe(1)
|
||||
retry.emitLoad()
|
||||
expect(retry.position).toBe(143)
|
||||
expect(retry.plays).toBe(1)
|
||||
expect(queue.isPlaying.value).toBe(true)
|
||||
expect(queue.loadError.value).toBeNull()
|
||||
})
|
||||
|
||||
it('surfaces a failed refresh without resetting the resume position', async () => {
|
||||
mocks.refresh.mockRejectedValue(new Error('refresh failed'))
|
||||
const queue = useAudioQueue(7, [file], vi.fn())
|
||||
queue.goToAsset(file.assetId, 120)
|
||||
mocks.howls[0]!.emitError()
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.howls).toHaveLength(1)
|
||||
expect(queue.position()).toBe(120)
|
||||
expect(queue.isPlaying.value).toBe(false)
|
||||
expect(queue.loadError.value).toBe('Failed to load audio file')
|
||||
})
|
||||
|
||||
it('does not refresh indefinitely when the retried media load also fails', async () => {
|
||||
mocks.refresh.mockResolvedValue('fresh-token')
|
||||
const queue = useAudioQueue(7, [file], vi.fn())
|
||||
queue.goToAsset(file.assetId, 120)
|
||||
mocks.howls[0]!.emitError()
|
||||
await flushPromises()
|
||||
mocks.howls[1]!.emitError()
|
||||
|
||||
expect(mocks.refresh).toHaveBeenCalledOnce()
|
||||
expect(mocks.howls).toHaveLength(2)
|
||||
expect(queue.position()).toBe(120)
|
||||
expect(queue.loadError.value).toBe('Failed to load audio file')
|
||||
})
|
||||
|
||||
it('can recover a later expiry after playback has successfully reloaded', async () => {
|
||||
const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000)
|
||||
mocks.refresh.mockResolvedValue('fresh-token')
|
||||
const queue = useAudioQueue(7, [file], vi.fn())
|
||||
queue.goToAsset(file.assetId, 120)
|
||||
mocks.howls[0]!.emitError()
|
||||
await flushPromises()
|
||||
mocks.howls[1]!.emitLoad()
|
||||
|
||||
now.mockReturnValue(1_006_000)
|
||||
mocks.howls[1]!.emitError()
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.refresh).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.howls).toHaveLength(3)
|
||||
expect(queue.loadError.value).toBeNull()
|
||||
})
|
||||
|
||||
it('does not resume playback if the user pauses while credentials refresh', async () => {
|
||||
const pending = deferred()
|
||||
mocks.refresh.mockReturnValue(pending.promise)
|
||||
const queue = useAudioQueue(7, [file], vi.fn())
|
||||
queue.goToAsset(file.assetId, 120)
|
||||
const original = mocks.howls[0]!
|
||||
original.emitLoad()
|
||||
queue.play()
|
||||
original.emitError()
|
||||
queue.pause()
|
||||
pending.resolve('fresh-token')
|
||||
await flushPromises()
|
||||
const retry = mocks.howls[1]!
|
||||
retry.emitLoad()
|
||||
|
||||
expect(retry.position).toBe(120)
|
||||
expect(retry.plays).toBe(0)
|
||||
expect(queue.isPlaying.value).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a refresh that completes after the queue is destroyed', async () => {
|
||||
const pending = deferred()
|
||||
mocks.refresh.mockReturnValue(pending.promise)
|
||||
const queue = useAudioQueue(7, [file], vi.fn())
|
||||
queue.goToAsset(file.assetId, 120)
|
||||
mocks.howls[0]!.emitError()
|
||||
queue.destroy()
|
||||
pending.resolve('fresh-token')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.howls).toHaveLength(1)
|
||||
expect(queue.loadError.value).toBeNull()
|
||||
})
|
||||
|
||||
it('does not replace a different asset after navigation during refresh', async () => {
|
||||
const pending = deferred()
|
||||
mocks.refresh.mockReturnValue(pending.promise)
|
||||
const nextFile = { assetId: 'aud_next', format: 'mp3', durationMs: 600_000 }
|
||||
const queue = useAudioQueue(7, [file, nextFile], vi.fn())
|
||||
queue.goToAsset(file.assetId, 120)
|
||||
mocks.howls[0]!.emitError()
|
||||
queue.nextFile()
|
||||
pending.resolve('fresh-token')
|
||||
await flushPromises()
|
||||
|
||||
expect(queue.currentIndex.value).toBe(1)
|
||||
expect(mocks.howls).toHaveLength(2)
|
||||
expect(queue.loadError.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { Howl } from 'howler'
|
||||
import { refreshAccessToken } from '@/lib/api'
|
||||
|
||||
export interface AudioFile {
|
||||
assetId: string
|
||||
@@ -7,6 +8,8 @@ export interface AudioFile {
|
||||
durationMs: number | null
|
||||
}
|
||||
|
||||
const RECOVERY_COOLDOWN_MS = 5_000
|
||||
|
||||
function serveUrl(bookId: number, assetId: string): string {
|
||||
return `/api/v1/audiobooks/${bookId}/assets/${assetId}/content`
|
||||
}
|
||||
@@ -24,19 +27,33 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
// Pending seek to apply when the current Howl finishes loading.
|
||||
// Using a single variable prevents stacking multiple once('load') seek handlers.
|
||||
let pendingSeek: number | null = null
|
||||
let playRequested = false
|
||||
let recoveryPending = false
|
||||
let destroyed = false
|
||||
let activationEpoch = 0
|
||||
let playbackRate = 1
|
||||
let playbackVolume = 1
|
||||
let lastRecoveryAt: number | null = null
|
||||
|
||||
function buildHowl(index: number): Howl {
|
||||
function buildHowl(index: number, allowRecovery = true): Howl {
|
||||
const file = files[index]!
|
||||
const fmt = file.format?.toLowerCase() ?? 'm4b'
|
||||
let recovering = false
|
||||
const howl = new Howl({
|
||||
src: [serveUrl(bookId, file.assetId)],
|
||||
format: [fmt],
|
||||
html5: true,
|
||||
preload: false,
|
||||
rate: playbackRate,
|
||||
volume: playbackVolume,
|
||||
onend() {
|
||||
if (recoveryPending || destroyed || howls.get(file.assetId) !== howl) return
|
||||
playRequested = false
|
||||
onFileEnd(file.assetId)
|
||||
},
|
||||
onplay() {
|
||||
if (recoveryPending || destroyed || howls.get(file.assetId) !== howl) return
|
||||
playRequested = true
|
||||
isPlaying.value = true
|
||||
},
|
||||
onpause() {
|
||||
@@ -46,14 +63,51 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
isPlaying.value = false
|
||||
},
|
||||
onload() {
|
||||
allowRecovery = true
|
||||
if (index === currentIndex.value) {
|
||||
duration.value = howl.duration()
|
||||
}
|
||||
},
|
||||
onloaderror(_id: number, err: unknown) {
|
||||
if (index === currentIndex.value) {
|
||||
loadError.value = typeof err === 'string' ? err : 'Failed to load audio file'
|
||||
if (index !== currentIndex.value || howls.get(file.assetId) !== howl || destroyed || recovering) return
|
||||
isPlaying.value = false
|
||||
const message = typeof err === 'string' ? err : 'Failed to load audio file'
|
||||
if (!allowRecovery || (lastRecoveryAt !== null && Date.now() - lastRecoveryAt < RECOVERY_COOLDOWN_MS)) {
|
||||
recoveryPending = false
|
||||
playRequested = false
|
||||
loadError.value = message
|
||||
return
|
||||
}
|
||||
allowRecovery = false
|
||||
lastRecoveryAt = Date.now()
|
||||
recovering = true
|
||||
recoveryPending = true
|
||||
const epoch = activationEpoch
|
||||
void refreshAccessToken()
|
||||
.then(() => {
|
||||
if (destroyed || epoch !== activationEpoch || index !== currentIndex.value || howls.get(file.assetId) !== howl) return
|
||||
howl.unload()
|
||||
howls.delete(file.assetId)
|
||||
const retry = buildHowl(index, false)
|
||||
howls.set(file.assetId, retry)
|
||||
retry.once('load', () => {
|
||||
if (destroyed || index !== currentIndex.value || howls.get(file.assetId) !== retry) return
|
||||
const position = currentPosition.value
|
||||
retry.seek(position)
|
||||
recoveryPending = false
|
||||
if (playRequested) retry.play()
|
||||
})
|
||||
retry.load()
|
||||
})
|
||||
.catch(() => {
|
||||
if (destroyed || epoch !== activationEpoch || index !== currentIndex.value || howls.get(file.assetId) !== howl) return
|
||||
recoveryPending = false
|
||||
playRequested = false
|
||||
loadError.value = message
|
||||
})
|
||||
.finally(() => {
|
||||
recovering = false
|
||||
})
|
||||
},
|
||||
})
|
||||
return howl
|
||||
@@ -85,6 +139,8 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
prev?.stop()
|
||||
currentIndex.value = clamped
|
||||
pendingSeek = null
|
||||
activationEpoch++
|
||||
recoveryPending = false
|
||||
}
|
||||
|
||||
const howl = getOrCreate(clamped)
|
||||
@@ -94,6 +150,7 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
|
||||
loadError.value = null
|
||||
duration.value = (files[clamped]!.durationMs ?? 0) / 1000
|
||||
currentPosition.value = positionSeconds
|
||||
|
||||
if (howl.state() === 'loaded') {
|
||||
duration.value = howl.duration()
|
||||
@@ -120,6 +177,8 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
function play() {
|
||||
const h = currentHowl()
|
||||
if (!h) return
|
||||
playRequested = true
|
||||
if (recoveryPending) return
|
||||
if (h.state() === 'unloaded') {
|
||||
h.once('load', () => h.play())
|
||||
h.load()
|
||||
@@ -129,6 +188,7 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
}
|
||||
|
||||
function pause() {
|
||||
playRequested = false
|
||||
currentHowl()?.pause()
|
||||
}
|
||||
|
||||
@@ -149,17 +209,21 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
}
|
||||
|
||||
function position(): number {
|
||||
if (loadError.value || recoveryPending) return currentPosition.value
|
||||
const h = currentHowl()
|
||||
if (!h) return currentPosition.value
|
||||
const p = h.seek()
|
||||
return typeof p === 'number' ? p : currentPosition.value
|
||||
if (typeof p === 'number' && Number.isFinite(p)) currentPosition.value = p
|
||||
return currentPosition.value
|
||||
}
|
||||
|
||||
function setSpeed(rate: number) {
|
||||
playbackRate = rate
|
||||
for (const h of howls.values()) h.rate(rate)
|
||||
}
|
||||
|
||||
function setVolume(vol: number) {
|
||||
playbackVolume = vol
|
||||
for (const h of howls.values()) h.volume(vol)
|
||||
}
|
||||
|
||||
@@ -178,6 +242,9 @@ export function useAudioQueue(bookId: number, files: AudioFile[], onFileEnd: (as
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
destroyed = true
|
||||
playRequested = false
|
||||
recoveryPending = false
|
||||
for (const h of howls.values()) {
|
||||
h.stop()
|
||||
h.unload()
|
||||
|
||||
Reference in New Issue
Block a user