feat(xtream): resume the latest series episode (#1187)

Dashboard Continue Watching now carries the exact saved season/episode into
Xtream series details and starts it at the persisted offset. Successful
external MPV/VLC launches persist the launched episode and retarget the
series CTA to "Play episode N". Recent-history rows keyed by an episode id
resolve their parent series before navigation.

Includes maintainer follow-ups: no zero-offset resume on failed position
loads, seriesXtreamId-gated resume targets for legacy rows, and patch
coverage raised from 76.7% to 93.9%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MahdiHrmandClaude Fable 5 authored and GitHub committed 2026-07-18 15:49:22 +02:00
1 parent d61fd5db19
commit 643dee1be3
45 files changed
+1735 -88

No files matched your search

@@ -0,0 +1,140 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import {
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { GlobalCollectionDetailHostComponent } from './global-collection-route.component';
@Component({
selector: 'app-xtream-collection-detail',
template: '',
})
class MockXtreamCollectionDetailComponent {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
}
jest.unstable_mockModule('@iptvnator/portal/xtream/feature', () => ({
XtreamCollectionDetailComponent: MockXtreamCollectionDetailComponent,
}));
@Component({
imports: [GlobalCollectionDetailHostComponent],
template: `
<app-global-collection-detail-host
[item]="item()"
[seriesResume]="seriesResume()"
(closeRequested)="closeCount = closeCount + 1"
/>
`,
})
class WrapperComponent {
readonly item = signal<UnifiedCollectionItem | null>(null);
readonly seriesResume = signal<SeriesResumeTarget | null>(null);
closeCount = 0;
}
const xtreamSeriesItem: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::series:103',
name: 'Resume Series',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 103,
categoryId: 3,
};
describe('GlobalCollectionDetailHostComponent', () => {
let fixture: ComponentFixture<WrapperComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [WrapperComponent],
}).compileComponents();
fixture = TestBed.createComponent(WrapperComponent);
});
afterEach(() => {
fixture?.destroy();
});
function queryDetail() {
return fixture.debugElement.query(
By.directive(MockXtreamCollectionDetailComponent)
);
}
async function stabilize(): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
await new Promise((resolve) => setTimeout(resolve, 0));
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
}
it('mocks the xtream feature barrel', async () => {
const mod = await import('@iptvnator/portal/xtream/feature');
expect(mod.XtreamCollectionDetailComponent).toBe(
MockXtreamCollectionDetailComponent as never
);
});
it('renders nothing until an item arrives', async () => {
await stabilize();
expect(queryDetail()).toBeNull();
});
it('ignores sources without a collection detail component', async () => {
fixture.componentInstance.item.set({
...xtreamSeriesItem,
uid: 'm3u::pl-1::vod:1',
sourceType: 'm3u' as UnifiedCollectionItem['sourceType'],
playlistId: 'pl-1',
});
await stabilize();
expect(queryDetail()).toBeNull();
});
it('creates the Xtream detail with the series resume target attached', async () => {
const seriesResume = {
seriesXtreamId: 103,
contentXtreamId: 2001,
seasonNumber: 2,
episodeNumber: 1,
};
fixture.componentInstance.seriesResume.set(seriesResume);
fixture.componentInstance.item.set(xtreamSeriesItem);
await stabilize();
const detail = queryDetail();
expect(detail).not.toBeNull();
const instance =
detail?.componentInstance as MockXtreamCollectionDetailComponent;
expect(instance.item()?.xtreamId).toBe(103);
expect(instance.seriesResume()).toEqual(seriesResume);
});
it('re-emits close requests and clears the detail with the item', async () => {
fixture.componentInstance.item.set(xtreamSeriesItem);
await stabilize();
const instance = queryDetail()
?.componentInstance as MockXtreamCollectionDetailComponent;
instance.closeRequested.emit();
expect(fixture.componentInstance.closeCount).toBe(1);
fixture.componentInstance.item.set(null);
await stabilize();
expect(queryDetail()).toBeNull();
});
});
@@ -24,6 +24,7 @@ import {
import {
CollectionScope,
PortalProvider,
SeriesResumeTarget,
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
@@ -68,6 +69,7 @@ function providerToPortalType(provider: PortalProvider): UnifiedPortalType {
})
export class GlobalCollectionDetailHostComponent implements OnDestroy {
readonly item = input<UnifiedCollectionItem | null>(null);
readonly seriesResume = input<SeriesResumeTarget | null>(null);
readonly closeRequested = output<void>();
private readonly viewContainer = inject(ViewContainerRef);
@@ -78,9 +80,10 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
constructor() {
effect(() => {
const item = this.item();
const seriesResume = this.seriesResume();
untracked(() => {
void this.renderDetail(item);
void this.renderDetail(item, seriesResume);
});
});
}
@@ -91,7 +94,8 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
}
private async renderDetail(
item: UnifiedCollectionItem | null
item: UnifiedCollectionItem | null,
seriesResume: SeriesResumeTarget | null
): Promise<void> {
const requestId = ++this.renderRequestId;
this.clearDetail();
@@ -109,6 +113,9 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
environmentInjector: this.environmentInjector,
});
componentRef.setInput('item', item);
if (item.sourceType === 'xtream') {
componentRef.setInput('seriesResume', seriesResume);
}
this.subscribeToClose(componentRef.instance);
}
@@ -175,9 +182,17 @@ export class GlobalCollectionDetailHostComponent implements OnDestroy {
[portalType]="activePortalType() ?? undefined"
[defaultScope]="effectiveDefaultScope()"
>
<ng-template unifiedCollectionDetail let-item let-close="close">
<ng-template
unifiedCollectionDetail
let-item
let-seriesResume="seriesResume"
let-close="close"
>
@if (item.sourceType === 'xtream') {
<app-global-collection-detail-host [item]="item" />
<app-global-collection-detail-host
[item]="item"
[seriesResume]="seriesResume"
/>
} @else if (item.sourceType === 'stalker') {
<app-global-collection-detail-host
[item]="item"
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "البدء من جديد",
"CONTINUE_WATCHING": "متابعة المشاهدة",
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
"PLAY_EPISODE": "تشغيل الحلقة {{episode}}",
"RESUME_EPISODE": "استئناف الحلقة",
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "بدا من الأول",
"CONTINUE_WATCHING": "كمل المشاهدة",
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
"PLAY_EPISODE": "شغل الحلقة {{episode}}",
"RESUME_EPISODE": "كمل الحلقة",
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
"SERIES_WATCHED": "السلسلة متشافة",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Спачатку",
"CONTINUE_WATCHING": "Працягнуць прагляд",
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
"PLAY_EPISODE": "Прайграць эпізод {{episode}}",
"RESUME_EPISODE": "Працягнуць эпізод",
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
"SERIES_WATCHED": "Серыял прагледжаны",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Neu starten",
"CONTINUE_WATCHING": "Weiter schauen",
"PLAY_FIRST_EPISODE": "Erste Folge abspielen",
"PLAY_EPISODE": "Folge {{episode}} abspielen",
"RESUME_EPISODE": "Folge fortsetzen",
"PLAY_NEXT_EPISODE": "Nächste Folge abspielen",
"SERIES_WATCHED": "Serie angesehen",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Από την αρχή",
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
"PLAY_EPISODE": "Αναπαραγωγή επεισοδίου {{episode}}",
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Start Over",
"CONTINUE_WATCHING": "Continue Watching",
"PLAY_FIRST_EPISODE": "Play first episode",
"PLAY_EPISODE": "Play episode {{episode}}",
"RESUME_EPISODE": "Resume episode",
"PLAY_NEXT_EPISODE": "Play next episode",
"SERIES_WATCHED": "Series watched",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Reiniciar",
"CONTINUE_WATCHING": "Continuar viendo",
"PLAY_FIRST_EPISODE": "Reproducir primer episodio",
"PLAY_EPISODE": "Reproducir episodio {{episode}}",
"RESUME_EPISODE": "Continuar episodio",
"PLAY_NEXT_EPISODE": "Reproducir siguiente episodio",
"SERIES_WATCHED": "Serie vista",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Recommencer",
"CONTINUE_WATCHING": "Reprendre la lecture",
"PLAY_FIRST_EPISODE": "Lire le premier épisode",
"PLAY_EPISODE": "Lire l'épisode {{episode}}",
"RESUME_EPISODE": "Reprendre l'épisode",
"PLAY_NEXT_EPISODE": "Lire l'épisode suivant",
"SERIES_WATCHED": "Série vue",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Ricomincia",
"CONTINUE_WATCHING": "Continua a guardare",
"PLAY_FIRST_EPISODE": "Riproduci primo episodio",
"PLAY_EPISODE": "Riproduci episodio {{episode}}",
"RESUME_EPISODE": "Riprendi episodio",
"PLAY_NEXT_EPISODE": "Riproduci episodio successivo",
"SERIES_WATCHED": "Serie vista",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "最初から再生",
"CONTINUE_WATCHING": "視聴を再開",
"PLAY_FIRST_EPISODE": "第1話を再生",
"PLAY_EPISODE": "第{{episode}}話を再生",
"RESUME_EPISODE": "エピソードを再開",
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
"SERIES_WATCHED": "シリーズ視聴済み",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "처음부터 보기",
"CONTINUE_WATCHING": "이어서 시청",
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
"PLAY_EPISODE": "에피소드 {{episode}} 재생",
"RESUME_EPISODE": "에피소드 이어 보기",
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
"SERIES_WATCHED": "시리즈 시청 완료",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Opnieuw beginnen",
"CONTINUE_WATCHING": "Verder kijken",
"PLAY_FIRST_EPISODE": "Eerste aflevering afspelen",
"PLAY_EPISODE": "Aflevering {{episode}} afspelen",
"RESUME_EPISODE": "Aflevering hervatten",
"PLAY_NEXT_EPISODE": "Volgende aflevering afspelen",
"SERIES_WATCHED": "Serie bekeken",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Zacznij od nowa",
"CONTINUE_WATCHING": "Kontynuuj oglądanie",
"PLAY_FIRST_EPISODE": "Odtwórz pierwszy odcinek",
"PLAY_EPISODE": "Odtwórz odcinek {{episode}}",
"RESUME_EPISODE": "Wznów odcinek",
"PLAY_NEXT_EPISODE": "Odtwórz następny odcinek",
"SERIES_WATCHED": "Serial obejrzany",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Recomeçar",
"CONTINUE_WATCHING": "Continuar assistindo",
"PLAY_FIRST_EPISODE": "Reproduzir primeiro episódio",
"PLAY_EPISODE": "Reproduzir episódio {{episode}}",
"RESUME_EPISODE": "Continuar episódio",
"PLAY_NEXT_EPISODE": "Reproduzir próximo episódio",
"SERIES_WATCHED": "Série assistida",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "С начала",
"CONTINUE_WATCHING": "Продолжить просмотр",
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
"PLAY_EPISODE": "Воспроизвести серию {{episode}}",
"RESUME_EPISODE": "Продолжить серию",
"PLAY_NEXT_EPISODE": "Следующая серия",
"SERIES_WATCHED": "Сериал просмотрен",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "Baştan Başla",
"CONTINUE_WATCHING": "İzlemeye Devam Et",
"PLAY_FIRST_EPISODE": "İlk bölümü oynat",
"PLAY_EPISODE": "{{episode}}. bölümü oynat",
"RESUME_EPISODE": "Bölüme devam et",
"PLAY_NEXT_EPISODE": "Sonraki bölümü oynat",
"SERIES_WATCHED": "Dizi izlendi",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "从头开始",
"CONTINUE_WATCHING": "继续观看",
"PLAY_FIRST_EPISODE": "播放第一集",
"PLAY_EPISODE": "播放第 {{episode}} 集",
"RESUME_EPISODE": "继续播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "剧集已看完",
+1
View File
@@ -789,6 +789,7 @@
"RESTART": "從頭開始",
"CONTINUE_WATCHING": "繼續觀看",
"PLAY_FIRST_EPISODE": "播放第一集",
"PLAY_EPISODE": "播放第 {{episode}} 集",
"RESUME_EPISODE": "繼續播放本集",
"PLAY_NEXT_EPISODE": "播放下一集",
"SERIES_WATCHED": "劇集已看完",
+43
View File
@@ -0,0 +1,43 @@
// The video.js CJS bundle fails to evaluate under the web project's ESM jest
// preset, so specs that transitively import the vjs player get this stub. It
// stays CommonJS because videojs plugin packages require() it at module load.
class StubComponent {
createEl() {
return {};
}
}
class StubPlugin {}
class StubEventTarget {
on() {
return undefined;
}
off() {
return undefined;
}
one() {
return undefined;
}
trigger() {
return undefined;
}
}
const videoJs = () => ({
dispose: () => undefined,
on: () => undefined,
off: () => undefined,
src: () => undefined,
});
videoJs.getComponent = () => StubComponent;
videoJs.registerComponent = () => undefined;
videoJs.getPlugin = () => StubPlugin;
videoJs.registerPlugin = () => undefined;
videoJs.EventTarget = StubEventTarget;
videoJs.dom = { createEl: () => ({}) };
videoJs.browser = {};
module.exports = videoJs;
module.exports.default = videoJs;