From a667bfbe05f2048351a5316a9278e61b6b3ab834 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 19:40:33 +0200 Subject: [PATCH] fix(downloads): accept nullable legacy coordinates --- CLAUDE.md | 8 +++--- docs/architecture/download-manager.md | 14 +++++----- .../episode-download-identity.spec.ts | 16 ++++++++++++ .../downloads/episode-download-identity.ts | 9 ++++--- .../season-container.component.spec.ts | 26 +++++++++++++++++++ 5 files changed, 61 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2cf49835..ac116808a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -955,9 +955,11 @@ engine` (restart required) or 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. + download. SQLite `null` and optional `undefined` coordinates both mean an + incomplete canonical legacy row, matching the backend resolver. 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 99f577fb4..9f801b24b 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -82,12 +82,14 @@ variants, contextual buttons, and theme-aware styling. coordinates are a legacy episode-compatibility fallback; ambiguous or 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 + fail closed for that state. SQLite `null` and optional `undefined` coordinates + both mean that a canonical legacy row is incomplete, matching the backend + resolver, 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, 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 686245c70..91ab59911 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 @@ -42,6 +42,22 @@ describe('episode download identity', () => { }); }); + it.each([ + ['series id', { seriesXtreamId: null }], + ['season number', { seasonNumber: null }], + ['episode number', { episodeNumber: null }], + ])( + 'treats a null legacy %s as an incomplete canonical row', + (_label, override) => { + const canonical = row(override); + + expect(resolveEpisodeDownload(identity, [canonical])).toEqual({ + kind: 'match', + download: canonical, + }); + } + ); + it('fails closed when canonical and coordinate lookups find different rows', () => { const coordinateFallback = row({ xtreamId: 999 }); const canonical = row(); 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 a6bac2394..e16a83afb 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 @@ -18,9 +18,9 @@ export interface EpisodeDownloadRecord { readonly playlistId: string; readonly contentType: ElectronBridgePlaybackContentType; readonly xtreamId: number; - readonly seriesXtreamId?: number; - readonly seasonNumber?: number; - readonly episodeNumber?: number; + readonly seriesXtreamId?: number | null; + readonly seasonNumber?: number | null; + readonly episodeNumber?: number | null; readonly status: ElectronBridgeDownloadStatus; readonly fileAvailability?: ElectronDownloadFileAvailability; readonly filePath?: string; @@ -37,8 +37,11 @@ function hasConflictingCompleteCoordinates( ): boolean { const { seriesXtreamId, seasonNumber, episodeNumber } = download; if ( + seriesXtreamId === null || seriesXtreamId === undefined || + seasonNumber === null || seasonNumber === undefined || + episodeNumber === null || episodeNumber === undefined ) { return false; 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 259ab7f7f..a174b8680 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 @@ -638,6 +638,32 @@ describe('SeasonContainerComponent', () => { expect(downloadsServiceStub.startDownload).not.toHaveBeenCalled(); }); + it('keeps a failed canonical legacy row with null coordinates downloadable', () => { + const episode = createEpisode(); + const legacy = { + ...createDownload(episode, { status: 'failed' }), + seriesXtreamId: null, + seasonNumber: null, + episodeNumber: null, + } as unknown as DownloadItem; + downloadsServiceStub.downloads.set([legacy]); + 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(false); + expect(action.querySelector('mat-icon')?.textContent).toContain( + 'download' + ); + expect(seasonButton.disabled).toBe(false); + expect(seasonButton.textContent).toContain('Download season (1)'); + }); + it('gives the grid and list view radios localized accessible names', () => { TestBed.inject(TranslateService).setTranslation( 'en',