mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
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:
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"
|
||||
|
||||
Reference in new issue
Block a user