mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
fix(downloads): align renderer queue safeguards
This commit is contained in:
1 parent
d26df7acb4
commit
ee7041e3c2
6 files changed
+64
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user