fix(downloads): refresh stable season skips

This commit is contained in:
4gray committed 2026-08-02 21:41:36 +02:00
1 parent 3ef5293cf4
commit dc8da931ea
4 files changed
+55 -16

No files matched your search

+5 -4
View File
@@ -938,10 +938,11 @@ engine` (restart required) or
the provider-neutral `SeasonDownloadCoordinator`. It reserves per-episode
pending identities synchronously, submits season candidates sequentially and
best-effort through the existing `DOWNLOADS_START` path, performs one final
authoritative refresh after accepted submissions, and reports added,
skipped, and failed counts. Xtream and Stalker adapters remain responsible
for provider URLs, headers, and metadata; the backend still runs one active
transfer with a FIFO queue. `DOWNLOADS_START` remains the sole start IPC; its
authoritative refresh after added or stable duplicate submissions, and
reports added, skipped, and failed counts. Xtream and Stalker adapters remain
responsible for provider URLs, headers, and metadata; the backend still runs
one active 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 an asynchronous main-process filesystem recheck before a
+6 -5
View File
@@ -70,11 +70,12 @@ variants, contextual buttons, and theme-aware styling.
reservations and submits an individual episode or selected-season snapshot
through `DownloadsService.startDownload()`. Season batches are sequential
and best-effort: one candidate failing does not stop later candidates. After
accepted submissions, one authoritative list refresh closes the pending-to-
queued handoff, and the coordinator returns `added`, `skipped`, and `failed`
counts. Xtream and Stalker adapters own provider URL, request header, and
metadata preparation; the coordinator owns only provider-neutral
orchestration. Both providers use normalized `episode.id` as the canonical
added or stable duplicate submissions, one authoritative list refresh closes
the pending-to-queued/downloaded handoff, and the coordinator returns
`added`, `skipped`, and `failed` counts. Xtream and Stalker adapters own
provider URL, request header, and metadata preparation; the coordinator owns
only provider-neutral orchestration. Both providers use normalized
`episode.id` as the canonical
episode `xtreamId`; Stalker `originalCmd` and `originalId` participate only
in URL resolution.
The exact `(playlistId, contentType, xtreamId)` identity is authoritative.
@@ -183,7 +183,44 @@ describe('SeasonDownloadCoordinator', () => {
expect(restored.prepare).toHaveBeenCalledTimes(1);
expect(downloadsService.startDownload).toHaveBeenCalledTimes(1);
expect(downloadsService.loadDownloads).not.toHaveBeenCalled();
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
});
it('refreshes once after a season contains only submitted stable skips', async () => {
const first = candidate(FIRST_IDENTITY);
const second = candidate(identity(102, 2));
const refresh = deferred<void>();
const refreshStarted = deferred<void>();
downloadsService.startDownload
.mockResolvedValueOnce({
success: false,
reason: 'already-downloaded' as never,
})
.mockResolvedValueOnce({
success: false,
reason: 'already-in-progress',
});
downloadsService.loadDownloads.mockImplementation(() => {
refreshStarted.resolve(undefined);
return refresh.promise;
});
const submission = coordinator.enqueueSeason([first, second]);
await refreshStarted.promise;
expect(downloadsService.startDownload).toHaveBeenCalledTimes(2);
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
expect(coordinator.isPending(first.identity)).toBe(true);
expect(coordinator.isPending(second.identity)).toBe(true);
refresh.resolve(undefined);
await expect(submission).resolves.toEqual({
added: 0,
skipped: 2,
failed: 0,
});
expect(coordinator.isPending(first.identity)).toBe(false);
expect(coordinator.isPending(second.identity)).toBe(false);
});
it('continues after preparation rejects and refreshes once after successes', async () => {
@@ -57,7 +57,7 @@ export class SeasonDownloadCoordinator {
}
const submission = await this.submit(candidate);
if (submission !== EPISODE_DOWNLOAD_SUBMISSIONS.Added) {
if (submission === EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
this.release(candidate.identity);
return submission;
}
@@ -84,22 +84,22 @@ export class SeasonDownloadCoordinator {
reserved.push(candidate);
}
const accepted: EpisodeDownloadIdentity[] = [];
const refreshPending: EpisodeDownloadIdentity[] = [];
for (const candidate of reserved) {
const submission = await this.submit(candidate);
result[submission] += 1;
if (submission === EPISODE_DOWNLOAD_SUBMISSIONS.Added) {
accepted.push(candidate.identity);
if (submission !== EPISODE_DOWNLOAD_SUBMISSIONS.Failed) {
refreshPending.push(candidate.identity);
} else {
this.release(candidate.identity);
}
}
if (accepted.length > 0) {
if (refreshPending.length > 0) {
try {
await this.downloadsService.loadDownloads();
} finally {
this.releaseAll(accepted);
this.releaseAll(refreshPending);
}
}