fix(downloads): await authoritative list refresh

This commit is contained in:
4gray committed 2026-08-02 23:06:02 +02:00
1 parent 0ef3006b61
commit ff9a422b82
5 files changed
+78 -12

No files matched your search

+6 -3
View File
@@ -943,8 +943,10 @@ engine` (restart required) or
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. A reserved completed-missing match triggers one authoritative
preflight refresh before provider preparation, so a restored Stalker file can
become a stable skip without a portal request. The IPC's stable
preflight refresh before provider preparation. A request superseded by a
download-update refresh waits for the winning request to settle, so a
restored Stalker file can become a stable skip without a portal request. The
IPC's 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
@@ -968,7 +970,8 @@ engine` (restart required) or
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.
snapshot succeeds. Superseded download-list callers do not resolve on their
discarded responses; they join the next request that settles as latest.
- Episode ownership uses normalized `episode.id` as the canonical `xtreamId`
for both providers; Stalker playback identifiers only resolve the URL. Exact
`(playlistId, contentType, xtreamId)` matches are authoritative, while
+10 -4
View File
@@ -55,8 +55,12 @@ variants, contextual buttons, and theme-aware styling.
`loadDownloads()` therefore always invokes the Electron list IPC without its
legacy optional playlist scope. Route scope, category, and search must never
replace or narrow that signal. Overlapping loads are request-ordered so a
late response cannot replace a newer snapshot. `hasLoadedDownloads` records
that the latest attempt completed, including an error, while
late response cannot replace a newer snapshot. A load promise whose response
is superseded remains pending until that request or a newer one settles as
latest; callers therefore observe either a committed successor snapshot or
its authoritative failure instead of proceeding after a discarded response.
`hasLoadedDownloads` records that the latest attempt completed, including an
error, while
`hasAuthoritativeDownloadList` becomes true after a successful request,
remains true while a later refresh is in flight, and clears only if the
latest request fails. Series download actions require both loaded and
@@ -76,8 +80,10 @@ variants, contextual buttons, and theme-aware styling.
provider URL, request header, and metadata preparation; the coordinator owns
only provider-neutral orchestration. When a reserved candidate matches a
completed-missing row, the coordinator performs one authoritative preflight
refresh before any provider preparation. Restored files therefore become
stable skips without requiring a Stalker URL/network request. Both providers
refresh before any provider preparation. If a download-update broadcast
supersedes that request, the preflight waits for the winning refresh to
settle. Restored files therefore become stable skips without requiring a
Stalker URL/network request. Both providers
use normalized `episode.id` as the canonical
episode `xtreamId`; Stalker `originalCmd` and `originalId` participate only
in URL resolution.
@@ -2,6 +2,8 @@ import { signal } from '@angular/core';
export class DownloadListLoadState {
private requestId = 0;
private settledRequestId = 0;
private readonly settlementWaiters = new Map<number, () => void>();
private readonly loading = signal(false);
private readonly loaded = signal(false);
private readonly authoritative = signal(false);
@@ -29,7 +31,29 @@ export class DownloadListLoadState {
this.loaded.set(true);
}
finish(): void {
async finishOrJoinLatest(requestId: number): Promise<void> {
if (!this.isLatest(requestId)) {
await this.waitForSettlementAtOrAfter(requestId);
return;
}
this.loading.set(false);
this.settledRequestId = requestId;
for (const [minimumRequestId, resolve] of this.settlementWaiters) {
if (minimumRequestId <= requestId) {
this.settlementWaiters.delete(minimumRequestId);
resolve();
}
}
}
private waitForSettlementAtOrAfter(requestId: number): Promise<void> {
if (this.settledRequestId >= requestId) {
return Promise.resolve();
}
return new Promise((resolve) => {
this.settlementWaiters.set(requestId, resolve);
});
}
}
@@ -561,6 +561,40 @@ describe('DownloadsService', () => {
expect(electron.downloadsGetList).toHaveBeenNthCalledWith(2);
});
it('keeps a superseded load pending until a newer request commits its snapshot', async () => {
const staleItem = createDownload(1, 'playlist-old');
const latestItem = createDownload(2, 'playlist-new');
const first = createDeferred<DownloadItem[]>();
const second = createDeferred<DownloadItem[]>();
const electron = {
downloadsGetList: jest
.fn()
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise),
};
testWindow.electron = electron;
const service = createService();
let supersededLoadSettled = false;
const firstRequest = service.loadDownloads().finally(() => {
supersededLoadSettled = true;
});
const secondRequest = service.loadDownloads();
first.resolve([staleItem]);
await first.promise;
await Promise.resolve();
expect(supersededLoadSettled).toBe(false);
expect(service.downloads()).toEqual([]);
second.resolve([latestItem]);
await Promise.all([firstRequest, secondRequest]);
expect(service.downloads()).toEqual([latestItem]);
expect(supersededLoadSettled).toBe(true);
});
it('reports paused content and resumes it by content identity', async () => {
const pausedItem = {
...createDownload(7),
+3 -4
View File
@@ -88,7 +88,8 @@ export class DownloadsService implements OnDestroy {
}
/**
* Load downloads from the backend
* Load downloads from the backend. A superseded caller waits until its
* request or a newer one settles as the current snapshot.
*/
async loadDownloads(): Promise<void> {
if (!this.isAvailable()) return;
@@ -107,9 +108,7 @@ export class DownloadsService implements OnDestroy {
this.downloadListLoadState.markFailed();
}
} finally {
if (this.downloadListLoadState.isLatest(requestId)) {
this.downloadListLoadState.finish();
}
await this.downloadListLoadState.finishOrJoinLatest(requestId);
}
}