fix(audiobook): refresh resumable jobs after resume
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user