diff --git a/.changes/xtream-vod-source-language-detection.md b/.changes/xtream-vod-source-language-detection.md new file mode 100644 index 000000000..907c27542 --- /dev/null +++ b/.changes/xtream-vod-source-language-detection.md @@ -0,0 +1,10 @@ +--- +type: feature +area: xtream +--- + +The movie sources popover now recognizes more language tags: prefixes with +Unicode pipes, brackets or dashes ("EN │ …", "[EN] …", "EN - …"), Cyrillic +tags ("РУС | …") and MULTI. When a stream title carries no tag at all, the +language is read from its category names ("EN | Netflix") — so the language +filter and chips appear on panels that only tag categories. diff --git a/CLAUDE.md b/CLAUDE.md index 541b6a7da..981c35cc8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1209,7 +1209,7 @@ engine` (restart required) or **VOD Multi-Source** (alternative sources for a movie): -- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 660px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the overlay's flexible bounding box so only the source list scrolls, and flips below when less than the overlay `minHeight` remains above; filter chips (All / Available / HD+ / language-prefix select) compose with the host search, "Available" auto-runs check-all when no verdicts exist, and expanded copy rows show a parsed language chip + raw stream title with diff-only tags ("same as above" for the parent's copy). Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source (`VodSourceProbeCacheService`). Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The details-page chip badge counts TOTAL **copies** across all playlists (the in-player chip still counts alternatives); the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when favorited, and a download idle icon → progress ring (real percent, indeterminate spin, paused-resume) → green done-checkmark whose click reveals the file (state read from the download manager; the labeled "Play from source" secondary is gone — provider playback for a downloaded movie goes through the Sources popover). +- Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 660px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the overlay's flexible bounding box so only the source list scrolls, and flips below when less than the overlay `minHeight` remains above; filter chips (All / Available / HD+ / language select) compose with the host search, "Available" auto-runs check-all when no verdicts exist, and expanded copy rows show a parsed language chip + raw stream title with diff-only tags ("same as above" for the parent's copy). A row's language is `vodSourceLanguage` (`libs/shared/interfaces/src/lib/vod-source-language.util.ts`): the title's own prefix (pipe incl. Unicode lookalikes, bracketed, or ALL-CAPS spaced-dash form; Latin/Cyrillic 2–4 letters + `MULTI`) wins, else the language the stream's visible categories unambiguously carry ("EN | Netflix" — discovery aggregates `group_concat(cat.name, char(31))` per (playlist, stream), prefixed categories must agree, and category prefixes must pass `isKnownLanguageTag`, since `new`/`top`/`hot` are real ISO 639-3 codes but everyday category words; the route's own row reads the one category the route arrived through). Both forms are parsed guesses: browse filter and chips only, never ranking/failover/dub-warning inputs. Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source (`VodSourceProbeCacheService`). Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The details-page chip badge counts TOTAL **copies** across all playlists (the in-player chip still counts alternatives); the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when favorited, and a download idle icon → progress ring (real percent, indeterminate spin, paused-resume) → green done-checkmark whose click reveals the file (state read from the download manager; the labeled "Play from source" secondary is gone — provider playback for a downloaded movie goes through the Sources popover). - Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing. - **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying `api` provenance. The route's OWN row is never resolved, so it takes its facts from the `get_vod_info` the page already loaded (`providerVodMetadataOf`, shared with the resolver) and picks them up via `refreshRouteFacts()` even when they arrive without changing the movie identity — otherwise `audioDiffersFactually` has nothing on one side and the dub warning cannot fire on a route-to-alternative switch. - Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches an ASCII token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an _exact_ match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials. diff --git a/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts index 18af42c84..073ea4306 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources-matching.spec.ts @@ -26,10 +26,31 @@ describe('title-sources.operations — confirmation and scoping', () => { posterUrl: 'https://cdn.example.com/dune.jpg', matchConfidence: 'exact', year: null, + categoryNames: [], }, ]); }); + it('splits aggregated category names on the unit separator', async () => { + // `group_concat`'s default `,` appears in real category names, so + // the queries aggregate with char(31) and the split must read + // exactly that — a comma inside a name stays part of the name. + const { db } = createDbMock([ + { + ...duneRow, + category_names: + 'EN | Netflix\u001fAction, Adventure\u001fEN | Netflix', + }, + ]); + + const matches = await findTitleSources(db, { title: 'Dune' }); + + expect(matches[0].categoryNames).toEqual([ + 'EN | Netflix', + 'Action, Adventure', + ]); + }); + it('confirms a year-stripped candidate as a fuzzy match', async () => { const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]); @@ -162,7 +183,9 @@ describe('title-sources.operations — confirmation and scoping', () => { expect( `${requested} confirms ${matches.length} of ${rows.length}` - ).toBe(`${requested} confirms ${rows.length} of ${rows.length}`); + ).toBe( + `${requested} confirms ${rows.length} of ${rows.length}` + ); } }); diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts index 1cf2da513..20ddda8f6 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts @@ -75,6 +75,28 @@ describe('title-sources.operations', () => { expect(compiledQuery(fts.all).sql).toContain('LIMIT'); }); + it('aggregates visible category names on both query paths', async () => { + // The renderer reads a language prefix off category names + // ("EN | Netflix"), so both paths must return them — grouped per + // (playlist, stream) and joined with char(31), because the + // default `,` appears inside real category names. + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'It' }); + const scanQuery = compiledQuery(scan.all); + expect(scanQuery.sql).toContain('group_concat(cat.name, char(31))'); + expect(scanQuery.sql).toContain( + 'GROUP BY cat.playlist_id, c.xtream_id' + ); + + const fts = createDbMock([]); + await findTitleSources(fts.db, { title: 'Dune' }); + const ftsQuery = compiledQuery(fts.all); + expect(ftsQuery.sql).toContain('group_concat(cat.name, char(31))'); + expect(ftsQuery.sql).toContain( + 'GROUP BY cat.playlist_id, c.xtream_id' + ); + }); + it('can still find a short non-ASCII title', async () => { // SQLite's LOWER() is ASCII-only, so folding "Он" to "он" never // happened and the film stayed invisible in the Sources chip. diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts index 932661e1f..98fb8228f 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -35,6 +35,37 @@ interface TitleSourceRow { category_xtream_id: number; playlist_id: string; playlist_name: string; + /** `group_concat` of every visible category name, `\u001f`-separated. */ + category_names: string | null; +} + +/** + * The separator for aggregated category names. `group_concat`'s default `,` + * appears in real category names ("Action, Adventure"), so splitting on it + * would shred them; the ASCII unit separator cannot. Written as `char(31)` in + * SQL and `\u001f` in the split. + */ +const CATEGORY_NAME_SEPARATOR = '\u001f'; + +/** + * Aggregated category names back into a list. Duplicates are dropped here + * rather than with `DISTINCT` in SQL, because SQLite refuses a custom + * separator on a DISTINCT aggregate — and duplicate names change nothing for + * a reader that only compares language prefixes. + */ +function splitCategoryNames(aggregated: string | null): string[] { + if (!aggregated) { + return []; + } + + const names: string[] = []; + for (const raw of aggregated.split(CATEGORY_NAME_SEPARATOR)) { + const name = raw.trim(); + if (name && !names.includes(name)) { + names.push(name); + } + } + return names; } export interface FindTitleSourcesRequest { @@ -197,6 +228,10 @@ function scanCandidateQuery( ), sql` AND ` ); + // Grouped like the FTS path: one row per (playlist, stream), with every + // visible category name aggregated onto it so the renderer can read a + // language prefix off categories ("EN | Netflix"). The hidden filter + // above keeps hidden categories out of that aggregate too. return sql` SELECT c.id AS content_id, @@ -205,7 +240,8 @@ function scanCandidateQuery( c.poster_url AS poster_url, cat.xtream_id AS category_xtream_id, cat.playlist_id AS playlist_id, - p.name AS playlist_name + p.name AS playlist_name, + group_concat(cat.name, char(31)) AS category_names FROM content AS c INNER JOIN categories AS cat ON c.category_id = cat.id INNER JOIN playlists AS p ON cat.playlist_id = p.id @@ -214,6 +250,7 @@ function scanCandidateQuery( AND p.type = 'xtream' AND ${wordMatches} ${excludePlaylist} + GROUP BY cat.playlist_id, c.xtream_id ORDER BY LENGTH(c.title), c.title `; } @@ -236,7 +273,8 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) { c.poster_url AS poster_url, cat.xtream_id AS category_xtream_id, cat.playlist_id AS playlist_id, - p.name AS playlist_name + p.name AS playlist_name, + group_concat(cat.name, char(31)) AS category_names FROM content_title_fts INNER JOIN content AS c ON c.id = content_title_fts.rowid INNER JOIN categories AS cat ON c.category_id = cat.id @@ -357,6 +395,7 @@ export async function findTitleSources( posterUrl: row.poster_url, matchConfidence: exactMatch ? 'exact' : 'fuzzy', year: rowYear, + categoryNames: splitCategoryNames(row.category_names), }); } diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 834b70262..7c16af237 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -156,9 +156,8 @@ fit contract: The chip row composes with the host search (AND): **All (N)** resets the chip filters and states the total copy count, **Available** keeps only sources whose probe verified them, **HD+** keeps sources whose quality tag reads -1080p or better, and the language select is built from the `EN|`-style -prefixes actually present in the raw titles. Two of these encode a decision -worth writing down: +1080p or better, and the language select is built from the languages actually +present in the list. Two of these encode a decision worth writing down: - "Available" is strict: only `probe.status === 'ok'` passes. An unchecked source must never pass a filter with that name — and because checks are @@ -174,14 +173,50 @@ worth writing down: When search or filters reduce the list, a muted "X of N" counter appears, and groups with no matching copy disappear entirely. +### Where a row's language comes from + +The language the select and the copy chips read is `vodSourceLanguage` +(`libs/shared/interfaces/src/lib/vod-source-language.util.ts`): the stream +title's own prefix when it has one, else the language the stream's categories +agree on. Both are parsed guesses — they feed browsing only, and neither +ranking, failover nor the dub warning can reach them (`factualOnly` and +`audioDiffersFactually` read other fields entirely). + +`titleLanguagePrefix` accepts the three shapes panels actually write: a 2–4 +letter Latin or Cyrillic tag (plus the five-letter `MULTI` marker) before a +pipe **or any of its Unicode lookalikes** (`¦`, `│`, `|`, …— visually +identical to `|`, invisible to a literal match), a bracketed tag at the very +start (`[EN] Movie`), and an ALL-uppercase tag before a **spaced** dash +(`EN - Movie`). The dash form is stricter on purpose: dashes are ordinary +title punctuation, and "Up - the movie" or "X-Men" must not read as a +language. + +The category path exists because many panels tag the CATEGORY ("EN | Netflix", +"DE | Apple TV") and leave stream titles bare. Discovery aggregates every +visible category name a stream sits in (`group_concat(cat.name, char(31))` +per `(playlist_id, xtream_id)` in both query tiers — `char(31)` because the +default `,` appears inside real category names), and +`unambiguousCategoryLanguage` reduces them: categories without a recognized +language prefix abstain, all prefixed ones must agree, and a conflict yields +nothing. Category prefixes must additionally pass `isKnownLanguageTag`, +because categories routinely start with "NEW |", "TOP |" or "VIP |" — and +`new`, `top` and `hot` are even assigned ISO 639-3 codes, which is why the +gate is an `Intl.DisplayNames` check for two-letter codes plus a curated list +for longer tags rather than a registry lookup. Title prefixes stay permissive: +a tag in front of a movie title is overwhelmingly a language, and tightening +there would drop working filter options. The route's own row reads the one +category the route arrived through (`VodMultiSourceMovie.categoryName`), +which is the visible one. + ### The popover: copy rows Expanding "N copies in this playlist" lists EVERY copy of the group — including the one the parent row already shows — as compact `app-vod-source-copy-row`s indented under the parent's text column. The playlist's name and monogram are not repeated: the primary text is the -provider's own raw stream title (mono), with the parsed `EN|`/`RU|` language -prefix promoted to a chip before it. The tag row shows only values that +provider's own raw stream title (mono), with the copy's parsed language +(`vodSourceLanguage` — title prefix first, category fallback) promoted to a +chip before it. The tag row shows only values that DIFFER from the parent's copy (identical container/codec are omitted), so what distinguishes a copy is the only thing on the line; the copy identical to the parent shows a muted "same as above" note instead. The fuzzy-match diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts index 05f3fc68b..b71d9e418 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts @@ -68,3 +68,46 @@ describe('VodSourceDiscoveryService — failure logging', () => { .join('\n'); } }); + +describe('VodSourceDiscoveryService — candidate mapping', () => { + afterEach(() => { + delete (window as { electron?: unknown }).electron; + }); + + it('derives the category language, leaving conflicts and noise empty', async () => { + (window as { electron?: unknown }).electron = { + dbFindTitleSources: jest + .fn() + .mockResolvedValue([ + row(1, ['EN | Netflix', 'EN | Action']), + row(2, ['EN | Netflix', 'DE | Cinema']), + row(3, ['TOP | 250']), + row(4, undefined), + ]), + }; + const service = new VodSourceDiscoveryService(); + + const result = await service.discover({ + title: 'Dune', + currentPlaylistId: 'playlist-0', + }); + + expect(result.sources.map((source) => source.categoryLanguage)).toEqual( + ['EN', null, null, null] + ); + }); + + function row(id: number, categoryNames: string[] | undefined) { + return { + playlistId: `playlist-${id}`, + playlistName: `Portal ${id}`, + categoryId: id, + xtreamId: 100 + id, + title: 'Dune', + posterUrl: null, + matchConfidence: 'exact' as const, + year: null, + categoryNames, + }; + } +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts index 78fd6384f..6e85c7165 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts @@ -1,8 +1,9 @@ import { Injectable } from '@angular/core'; -import type { - VodSourceCandidate, - VodSourceCandidateRow, - VodSourceMatchKind, +import { + unambiguousCategoryLanguage, + type VodSourceCandidate, + type VodSourceCandidateRow, + type VodSourceMatchKind, } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; import { parseTitleMetadata } from './vod-source-metadata.util'; @@ -99,5 +100,8 @@ function toCandidate(row: VodSourceCandidateRow): VodSourceCandidate { quality: parsed.quality, codec: parsed.codec, audio: parsed.audio, + // A guess like everything else here: the language the stream's + // categories agree on, standing in when the title has no prefix. + categoryLanguage: unambiguousCategoryLanguage(row.categoryNames), }; } 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 51c26dc8c..8c011c0be 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 @@ -259,8 +259,15 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { ); }); /** Movie identity for multi-source discovery; null until a title exists */ - private readonly multiSourceMovie = computed(() => - resolveVodMultiSourceMovie({ + private readonly multiSourceMovie = computed(() => { + // Electron stores categories under `name`, the live API under + // `category_name` — the same duality the fallback view reads. + const category = this.selectedCategory() as { + name?: string; + category_name?: string; + } | null; + + return resolveVodMultiSourceMovie({ playlistId: this.xtreamStore.currentPlaylist()?.id, // `title` is the alias the Xtream data source actually writes // (createPlaylist maps name -> title), so reading only `name` @@ -273,8 +280,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { catalogItem: this.selectedCatalogItem(), containerExtension: this.selectedItem()?.movie_data?.container_extension, - }) - ); + categoryName: category?.name ?? category?.category_name ?? null, + }); + }); readonly selectedVodInfo = computed(() => { const item = this.selectedItem(); return item && hasUsableXtreamVodMetadata(item) diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts index 3340916dd..95198a26f 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -58,6 +58,7 @@ export function createVodDetailsRouteStubs() { addRecentItem: jest.fn(), cancelDetailsRequest: jest.fn(), vodStreamsPlaylistId: signal(null), + vodCategoriesPlaylistId: signal(null), downloadsAvailable: signal(false), downloads: signal([]), isDownloaded: jest.fn().mockReturnValue(false), @@ -198,6 +199,7 @@ export async function configureVodDetailsRouteTestBed( addRecentItem: stubs.addRecentItem, cancelDetailsRequest: stubs.cancelDetailsRequest, vodStreamsPlaylistId: stubs.vodStreamsPlaylistId, + vodCategoriesPlaylistId: stubs.vodCategoriesPlaylistId, }, }, { 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 index 9bd08742b..1a605d9c5 100644 --- 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 @@ -85,6 +85,33 @@ describe('currentSourceRow', () => { expect(audioDiffersFactually(row, alternative('ru'))).toBe(false); }); + it('reads the language off the route category, title prefix first', () => { + const viaCategory = resolveVodMultiSourceMovie({ + ...MOVIE, + vodInfo: null, + categoryName: 'EN | Netflix', + }); + expect(currentSourceRow(viaCategory as never).categoryLanguage).toBe( + 'EN' + ); + + // A category prefix that is not a language stays out entirely. + const viaNoise = resolveVodMultiSourceMovie({ + ...MOVIE, + vodInfo: null, + categoryName: 'TOP | 250', + }); + expect(currentSourceRow(viaNoise as never).categoryLanguage).toBeNull(); + + const withoutCategory = resolveVodMultiSourceMovie({ + ...MOVIE, + vodInfo: null, + }); + expect( + currentSourceRow(withoutCategory as never).categoryLanguage + ).toBeNull(); + }); + it('does not call a codec change a dub change', () => { const movie = resolveVodMultiSourceMovie({ ...MOVIE, 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 84a7aa313..d8319975c 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,5 +1,8 @@ import { applyApiMetadata } from '@iptvnator/portal/shared/data-access'; -import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import { + unambiguousCategoryLanguage, + type VodSourceCandidate, +} from '@iptvnator/shared/interfaces'; import type { VodMultiSourceMovie } from './vod-multi-source-identity'; /** @@ -30,6 +33,11 @@ export function currentSourceRow( rawTitle: movie.title, matchConfidence: 'exact', year: movie.year ?? null, + // Alternatives read this off every category the DB knows; the route + // only knows the one it arrived through, which is the visible one. + categoryLanguage: unambiguousCategoryLanguage( + movie.categoryName ? [movie.categoryName] : 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 72e413b5d..72718f1ce 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 @@ -29,12 +29,17 @@ export interface VodMultiSourceMovie { * would restart the search for no gain. */ metadata?: ProviderVodMetadata; + /** + * Name of the category the route arrived through ("EN | Netflix"), so the + * route's own row can carry a category-derived language like discovered + * alternatives do. Outside the movie key for the same reason as + * `metadata`: it describes presentation, not which film this is. + */ + categoryName?: string | null; } type CatalogItem = - | (Partial & { title?: string }) - | null - | undefined; + (Partial & { title?: string }) | null | undefined; /** * Resolve the movie identity, or null while it is not yet knowable. @@ -52,6 +57,8 @@ export function resolveVodMultiSourceMovie(input: { catalogItem: CatalogItem; /** From `movie_data`, which sits beside `info` rather than inside it. */ containerExtension?: string | null; + /** Name of the category the route arrived through, when known. */ + categoryName?: string | null; }): VodMultiSourceMovie | null { const { playlistId, vodId, vodInfo, catalogItem } = input; @@ -79,6 +86,7 @@ export function resolveVodMultiSourceMovie(input: { // supplies the real one. year: extractYear(vodInfo?.releasedate) ?? releaseTagYear(title), tmdbId: vodInfo?.tmdb_id, + categoryName: input.categoryName ?? null, // 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 diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 1328de28b..1abab7749 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -52,6 +52,7 @@ export * from './lib/stalker-account-info-dialog-data.interface'; export * from './lib/title-normalization.util'; export * from './lib/tmdb.interface'; export * from './lib/vod-source.interface'; +export * from './lib/vod-source-language.util'; export * from './lib/vod-source-match-key.util'; export * from './lib/xtream-account-info-dialog-data.interface'; export * from './lib/xtream-category.interface'; diff --git a/libs/shared/interfaces/src/lib/vod-source-language.util.spec.ts b/libs/shared/interfaces/src/lib/vod-source-language.util.spec.ts new file mode 100644 index 000000000..06852b578 --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-source-language.util.spec.ts @@ -0,0 +1,146 @@ +import { + isKnownLanguageTag, + titleLanguagePrefix, + unambiguousCategoryLanguage, + vodSourceLanguage, +} from './vod-source-language.util'; + +describe('titleLanguagePrefix', () => { + it('reads the tag before a pipe, any case', () => { + expect(titleLanguagePrefix('EN| Night of the Living Dead')).toBe('EN'); + expect(titleLanguagePrefix('ALB |Some Movie')).toBe('ALB'); + expect(titleLanguagePrefix(' ru| Ночь')).toBe('RU'); + }); + + it('reads Cyrillic tags', () => { + expect(titleLanguagePrefix('РУС | Фильм')).toBe('РУС'); + expect(titleLanguagePrefix('укр| Фільм')).toBe('УКР'); + }); + + it('reads Unicode pipe lookalikes as the separator', () => { + expect(titleLanguagePrefix('EN │ Movie')).toBe('EN'); + expect(titleLanguagePrefix('DE ¦ Der Film')).toBe('DE'); + expect(titleLanguagePrefix('FR|Le Film')).toBe('FR'); + }); + + it('reads a bracketed tag at the start', () => { + expect(titleLanguagePrefix('[EN] Movie')).toBe('EN'); + expect(titleLanguagePrefix('(ru) Ночь')).toBe('RU'); + // A bracket deeper in the title is not a prefix. + expect(titleLanguagePrefix('Movie [EN]')).toBeNull(); + }); + + it('reads an uppercase tag before a spaced dash, and only that form', () => { + expect(titleLanguagePrefix('EN - Movie')).toBe('EN'); + expect(titleLanguagePrefix('РУС - Фильм')).toBe('РУС'); + // Lowercase before a dash is a title word ("Up - the movie"). + expect(titleLanguagePrefix('Up - the movie')).toBeNull(); + // Unspaced dashes are ordinary punctuation. + expect(titleLanguagePrefix('X-Men')).toBeNull(); + expect(titleLanguagePrefix('EN-Movie')).toBeNull(); + }); + + it('accepts the MULTI marker despite its five letters', () => { + expect(titleLanguagePrefix('MULTI | Movie')).toBe('MULTI'); + expect(titleLanguagePrefix('Multi| Movie')).toBe('MULTI'); + }); + + it('rejects titles whose separator is not a language marker', () => { + // Five letters is a word, not a language code. + expect(titleLanguagePrefix('NIGHT| of something')).toBeNull(); + expect(titleLanguagePrefix('Night of the Living Dead')).toBeNull(); + expect(titleLanguagePrefix('4K| Movie')).toBeNull(); + expect(titleLanguagePrefix(undefined)).toBeNull(); + expect(titleLanguagePrefix('')).toBeNull(); + }); +}); + +describe('isKnownLanguageTag', () => { + it('accepts assigned two-letter ISO codes in any case', () => { + expect(isKnownLanguageTag('EN')).toBe(true); + expect(isKnownLanguageTag('de')).toBe(true); + expect(isKnownLanguageTag('uk')).toBe(true); + }); + + it('rejects unassigned two-letter tokens', () => { + expect(isKnownLanguageTag('HD')).toBe(false); + expect(isKnownLanguageTag('XX')).toBe(false); + }); + + it('accepts curated long tags, Latin and Cyrillic', () => { + expect(isKnownLanguageTag('ENG')).toBe(true); + expect(isKnownLanguageTag('DEU')).toBe(true); + expect(isKnownLanguageTag('LAT')).toBe(true); + expect(isKnownLanguageTag('РУС')).toBe(true); + expect(isKnownLanguageTag('MULTI')).toBe(true); + }); + + it('rejects everyday category words that are real ISO 639-3 codes', () => { + // `new`, `top` and `hot` are assigned in ISO 639-3, which is exactly + // why validation is curated instead of registry-driven. + expect(isKnownLanguageTag('NEW')).toBe(false); + expect(isKnownLanguageTag('TOP')).toBe(false); + expect(isKnownLanguageTag('HOT')).toBe(false); + expect(isKnownLanguageTag('VIP')).toBe(false); + expect(isKnownLanguageTag('KIDS')).toBe(false); + }); +}); + +describe('unambiguousCategoryLanguage', () => { + it('reads the language every prefixed category agrees on', () => { + expect( + unambiguousCategoryLanguage(['EN | Netflix', 'EN | Action']) + ).toBe('EN'); + }); + + it('lets unprefixed categories abstain rather than veto', () => { + expect( + unambiguousCategoryLanguage(['EN | Netflix', 'Netflix 4K']) + ).toBe('EN'); + }); + + it('yields nothing on a conflict', () => { + expect( + unambiguousCategoryLanguage(['EN | Netflix', 'DE | Cinema']) + ).toBeNull(); + }); + + it('rejects category prefixes that are not languages', () => { + expect(unambiguousCategoryLanguage(['NEW | 2024'])).toBeNull(); + expect(unambiguousCategoryLanguage(['TOP | 250'])).toBeNull(); + expect(unambiguousCategoryLanguage(['VIP | Cinema'])).toBeNull(); + // ...while a real language beside them still reads through. + expect( + unambiguousCategoryLanguage(['NEW | 2024', 'DE | Apple TV']) + ).toBe('DE'); + }); + + it('handles empty and missing input', () => { + expect(unambiguousCategoryLanguage([])).toBeNull(); + expect(unambiguousCategoryLanguage(null)).toBeNull(); + expect(unambiguousCategoryLanguage(undefined)).toBeNull(); + expect(unambiguousCategoryLanguage([null, ''])).toBeNull(); + }); +}); + +describe('vodSourceLanguage', () => { + it('prefers the title prefix over the category language', () => { + expect( + vodSourceLanguage({ + rawTitle: 'RU| Movie', + categoryLanguage: 'EN', + }) + ).toBe('RU'); + }); + + it('falls back to the category language when the title says nothing', () => { + expect( + vodSourceLanguage({ rawTitle: 'Movie', categoryLanguage: 'EN' }) + ).toBe('EN'); + }); + + it('yields nothing when neither side knows', () => { + expect(vodSourceLanguage({ rawTitle: 'Movie' })).toBeNull(); + expect(vodSourceLanguage({})).toBeNull(); + }); +}); diff --git a/libs/shared/interfaces/src/lib/vod-source-language.util.ts b/libs/shared/interfaces/src/lib/vod-source-language.util.ts new file mode 100644 index 000000000..09a383169 --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-source-language.util.ts @@ -0,0 +1,267 @@ +/** + * Language prefixes for VOD multi-source rows. + * + * Panels rarely state a spoken language as a fact; what they do instead is + * prefix stream titles ("EN| Movie") and category names ("EN | Netflix") with + * a short tag. Everything here is therefore a GUESS by construction: it feeds + * the browse filter and the copy-row chips, and is structurally excluded from + * ranking and failover (`factualOnly` never reads it). + * + * Two parsers with different strictness, on purpose: + * + * - `titleLanguagePrefix` is permissive. A short tag in front of a MOVIE title + * is overwhelmingly a language — titles do not start with "VIP |" — so any + * 2–4 letter prefix counts, as it always has. + * - Category names are noisier: "NEW | 2024", "TOP | 250" and "VIP | Cinema" + * are everyday category shapes, and `new`, `top` and `hot` are even real + * ISO 639-3 codes, so a prefix read off a category must additionally pass + * `isKnownLanguageTag`. Empty beats wrong: an unrecognized tag yields no + * language rather than a wrong filter option. + */ + +/** + * The pipe and its display lookalikes. Panels routinely decorate with + * `¦`, `│` or fullwidth bars that are visually identical to `|` but fail a + * literal match — the user sees "EN | Movie" either way. + */ +const PIPE = '[|¦│┃❘∣⏐⎪︱︳丨|]'; + +/** + * A candidate language token: 2–4 letters of ONE script, or the `MULTI` + * marker panels use for multi-audio releases. Single-script on purpose — + * a mixed-script "word" before a pipe is decoration, not a tag. Digits are + * excluded, so "4K |" never reads as a language. + */ +const TOKEN = '(?:[A-Za-z]{2,4}|[А-Яа-яЁё]{2,4}|[Mm][Uu][Ll][Tt][Ii])'; + +/** `EN| Movie`, `ru │ Фильм`, `MULTI ¦ Movie` — any case before a pipe. */ +const PIPE_FORM = new RegExp(`^\\s*(${TOKEN})\\s*${PIPE}`); + +/** `[EN] Movie`, `(RU) Фильм` — a bracketed tag at the very start. */ +const BRACKET_FORM = new RegExp(`^\\s*[[(]\\s*(${TOKEN})\\s*[\\])]`); + +/** + * `EN - Movie` — dash-separated, and deliberately stricter than the pipe + * form: the tag must be ALL uppercase and the dash spaced on both sides. + * Dashes are ordinary title punctuation ("X-Men", "Up - the movie"), so a + * lowercase or unspaced form is a title, not a tag. + */ +const DASH_FORM = new RegExp( + `^\\s*((?:[A-Z]{2,4}|[А-ЯЁ]{2,4}|MULTI))\\s+[-–—]\\s+` +); + +/** + * `EN| Night of the Living Dead` → `EN`. + * + * The provider's language convention for stream titles, in the three shapes + * seen in the wild: tag-before-pipe (including Unicode pipe lookalikes), + * bracketed tag, and uppercase tag before a spaced dash. Anything longer than + * four letters is a word that happens to precede a separator, not a language. + */ +export function titleLanguagePrefix( + rawTitle: string | null | undefined +): string | null { + const title = rawTitle ?? ''; + const match = + PIPE_FORM.exec(title) ?? + BRACKET_FORM.exec(title) ?? + DASH_FORM.exec(title); + return match ? match[1].toUpperCase() : null; +} + +/** + * `Intl.DisplayNames` is ES2021 and this workspace compiles against the + * es2018 lib, so it is reached through a narrow shim (the same pattern + * `Intl.Locale` uses in the metadata util). Absent — which no supported + * runtime actually is — two-letter validation declines rather than guesses. + */ +const DisplayNamesCtor = ( + Intl as unknown as { + DisplayNames?: new ( + locales: string[], + options: { type: string; fallback: string } + ) => { of(code: string): string | undefined }; + } +).DisplayNames; + +let languageNames: { of(code: string): string | undefined } | null | undefined; + +/** + * Whether a two-letter token is an assigned ISO 639-1 code. + * + * With `fallback: 'code'`, `DisplayNames.of()` answers a real language with + * its name ("en" → "English") and echoes an unassigned code back unchanged + * ("hd" → "hd") — so "name differs from code" is precisely "this language + * exists". The two-letter space is safe for this trick; the three-letter + * space is NOT (ISO 639-3 assigns `new`, `top` and `hot`), which is why + * longer tokens go through the curated list instead. + */ +function isAssignedTwoLetterCode(lower: string): boolean { + if (!DisplayNamesCtor) { + return false; + } + + try { + languageNames ??= new DisplayNamesCtor(['en'], { + type: 'language', + fallback: 'code', + }); + const name = languageNames.of(lower); + return typeof name === 'string' && name.toLowerCase() !== lower; + } catch { + // Structurally invalid tag, or a runtime without language data — + // declining beats guessing. + return false; + } +} + +/** + * Three-and-more-letter tags panels actually use, plus the Cyrillic + * shorthands `Intl` cannot validate. Curated rather than derived from ISO + * 639-2/3: those registries assign codes to `new`, `top` and `hot`, so + * validating against them would turn everyday category prefixes into + * languages. + */ +const KNOWN_LONG_TAGS = new Set([ + // ISO 639-2 pairs (B/T) and common panel spellings, Latin script + 'eng', + 'rus', + 'ukr', + 'bel', + 'kaz', + 'ger', + 'deu', + 'fra', + 'fre', + 'spa', + 'esp', + 'lat', + 'ita', + 'por', + 'tur', + 'ara', + 'pol', + 'nld', + 'dut', + 'swe', + 'nor', + 'dan', + 'fin', + 'gre', + 'ell', + 'hun', + 'cze', + 'ces', + 'svk', + 'slo', + 'srb', + 'srp', + 'hrv', + 'cro', + 'bul', + 'ron', + 'rum', + 'alb', + 'sqi', + 'mkd', + 'bos', + 'heb', + 'hin', + 'vie', + 'tha', + 'kor', + 'jpn', + 'chi', + 'zho', + 'per', + 'fas', + 'aze', + 'kat', + 'geo', + 'hye', + 'arm', + 'uzb', + 'lit', + 'lav', + 'est', + 'multi', + // Cyrillic shorthands + 'ру', + 'уа', + 'рус', + 'укр', + 'бел', + 'каз', + 'анг', + 'англ', + 'нем', + 'фра', + 'исп', + 'ита', + 'пол', + 'тур', + 'узб', + 'арм', + 'груз', + 'азе', +]); + +/** + * Whether a parsed prefix names a language, as opposed to any short word a + * category happens to start with. + */ +export function isKnownLanguageTag(tag: string): boolean { + const lower = tag.toLowerCase(); + if (KNOWN_LONG_TAGS.has(lower)) { + return true; + } + return /^[a-z]{2}$/.test(lower) && isAssignedTwoLetterCode(lower); +} + +/** + * The language a stream's categories agree on, or null. + * + * A stream usually sits in several categories of one playlist ("EN | Netflix" + * and "EN | Action"), and the aggregation is what makes the answer honest: + * every prefixed category must name the SAME language, and that language must + * pass `isKnownLanguageTag`. Categories without a recognized language prefix + * abstain rather than veto — "EN | Netflix" plus "Netflix 4K" still reads EN, + * while "EN | Netflix" plus "DE | Cinema" is a conflict and yields nothing. + */ +export function unambiguousCategoryLanguage( + categoryNames: readonly (string | null | undefined)[] | null | undefined +): string | null { + if (!categoryNames?.length) { + return null; + } + + let language: string | null = null; + for (const name of categoryNames) { + const prefix = titleLanguagePrefix(name); + if (!prefix || !isKnownLanguageTag(prefix)) { + continue; + } + if (language !== null && language !== prefix) { + return null; + } + language = prefix; + } + return language; +} + +/** + * The language shown and filtered on for one source row. + * + * The stream's own title prefix is the more specific signal and wins; the + * category-derived language stands in only when the title says nothing. Both + * are guesses — this feeds the filter select and the copy-row chip, never a + * ranking decision. + */ +export function vodSourceLanguage(source: { + rawTitle?: string | null; + categoryLanguage?: string | null; +}): string | null { + return ( + titleLanguagePrefix(source.rawTitle) ?? source.categoryLanguage ?? null + ); +} diff --git a/libs/shared/interfaces/src/lib/vod-source.interface.ts b/libs/shared/interfaces/src/lib/vod-source.interface.ts index 46745e19c..37c4f84f8 100644 --- a/libs/shared/interfaces/src/lib/vod-source.interface.ts +++ b/libs/shared/interfaces/src/lib/vod-source.interface.ts @@ -90,6 +90,12 @@ export interface VodSourceCandidateRow { posterUrl: string | null; matchConfidence: VodSourceMatchConfidence; year: number | null; + /** + * Names of every visible category this stream sits in within its + * playlist. Carried so the renderer can read a language prefix off them + * ("EN | Netflix") when the stream title itself states none. + */ + categoryNames?: string[]; } /** @@ -132,6 +138,15 @@ export interface VodSourceCandidate { * the dub change". */ audioLanguage?: VodSourceField; + /** + * Language read off the stream's category names ("EN | Netflix"), when + * every prefixed category agrees (`unambiguousCategoryLanguage`). A + * guess by nature — it stands in for a missing title prefix in the + * browse filter and chips, and is never read by ranking, failover or + * the dub warning (`factualOnly` and `audioDiffersFactually` cannot + * reach it). + */ + categoryLanguage?: string | null; /** ISO timestamp of the last failed playback attempt, if any. */ lastFailedAt?: string; diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts index 59db2ec8c..e7a13b385 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-source-copy-row.component.ts @@ -9,7 +9,7 @@ import { MatIcon } from '@angular/material/icon'; import { MatTooltip } from '@angular/material/tooltip'; import { TranslatePipe } from '@ngx-translate/core'; import { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; -import { titleLanguagePrefix } from './vod-source-filtering.util'; +import { vodSourceLanguage } from './vod-source-filtering.util'; import { VodSourceTagListComponent } from './vod-source-tag-list.component'; import { buildVodSourceCopyTags } from './vod-source-tags'; @@ -39,9 +39,7 @@ export class VodSourceCopyRowComponent { readonly playRequested = output(); readonly checkRequested = output(); - readonly language = computed(() => - titleLanguagePrefix(this.source().rawTitle) - ); + readonly language = computed(() => vodSourceLanguage(this.source())); readonly title = computed( () => this.source().rawTitle || `#${this.source().contentId}` diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts index a4b7cf0d3..baaeb6655 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.spec.ts @@ -30,10 +30,13 @@ function source( } describe('titleLanguagePrefix', () => { + // The full parsing matrix lives with the util in shared/interfaces; + // this asserts the re-export still reads the classic pipe form. it('reads the uppercase prefix before a pipe', () => { expect(titleLanguagePrefix('EN| Night of the Living Dead')).toBe('EN'); expect(titleLanguagePrefix('ALB |Some Movie')).toBe('ALB'); expect(titleLanguagePrefix(' ru| Ночь')).toBe('RU'); + expect(titleLanguagePrefix('РУС | Фильм')).toBe('РУС'); }); it('rejects titles whose pipe is not a language marker', () => { @@ -56,6 +59,15 @@ describe('collectLanguagePrefixes', () => { ]); expect(prefixes).toEqual(['EN', 'RU']); }); + + it('includes the category-derived language of untagged titles', () => { + const prefixes = collectLanguagePrefixes([ + source({ rawTitle: 'Plain title', categoryLanguage: 'DE' }), + // The title prefix outranks a conflicting category language. + source({ rawTitle: 'EN| Movie', categoryLanguage: 'RU' }), + ]); + expect(prefixes).toEqual(['DE', 'EN']); + }); }); describe('qualityPixels', () => { @@ -71,9 +83,9 @@ describe('qualityPixels', () => { describe('sourceMatchesFilters', () => { it('passes everything with no filters active', () => { expect(hasActiveVodSourceFilters(EMPTY_VOD_SOURCE_FILTERS)).toBe(false); - expect( - sourceMatchesFilters(source(), EMPTY_VOD_SOURCE_FILTERS) - ).toBe(true); + expect(sourceMatchesFilters(source(), EMPTY_VOD_SOURCE_FILTERS)).toBe( + true + ); }); it('available keeps only probe-verified sources', () => { @@ -129,6 +141,23 @@ describe('sourceMatchesFilters', () => { ).toBe(false); }); + it('language falls back to the category-derived language', () => { + const filters = { ...EMPTY_VOD_SOURCE_FILTERS, language: 'EN' }; + expect( + sourceMatchesFilters( + source({ rawTitle: 'Movie', categoryLanguage: 'EN' }), + filters + ) + ).toBe(true); + // A title prefix always outranks the category. + expect( + sourceMatchesFilters( + source({ rawTitle: 'RU| Movie', categoryLanguage: 'EN' }), + filters + ) + ).toBe(false); + }); + it('filters compose with AND', () => { const filters = { availableOnly: true, @@ -158,8 +187,8 @@ describe('noChecksRunYet', () => { expect( noChecksRunYet([source(), source({ probe: { status: 'probing' } })]) ).toBe(false); - expect( - noChecksRunYet([source({ probe: { status: 'fail' } })]) - ).toBe(false); + expect(noChecksRunYet([source({ probe: { status: 'fail' } })])).toBe( + false + ); }); }); diff --git a/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts index 9a3f2b662..c81476e54 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-source-filtering.util.ts @@ -1,4 +1,8 @@ -import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; +import { + titleLanguagePrefix, + vodSourceLanguage, + type VodSourceDescriptor, +} from '@iptvnator/shared/interfaces'; /** * Filter logic for the sources popover's chip row. @@ -6,14 +10,20 @@ import type { VodSourceDescriptor } from '@iptvnator/shared/interfaces'; * Pure functions over descriptors so the menu component stays a thin view and * the composition rules (filters AND each other and the host search) can be * tested without a fixture. + * + * The language a row filters on is `vodSourceLanguage`: the stream title's + * own prefix ("EN| Movie") when it has one, else the language its categories + * unambiguously carry ("EN | Netflix") — both parsed guesses, both browse-only. */ +export { titleLanguagePrefix, vodSourceLanguage }; + export interface VodSourceFilterState { /** Keep only copies whose probe verified them reachable. */ availableOnly: boolean; /** Keep only copies whose stated quality is 1080p or better. */ hdOnly: boolean; - /** Keep only copies carrying this title language prefix; null = all. */ + /** Keep only copies carrying this language (title or category); null = all. */ language: string | null; } @@ -29,28 +39,13 @@ export function hasActiveVodSourceFilters( return filters.availableOnly || filters.hdOnly || filters.language !== null; } -/** - * `EN| Night of the Living Dead` → `EN`. - * - * Providers prefix the raw stream title with a short uppercase language tag - * before a pipe; that convention is the only language signal most panels - * emit. Anything longer than four letters is a title that happens to contain - * a pipe, not a language. - */ -export function titleLanguagePrefix( - rawTitle: string | null | undefined -): string | null { - const match = /^\s*([A-Za-z]{2,4})\s*\|/.exec(rawTitle ?? ''); - return match ? match[1].toUpperCase() : null; -} - -/** Every language prefix present in the list, in first-seen order. */ +/** Every language present in the list, in first-seen order. */ export function collectLanguagePrefixes( sources: readonly VodSourceDescriptor[] ): string[] { const languages: string[] = []; for (const source of sources) { - const language = titleLanguagePrefix(source.rawTitle); + const language = vodSourceLanguage(source); if (language && !languages.includes(language)) { languages.push(language); } @@ -93,7 +88,7 @@ export function sourceMatchesFilters( if ( filters.language !== null && - titleLanguagePrefix(source.rawTitle) !== filters.language + vodSourceLanguage(source) !== filters.language ) { return false; } diff --git a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts index d5d42e5dc..8ee678fe0 100644 --- a/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts +++ b/libs/ui/components/src/lib/vod-sources/vod-sources-menu.component.spec.ts @@ -403,6 +403,34 @@ describe('VodSourcesMenuComponent', () => { expect(rowNames()).toEqual(['Russian Portal']); }); + it('offers a category-derived language when the title has no prefix', () => { + render([ + createSource({ + id: 'a:xtream:1', + playlistId: 'a', + playlistName: 'German Portal', + rawTitle: 'Der Film (2021)', + categoryLanguage: 'DE', + }), + createSource({ + id: 'b:xtream:2', + playlistId: 'b', + playlistName: 'Bare Portal', + rawTitle: 'Der Film', + }), + ]); + + const select = fixture.debugElement.query( + By.css('.sources-menu__lang') + ); + expect(select.nativeElement.textContent).toContain('DE'); + + fixture.componentInstance.setLanguageFilter('DE'); + fixture.detectChanges(); + + expect(rowNames()).toEqual(['German Portal']); + }); + it('the All chip resets every filter and restores the full list', () => { render([ createSource({