mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -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"
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "البدء من جديد",
|
||||
"CONTINUE_WATCHING": "متابعة المشاهدة",
|
||||
"PLAY_FIRST_EPISODE": "تشغيل الحلقة الأولى",
|
||||
"PLAY_EPISODE": "تشغيل الحلقة {{episode}}",
|
||||
"RESUME_EPISODE": "استئناف الحلقة",
|
||||
"PLAY_NEXT_EPISODE": "تشغيل الحلقة التالية",
|
||||
"SERIES_WATCHED": "تمت مشاهدة المسلسل",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "بدا من الأول",
|
||||
"CONTINUE_WATCHING": "كمل المشاهدة",
|
||||
"PLAY_FIRST_EPISODE": "شغل الحلقة الأولى",
|
||||
"PLAY_EPISODE": "شغل الحلقة {{episode}}",
|
||||
"RESUME_EPISODE": "كمل الحلقة",
|
||||
"PLAY_NEXT_EPISODE": "شغل الحلقة الجاية",
|
||||
"SERIES_WATCHED": "السلسلة متشافة",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Спачатку",
|
||||
"CONTINUE_WATCHING": "Працягнуць прагляд",
|
||||
"PLAY_FIRST_EPISODE": "Прайграць першы эпізод",
|
||||
"PLAY_EPISODE": "Прайграць эпізод {{episode}}",
|
||||
"RESUME_EPISODE": "Працягнуць эпізод",
|
||||
"PLAY_NEXT_EPISODE": "Прайграць наступны эпізод",
|
||||
"SERIES_WATCHED": "Серыял прагледжаны",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "Από την αρχή",
|
||||
"CONTINUE_WATCHING": "Συνέχιση παρακολούθησης",
|
||||
"PLAY_FIRST_EPISODE": "Αναπαραγωγή πρώτου επεισοδίου",
|
||||
"PLAY_EPISODE": "Αναπαραγωγή επεισοδίου {{episode}}",
|
||||
"RESUME_EPISODE": "Συνέχιση επεισοδίου",
|
||||
"PLAY_NEXT_EPISODE": "Αναπαραγωγή επόμενου επεισοδίου",
|
||||
"SERIES_WATCHED": "Η σειρά προβλήθηκε",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "最初から再生",
|
||||
"CONTINUE_WATCHING": "視聴を再開",
|
||||
"PLAY_FIRST_EPISODE": "第1話を再生",
|
||||
"PLAY_EPISODE": "第{{episode}}話を再生",
|
||||
"RESUME_EPISODE": "エピソードを再開",
|
||||
"PLAY_NEXT_EPISODE": "次のエピソードを再生",
|
||||
"SERIES_WATCHED": "シリーズ視聴済み",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "처음부터 보기",
|
||||
"CONTINUE_WATCHING": "이어서 시청",
|
||||
"PLAY_FIRST_EPISODE": "첫 에피소드 재생",
|
||||
"PLAY_EPISODE": "에피소드 {{episode}} 재생",
|
||||
"RESUME_EPISODE": "에피소드 이어 보기",
|
||||
"PLAY_NEXT_EPISODE": "다음 에피소드 재생",
|
||||
"SERIES_WATCHED": "시리즈 시청 완료",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "С начала",
|
||||
"CONTINUE_WATCHING": "Продолжить просмотр",
|
||||
"PLAY_FIRST_EPISODE": "Воспроизвести первую серию",
|
||||
"PLAY_EPISODE": "Воспроизвести серию {{episode}}",
|
||||
"RESUME_EPISODE": "Продолжить серию",
|
||||
"PLAY_NEXT_EPISODE": "Следующая серия",
|
||||
"SERIES_WATCHED": "Сериал просмотрен",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "从头开始",
|
||||
"CONTINUE_WATCHING": "继续观看",
|
||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||
"PLAY_EPISODE": "播放第 {{episode}} 集",
|
||||
"RESUME_EPISODE": "继续播放本集",
|
||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||
"SERIES_WATCHED": "剧集已看完",
|
||||
|
||||
@@ -789,6 +789,7 @@
|
||||
"RESTART": "從頭開始",
|
||||
"CONTINUE_WATCHING": "繼續觀看",
|
||||
"PLAY_FIRST_EPISODE": "播放第一集",
|
||||
"PLAY_EPISODE": "播放第 {{episode}} 集",
|
||||
"RESUME_EPISODE": "繼續播放本集",
|
||||
"PLAY_NEXT_EPISODE": "播放下一集",
|
||||
"SERIES_WATCHED": "劇集已看完",
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user