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:
neon
2026-09-26 05:18:27 +00:00
parent 0b2df6e3d3
commit a52d005ff5
4 changed files with 318 additions and 5 deletions
@@ -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()