fix(downloads): block ambiguous episode actions

This commit is contained in:
4gray committed 2026-08-02 19:10:58 +02:00
1 parent ee7041e3c2
commit 339dbde3c4
8 files changed
+136 -54

No files matched your search

+6 -3
View File
@@ -952,9 +952,12 @@ engine` (restart required) or
for both providers; Stalker playback identifiers only resolve the URL. Exact
`(playlistId, contentType, xtreamId)` matches are authoritative, while
complete playlist/series/season/episode coordinates are a fail-closed legacy
fallback that migrates reusable rows to the canonical id. Pending and active
rows plus completed available/unknown rows are skipped; failed, canceled,
completed-missing, and row-less episodes remain eligible.
fallback that migrates reusable rows to the canonical id. Renderer lookup
preserves ambiguity or conflicting ownership as a distinct ineligible state,
so neither the episode action nor the season count treats it as a row-less
download. Pending and active rows plus completed available/unknown rows are
skipped; failed, canceled, completed-missing, and unambiguous row-less
episodes remain eligible.
- Ready movie and grouped-series cards open a focused local detail. Movies play
the finalized local file; series list only locally available episode rows and
every episode action targets its own downloaded file. Focused routes disable
+13 -11
View File
@@ -80,17 +80,19 @@ variants, contextual buttons, and theme-aware styling.
The exact `(playlistId, contentType, xtreamId)` identity is authoritative.
Complete `(playlistId, seriesXtreamId, seasonNumber, episodeNumber)`
coordinates are a legacy episode-compatibility fallback; ambiguous or
conflicting matches fail closed, while restarting an eligible legacy row
migrates it to the canonical id. Renderer-pending, queued, downloading, and
paused episodes are skipped, as are completed rows whose file is available
or whose availability is still unknown. Failed, canceled, completed-missing,
and row-less episodes are eligible; a completed-missing row is restarted as
a fresh download. `DOWNLOADS_START` remains the only start IPC and may return
the stable `reason: 'already-in-progress'` for an active match, which the
coordinator counts as skipped. There is no batch IPC, schema migration,
parallel transfer, or queue reordering: destination authorization, persisted
header handling, and the backend's one-active-transfer FIFO semantics remain
unchanged.
conflicting matches resolve to an explicit renderer conflict state rather
than masquerading as a missing row. Both the episode action and season count
fail closed for that state, while restarting an eligible legacy row migrates
it to the canonical id. Renderer-pending, queued, downloading, and paused
episodes are skipped, as are completed rows whose file is available or whose
availability is still unknown. Failed, canceled, completed-missing, and
unambiguous row-less episodes are eligible; a completed-missing row is
restarted as a fresh download. `DOWNLOADS_START` remains the only start IPC
and may return the stable `reason: 'already-in-progress'` for an active match,
which the coordinator counts as skipped. There is no batch IPC, schema
migration, parallel transfer, or queue reordering: destination authorization,
persisted header handling, and the backend's one-active-transfer FIFO
semantics remain unchanged.
- **Pure manager model**
(`download-manager.viewmodel.ts` and `download-library.viewmodel.ts`)
derives the current route scope, search/category filtering, queue partitions,
@@ -248,9 +248,10 @@ describe('SeasonDownloadCoordinator', () => {
}),
]);
expect(coordinator.findDownload(missing.identity)?.fileAvailability).toBe(
'missing'
);
expect(coordinator.resolveDownload(missing.identity)).toMatchObject({
kind: 'match',
download: { fileAvailability: 'missing' },
});
expect(coordinator.isEligible(missing)).toBe(true);
expect(coordinator.isEligible(available)).toBe(false);
await expect(coordinator.enqueueOne(available)).resolves.toBe(
@@ -259,6 +260,23 @@ describe('SeasonDownloadCoordinator', () => {
expect(available.prepare).not.toHaveBeenCalled();
});
it('skips an episode when multiple managed rows claim its coordinates', async () => {
const item = candidate(FIRST_IDENTITY);
downloadsService.downloads.set([
download(item.identity, { id: 71, xtreamId: 9001 }),
download(item.identity, { id: 72, xtreamId: 9002 }),
]);
expect(coordinator.isEligible(item)).toBe(false);
await expect(coordinator.enqueueSeason([item])).resolves.toEqual({
added: 0,
skipped: 1,
failed: 0,
});
expect(item.prepare).not.toHaveBeenCalled();
expect(downloadsService.startDownload).not.toHaveBeenCalled();
});
it('prepares a duplicate identity only once within one batch', async () => {
const first = candidate(FIRST_IDENTITY);
const duplicate = candidate(FIRST_IDENTITY);
@@ -8,9 +8,10 @@ import { ELECTRON_BRIDGE_DOWNLOAD_START_REASONS } from '@iptvnator/shared/interf
import {
createEpisodeDownloadIdentityKey,
createLogger,
findEpisodeDownload,
isEpisodeDownloadEligible,
resolveEpisodeDownload,
type EpisodeDownloadIdentity,
type EpisodeDownloadResolution,
} from '@iptvnator/portal/shared/util';
import {
EPISODE_DOWNLOAD_SUBMISSIONS,
@@ -29,8 +30,13 @@ export class SeasonDownloadCoordinator {
return this.pending().has(createEpisodeDownloadIdentityKey(identity));
}
findDownload(identity: EpisodeDownloadIdentity): DownloadItem | undefined {
return findEpisodeDownload(identity, this.downloadsService.downloads());
resolveDownload(
identity: EpisodeDownloadIdentity
): EpisodeDownloadResolution<DownloadItem> {
return resolveEpisodeDownload(
identity,
this.downloadsService.downloads()
);
}
isEligible(candidate: EpisodeDownloadCandidate): boolean {
@@ -39,7 +45,7 @@ export class SeasonDownloadCoordinator {
this.downloadsService.hasAuthoritativeDownloadList() &&
this.downloadsService.hasLoadedDownloads() &&
!this.isPending(candidate.identity) &&
isEpisodeDownloadEligible(this.findDownload(candidate.identity))
isEpisodeDownloadEligible(this.resolveDownload(candidate.identity))
);
}
@@ -1,7 +1,7 @@
import {
createEpisodeDownloadIdentityKey,
findEpisodeDownload,
isEpisodeDownloadEligible,
resolveEpisodeDownload,
type EpisodeDownloadIdentity,
type EpisodeDownloadRecord,
} from './episode-download-identity';
@@ -36,7 +36,10 @@ describe('episode download identity', () => {
it('returns a canonical row when the same row also owns the coordinates', () => {
const canonical = row();
expect(findEpisodeDownload(identity, [canonical])).toBe(canonical);
expect(resolveEpisodeDownload(identity, [canonical])).toEqual({
kind: 'match',
download: canonical,
});
});
it('fails closed when canonical and coordinate lookups find different rows', () => {
@@ -44,8 +47,8 @@ describe('episode download identity', () => {
const canonical = row();
expect(
findEpisodeDownload(identity, [coordinateFallback, canonical])
).toBeUndefined();
resolveEpisodeDownload(identity, [coordinateFallback, canonical])
).toEqual({ kind: 'conflict' });
});
it('fails closed when an incomplete canonical row conflicts with a coordinate row', () => {
@@ -53,11 +56,11 @@ describe('episode download identity', () => {
const incompleteCanonical = row({ seriesXtreamId: undefined });
expect(
findEpisodeDownload(identity, [
resolveEpisodeDownload(identity, [
coordinateFallback,
incompleteCanonical,
])
).toBeUndefined();
).toEqual({ kind: 'conflict' });
});
it('fails closed when a canonical match has conflicting complete coordinates', () => {
@@ -69,26 +72,30 @@ describe('episode download identity', () => {
});
expect(
findEpisodeDownload(identity, [
resolveEpisodeDownload(identity, [
coordinateFallback,
conflictingCanonical,
])
).toBeUndefined();
).toEqual({ kind: 'conflict' });
});
it('falls back to complete matching episode coordinates', () => {
const coordinateFallback = row({ xtreamId: 999 });
expect(findEpisodeDownload(identity, [coordinateFallback])).toBe(
coordinateFallback
);
expect(resolveEpisodeDownload(identity, [coordinateFallback])).toEqual({
kind: 'match',
download: coordinateFallback,
});
});
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();
const resolution = resolveEpisodeDownload(identity, [first, second]);
expect(resolution).toEqual({ kind: 'conflict' });
expect(isEpisodeDownloadEligible(resolution)).toBe(false);
});
it('does not fall back across playlists or to incomplete coordinates', () => {
@@ -100,11 +107,15 @@ describe('episode download identity', () => {
row({ contentType: 'vod', xtreamId: 995 }),
];
expect(findEpisodeDownload(identity, candidates)).toBeUndefined();
expect(resolveEpisodeDownload(identity, candidates)).toEqual({
kind: 'missing',
});
});
it('allows an episode with no existing download', () => {
expect(isEpisodeDownloadEligible(undefined)).toBe(true);
expect(
isEpisodeDownloadEligible(resolveEpisodeDownload(identity, []))
).toBe(true);
});
it.each([
@@ -121,7 +132,10 @@ describe('episode download identity', () => {
'returns %s with availability %s as eligible=%s',
(status, fileAvailability, expected) => {
expect(
isEpisodeDownloadEligible(row({ status, fileAvailability }))
isEpisodeDownloadEligible({
kind: 'match',
download: row({ status, fileAvailability }),
})
).toBe(expected);
}
);
@@ -26,6 +26,11 @@ export interface EpisodeDownloadRecord {
readonly filePath?: string;
}
export type EpisodeDownloadResolution<T extends EpisodeDownloadRecord> =
| { readonly kind: 'match'; readonly download: T }
| { readonly kind: 'missing' }
| { readonly kind: 'conflict' };
function hasConflictingCompleteCoordinates(
download: EpisodeDownloadRecord,
identity: EpisodeDownloadIdentity
@@ -49,10 +54,10 @@ function hasConflictingCompleteCoordinates(
);
}
export function findEpisodeDownload<T extends EpisodeDownloadRecord>(
export function resolveEpisodeDownload<T extends EpisodeDownloadRecord>(
identity: EpisodeDownloadIdentity,
downloads: readonly T[]
): T | undefined {
): EpisodeDownloadResolution<T> {
const canonicalMatch = downloads.find(
(download) =>
download.playlistId === identity.playlistId &&
@@ -71,7 +76,7 @@ export function findEpisodeDownload<T extends EpisodeDownloadRecord>(
download.episodeNumber === identity.episodeNumber
);
if (coordinateMatches.length > 1) {
return undefined;
return { kind: 'conflict' };
}
const coordinateMatch = coordinateMatches[0];
@@ -80,20 +85,26 @@ export function findEpisodeDownload<T extends EpisodeDownloadRecord>(
hasConflictingCompleteCoordinates(canonicalMatch, identity) ||
(coordinateMatch && coordinateMatch.id !== canonicalMatch.id)
) {
return undefined;
return { kind: 'conflict' };
}
return canonicalMatch;
return { kind: 'match', download: canonicalMatch };
}
return coordinateMatch;
return coordinateMatch
? { kind: 'match', download: coordinateMatch }
: { kind: 'missing' };
}
export function isEpisodeDownloadEligible(
download: EpisodeDownloadRecord | undefined
resolution: EpisodeDownloadResolution<EpisodeDownloadRecord>
): boolean {
if (!download) {
if (resolution.kind === 'missing') {
return true;
}
if (resolution.kind === 'conflict') {
return false;
}
const { download } = resolution;
if (download.status === 'completed') {
return download.fileAvailability === 'missing';
}
@@ -615,6 +615,29 @@ describe('SeasonContainerComponent', () => {
);
});
it('blocks episode and season actions when managed rows claim an ambiguous identity', () => {
const episode = createEpisode();
downloadsServiceStub.downloads.set([
createDownload(episode, { id: 71, xtreamId: 9001 }),
createDownload(episode, { id: 72, xtreamId: 9002 }),
]);
enableDownloads();
setRequiredInputs({ '1': [episode] });
fixture.detectChanges();
const action = episodeAction(episode.id);
const seasonButton = fixture.nativeElement.querySelector(
'[data-test-id="download-season"]'
) as HTMLButtonElement;
expect(action.disabled).toBe(true);
expect(action.querySelector('mat-icon')?.textContent).toContain(
'block'
);
expect(seasonButton.disabled).toBe(true);
expect(downloadsServiceStub.startDownload).not.toHaveBeenCalled();
});
it('gives the grid and list view radios localized accessible names', () => {
TestBed.inject(TranslateService).setTranslation(
'en',
@@ -872,14 +895,14 @@ describe('SeasonContainerComponent', () => {
]);
const candidateSpy = jest.spyOn(downloadAdapter, 'createCandidate');
const coordinator = TestBed.inject(SeasonDownloadCoordinator);
const findDownloadSpy = jest.spyOn(coordinator, 'findDownload');
const resolveDownloadSpy = jest.spyOn(coordinator, 'resolveDownload');
enableDownloads();
setRequiredInputs({ '1': [paused, local] });
fixture.detectChanges();
fixture.detectChanges();
expect(candidateSpy).toHaveBeenCalledTimes(2);
expect(findDownloadSpy).toHaveBeenCalledTimes(2);
expect(resolveDownloadSpy).toHaveBeenCalledTimes(2);
episodeAction(101).click();
episodeAction(102).click();
@@ -887,7 +910,7 @@ describe('SeasonContainerComponent', () => {
fixture.detectChanges();
expect(candidateSpy).toHaveBeenCalledTimes(2);
expect(findDownloadSpy).toHaveBeenCalledTimes(2);
expect(resolveDownloadSpy).toHaveBeenCalledTimes(2);
});
it('resumes by managed row id and plays the coordinate-matched local file', async () => {
@@ -131,9 +131,12 @@ export class SeasonDownloadPresenter {
const pending = candidate
? this.coordinator.isPending(candidate.identity)
: false;
const download = candidate
? this.coordinator.findDownload(candidate.identity)
: undefined;
const resolution = candidate
? this.coordinator.resolveDownload(candidate.identity)
: { kind: 'missing' as const };
const download =
resolution.kind === 'match' ? resolution.download : undefined;
const identityConflict = resolution.kind === 'conflict';
return {
candidate,
download,
@@ -142,14 +145,15 @@ export class SeasonDownloadPresenter {
downloadsAvailable &&
downloadsReady &&
!pending &&
isEpisodeDownloadEligible(download)
isEpisodeDownloadEligible(resolution)
),
episode,
presentation: this.createPresentation(
candidate,
download,
pending,
downloadsReady
downloadsReady,
identityConflict
),
};
});
@@ -281,9 +285,10 @@ export class SeasonDownloadPresenter {
candidate: EpisodeDownloadCandidate | null,
download: DownloadItem | undefined,
pending: boolean,
downloadsReady: boolean
downloadsReady: boolean,
identityConflict: boolean
): EpisodeDownloadPresentation {
if (!candidate || !downloadsReady) {
if (!candidate || !downloadsReady || identityConflict) {
return EPISODE_DOWNLOAD_STATES.blocked;
}
if (