Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka

# Conflicts:
#	CLAUDE.md
#	libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
4gray committed 2026-07-24 18:44:22 +02:00
commit 5b2dd3b8b0
425 files changed
+59216 -3432

No files matched your search

@@ -217,6 +217,74 @@ describe('catchup.utils', () => {
);
});
it('applies negative sub-hour XMLTV offsets such as -0030', () => {
// Regression: Number('-00') === -0, so a Math.sign()-based conversion
// dropped the minutes' sign and treated -0030 as UTC.
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 -0030',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775809800&lutc=1775820000'
);
});
it('applies positive offsets with minutes such as +0530', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0530',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775788200&lutc=1775820000'
);
});
it('applies positive sub-hour XMLTV offsets such as +0030', () => {
// Regression: Number('+00') === 0, so a Math.sign()-based conversion
// dropped the minutes and treated +0030 as UTC.
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0030',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775806200&lutc=1775820000'
);
});
it('treats a -0000 offset as UTC', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 -0000',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000'
);
});
it('preserves catchup metadata when storing parsed playlist items', () => {
const playlist = createPlaylistObject('Catchup playlist', {
header: {
@@ -112,9 +112,13 @@ function getEpgProgramTimestampSeconds(
Number(hour),
Number(minute)
);
// Derive the sign from the string: for offsets like "-0030" the hour part
// is "-00", and Number("-00") === -0, so Math.sign() would drop the
// minutes' sign and yield a zero offset instead of -30 minutes.
const offsetSign = offsetHours.startsWith('-') ? -1 : 1;
const offsetTotalMinutes =
Number(offsetHours) * 60 +
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
offsetSign *
(Math.abs(Number(offsetHours)) * 60 + Number(offsetMinutes));
return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000);
}
@@ -43,6 +43,36 @@ describe('stripCountryPrefix', () => {
expect(stripCountryPrefix('US: CNN')).toBe('CNN');
});
it('strips compound quality/provider tags', () => {
expect(stripCountryPrefix('4K-DE - The Pitt (2025)')).toBe(
'The Pitt (2025)'
);
expect(stripCountryPrefix('AR-SUBS - Fallout')).toBe('Fallout');
expect(stripCountryPrefix('4K-OSN+ - The Last of Us')).toBe(
'The Last of Us'
);
});
it('strips longer pipe-tagged prefixes', () => {
expect(stripCountryPrefix('EXYU| News')).toBe('News');
expect(stripCountryPrefix('MULTI| Movies')).toBe('Movies');
});
it('never treats numeric fragments as tags', () => {
expect(stripCountryPrefix('1917 - Documentary')).toBe(
'1917 - Documentary'
);
});
it('keeps bare 4-5 char words before a spaced dash (real titles)', () => {
expect(stripCountryPrefix('DUNE - Part Two')).toBe(
'DUNE - Part Two'
);
expect(stripCountryPrefix('ALIEN - Covenant')).toBe(
'ALIEN - Covenant'
);
});
it('only strips the first tag segment', () => {
expect(stripCountryPrefix('ES - A3 - Sports')).toBe('A3 - Sports');
});
@@ -3,9 +3,19 @@
* country/group tag — they routinely appear inside real channel names
* ("Sky - Sports F1", "Discovery - Science"). Pipes are conventionally
* used only as tag separators, so they always count.
*
* Before a dash a tag is either a compound ("4K-DE", "AR-SUBS", "4K-OSN+"
* — the inner hyphen is the tag signal) or a plain 2–3 char code ("US",
* "4K"). A bare 4–5 char word before a spaced dash is a real title
* ("DUNE - Part Two", "ALIEN - Covenant"), so it is not a tag. Colon tags
* stay 2–3 chars — longer acronyms are franchise titles ("NCIS: LA").
* Every segment must contain a letter so numbers ("1917 - ...") are safe.
*/
const DASH_OR_COLON_SEPARATORS = [' - ', '- ', ' -', ': '];
const TAG_PREFIX_PATTERN = /^[A-Z0-9]{2,3}$/;
const DASH_SEPARATORS = [' - ', '- ', ' -'];
const COLON_SEPARATOR = ': ';
const TAG_PREFIX_PATTERN =
/^(?:(?=[0-9+]*[A-Z])[A-Z0-9+]{2,5}(?:-(?=[0-9+]*[A-Z])[A-Z0-9+]{2,6}){1,2}|(?=[0-9+]*[A-Z])[A-Z0-9+]{2,3})$/;
const COLON_TAG_PATTERN = /^(?=[0-9+]*[A-Z])[A-Z0-9+]{2,3}$/;
interface SeparatorMatch {
index: number;
@@ -68,10 +78,14 @@ function findTagSeparator(name: string): SeparatorMatch | null {
best = { index: pipeIndex, length: 1 };
}
for (const separator of DASH_OR_COLON_SEPARATORS) {
for (const separator of [...DASH_SEPARATORS, COLON_SEPARATOR]) {
const index = name.indexOf(separator);
if (index === -1 || (best && best.index <= index)) continue;
if (!TAG_PREFIX_PATTERN.test(name.slice(0, index).trim())) continue;
const pattern =
separator === COLON_SEPARATOR
? COLON_TAG_PATTERN
: TAG_PREFIX_PATTERN;
if (!pattern.test(name.slice(0, index).trim())) continue;
best = { index, length: separator.length };
}