diff --git a/docs/architecture/m3u-playlist-module.md b/docs/architecture/m3u-playlist-module.md index 6cfd59ad6..4f2671d69 100644 --- a/docs/architecture/m3u-playlist-module.md +++ b/docs/architecture/m3u-playlist-module.md @@ -1134,8 +1134,9 @@ catch-up capability and therefore does not expose this action. The URL can contain account credentials; the dialog explains that external players may also need the source's HTTP headers. Copy is explicit, errors use -localized feedback without raw URLs, and repeated clicks share one pending -copy operation. Timeline/list dialogs capture the host's source/channel +localized feedback without raw URLs, and each explicit request supersedes older +unresolved copies. Only the latest request may update the clipboard or feedback, +so a slow resolver cannot block another channel or overwrite its copied URL. Timeline/list dialogs capture the host's source/channel `archiveContextKey`, refuse actions after it changes, and unsubscribe when the view is destroyed. A copied M3U start-over URL need not stop at the programme's end. Copying a URL does not download or preserve an expiring archive. diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts index 18c0d89dd..03a7ccff8 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts @@ -1,3 +1,4 @@ +import { EpgArchiveCopyService } from '@iptvnator/ui/epg'; import { computed, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { @@ -89,6 +90,10 @@ describe('Xtream live auto-open playback queue', () => { await TestBed.configureTestingModule({ imports: [LiveStreamLayoutComponent], providers: [ + { + provide: EpgArchiveCopyService, + useValue: { copy: jest.fn() }, + }, { provide: XtreamStore, useValue: store }, { provide: ActivatedRoute, diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts index e7d38b71d..202f80e2a 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.sidebar-levels.spec.ts @@ -1,3 +1,4 @@ +import { EpgArchiveCopyService } from '@iptvnator/ui/epg'; import { Component, Directive, input, output, signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -130,6 +131,10 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { await TestBed.configureTestingModule({ imports: [LiveStreamLayoutComponent, NoopAnimationsModule], providers: [ + { + provide: EpgArchiveCopyService, + useValue: { copy: jest.fn() }, + }, { provide: ActivatedRoute, useValue: { @@ -182,7 +187,10 @@ describe('LiveStreamLayoutComponent sidebar levels', () => { openExternalPlayback: jest.fn(), }, }, - { provide: LIVE_CATEGORIES_POPOVER, useValue: categoriesPopover }, + { + provide: LIVE_CATEGORIES_POPOVER, + useValue: categoriesPopover, + }, ], }) .overrideComponent(LiveStreamLayoutComponent, { diff --git a/libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts b/libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts index 83966c040..3b7964e62 100644 --- a/libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts +++ b/libs/ui/epg/src/lib/epg-archive-copy.service.spec.ts @@ -57,19 +57,49 @@ describe('EpgArchiveCopyService', () => { 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_FAILED', ]); }); - it('coalesces repeated clicks while the URL is resolving', async () => { - let resolve!: (url: string) => void; - const pending = service.copy( + it.each([false, true])( + 'lets a new copy replace a slow request (old failure: %s)', + async (failOld) => { + let finish!: (url: string) => void; + let reject!: (error: Error) => void; + const first = service.copy( + () => + new Promise((resolve, fail) => { + finish = resolve; + reject = fail; + }) + ); + const latest = jest.fn(async () => 'https://provider.test/new.ts'); + await service.copy(latest); + expect(latest).toHaveBeenCalledTimes(1); + if (failOld) reject(new Error('secret old request')); + else finish('https://provider.test/old.ts'); + await first; + expect(copy.mock.calls).toEqual([['https://provider.test/new.ts']]); + expect(open).toHaveBeenCalledTimes(1); + } + ); + it('discards an old result even while the newer request is still pending', async () => { + let finishOld!: (url: string) => void, + finishNew!: (url: string) => void; + const first = service.copy( () => - new Promise((done) => { - resolve = done; + new Promise((resolve) => { + finishOld = resolve; }) ); - const duplicate = jest.fn(); - await service.copy(duplicate); - expect(duplicate).not.toHaveBeenCalled(); - resolve('https://provider.test/archive.ts'); - await pending; - expect(copy).toHaveBeenCalledTimes(1); + const second = service.copy( + () => + new Promise((resolve) => { + finishNew = resolve; + }) + ); + finishOld('https://provider.test/old.ts'); + await first; + expect(copy).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + finishNew('https://provider.test/new.ts'); + await second; + expect(copy).toHaveBeenCalledWith('https://provider.test/new.ts'); }); }); diff --git a/libs/ui/epg/src/lib/epg-archive-copy.service.ts b/libs/ui/epg/src/lib/epg-archive-copy.service.ts index d1e3aef04..5fe0274a9 100644 --- a/libs/ui/epg/src/lib/epg-archive-copy.service.ts +++ b/libs/ui/epg/src/lib/epg-archive-copy.service.ts @@ -9,24 +9,23 @@ export class EpgArchiveCopyService { private readonly clipboard = inject(Clipboard); private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); - private pending = false; + private requestGeneration = 0; async copy( resolve: () => string | null | Promise ): Promise { - if (this.pending) return; - this.pending = true; + const generation = ++this.requestGeneration; let copied = false; try { const url = await resolve(); + if (generation !== this.requestGeneration) return; if (url && ['http:', 'https:'].includes(new URL(url).protocol)) { copied = this.clipboard.copy(url); } } catch { // Provider URLs and error messages may contain credentials. - } finally { - this.pending = false; } + if (generation !== this.requestGeneration) return; this.snackBar.open( this.translate.instant( copied