fix(downloads): align renderer queue safeguards

This commit is contained in:
4gray committed 2026-08-02 18:53:36 +02:00
1 parent d26df7acb4
commit ee7041e3c2
6 files changed
+64 -23

No files matched your search

+4 -3
View File
@@ -944,9 +944,10 @@ engine` (restart required) or
transfer with a FIFO queue. `DOWNLOADS_START` remains the sole start IPC; its
stable `reason: 'already-in-progress'` result is counted as skipped, and no
batch IPC or schema migration is introduced. Episode and season download
actions require the latest global list request to have succeeded; a failed
refresh leaves loading/empty-state resolution intact but disables starts
until an authoritative snapshot arrives.
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`
for both providers; Stalker playback identifiers only resolve the URL. Exact
`(playlistId, contentType, xtreamId)` matches are authoritative, while
+8 -6
View File
@@ -57,12 +57,14 @@ variants, contextual buttons, and theme-aware styling.
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
`hasAuthoritativeDownloadList` is true only after the latest request
succeeds. Series download actions require both so a failed refresh cannot
restart rows missing from a stale or empty renderer snapshot. Before each
fresh download or resume the service asks the main process for an authorized
folder and calls the corresponding IPC command. The `onDownloadsUpdate`
broadcast triggers a new global load.
`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
authoritative state so a failed refresh cannot restart rows missing from a
stale or empty renderer snapshot. Before each fresh download or resume the
service asks the main process for an authorized folder and calls the
corresponding IPC command. The `onDownloadsUpdate` broadcast triggers a new
global load.
- **Series season queueing**
`SeasonDownloadCoordinator` owns synchronous, per-identity pending
reservations and submits an individual episode or selected-season snapshot
@@ -33,13 +33,31 @@ function row(
}
describe('episode download identity', () => {
it('prefers a canonical match over an earlier coordinate fallback', () => {
it('returns a canonical row when the same row also owns the coordinates', () => {
const canonical = row();
expect(findEpisodeDownload(identity, [canonical])).toBe(canonical);
});
it('fails closed when canonical and coordinate lookups find different rows', () => {
const coordinateFallback = row({ xtreamId: 999 });
const canonical = row();
expect(
findEpisodeDownload(identity, [coordinateFallback, canonical])
).toBe(canonical);
).toBeUndefined();
});
it('fails closed when an incomplete canonical row conflicts with a coordinate row', () => {
const coordinateFallback = row({ id: 2, xtreamId: 999 });
const incompleteCanonical = row({ seriesXtreamId: undefined });
expect(
findEpisodeDownload(identity, [
coordinateFallback,
incompleteCanonical,
])
).toBeUndefined();
});
it('fails closed when a canonical match has conflicting complete coordinates', () => {
@@ -66,6 +84,13 @@ describe('episode download identity', () => {
);
});
it('fails closed when multiple rows share the fallback coordinates', () => {
const first = row({ id: 2, xtreamId: 999 });
const second = row({ id: 3, xtreamId: 998 });
expect(findEpisodeDownload(identity, [first, second])).toBeUndefined();
});
it('does not fall back across playlists or to incomplete coordinates', () => {
const candidates = [
row({ playlistId: 'playlist-2' }),
@@ -59,13 +59,7 @@ export function findEpisodeDownload<T extends EpisodeDownloadRecord>(
download.contentType === identity.contentType &&
download.xtreamId === identity.xtreamId
);
if (canonicalMatch) {
return hasConflictingCompleteCoordinates(canonicalMatch, identity)
? undefined
: canonicalMatch;
}
return downloads.find(
const coordinateMatches = downloads.filter(
(download) =>
download.playlistId === identity.playlistId &&
download.contentType === identity.contentType &&
@@ -76,6 +70,22 @@ export function findEpisodeDownload<T extends EpisodeDownloadRecord>(
download.episodeNumber !== undefined &&
download.episodeNumber === identity.episodeNumber
);
if (coordinateMatches.length > 1) {
return undefined;
}
const coordinateMatch = coordinateMatches[0];
if (canonicalMatch) {
if (
hasConflictingCompleteCoordinates(canonicalMatch, identity) ||
(coordinateMatch && coordinateMatch.id !== canonicalMatch.id)
) {
return undefined;
}
return canonicalMatch;
}
return coordinateMatch;
}
export function isEpisodeDownloadEligible(
@@ -12,7 +12,6 @@ export class DownloadListLoadState {
begin(): number {
this.loading.set(true);
this.authoritative.set(false);
return ++this.requestId;
}
@@ -26,6 +25,7 @@ export class DownloadListLoadState {
}
markFailed(): void {
this.authoritative.set(false);
this.loaded.set(true);
}
@@ -502,16 +502,19 @@ describe('DownloadsService', () => {
it('marks a failed request complete while making the preserved list non-authoritative', async () => {
const existing = createDownload(1);
const error = new Error('download query failed');
const pending = createDeferred<DownloadItem[]>();
jest.spyOn(console, 'error').mockImplementation(() => undefined);
testWindow.electron = {
downloadsGetList: jest.fn(async () => {
throw error;
}),
downloadsGetList: jest.fn(() => pending.promise),
};
const service = createService([existing]);
service.downloadListLoadState.markSucceeded();
await service.loadDownloads();
const request = service.loadDownloads();
expect(service.hasAuthoritativeDownloadList()).toBe(true);
pending.reject(error);
await request;
expect(service.downloads()).toEqual([existing]);
expect(service.isLoadingDownloads()).toBe(false);