fix(epg): let newer archive copy requests supersede pending work

This commit is contained in:
4gray committed 2026-09-07 23:50:32 +02:00
1 parent dcaeb26849
commit 530c4db03f
5 files changed
+62 -19

No files matched your search

+3 -2
View File
@@ -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.
@@ -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,
@@ -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, {
@@ -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<string>((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<string>((done) => {
resolve = done;
new Promise<string>((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<string>((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');
});
});
@@ -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<string | null>
): Promise<void> {
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