fix(portals): match the sub-HD formats, and drop the caption on failure

Two follow-ups to the previous round, both the same rule again.

The 800-wide band still answered from the width alone, so 800x600 and
800x450 were labelled 480p — published with `api` provenance, so read as a
measurement. Sub-HD formats are now matched against known shapes with the
same 5% tolerance the height path uses, and anything unrecognised carries no
tag at all.

And "Playing from ..." survived a playback failure: the inline host stays
mounted while the diagnostic is on screen, so the page named a source for a
stream it had just reported it could not play. The caption now clears on
failure and returns when the engine produces time again.

Splits the route playback spec along the "what it does" / "what it claims"
seam and lifts the repeated active-source stub into one helper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 05:22:44 +02:00
1 parent 7045f2d665
commit 2915771b0f
6 files changed
+388 -87

No files matched your search

+10 -7
View File
@@ -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
@@ -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();
@@ -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(
@@ -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<VodDetailsRouteComponent>;
let consoleDebugSpy: jest.SpyInstance | undefined;
let consoleWarnSpy: jest.SpyInstance | undefined;
const selectedItem = signal<XtreamVodDetails | null>(null);
const isLoadingDetails = signal(false);
const detailsError = signal<string | null>(null);
const isFavorite = signal(false);
const currentPlaylist = signal<{
id: string;
userAgent?: string;
referrer?: string;
origin?: string;
} | null>(null);
const vodStreams = signal<Partial<XtreamVodStream>[]>([]);
const vodCategories = signal<Partial<XtreamCategory>[]>([]);
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<unknown>(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();
});
});
@@ -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',
@@ -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<void> {
// 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