fix(audiobook): refresh resumable jobs after resume

This commit is contained in:
psiberfunk
2026-09-07 22:03:02 -04:00
parent fb98bbfaf6
commit 7668d045fa
2 changed files with 50 additions and 6 deletions
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { FolderOpen, RotateCcw } from 'lucide-react';
import toast from 'react-hot-toast';
@@ -11,17 +11,22 @@ import { Button } from '../../ui';
export default function AudiobookRecovery({ t, generating, onResume }) {
const [jobs, setJobs] = useState([]);
const [hiddenJobId, setHiddenJobId] = useState(null);
const refreshIdRef = useRef(0);
const { data: systemInfo } = useSystemInfo();
useEffect(() => {
let current = true;
const refreshId = ++refreshIdRef.current;
audiobookListJobs()
.then((result) => {
if (current) setJobs(Array.isArray(result?.jobs) ? result.jobs : []);
if (refreshId === refreshIdRef.current) {
setJobs(Array.isArray(result?.jobs) ? result.jobs : []);
}
})
.catch(() => {});
return () => {
current = false;
// Invalidate any in-flight response so an unmounted recovery card is
// never repopulated by a late inventory request.
refreshIdRef.current += 1;
};
}, []);
@@ -41,7 +46,20 @@ export default function AudiobookRecovery({ t, generating, onResume }) {
const resume = async () => {
const jobId = job.job_id;
setHiddenJobId(jobId);
if ((await onResume(jobId)) === false) setHiddenJobId(null);
const accepted = (await onResume(jobId)) !== false;
// A resumed render runs under a fresh backend id. If it is stopped or
// fails, that fresh manifest becomes the next recovery card; if it
// completes, the inventory is empty. Refresh in every terminal case.
const refreshId = ++refreshIdRef.current;
try {
const result = await audiobookListJobs();
if (refreshId === refreshIdRef.current) {
setJobs(Array.isArray(result?.jobs) ? result.jobs : []);
setHiddenJobId(null);
}
} catch {
if (!accepted) setHiddenJobId(null);
}
};
const openCache = async () => {
if (!cachePath) return;
+27 -1
View File
@@ -93,7 +93,7 @@ describe('Audiobook recovery surface (#1911)', () => {
});
it('shows interrupted work and resumes the selected backend job', async () => {
const onResume = vi.fn();
const onResume = vi.fn().mockResolvedValue(true);
renderRecovery(onResume);
expect(await screen.findByRole('heading', { name: en.audiobook.recovery_title })).toBeTruthy();
@@ -102,9 +102,35 @@ describe('Audiobook recovery surface (#1911)', () => {
fireEvent.click(screen.getByRole('button', { name: en.audiobook.resume }));
expect(onResume).toHaveBeenCalledWith('job-safe-123');
mocks.jobs.mockResolvedValue({ jobs: [] });
await waitFor(() => expect(screen.queryByText('A Long Book')).toBeNull());
});
it('refreshes recovery inventory after an interrupted resumed render', async () => {
const onResume = vi.fn().mockImplementation(async () => {
mocks.jobs.mockResolvedValue({
jobs: [
{
job_id: 'job-fresh-456',
type: 'audiobook',
status: 'cancelled',
title: 'A Long Book (resumed)',
total_chapters: 30,
chapters_done: 9,
created_at: 2,
},
],
});
return true;
});
renderRecovery(onResume);
fireEvent.click(await screen.findByRole('button', { name: en.audiobook.resume }));
expect(await screen.findByText('A Long Book (resumed)')).toBeTruthy();
expect(mocks.jobs).toHaveBeenCalledTimes(2);
});
it('reveals the server-owned chapter cache through the existing safe seam', async () => {
renderRecovery();