mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
fix(downloads): block ambiguous episode actions
This commit is contained in:
1 parent
ee7041e3c2
commit
339dbde3c4
8 files changed
+136
-54
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+21
-3
@@ -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);
|
||||
|
||||
+10
-4
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user