diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 125af10c7..850b0a0d4 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -61,10 +61,11 @@ Three rules follow, and each is enforced in code rather than by convention: letterboxed masters are cropped vertically — a 2.39:1 1080p film is 1920×800, and 800 alone is indistinguishable from a 1280×800 encode. With no width, a height is trusted only within 5% of a standard frame height; otherwise no tag - is emitted. Below the HD widths the numbers stop separating cleanly — 720 - wide is NTSC 480p or PAL 576p depending on the height, 640 is 360p — so an - unrecognised shape returns nothing rather than a bucket that would be - published as an `api` fact. + is emitted. Below the HD widths the ranges stop working: 800×600 + and 800×450 are neither 480p nor each other, and 720 wide is NTSC 480p or + PAL 576p depending only on the height. Those formats are therefore matched + rather than bucketed, and an unrecognised shape returns nothing rather than + a label that would be published as an `api` fact. Provenance is per-field and changes over time: at discovery a row has only `parsed` tags, because the `content` table stores no container, codec or audio. @@ -368,9 +369,11 @@ the resume point at wherever playback began, so a switch an hour later rewinds the whole session. The caption itself appears only while a player is actually running — inline or -a matched external session. Discovery marks a source active as the page opens, -so gating on that alone would have the page claim "Playing from …" before Play -was pressed, and again after the player was closed. +a matched external session, and not while a playback diagnostic is up. +Discovery marks a source active as the page opens, so gating on that alone +would have the page claim "Playing from …" before Play was pressed, after the +player was closed, and over the error screen for a stream that would not +play. Whichever source ends up playing, the "playing" badge follows it: starting the route's own stream (Play, Resume, Restart, or the fallback after a pin does not diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts index 76c775761..0e0bacdcc 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.spec.ts @@ -148,7 +148,15 @@ describe('applyApiMetadata', () => { ).toBeUndefined(); }); - it('emits nothing for a width below every known format', () => { + it('emits nothing for a shape that is not a known format', () => { + // 800 wide is neither 854x480 nor anything else in the table, and + // 800x600 is certainly not 480 lines high. + expect( + applyApiMetadata(candidate(), { width: 800, height: 600 }).quality + ).toBeUndefined(); + expect( + applyApiMetadata(candidate(), { width: 800, height: 450 }).quality + ).toBeUndefined(); expect( applyApiMetadata(candidate(), { width: 320, height: 240 }).quality ).toBeUndefined(); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts index 4f503aebc..c751e00db 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-metadata.util.ts @@ -239,31 +239,47 @@ function isPositiveNumber(value: number | null | undefined): value is number { * check instead. Empty beats wrong. */ /** - * Below the HD widths the numbers stop separating cleanly. + * The sub-HD formats, matched rather than bucketed. * - * 854 is 480p, but 720 is NTSC 480p or PAL 576p depending on the height, and - * 640 is 360p — which the old "anything under 900 is 480p" rule published as - * an `api` FACT for all of them. Empty beats wrong: an unrecognised shape - * returns nothing and the row simply carries no quality tag. + * Ranges work above 900 because the encodes cluster there. Below it they do + * not: 800×600 and 800×450 are neither 480p nor each other, and 720 is NTSC + * 480p or PAL 576p depending only on the height. Anything that is not one of + * these shapes gets no tag at all — a bucket here would be published with + * `api` provenance and read as a measurement. */ +const SMALL_FORMATS: ReadonlyArray<[number, number, string]> = [ + [854, 480, '480p'], + [720, 576, '576p'], + [720, 480, '480p'], + [640, 360, '360p'], +]; + +function within5Percent(value: number, reference: number): boolean { + return Math.abs(value - reference) / reference <= 0.05; +} + function smallFormatQuality( width: number, height: number | null | undefined ): string | null { - if (width >= 800) { - return '480p'; + const byWidth = SMALL_FORMATS.filter(([reference]) => + within5Percent(width, reference) + ); + if (byWidth.length === 0) { + return null; } - if (width >= 700) { - // The one width two formats share; only the height tells them apart. - if (!isPositiveNumber(height)) { - return null; - } - return height >= 520 ? '576p' : '480p'; + if (byWidth.length === 1) { + return byWidth[0][2]; } - if (width >= 600) { - return '360p'; + + // One width, two formats: only the height separates them. + if (!isPositiveNumber(height)) { + return null; } - return null; + const exact = byWidth.find(([, reference]) => + within5Percent(height, reference) + ); + return exact ? exact[2] : null; } function qualityFromDimensions( diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts new file mode 100644 index 000000000..580aea87a --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-caption.spec.ts @@ -0,0 +1,291 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { Location } from '@angular/common'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + XtreamCategory, + XtreamVodDetails, + XtreamVodStream, +} from '@iptvnator/shared/interfaces'; +import { DownloadsService, SettingsStore } from '@iptvnator/services'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { VodDetailsPlaybackService } from './vod-details-playback.service'; +import { VodDetailsRouteComponent } from './vod-details-route.component'; + +/** + * What the page CLAIMS is playing. + * + * "Playing from ..." is a statement of fact about the stream on screen, and + * discovery marks a source active long before one exists — so the line has to + * be gated on playback rather than on selection. + */ +describe('VodDetailsRouteComponent — source caption', () => { + let fixture: ComponentFixture; + let consoleDebugSpy: jest.SpyInstance | undefined; + let consoleWarnSpy: jest.SpyInstance | undefined; + const selectedItem = signal(null); + const isLoadingDetails = signal(false); + const detailsError = signal(null); + const isFavorite = signal(false); + const currentPlaylist = signal<{ + id: string; + userAgent?: string; + referrer?: string; + origin?: string; + } | null>(null); + const vodStreams = signal[]>([]); + const vodCategories = signal[]>([]); + const fetchVodDetailsWithMetadata = jest.fn(); + const checkFavoriteStatus = jest.fn(); + const setSelectedItem = jest.fn(); + const toggleFavorite = jest.fn(); + const constructVodStreamUrl = jest + .fn() + .mockReturnValue('http://example.com/movie/650020.mp4'); + const addRecentItem = jest.fn(); + const downloads = signal([]); + const getPlaybackPosition = jest.fn().mockResolvedValue(null); + const activeSession = signal(null); + const closeSession = jest.fn(); + + beforeEach(async () => { + const consoleDebug = console.debug.bind(console); + const consoleWarn = console.warn.bind(console); + consoleDebugSpy = jest + .spyOn(console, 'debug') + .mockImplementation((...args: unknown[]) => { + if ( + args[0] === '[VodDetailsRoute]' || + args[0] === '[VodDetailsPlayback]' + ) { + return; + } + + consoleDebug(...args); + }); + consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation((...args: unknown[]) => { + if ( + args[0] === '[VodDetailsRoute]' && + args[1] === 'Deferring VOD details init: playlist not ready' + ) { + return; + } + + consoleWarn(...args); + }); + + selectedItem.set(null); + isLoadingDetails.set(false); + detailsError.set(null); + isFavorite.set(false); + currentPlaylist.set(null); + vodStreams.set([]); + vodCategories.set([]); + fetchVodDetailsWithMetadata.mockClear(); + checkFavoriteStatus.mockClear(); + setSelectedItem.mockClear(); + toggleFavorite.mockClear(); + constructVodStreamUrl.mockClear(); + addRecentItem.mockClear(); + getPlaybackPosition.mockClear(); + activeSession.set(null); + closeSession.mockClear(); + + await TestBed.configureTestingModule({ + imports: [VodDetailsRouteComponent], + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({ + vodId: '650020', + categoryId: '235', + }), + snapshot: { + params: { + vodId: '650020', + categoryId: '235', + }, + }, + }, + }, + { + provide: TranslateService, + useValue: { + instant: (key: string) => key, + get: (key: string) => of(key), + stream: (key: string) => of(key), + onLangChange: of(null), + onTranslationChange: of(null), + onDefaultLangChange: of(null), + currentLang: 'en', + defaultLang: 'en', + }, + }, + { + provide: XtreamStore, + useValue: { + selectedItem, + isLoadingDetails, + detailsError, + isFavorite, + currentPlaylist, + vodStreams, + vodCategories, + fetchVodDetailsWithMetadata, + checkFavoriteStatus, + setSelectedItem, + toggleFavorite, + constructVodStreamUrl, + addRecentItem, + }, + }, + { + provide: SettingsStore, + useValue: { + theme: signal('dark'), + }, + }, + { + provide: DownloadsService, + useValue: { + isAvailable: signal(false), + downloads, + isDownloaded: jest.fn().mockReturnValue(false), + isDownloading: jest.fn().mockReturnValue(false), + startDownload: jest.fn(), + getDownloadedFilePath: jest.fn(), + playDownload: jest.fn(), + }, + }, + { + provide: PORTAL_EXTERNAL_PLAYBACK, + useValue: { activeSession, closeSession }, + }, + { + provide: PORTAL_PLAYBACK_POSITIONS, + useValue: { + getPlaybackPosition, + savePlaybackPosition: jest + .fn() + .mockResolvedValue(undefined), + }, + }, + { + provide: PORTAL_PLAYER, + useValue: { + isEmbeddedPlayer: jest.fn().mockReturnValue(false), + openResolvedPlayback: jest.fn(), + }, + }, + { + provide: MatSnackBar, + useValue: { + open: jest.fn(), + }, + }, + { + provide: Location, + useValue: { + back: jest.fn(), + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(VodDetailsRouteComponent); + }); + + afterEach(() => { + consoleDebugSpy?.mockRestore(); + consoleWarnSpy?.mockRestore(); + }); + + /** + * Stand in for a discovered source list. + * + * The real one comes from a worker-backed discovery the route spec cannot + * reach, and every test here only needs "this row is the active one". + */ + function withActiveSource(playlistId: string, contentId: number): void { + Object.defineProperty( + fixture.componentInstance.multiSource, + 'sources', + { + configurable: true, + value: () => [ + { + id: `${playlistId}:xtream:${contentId}`, + playlistId, + playlistName: 'Portal One', + portalType: 'xtream', + contentId, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive: true, + isPinned: false, + isTried: true, + probe: { status: 'idle' }, + }, + ], + } + ); + } + + it('claims to be playing only while something is', () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + withActiveSource('playlist-1', 650020); + + // Discovery marks a source active as soon as the page opens, so the + // caption would otherwise say "Playing from ..." before Play is + // pressed — and again after the player is closed. + expect(component.activeSourceCaption()).toBeNull(); + + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + expect(component.activeSourceCaption()).not.toBeNull(); + + playback.inlinePlayback.set(null); + expect(component.activeSourceCaption()).toBeNull(); + }); + + it('stops claiming playback once the error screen is up', async () => { + currentPlaylist.set({ id: 'playlist-1' }); + const component = fixture.componentInstance; + const playback = fixture.debugElement.injector.get( + VodDetailsPlaybackService + ); + withActiveSource('playlist-1', 650020); + playback.inlinePlayback.set({ + streamUrl: 'http://example.com/movie.mkv', + title: 'Example', + }); + expect(component.activeSourceCaption()).not.toBeNull(); + + // The host stays mounted through a failure, so the caption would go on + // naming a source while the diagnostic says it could not be played. + await component.onPlaybackFailed(); + expect(component.activeSourceCaption()).toBeNull(); + + // And comes back when the engine produces time again. + component.handleInlineTimeUpdate({ currentTime: 3, duration: 90 }); + expect(component.activeSourceCaption()).not.toBeNull(); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts index ab333653b..977eae369 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route-playback.spec.ts @@ -1,11 +1,9 @@ import { signal } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { of } from 'rxjs'; import { Location } from '@angular/common'; -import { ContentHeroComponent } from '@iptvnator/ui/components'; import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, @@ -211,46 +209,37 @@ describe('VodDetailsRouteComponent — playback actions', () => { consoleWarnSpy?.mockRestore(); }); - it('claims to be playing only while something is', () => { - currentPlaylist.set({ id: 'playlist-1' }); - const component = fixture.componentInstance; - const playback = fixture.debugElement.injector.get( - VodDetailsPlaybackService + /** + * Stand in for a discovered source list. + * + * The real one comes from a worker-backed discovery the route spec cannot + * reach, and every test here only needs "this row is the active one". + */ + function withActiveSource(playlistId: string, contentId: number): void { + Object.defineProperty( + fixture.componentInstance.multiSource, + 'sources', + { + configurable: true, + value: () => [ + { + id: `${playlistId}:xtream:${contentId}`, + playlistId, + playlistName: 'Portal One', + portalType: 'xtream', + contentId, + rawTitle: 'Example', + matchConfidence: 'exact', + year: null, + isActive: true, + isPinned: false, + isTried: true, + probe: { status: 'idle' }, + }, + ], + } ); - Object.defineProperty(component.multiSource, 'sources', { - configurable: true, - value: () => [ - { - id: 'playlist-1:xtream:650020', - playlistId: 'playlist-1', - playlistName: 'Portal One', - portalType: 'xtream', - contentId: 650020, - rawTitle: 'Example', - matchConfidence: 'exact', - year: null, - isActive: true, - isPinned: false, - isTried: true, - probe: { status: 'idle' }, - }, - ], - }); - - // Discovery marks a source active as soon as the page opens, so the - // caption would otherwise say "Playing from ..." before Play is - // pressed — and again after the player is closed. - expect(component.activeSourceCaption()).toBeNull(); - - playback.inlinePlayback.set({ - streamUrl: 'http://example.com/movie.mkv', - title: 'Example', - }); - expect(component.activeSourceCaption()).not.toBeNull(); - - playback.inlinePlayback.set(null); - expect(component.activeSourceCaption()).toBeNull(); - }); + } it('owns an external session for a copy in its own playlist', () => { currentPlaylist.set({ id: 'playlist-1' }); @@ -262,25 +251,7 @@ describe('VodDetailsRouteComponent — playback actions', () => { // A pinned copy can now live in the route's OWN playlist. Comparing // playlists alone would call this "the route source", and the page // would disown the session it started for it. - Object.defineProperty(component.multiSource, 'sources', { - configurable: true, - value: () => [ - { - id: 'playlist-1:xtream:4242', - playlistId: 'playlist-1', - playlistName: 'Portal One', - portalType: 'xtream', - contentId: 4242, - rawTitle: 'Example', - matchConfidence: 'exact', - year: null, - isActive: true, - isPinned: true, - isTried: true, - probe: { status: 'idle' }, - }, - ], - }); + withActiveSource('playlist-1', 4242); activeSession.set({ player: 'mpv', status: 'playing', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 0939e27fc..9efec927a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -459,11 +459,13 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // timeupdate would otherwise resolve the next source back at it. this.multiSource.reportPosition(0); this.multiSource.markRouteSourceActive(); + this.playbackFailed.set(false); this.playback.playVod(vodItem); } resumeVod(vodItem: XtreamVodDetails | null): void { this.multiSource.markRouteSourceActive(); + this.playbackFailed.set(false); this.playback.resumeVod(vodItem); } @@ -551,10 +553,11 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { const active = this.multiSource.sources().find((s) => s.isActive); // "Playing from" only while something actually is. Discovery marks a // source active as soon as the page opens, so gating on that alone - // makes the line a claim about a player that has not started — or one - // the user has since closed. + // makes the line a claim about a player that has not started, one the + // user has since closed, or one that failed and is showing an error. const playing = - !!this.inlinePlayback() || !!this.matchedExternalPlayback(); + (!!this.inlinePlayback() && !this.playbackFailed()) || + !!this.matchedExternalPlayback(); if (!active || !playing) { return null; } @@ -585,6 +588,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { }); playFromSource(sourceId: string): void { + this.playbackFailed.set(false); void this.multiSource.play(sourceId); } @@ -606,6 +610,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { * is already showing the alternatives — is left to do its job. */ async onPlaybackFailed(): Promise { + // The error screen is up: nothing is playing from anywhere until a + // source actually starts again. + this.playbackFailed.set(true); const notice = await this.multiSource.failover(); if (!notice) { return; @@ -637,10 +644,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { } } + /** True from a playback failure until something plays again. */ + private readonly playbackFailed = signal(false); + handleInlineTimeUpdate(event: { currentTime: number; duration: number; }): void { + // The engine is producing time, so whatever failed before is over. + this.playbackFailed.set(false); const settled = this.playback.handleInlineTimeUpdate(event); // Ahead of the service's 15s persistence throttle, so a source switch