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 094120b9d..5ada5ee13 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,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. 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 7b26db6d1..667aef313 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 @@ -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)) { diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts index 5bdc3ef81..291a37337 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts @@ -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 { 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 62b8bda67..343c3bd6e 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 @@ -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(() => { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.spec.ts new file mode 100644 index 000000000..9ff2fdd2a --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.spec.ts @@ -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); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts index 1cb7cd17b..84a7aa313 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts @@ -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; } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts index 051cbf0d8..72e413b5d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-identity.ts @@ -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, }; }