diff --git a/CLAUDE.md b/CLAUDE.md index 050dde727..74e9b6dd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -944,11 +944,12 @@ engine` (restart required) or transfer with a FIFO queue. `DOWNLOADS_START` remains the sole start IPC; its stable `reason: 'already-in-progress'` and `reason: 'already-downloaded'` results are counted as skipped, and no batch IPC is introduced. The latter - comes from a main-process filesystem recheck before a completed-missing row - can be reset, so a file restored after the renderer snapshot is not orphaned - or downloaded again. Episode and season download actions require an - authoritative global list. A successful snapshot remains authoritative while - a later background refresh is in flight; a latest refresh failure leaves + comes from an asynchronous main-process filesystem recheck before a + completed-missing row can be reset, so a file restored after the renderer + snapshot is not orphaned or downloaded again. Episode and season download + actions require an authoritative global list. A successful snapshot remains + authoritative while a later background refresh is in flight; a latest + refresh failure leaves loading/empty-state resolution intact but disables starts until another snapshot succeeds. - Episode ownership uses normalized `episode.id` as the canonical `xtreamId` diff --git a/apps/electron-backend/src/app/events/database/download-requests.spec.ts b/apps/electron-backend/src/app/events/database/download-requests.spec.ts index 03f8e7ab2..b091568fd 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.spec.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.spec.ts @@ -41,6 +41,10 @@ async function setupStartMetadataRequest( update: jest.fn(() => ({ set })), }; const enqueueDownload = jest.fn(); + const getDownloadFileAvailabilityAsync = jest.fn(async () => + completedFileAvailable ? 'available' : 'missing' + ); + const isAvailableDownloadFile = jest.fn(() => completedFileAvailable); const authorizer = { requireAuthorized: jest.fn(async (directory: string) => directory), } as unknown as DownloadDirectoryAuthorizer; @@ -55,7 +59,8 @@ async function setupStartMetadataRequest( enqueueDownload, })); jest.doMock('./download-file-availability', () => ({ - isAvailableDownloadFile: jest.fn(() => completedFileAvailable), + getDownloadFileAvailabilityAsync, + isAvailableDownloadFile, })); const { startDownloadRequest } = await import('./download-requests'); @@ -64,7 +69,9 @@ async function setupStartMetadataRequest( db, downloadLimit, enqueueDownload, + getDownloadFileAvailabilityAsync, insertValues, + isAvailableDownloadFile, set, startDownloadRequest, }; @@ -461,6 +468,10 @@ describe('download request identity resolution', () => { expect(request.db.insert).not.toHaveBeenCalled(); expect(request.db.update).not.toHaveBeenCalled(); expect(request.enqueueDownload).not.toHaveBeenCalled(); + expect(request.getDownloadFileAvailabilityAsync).toHaveBeenCalledWith( + completedRow + ); + expect(request.isAvailableDownloadFile).not.toHaveBeenCalled(); }); it.each(['queued', 'downloading', 'paused'] as const)( diff --git a/apps/electron-backend/src/app/events/database/download-requests.ts b/apps/electron-backend/src/app/events/database/download-requests.ts index e8266c039..45cb66faf 100644 --- a/apps/electron-backend/src/app/events/database/download-requests.ts +++ b/apps/electron-backend/src/app/events/database/download-requests.ts @@ -11,7 +11,7 @@ import * as schema from '../../database/schema'; import { assertRemoteUrlAllowed } from '../url-safety'; import { DownloadDirectoryAuthorizer } from './download-directory-authorization'; import { removePartialDownloadFile } from './download-file-path'; -import { isAvailableDownloadFile } from './download-file-availability'; +import { getDownloadFileAvailabilityAsync } from './download-file-availability'; import { resolveExistingDownloadIdentity } from './download-request-identity'; import { resolveStoredDownloadHeaders } from './download-request-headers'; import { @@ -150,7 +150,7 @@ export async function startDownloadRequest( if ( item.contentType === 'episode' && item.status === 'completed' && - isAvailableDownloadFile(item.filePath) + (await getDownloadFileAvailabilityAsync(item)) === 'available' ) { return { error: 'Download already completed', diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index 110473254..a4624e88f 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -93,9 +93,10 @@ variants, contextual buttons, and theme-aware styling. whose file is available or whose availability is still unknown. Failed, canceled, completed-missing, and unambiguous row-less episodes are eligible; a completed-missing row is restarted as a fresh download. Before resetting - such a completed row, `DOWNLOADS_START` rechecks its retained path in the main - process. A restored file returns stable `reason: 'already-downloaded'` without - mutation; active matches return `reason: 'already-in-progress'`. The + such a completed row, `DOWNLOADS_START` asynchronously rechecks its retained + path in the main process. A restored file returns stable + `reason: 'already-downloaded'` without mutation; active matches return + `reason: 'already-in-progress'`. The coordinator counts both as skipped. There is no batch IPC, parallel transfer, or queue reordering: destination authorization, persisted header handling, and the backend's one-active-transfer FIFO semantics remain unchanged.