xtream bundler
Build and Push Docker Images / build-and-push (push) Successful in 36s

This commit is contained in:
Bram committed 2026-11-06 19:24:39 +01:00
1 parent d7aea0be65
commit 91f22b54b4
30 files changed
+2475

No files matched your search

@@ -0,0 +1,41 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { parseChannelName, normalizeText } = require('../src/channel-name');
const sample =
'View BE| DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]';
test('parses the DAZN sample', () => {
const parsed = parseChannelName(sample, { timezone: 'Europe/Brussels' });
assert.equal(parsed.title, 'Standard de Liège - SK Beveren');
assert.equal(parsed.league, 'Jupiler Pro League');
assert.equal(parsed.language, 'nl');
assert.equal(parsed.startAt.toISOString(), '2026-11-06T18:25:00.000Z');
});
test('parses a French stream', () => {
const parsed = parseChannelName(
'View BE| DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (fr) (2026-11-06 19:25:00)]',
{ timezone: 'Europe/Brussels' }
);
assert.equal(parsed.language, 'fr');
});
test('allows a missing language code', () => {
const parsed = parseChannelName(
'DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (2026-11-06 19:25:00)]',
{ timezone: 'Europe/Brussels' }
);
assert.equal(parsed.language, null);
});
test('returns null for unparseable names', () => {
assert.equal(parseChannelName('DAZN 03', { timezone: 'Europe/Brussels' }), null);
assert.equal(parseChannelName('', { timezone: 'Europe/Brussels' }), null);
});
test('normalizes diacritics and whitespace', () => {
assert.equal(normalizeText(' Standard de Liège - SK Beveren '), 'standard de liege - sk beveren');
});
@@ -0,0 +1,47 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildXmltv, xmlEscape, xmltvDate } = require('../src/epg');
function event(key, title, startIso, endIso, league = 'Jupiler Pro League') {
return {
key,
title,
league,
language: 'nl',
startAt: new Date(startIso),
endAt: new Date(endIso),
sources: [],
};
}
test('builds XMLTV with channels and projected programmes', () => {
const a = event('a', 'Standard de Liège - SK Beveren', '2026-11-06T18:25:00Z', '2026-11-06T20:55:00Z');
const b = event('b', 'Anderlecht & Genk', '2026-11-06T20:55:00Z', '2026-11-06T23:25:00Z');
const c = event('c', 'Club Brugge - Antwerp', '2026-11-06T20:55:00Z', '2026-11-06T23:25:00Z');
const state = {
slots: [{ eventKey: 'a' }, { eventKey: null }, { eventKey: null }],
events: new Map([['a', a]]),
upcoming: [a, b, c],
};
const scheduler = { state, getSlotCount: () => 3 };
const xml = buildXmltv(scheduler, Date.parse('2026-11-06T19:00:00Z'));
assert.ok(xml.startsWith('<?xml version="1.0" encoding="UTF-8"?>'));
assert.ok(xml.includes('<channel id="jupiler-1">'));
assert.ok(xml.includes('<channel id="jupiler-3">'));
assert.ok(xml.includes('start="20261106182500 +0000"'));
assert.ok(xml.includes('stop="20261106205500 +0000"'));
assert.ok(xml.includes('channel="jupiler-1"'));
assert.ok(xml.includes('Standard de Li&#232;ge') === false);
assert.ok(xml.includes('Standard de Liège - SK Beveren'));
assert.ok(xml.includes('Anderlecht &amp; Genk'));
assert.ok(xml.includes('Club Brugge - Antwerp'));
});
test('escapes XML and formats UTC dates', () => {
assert.equal(xmlEscape('a & <b> "c" \'d\''), 'a &amp; &lt;b&gt; &quot;c&quot; &apos;d&apos;');
assert.equal(xmltvDate(new Date('2026-11-06T18:25:00Z')), '20261106182500 +0000');
});
@@ -0,0 +1,86 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildEvents, compareEvents, isEligible } = require('../src/events');
const timezone = 'Europe/Brussels';
function stream(id, name) {
return { stream_id: id, name };
}
function build(streams, overrides = {}) {
return buildEvents(streams, {
searchTerm: 'Jupiler Pro League',
language: 'nl',
timezone,
eventDurationMs: 150 * 60 * 1000,
urlBuilder: (id) => [`http://upstream/live/u/p/${id}.ts`],
...overrides,
});
}
test('filters by search term and language, dedupes the same match', () => {
const { events } = build([
stream(
1,
'DAZN 01 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]'
),
stream(
2,
'DAZN 02 [Standard de Liège - SK Beveren | Jupiler Pro League (nl) (2026-11-06 19:25:00)]'
),
stream(
3,
'DAZN 03 [Standard de Liège - SK Beveren | Jupiler Pro League (fr) (2026-11-06 19:25:00)]'
),
stream(4, 'DAZN 04 [Anderlecht - Genk | Jupiler Pro League (2026-11-06 19:25:00)]'),
stream(5, 'DAZN 05 [Other League (nl) (2026-11-06 19:25:00)]'),
]);
assert.equal(events.size, 2);
const [first, second] = [...events.values()];
assert.equal(first.title, 'Standard de Liège - SK Beveren');
assert.deepEqual(first.sources.map((source) => source.streamId), ['1', '2']);
assert.equal(first.language, 'nl');
assert.equal(second.sources[0].streamId, '4');
});
test('reports unparseable matching names as warnings', () => {
const { events, warnings } = build([stream(1, 'DAZN 01 | Jupiler Pro League zonder tijd')]);
assert.equal(events.size, 0);
assert.equal(warnings.length, 1);
assert.equal(warnings[0].reason, 'unparseable_name');
});
test('an event is eligible inside the start grace and before its end', () => {
const event = {
startAt: new Date('2026-11-06T18:25:00.000Z'),
endAt: new Date('2026-11-06T20:55:00.000Z'),
};
const options = { startGraceMs: 10 * 60 * 1000 };
assert.equal(isEligible(event, Date.parse('2026-11-06T18:16:00.000Z'), options), true);
assert.equal(isEligible(event, Date.parse('2026-11-06T18:14:00.000Z'), options), false);
assert.equal(isEligible(event, Date.parse('2026-11-06T20:54:00.000Z'), options), true);
assert.equal(isEligible(event, Date.parse('2026-11-06T20:56:00.000Z'), options), false);
});
test('sorting is by start time and then arrival order', () => {
const a = {
key: 'a',
startAt: new Date('2026-11-06T19:00:00Z'),
firstSeenSeq: 2,
};
const b = {
key: 'b',
startAt: new Date('2026-11-06T19:00:00Z'),
firstSeenSeq: 1,
};
const c = {
key: 'c',
startAt: new Date('2026-11-06T18:00:00Z'),
firstSeenSeq: 3,
};
assert.deepEqual([a, b, c].sort(compareEvents).map((event) => event.key), ['c', 'b', 'a']);
});
@@ -0,0 +1,46 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { rewritePlaylist, signUrl, verifyToken } = require('../src/hls');
test('rewrites playlist URIs to signed proxy URLs', () => {
const base = 'http://upstream.example/live/user/pass/index.m3u8';
const body = [
'#EXTM3U',
'#EXT-X-KEY:METHOD=AES-128,URI="keys/key.bin"',
'#EXTINF:6,',
'segment1.ts',
'#EXTINF:6,',
'http://cdn.example/segment2.ts',
'#EXT-X-ENDLIST',
].join('\n');
const rewritten = rewritePlaylist(body, base, 2, 'http://proxy.example');
const lines = rewritten.split('\n');
assert.equal(lines[0], '#EXTM3U');
assert.match(lines[1], /^#EXT-X-KEY:METHOD=AES-128,URI="http:\/\/proxy\.example\/hls\/2\/.+"/);
assert.match(lines[3], /^http:\/\/proxy\.example\/hls\/2\/.+/);
assert.match(lines[5], /^http:\/\/proxy\.example\/hls\/2\/.+/);
const token = lines[3].split('/hls/2/')[1];
assert.equal(
verifyToken(2, token),
'http://upstream.example/live/user/pass/segment1.ts'
);
const keyToken = lines[1].match(/URI="http:\/\/proxy\.example\/hls\/2\/([^"]+)"/)[1];
assert.equal(
verifyToken(2, keyToken),
'http://upstream.example/live/user/pass/keys/key.bin'
);
});
test('rejects tampered tokens', () => {
const token = signUrl('http://upstream.example/segment.ts', 1);
assert.equal(verifyToken(1, token), 'http://upstream.example/segment.ts');
assert.equal(verifyToken(2, token), null);
const tampered = token.slice(0, -1) + (token.endsWith('a') ? 'b' : 'a');
assert.equal(verifyToken(1, tampered), null);
});
@@ -0,0 +1,35 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { buildPlaylist } = require('../src/playlist');
function fakeRequest() {
return { headers: { host: 'example.test:8080' }, socket: {} };
}
const event = {
key: 'a',
title: 'Standard de Liège - SK Beveren',
startAt: new Date('2026-11-06T18:25:00Z'),
endAt: new Date('2026-11-06T20:55:00Z'),
};
test('playlist always contains three fixed channels', () => {
const scheduler = {
getSlotCount: () => 3,
getSlotEvent: (index) => (index === 0 ? event : null),
};
const body = buildPlaylist(fakeRequest(), scheduler);
const lines = body.trim().split('\n');
assert.equal(lines[0], '#EXTM3U');
assert.equal(
lines[1],
'#EXTINF:-1 tvg-id="jupiler-1" tvg-name="Jupiler 1",Jupiler 1 | Standard de Liège - SK Beveren (19:25)'
);
assert.equal(lines[2], 'http://example.test:8080/stream/1');
assert.ok(body.includes('Jupiler 2 | Geen wedstrijd'));
assert.ok(body.includes('Jupiler 3 | Geen wedstrijd'));
assert.ok(body.includes('http://example.test:8080/stream/3'));
});
@@ -0,0 +1,120 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { createState, reconcile, summarize } = require('../src/scheduler');
const options = { maxChannels: 3, startGraceMs: 24 * 60 * 60 * 1000 };
function makeEvent(id, startIso, endIso) {
return {
key: id,
title: id,
league: 'Jupiler Pro League',
language: 'nl',
startAt: new Date(startIso),
endAt: new Date(endIso),
sources: [{ streamId: id, name: id, candidates: [`http://upstream/${id}.ts`] }],
missingCount: 0,
};
}
function current(...events) {
return new Map(events.map((event) => [event.key, event]));
}
function slotKeys(state) {
return state.slots.map((slot) => slot.eventKey);
}
test('fills the first three slots by start time and queues the rest', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z'),
makeEvent('D', '2026-11-06T17:30:00Z', '2026-11-06T21:30:00Z'),
makeEvent('E', '2026-11-06T18:00:00Z', '2026-11-06T22:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', 'C']);
const summary = summarize(state);
assert.deepEqual(summary.queue.map((event) => event.key), ['D', 'E']);
});
test('a freed slot is filled by the oldest waiting event', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z'),
makeEvent('D', '2026-11-06T17:30:00Z', '2026-11-06T21:30:00Z'),
makeEvent('E', '2026-11-06T18:00:00Z', '2026-11-06T22:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', 'C']);
reconcile(state, events, Date.parse('2026-11-06T20:01:00Z'), options);
assert.deepEqual(slotKeys(state), ['D', 'B', 'C']);
});
test('an existing assignment is not preempted by an earlier event', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
const withEarlier = current(
...events.values(),
makeEvent('X', '2026-11-06T17:45:00Z', '2026-11-06T21:45:00Z')
);
reconcile(state, withEarlier, Date.parse('2026-11-06T18:10:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', 'C']);
});
test('an assigned event keeps its slot until its end even when it disappears', () => {
const state = createState(3);
const a = makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z');
const b = makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z');
reconcile(state, current(a, b), Date.parse('2026-11-06T18:00:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', null]);
reconcile(state, current(), Date.parse('2026-11-06T19:00:00Z'), options);
reconcile(state, current(), Date.parse('2026-11-06T19:30:00Z'), options);
assert.deepEqual(slotKeys(state), ['A', 'B', null]);
reconcile(state, current(), Date.parse('2026-11-06T20:31:00Z'), options);
assert.deepEqual(slotKeys(state), [null, null, null]);
});
test('an unassigned event that disappears twice is dropped from the queue', () => {
const state = createState(3);
const events = current(
makeEvent('A', '2026-11-06T16:00:00Z', '2026-11-06T20:00:00Z'),
makeEvent('B', '2026-11-06T16:30:00Z', '2026-11-06T20:30:00Z'),
makeEvent('C', '2026-11-06T17:00:00Z', '2026-11-06T21:00:00Z'),
makeEvent('D', '2026-11-06T17:30:00Z', '2026-11-06T21:30:00Z'),
makeEvent('E', '2026-11-06T18:00:00Z', '2026-11-06T22:00:00Z')
);
reconcile(state, events, Date.parse('2026-11-06T18:00:00Z'), options);
const withoutD = current(
events.get('A'),
events.get('B'),
events.get('C'),
events.get('E')
);
reconcile(state, withoutD, Date.parse('2026-11-06T18:20:00Z'), options);
reconcile(state, withoutD, Date.parse('2026-11-06T18:40:00Z'), options);
reconcile(state, withoutD, Date.parse('2026-11-06T20:01:00Z'), options);
assert.deepEqual(slotKeys(state), ['E', 'B', 'C']);
});
@@ -0,0 +1,25 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { parseLocalTimestamp, zonedTimeToUtc } = require('../src/time');
test('parses winter time in Europe/Brussels', () => {
const date = parseLocalTimestamp('2026-11-06 19:25:00', 'Europe/Brussels');
assert.equal(date.toISOString(), '2026-11-06T18:25:00.000Z');
});
test('parses summer time in Europe/Brussels', () => {
const date = parseLocalTimestamp('2026-07-01 19:25:00', 'Europe/Brussels');
assert.equal(date.toISOString(), '2026-07-01T17:25:00.000Z');
});
test('zonedTimeToUtc handles UTC', () => {
const date = zonedTimeToUtc(2026, 11, 6, 19, 25, 0, 'UTC');
assert.equal(date.toISOString(), '2026-11-06T19:25:00.000Z');
});
test('rejects invalid timestamps', () => {
assert.equal(parseLocalTimestamp('not-a-date', 'Europe/Brussels'), null);
assert.equal(parseLocalTimestamp('2026-13-06 19:25:00', 'Europe/Brussels'), null);
});
@@ -0,0 +1,90 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('node:http');
const { settings } = require('../src/settings');
const {
fetchLiveStreams,
liveUrlCandidates,
parseM3u,
resolveFallbackUrls,
resetM3uCache,
} = require('../src/xtream-client');
function listen(server) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
}
function close(server) {
return new Promise((resolve) => server.close(resolve));
}
test('fetches live streams from player_api and resolves get.php fallback URLs', async () => {
const server = http.createServer((req, res) => {
if (req.url.startsWith('/player_api.php')) {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify([
{
stream_id: 101,
name: 'DAZN 01 [A - B | Jupiler Pro League (nl) (2026-11-06 19:25:00)]',
},
])
);
return;
}
if (req.url.startsWith('/get.php')) {
res.setHeader('Content-Type', 'application/x-mpegurl');
res.end(
[
'#EXTM3U',
'#EXTINF:-1 tvg-id="dazn1" tvg-name="DAZN 01",DAZN 01 [A - B | Jupiler Pro League (nl) (2026-11-06 19:25:00)]',
'http://cdn.example/live/u/p/101.ts',
].join('\n')
);
return;
}
res.statusCode = 404;
res.end('nope');
});
await listen(server);
const port = server.address().port;
const previous = { ...settings };
settings.xtreamUrl = `http://127.0.0.1:${port}`;
settings.xtreamUsername = 'user';
settings.xtreamPassword = 'pass';
resetM3uCache();
try {
const streams = await fetchLiveStreams();
assert.equal(streams.length, 1);
assert.equal(streams[0].stream_id, 101);
const urls = liveUrlCandidates(101);
assert.ok(urls.includes('http://127.0.0.1:' + port + '/live/user/pass/101.ts'));
const fallbacks = await resolveFallbackUrls(101, 'DAZN 01');
assert.deepEqual(fallbacks, ['http://cdn.example/live/u/p/101.ts']);
} finally {
Object.assign(settings, previous);
resetM3uCache();
await close(server);
}
});
test('parses M3U entries by stream id and tvg-name', () => {
const index = parseM3u(
[
'#EXTM3U',
'#EXTINF:-1 tvg-id="x" tvg-name="DAZN 01 [A - B | Jupiler Pro League (nl) (2026-11-06 19:25:00)]",display',
'http://cdn.example/live/u/p/55.m3u8?token=1',
].join('\n')
);
assert.equal(index.byStreamId.get('55'), 'http://cdn.example/live/u/p/55.m3u8?token=1');
assert.equal(
index.byName.get('dazn 01 [a - b | jupiler pro league (nl) (2026-11-06 19:25:00)]'),
'http://cdn.example/live/u/p/55.m3u8?token=1'
);
});