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"