mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 20:56:19 -08:00
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:
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({
|
||||
|
||||
Reference in new issue
Block a user