diff --git a/CLAUDE.md b/CLAUDE.md index ba48ec654..c2cf49835 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index c3607c1c1..99f577fb4 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -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, diff --git a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts index d1109cb80..2c86a94d1 100644 --- a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.spec.ts @@ -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); diff --git a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts index 9f90c173f..3555ed8de 100644 --- a/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts +++ b/libs/portal/shared/data-access/src/lib/downloads/season-download-coordinator.service.ts @@ -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 { + 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)) ); } diff --git a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts index e4efd8a92..686245c70 100644 --- a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts +++ b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.spec.ts @@ -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); } ); diff --git a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts index 03eb1d1cc..a6bac2394 100644 --- a/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts +++ b/libs/portal/shared/util/src/lib/downloads/episode-download-identity.ts @@ -26,6 +26,11 @@ export interface EpisodeDownloadRecord { readonly filePath?: string; } +export type EpisodeDownloadResolution = + | { 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( +export function resolveEpisodeDownload( identity: EpisodeDownloadIdentity, downloads: readonly T[] -): T | undefined { +): EpisodeDownloadResolution { const canonicalMatch = downloads.find( (download) => download.playlistId === identity.playlistId && @@ -71,7 +76,7 @@ export function findEpisodeDownload( download.episodeNumber === identity.episodeNumber ); if (coordinateMatches.length > 1) { - return undefined; + return { kind: 'conflict' }; } const coordinateMatch = coordinateMatches[0]; @@ -80,20 +85,26 @@ export function findEpisodeDownload( 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 ): 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'; } diff --git a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts index 08da701c9..259ab7f7f 100644 --- a/libs/ui/components/src/lib/season-container/season-container.component.spec.ts +++ b/libs/ui/components/src/lib/season-container/season-container.component.spec.ts @@ -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 () => { diff --git a/libs/ui/components/src/lib/season-container/season-download-presenter.ts b/libs/ui/components/src/lib/season-container/season-download-presenter.ts index 1958b0380..32ac54610 100644 --- a/libs/ui/components/src/lib/season-container/season-download-presenter.ts +++ b/libs/ui/components/src/lib/season-container/season-download-presenter.ts @@ -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 (