mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(portals): give the route's own row the facts the page already has
Two provenance defects found in review. The current-source row is never resolved — nothing needs to fetch a URL for the stream already playing — so it carried no provider metadata at all, while every alternative got its facts from the resolve preceding playback. `audioDiffersFactually` requires a fact on BOTH sides, so the "dub may differ" warning was structurally unreachable on the commonest switch there is: route to alternative. It could only ever fire between two alternatives that had both been resolved. The row now carries what `get_vod_info` already told the page, via a `providerVodMetadataOf` mapper shared with the resolver so the two cannot describe one movie differently. Quality bucketed every width from 900 to 1199 as 576p, so a 960x540 stream — an ordinary 540p encode — was published as "576p" with `api` provenance: a measurement its own pixels contradict, from the one field that is supposed to mean the provider said so. That range holds two standard formats, so it is matched now rather than bucketed, exactly as the sub-HD sizes already were. A width matching no known format yields no tag and a check chip, which is the honest answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
501282289a
commit
17c3fbffba
7 files changed
+199
-27
No files matched your search
@@ -148,6 +148,30 @@ describe('applyApiMetadata', () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tells 540p apart from 576p instead of bucketing both', () => {
|
||||
// 960x540 and 1024x576 are two standard formats inside what used to be
|
||||
// one 900-1199 range, so every 540p encode was published as "576p"
|
||||
// with `api` provenance — a measurement its own pixels contradict.
|
||||
expect(
|
||||
applyApiMetadata(candidate(), { width: 960, height: 540 }).quality
|
||||
).toEqual({ value: '540p', provenance: 'api' });
|
||||
expect(
|
||||
applyApiMetadata(candidate(), { width: 1024, height: 576 }).quality
|
||||
).toEqual({ value: '576p', provenance: 'api' });
|
||||
// Letterboxing only ever removes lines, so the width still names it.
|
||||
expect(
|
||||
applyApiMetadata(candidate(), { width: 1024, height: 432 }).quality
|
||||
).toEqual({ value: '576p', provenance: 'api' });
|
||||
});
|
||||
|
||||
it('gives no quality for a width between the known formats', () => {
|
||||
// 1100 wide is no standard shape. The old range answered "576p" for
|
||||
// it; an absent tag and a check chip is the honest reply.
|
||||
expect(
|
||||
applyApiMetadata(candidate(), { width: 1100, height: 620 }).quality
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a height the width cannot account for', () => {
|
||||
// 640x480 is 4:3 VGA, not a letterboxed 360p — the width alone would
|
||||
// have called it 360p and published that as a measurement.
|
||||
|
||||
@@ -241,13 +241,19 @@ function isPositiveNumber(value: number | null | undefined): value is number {
|
||||
/**
|
||||
* The sub-HD formats, matched rather than bucketed.
|
||||
*
|
||||
* 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.
|
||||
* Bucketing only survives above 1200, where the standard widths are far apart.
|
||||
* Below that they are not: 960×540 and 1024×576 are two different formats
|
||||
* inside what used to be one 900–1199 range, so every 540p encode was published
|
||||
* as "576p" with `api` provenance — a measurement the pixels contradict. Lower
|
||||
* still, 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, because a guess
|
||||
* here would be read as a measurement.
|
||||
*/
|
||||
const SMALL_FORMATS: ReadonlyArray<[number, number, string]> = [
|
||||
const KNOWN_FORMATS: ReadonlyArray<[number, number, string]> = [
|
||||
[1024, 576, '576p'],
|
||||
[960, 540, '540p'],
|
||||
[854, 480, '480p'],
|
||||
[720, 576, '576p'],
|
||||
[720, 480, '480p'],
|
||||
@@ -258,11 +264,11 @@ function within5Percent(value: number, reference: number): boolean {
|
||||
return Math.abs(value - reference) / reference <= 0.05;
|
||||
}
|
||||
|
||||
function smallFormatQuality(
|
||||
function knownFormatQuality(
|
||||
width: number,
|
||||
height: number | null | undefined
|
||||
): string | null {
|
||||
const byWidth = SMALL_FORMATS.filter(([reference]) =>
|
||||
const byWidth = KNOWN_FORMATS.filter(([reference]) =>
|
||||
within5Percent(width, reference)
|
||||
);
|
||||
if (byWidth.length === 0) {
|
||||
@@ -309,10 +315,7 @@ function qualityFromDimensions(
|
||||
if (width >= 1200) {
|
||||
return '720p';
|
||||
}
|
||||
if (width >= 900) {
|
||||
return '576p';
|
||||
}
|
||||
return smallFormatQuality(width, height);
|
||||
return knownFormatQuality(width, height);
|
||||
}
|
||||
|
||||
if (!isPositiveNumber(height)) {
|
||||
|
||||
+38
-14
@@ -11,7 +11,10 @@ import type {
|
||||
VodSourceCandidate,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { createLogger } from '@iptvnator/portal/shared/util';
|
||||
import { applyApiMetadata } from './vod-source-metadata.util';
|
||||
import {
|
||||
applyApiMetadata,
|
||||
type ProviderVodMetadata,
|
||||
} from './vod-source-metadata.util';
|
||||
|
||||
/**
|
||||
* Turns a discovered source into something playable.
|
||||
@@ -177,12 +180,6 @@ export class VodSourceResolverService {
|
||||
candidate.contentId
|
||||
);
|
||||
|
||||
const info = Array.isArray(vodDetails?.info)
|
||||
? undefined
|
||||
: vodDetails?.info;
|
||||
const video = readStreamInfo(info?.video);
|
||||
const audio = readStreamInfo(info?.audio);
|
||||
|
||||
const container = vodDetails?.movie_data?.container_extension;
|
||||
if (container) {
|
||||
this.containerCache.set(cacheKey, container);
|
||||
@@ -190,13 +187,7 @@ export class VodSourceResolverService {
|
||||
|
||||
return {
|
||||
vodDetails,
|
||||
metadata: {
|
||||
containerExtension: container ?? null,
|
||||
videoCodec: video?.codec_name ?? null,
|
||||
audioCodec: audio?.codec_name ?? null,
|
||||
width: video?.width ?? null,
|
||||
height: video?.height ?? null,
|
||||
},
|
||||
metadata: providerVodMetadataOf(vodDetails),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn('Reading alternative VOD details failed:', error);
|
||||
@@ -230,6 +221,39 @@ export class VodSourceResolverService {
|
||||
* the codec on every array-shaped response — so the source rows showed no
|
||||
* provider-stated codec and the "dub may differ" warning could never fire.
|
||||
*/
|
||||
/**
|
||||
* The provider's own stated facts about one movie, in the shape
|
||||
* `applyApiMetadata` consumes.
|
||||
*
|
||||
* Shared with the ROUTE's source row, which the page can build from details it
|
||||
* has already loaded. Without it the row the user is switching *from* carries
|
||||
* no facts at all, so `audioDiffersFactually` has nothing to compare and the
|
||||
* "dub may differ" warning cannot fire on the commonest switch there is.
|
||||
*/
|
||||
export function providerVodMetadataOf(
|
||||
vodDetails:
|
||||
| { info?: unknown; movie_data?: { container_extension?: string } }
|
||||
| null
|
||||
| undefined
|
||||
): ProviderVodMetadata {
|
||||
// A provider can send `info` as an array, which carries no stream data.
|
||||
const info = Array.isArray(vodDetails?.info)
|
||||
? undefined
|
||||
: (vodDetails?.info as
|
||||
| { video?: unknown; audio?: unknown }
|
||||
| undefined);
|
||||
const video = readStreamInfo(info?.video);
|
||||
const audio = readStreamInfo(info?.audio);
|
||||
|
||||
return {
|
||||
containerExtension: vodDetails?.movie_data?.container_extension ?? null,
|
||||
videoCodec: video?.codec_name ?? null,
|
||||
audioCodec: audio?.codec_name ?? null,
|
||||
width: video?.width ?? null,
|
||||
height: video?.height ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function readStreamInfo(
|
||||
value: unknown
|
||||
): { codec_name?: string; width?: number; height?: number } | undefined {
|
||||
|
||||
@@ -228,6 +228,8 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
|
||||
vodId: this.selectedVodId(),
|
||||
vodInfo: this.selectedVodInfo(),
|
||||
catalogItem: this.selectedCatalogItem(),
|
||||
containerExtension:
|
||||
this.selectedItem()?.movie_data?.container_extension,
|
||||
})
|
||||
);
|
||||
readonly selectedVodInfo = computed(() => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { audioDiffersFactually } from '@iptvnator/portal/shared/data-access';
|
||||
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
|
||||
import { currentSourceRow } from './vod-multi-source-current-row';
|
||||
import { resolveVodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
|
||||
/**
|
||||
* The row standing for the source the film is already playing from.
|
||||
*
|
||||
* It is never resolved — nothing needs to fetch a URL for the stream already on
|
||||
* screen — so it is the one row that can silently hold no provider facts, and
|
||||
* every comparison against it then has nothing on one side.
|
||||
*/
|
||||
|
||||
const MOVIE = {
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Portal One',
|
||||
vodId: 101,
|
||||
catalogItem: { title: 'Dune' },
|
||||
};
|
||||
|
||||
function alternative(audio: string): VodSourceCandidate {
|
||||
return {
|
||||
id: 'playlist-2:xtream:991',
|
||||
playlistId: 'playlist-2',
|
||||
playlistName: 'Portal Two',
|
||||
portalType: 'xtream',
|
||||
contentId: 991,
|
||||
rawTitle: 'Dune',
|
||||
matchConfidence: 'exact',
|
||||
year: null,
|
||||
audio: { value: audio, provenance: 'api' },
|
||||
} as VodSourceCandidate;
|
||||
}
|
||||
|
||||
describe('currentSourceRow', () => {
|
||||
it('states no facts before get_vod_info has landed', () => {
|
||||
const movie = resolveVodMultiSourceMovie({ ...MOVIE, vodInfo: null });
|
||||
|
||||
const row = currentSourceRow(movie as never);
|
||||
|
||||
// Silence is the honest reading of "not known yet" — a guess here
|
||||
// would be published with `api` provenance.
|
||||
expect(row.audio).toBeUndefined();
|
||||
expect(row.container).toBeUndefined();
|
||||
expect(row.quality).toBeUndefined();
|
||||
});
|
||||
|
||||
it('carries the provider facts the route already loaded', () => {
|
||||
const movie = resolveVodMultiSourceMovie({
|
||||
...MOVIE,
|
||||
vodInfo: {
|
||||
name: 'Dune',
|
||||
audio: { codec_name: 'ac3' },
|
||||
video: { codec_name: 'h264', width: 1920, height: 1080 },
|
||||
} as never,
|
||||
containerExtension: 'mkv',
|
||||
});
|
||||
|
||||
const row = currentSourceRow(movie as never);
|
||||
|
||||
// Only the video codecs have canonical spellings; an audio name is
|
||||
// passed through as the provider wrote it.
|
||||
expect(row.audio).toEqual({ value: 'ac3', provenance: 'api' });
|
||||
expect(row.container).toEqual({ value: 'mkv', provenance: 'api' });
|
||||
expect(row.quality).toEqual({ value: '1080p', provenance: 'api' });
|
||||
});
|
||||
|
||||
it('lets the dub warning fire on a route-to-alternative switch', () => {
|
||||
const movie = resolveVodMultiSourceMovie({
|
||||
...MOVIE,
|
||||
vodInfo: { name: 'Dune', audio: { codec_name: 'ac3' } } as never,
|
||||
});
|
||||
|
||||
const row = currentSourceRow(movie as never);
|
||||
|
||||
// `audioDiffersFactually` needs a fact on BOTH sides, so an empty
|
||||
// route row made the warning structurally unreachable on the
|
||||
// commonest switch there is — route to alternative. It only ever
|
||||
// fired between two alternatives that had both been resolved.
|
||||
expect(audioDiffersFactually(row, alternative('aac'))).toBe(true);
|
||||
expect(audioDiffersFactually(row, alternative('ac3'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { applyApiMetadata } from '@iptvnator/portal/shared/data-access';
|
||||
import type { VodSourceCandidate } from '@iptvnator/shared/interfaces';
|
||||
import type { VodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
|
||||
@@ -7,11 +8,20 @@ import type { VodMultiSourceMovie } from './vod-multi-source-identity';
|
||||
* Discovery only returns OTHER playlists, so without this the popover would
|
||||
* list the alternatives and say nothing about where the film is coming from
|
||||
* right now — and the "playing" badge would have nothing to attach to.
|
||||
*
|
||||
* It carries the provider's stated facts when the page has them. An
|
||||
* alternative gets its facts from the resolve that precedes playing it, but
|
||||
* this row is never resolved — the film is already playing from it — so
|
||||
* without this it held no metadata at all. Everything comparing two sources
|
||||
* then had nothing on one side: `audioDiffersFactually` in particular returns
|
||||
* false whenever either side is silent, so the "dub may differ" warning could
|
||||
* not fire on a route-to-alternative switch, which is the commonest one there
|
||||
* is.
|
||||
*/
|
||||
export function currentSourceRow(
|
||||
movie: VodMultiSourceMovie
|
||||
): VodSourceCandidate {
|
||||
return {
|
||||
const row: VodSourceCandidate = {
|
||||
id: `${movie.playlistId}:xtream:${movie.contentId}`,
|
||||
playlistId: movie.playlistId,
|
||||
playlistName: movie.playlistName,
|
||||
@@ -21,4 +31,6 @@ export function currentSourceRow(
|
||||
matchConfidence: 'exact',
|
||||
year: movie.year ?? null,
|
||||
};
|
||||
|
||||
return movie.metadata ? applyApiMetadata(row, movie.metadata) : row;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
providerVodMetadataOf,
|
||||
type ProviderVodMetadata,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
extractYear,
|
||||
releaseTagYear,
|
||||
@@ -18,6 +22,13 @@ export interface VodMultiSourceMovie {
|
||||
title: string;
|
||||
year: number | null;
|
||||
tmdbId?: number | string | null;
|
||||
/**
|
||||
* The provider's stated facts about THIS copy, when `get_vod_info` has
|
||||
* landed. Deliberately outside `vodMultiSourceMovieKey`: it describes the
|
||||
* stream, not which film this is, and rediscovering on a codec string
|
||||
* would restart the search for no gain.
|
||||
*/
|
||||
metadata?: ProviderVodMetadata;
|
||||
}
|
||||
|
||||
type CatalogItem =
|
||||
@@ -39,6 +50,8 @@ export function resolveVodMultiSourceMovie(input: {
|
||||
vodId: number;
|
||||
vodInfo: XtreamVodInfo | null;
|
||||
catalogItem: CatalogItem;
|
||||
/** From `movie_data`, which sits beside `info` rather than inside it. */
|
||||
containerExtension?: string | null;
|
||||
}): VodMultiSourceMovie | null {
|
||||
const { playlistId, vodId, vodInfo, catalogItem } = input;
|
||||
|
||||
@@ -66,6 +79,17 @@ export function resolveVodMultiSourceMovie(input: {
|
||||
// supplies the real one.
|
||||
year: extractYear(vodInfo?.releasedate) ?? releaseTagYear(title),
|
||||
tmdbId: vodInfo?.tmdb_id,
|
||||
// Only once `get_vod_info` has landed. Before that the row simply
|
||||
// states nothing, which is the honest reading of "not known yet".
|
||||
metadata: vodInfo
|
||||
? providerVodMetadataOf({
|
||||
info: vodInfo,
|
||||
movie_data: {
|
||||
container_extension:
|
||||
input.containerExtension ?? undefined,
|
||||
},
|
||||
})
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user