mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(downloads): keep queue coordination provider neutral
This commit is contained in:
1 parent
be2cd92d55
commit
02fc0351d1
2 files changed
+46
-118
No files matched your search
+43
-91
@@ -5,15 +5,9 @@ import {
|
||||
type DownloadItem,
|
||||
type DownloadStartInput,
|
||||
} from '@iptvnator/services';
|
||||
import type {
|
||||
ElectronBridgeDownloadStartResult,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { ElectronBridgeDownloadStartResult } from '@iptvnator/shared/interfaces';
|
||||
import type { EpisodeDownloadIdentity } from '@iptvnator/portal/shared/util';
|
||||
import type {
|
||||
EpisodeDownloadCandidate,
|
||||
SeasonEpisodeDownloadAdapter,
|
||||
} from './season-download.models';
|
||||
import type { EpisodeDownloadCandidate } from './season-download.models';
|
||||
import { SeasonDownloadCoordinator } from './season-download-coordinator.service';
|
||||
|
||||
interface DownloadsServiceStub {
|
||||
@@ -101,16 +95,11 @@ describe('SeasonDownloadCoordinator', () => {
|
||||
const firstStart = deferred<ElectronBridgeDownloadStartResult>();
|
||||
const first = candidate(FIRST_IDENTITY);
|
||||
const second = candidate(identity(102, 2));
|
||||
const adapter = adapterFor([first, second]);
|
||||
downloadsService.startDownload
|
||||
.mockImplementationOnce(() => firstStart.promise)
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
|
||||
const submission = coordinator.enqueueSeason(
|
||||
[episode(101, 1), episode(102, 2)],
|
||||
adapter,
|
||||
'1'
|
||||
);
|
||||
const submission = coordinator.enqueueSeason([first, second]);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(first.prepare).toHaveBeenCalledTimes(1);
|
||||
@@ -132,50 +121,53 @@ describe('SeasonDownloadCoordinator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('partitions invalid, duplicate, blocked, stable skip, and unsuccessful submissions', async () => {
|
||||
const stable = candidate(FIRST_IDENTITY);
|
||||
const duplicate = candidate(FIRST_IDENTITY);
|
||||
const unsuccessful = candidate(identity(102, 2));
|
||||
const paused = candidate(identity(103, 3));
|
||||
it('partitions added, invalid, paused, stable skip, and unsuccessful candidates', async () => {
|
||||
const added = candidate(FIRST_IDENTITY);
|
||||
const paused = candidate(identity(102, 2));
|
||||
const stable = candidate(identity(103, 3));
|
||||
const unsuccessful = candidate(identity(104, 4));
|
||||
downloadsService.downloads.set([
|
||||
download(paused.identity, { status: 'paused' }),
|
||||
]);
|
||||
downloadsService.startDownload
|
||||
.mockResolvedValueOnce({ success: true })
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
reason: 'already-in-progress',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Backend declined the request',
|
||||
error: 'rejected',
|
||||
});
|
||||
const adapter = adapterFor([
|
||||
null,
|
||||
stable,
|
||||
duplicate,
|
||||
unsuccessful,
|
||||
paused,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
coordinator.enqueueSeason(
|
||||
[
|
||||
episode(100, 0),
|
||||
episode(101, 1),
|
||||
episode(101, 1),
|
||||
episode(102, 2),
|
||||
episode(103, 3),
|
||||
],
|
||||
adapter,
|
||||
'1'
|
||||
)
|
||||
).resolves.toEqual({ added: 0, skipped: 4, failed: 1 });
|
||||
coordinator.enqueueSeason([
|
||||
added,
|
||||
null,
|
||||
paused,
|
||||
stable,
|
||||
unsuccessful,
|
||||
])
|
||||
).resolves.toEqual({ added: 1, skipped: 3, failed: 1 });
|
||||
|
||||
expect(stable.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(duplicate.prepare).not.toHaveBeenCalled();
|
||||
expect(unsuccessful.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(added.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(paused.prepare).not.toHaveBeenCalled();
|
||||
expect(downloadsService.loadDownloads).not.toHaveBeenCalled();
|
||||
expect(stable.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(unsuccessful.prepare).toHaveBeenCalledTimes(1);
|
||||
expect(downloadsService.startDownload).toHaveBeenCalledTimes(3);
|
||||
expect(downloadsService.startDownload).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
request(added.identity)
|
||||
);
|
||||
expect(downloadsService.startDownload).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
request(stable.identity)
|
||||
);
|
||||
expect(downloadsService.startDownload).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
request(unsuccessful.identity)
|
||||
);
|
||||
expect(downloadsService.loadDownloads).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('continues after preparation rejects and refreshes once after successes', async () => {
|
||||
@@ -200,20 +192,12 @@ describe('SeasonDownloadCoordinator', () => {
|
||||
|
||||
try {
|
||||
await expect(
|
||||
coordinator.enqueueSeason(
|
||||
[
|
||||
episode(101, 1),
|
||||
episode(102, 2),
|
||||
episode(103, 3),
|
||||
episode(104, 4),
|
||||
],
|
||||
adapterFor([
|
||||
rejected,
|
||||
ipcFailure,
|
||||
firstSuccess,
|
||||
secondSuccess,
|
||||
])
|
||||
)
|
||||
coordinator.enqueueSeason([
|
||||
rejected,
|
||||
ipcFailure,
|
||||
firstSuccess,
|
||||
secondSuccess,
|
||||
])
|
||||
).resolves.toEqual({ added: 2, skipped: 0, failed: 2 });
|
||||
|
||||
expect(downloadsService.startDownload).toHaveBeenCalledTimes(3);
|
||||
@@ -270,10 +254,7 @@ describe('SeasonDownloadCoordinator', () => {
|
||||
const duplicate = candidate(FIRST_IDENTITY);
|
||||
|
||||
await expect(
|
||||
coordinator.enqueueSeason(
|
||||
[episode(101, 1), episode(101, 1)],
|
||||
adapterFor([first, duplicate])
|
||||
)
|
||||
coordinator.enqueueSeason([first, duplicate])
|
||||
).resolves.toEqual({ added: 1, skipped: 1, failed: 0 });
|
||||
|
||||
expect(first.prepare).toHaveBeenCalledTimes(1);
|
||||
@@ -291,10 +272,7 @@ describe('SeasonDownloadCoordinator', () => {
|
||||
return refresh.promise;
|
||||
});
|
||||
|
||||
const submission = coordinator.enqueueSeason(
|
||||
[episode(101, 1), episode(102, 2)],
|
||||
adapterFor([first, second])
|
||||
);
|
||||
const submission = coordinator.enqueueSeason([first, second]);
|
||||
await refreshStarted.promise;
|
||||
|
||||
expect(coordinator.isPending(first.identity)).toBe(true);
|
||||
@@ -335,32 +313,6 @@ function candidate(
|
||||
return { identity: value, prepare: jest.fn(prepare) };
|
||||
}
|
||||
|
||||
function episode(
|
||||
xtreamId: number,
|
||||
episodeNumber: number
|
||||
): XtreamSerieEpisode {
|
||||
return {
|
||||
id: String(xtreamId),
|
||||
episode_num: episodeNumber,
|
||||
title: `Episode ${episodeNumber}`,
|
||||
container_extension: 'mp4',
|
||||
info: [],
|
||||
custom_sid: '',
|
||||
added: '',
|
||||
season: 1,
|
||||
direct_source: '',
|
||||
};
|
||||
}
|
||||
|
||||
function adapterFor(
|
||||
candidates: readonly (EpisodeDownloadCandidate | null)[]
|
||||
): SeasonEpisodeDownloadAdapter {
|
||||
let index = 0;
|
||||
return {
|
||||
createCandidate: jest.fn(() => candidates[index++] ?? null),
|
||||
};
|
||||
}
|
||||
|
||||
function download(
|
||||
value: EpisodeDownloadIdentity,
|
||||
overrides: Partial<DownloadItem> = {}
|
||||
|
||||
+3
-27
@@ -4,10 +4,7 @@ import {
|
||||
type DownloadItem,
|
||||
type DownloadStartInput,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
ELECTRON_BRIDGE_DOWNLOAD_START_REASONS,
|
||||
type XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { ELECTRON_BRIDGE_DOWNLOAD_START_REASONS } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
createEpisodeDownloadIdentityKey,
|
||||
createLogger,
|
||||
@@ -20,7 +17,6 @@ import {
|
||||
type EpisodeDownloadCandidate,
|
||||
type EpisodeDownloadSubmission,
|
||||
type SeasonDownloadResult,
|
||||
type SeasonEpisodeDownloadAdapter,
|
||||
} from './season-download.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -68,19 +64,12 @@ export class SeasonDownloadCoordinator {
|
||||
}
|
||||
|
||||
async enqueueSeason(
|
||||
episodes: readonly XtreamSerieEpisode[],
|
||||
adapter: SeasonEpisodeDownloadAdapter,
|
||||
fallbackSeasonKey?: string
|
||||
candidates: readonly (EpisodeDownloadCandidate | null)[]
|
||||
): Promise<SeasonDownloadResult> {
|
||||
const result = { added: 0, skipped: 0, failed: 0 };
|
||||
const reserved: EpisodeDownloadCandidate[] = [];
|
||||
|
||||
for (const episode of episodes) {
|
||||
const candidate = this.createCandidate(
|
||||
adapter,
|
||||
episode,
|
||||
fallbackSeasonKey
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || !this.reserve(candidate)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
@@ -144,19 +133,6 @@ export class SeasonDownloadCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
private createCandidate(
|
||||
adapter: SeasonEpisodeDownloadAdapter,
|
||||
episode: XtreamSerieEpisode,
|
||||
fallbackSeasonKey: string | undefined
|
||||
): EpisodeDownloadCandidate | null {
|
||||
try {
|
||||
return adapter.createCandidate(episode, fallbackSeasonKey);
|
||||
} catch {
|
||||
this.logger.warn('Episode download candidate creation failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private release(identity: EpisodeDownloadIdentity): void {
|
||||
const key = createEpisodeDownloadIdentityKey(identity);
|
||||
const next = new Set(this.pending());
|
||||
|
||||
Reference in new issue
Block a user