feat(xtream): recognize more language tags in VOD multi-source

The sources popover's language filter and copy chips now read prefixes
with Unicode pipe lookalikes, brackets and spaced dashes, Cyrillic tags
and MULTI. When a stream title carries no tag, the language falls back
to what the stream's visible categories unambiguously state ("EN |
Netflix") — discovery aggregates category names per (playlist, stream)
in SQL, and category prefixes must pass a known-language gate because
everyday category words like new/top/hot are real ISO 639-3 codes.

Both signals stay parsed guesses: browse filter and chips only, never
ranking, failover or dub-warning inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-11 23:48:58 +02:00
1 parent 6041233f41
commit da0889aece
21 files changed
+759 -51

No files matched your search

@@ -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<string>();
readonly checkRequested = output<string>();
readonly language = computed(() =>
titleLanguagePrefix(this.source().rawTitle)
);
readonly language = computed(() => vodSourceLanguage(this.source()));
readonly title = computed(
() => this.source().rawTitle || `#${this.source().contentId}`
@@ -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
);
});
});
@@ -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;
}
@@ -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({