Files
iptvnator/apps/xtream-mock-server/src/app/server.ts
T
4grayandClaude Opus 5.5 015ea203b7 fix(player): keyboard-reachable, bounded labels for timeline segments (#1854)
Seek-bar segments (catch-up programmes, file chapters) now reach keyboard,
touch and screen-reader users through the shared timelineSegments path:

- aria-valuetext reads the translated "<title> · <time>" inside a titled
  segment, the plain time otherwise.
- ControlsTimelineLabel anchors the label on keyboard focus and drag
  previews as well as pointer hover.
- Two-part label (ellipsized title, whole time), clamped by its measured
  width and re-placed on resize to stay 8px inside the player.
- Opaque --pc-timeline-track with white separators clamped to the track,
  so boundaries hold 3:1 over any frame.
- Translated LIVE badge; LIVE and --:-- are named role="img" elements in
  both docks.
- epglong Xtream mock scenario and an Electron E2E in both themes at
  1280/800px and in de/ru.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 08:47:38 +02:00

368 lines
13 KiB
TypeScript

import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import cors from 'cors';
import express, {
type NextFunction,
type Request,
type Response,
} from 'express';
import { installLiveFormatFixture } from './live-format-fixture.js';
import { resolveXtreamMockPortString } from './mock-port.js';
import { resetAll } from './data-store.js';
import { renderMarketingAssetSvg } from './generators/marketing.generator.js';
import { installPerformanceControlRoutes } from './performance-control-routes.js';
import {
type PerformanceControlState,
XtreamPerformanceController,
} from './performance-control.js';
import { dispatchAction } from './routes/dispatch.js';
import { getScenario, type ScenarioConfig } from './scenarios.js';
import { renderDemoXmltv } from './demo-xmltv.js';
import {
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS,
LOCAL_MEDIA_LIVE_STREAM_OPTIONS,
LOCAL_MEDIA_MOVIE_DOWNLOAD_OPTIONS,
streamSlowSeriesDownload,
} from './slow-series-download.js';
export { createXtreamMockServerShutdown } from './server-lifecycle.js';
export interface XtreamMockServerOptions {
readonly control?: {
readonly enabled: boolean;
readonly token: string;
};
readonly host: string;
readonly port: number;
}
export interface XtreamMockApplication extends express.Express {
shutdown(): PerformanceControlState | null;
}
const M3U_FIXTURE = `#EXTM3U
#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1
https://example.channels/path-to-file/1.m3u8
#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV
https://example.channels/path-to-file/2.m3u8
#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX
https://example.channels/path-to-file/3.m3u8
#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV
https://example.channels/path-to-file/4.m3u8
`;
const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
/** EPG-fixture users whose timeshift URLs play the local MPEG-TS clip. */
const TIMESHIFT_CLIP_USERS = new Set(['epg', 'epglong']);
// Loopback by default: the fixtures serve fabricated but unauthenticated
// content, so they should not be reachable from other hosts unless a dev
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them).
const DEFAULT_NORMAL_HOST = '127.0.0.1';
const DEFAULT_CONTROL_HOST = '127.0.0.1';
const PERFORMANCE_USERNAME = 'performance';
const PERFORMANCE_PASSWORD = 'performance';
const marketingRasterAssetRoot = join(
process.cwd(),
'apps/xtream-mock-server/public/marketing'
);
export function createXtreamMockApp(
options: XtreamMockServerOptions
): XtreamMockApplication {
validateServerOptions(options);
const app = express() as XtreamMockApplication;
const controlEnabled = options.control?.enabled === true;
const controller = controlEnabled
? new XtreamPerformanceController()
: undefined;
app.shutdown = () => controller?.shutdown() ?? null;
if (controller && options.control) {
installPerformanceControlRoutes(app, controller, options.control.token);
}
app.use(cors());
app.use(express.json());
app.get('/health', (_request, response) => {
response.json({
status: 'ok',
server: 'xtream-mock-server',
port: options.port,
});
});
app.post('/reset', (_request, response) => {
if (controlEnabled) {
response
.status(410)
.json({ error: 'use-performance-control-reset' });
return;
}
resetAll();
response.json({ status: 'reset' });
});
app.get('/playlist.m3u', (_request, response) => {
if (controlEnabled) {
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
response
.type('audio/x-mpegurl')
.set('Cache-Control', 'no-store')
.send(M3U_FIXTURE);
});
installMarketingAssetRoute(app);
installDemoGuideRoute(app);
app.get('/player_api.php', (request, response, next) => {
dispatchWithControl(controller, request, response, next, 'direct', () =>
dispatchAction(request, response)
);
});
if (!controlEnabled) installLiveFormatFixture(app);
installStreamRoutes(app, controlEnabled);
app.get('/xtream', (request, response, next) => {
dispatchWithControl(controller, request, response, next, 'proxy', () =>
dispatchProxyAction(request, response)
);
});
return app;
}
export function parseXtreamMockServerEnvironment(
environment: NodeJS.ProcessEnv
): XtreamMockServerOptions {
const rawPort = resolveXtreamMockPortString(environment);
if (!/^\d+$/.test(rawPort)) {
throw new Error('Xtream mock port must be an integer');
}
const port = Number(rawPort);
const controlEnabled = environment['IPTVNATOR_XTREAM_MOCK_CONTROL'] === '1';
const host =
environment['HOST'] ??
(controlEnabled ? DEFAULT_CONTROL_HOST : DEFAULT_NORMAL_HOST);
const token = environment['IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN'] ?? '';
const options: XtreamMockServerOptions = {
...(controlEnabled
? { control: { enabled: true, token } as const }
: {}),
host,
port,
};
validateServerOptions(options);
return options;
}
function validateServerOptions(options: XtreamMockServerOptions): void {
if (
!Number.isInteger(options.port) ||
options.port < 0 ||
options.port > 65_535
) {
throw new Error('Xtream mock port must be in 0..65535');
}
if (!options.host) throw new Error('Xtream mock host is required');
if (!options.control?.enabled) return;
if (options.control.token.trim().length === 0) {
throw new Error('Xtream performance control token is required');
}
if (options.host !== '127.0.0.1' && options.host !== '::1') {
throw new Error(
'Xtream performance control requires a loopback bind host'
);
}
}
function dispatchWithControl(
controller: XtreamPerformanceController | undefined,
request: Request,
response: Response,
next: NextFunction,
transport: 'direct' | 'proxy',
dispatch: () => void
): void {
if (!controller) {
dispatch();
return;
}
void controller
.intercept(request, response, transport, dispatch)
.catch(next);
}
function dispatchProxyAction(request: Request, response: Response): void {
const { url, action, ...rest } = request.query as Record<string, string>;
void url;
const syntheticRequest = {
query: { action, ...rest },
headers: request.headers,
params: {},
} as unknown as Request;
let payload: unknown;
let statusCode = 200;
const syntheticResponse = {
json(data: unknown) {
payload = data;
return this;
},
status(code: number) {
statusCode = code;
return this;
},
} as unknown as Response;
dispatchAction(syntheticRequest, syntheticResponse);
if (statusCode !== 200) {
response.status(statusCode).json({ error: payload });
return;
}
response.json({ payload, action });
}
/** XMLTV for the marketing live channels; see `demo-xmltv.ts`. */
function installDemoGuideRoute(app: express.Express): void {
app.get('/demo/guide.xml', (request, response) => {
const origin = `${request.protocol}://${request.get('host') ?? `localhost:${resolveXtreamMockPortString(process.env)}`}`;
response
.type('application/xml')
.set('Cache-Control', 'no-store')
.send(renderDemoXmltv(origin));
});
}
function installMarketingAssetRoute(app: express.Express): void {
app.get('/assets/marketing/:kind/:slug', (request, response) => {
const kind = request.params['kind'] as
'backdrop' | 'episode' | 'logo' | 'poster' | 'season';
const slug = request.params['slug'] ?? '';
const size =
typeof request.query['size'] === 'string'
? request.query['size']
: undefined;
if (
!['backdrop', 'episode', 'logo', 'poster', 'season'].includes(kind)
) {
response.status(404).send('Unknown marketing asset kind');
return;
}
const rasterSlug = slug.replace(/\.(svg|png)$/i, '');
const rasterPath = join(
marketingRasterAssetRoot,
kind,
`${rasterSlug}.png`
);
if (existsSync(rasterPath)) {
response
.type('image/png')
.set('Cache-Control', 'public, max-age=3600')
.send(readFileSync(rasterPath));
return;
}
response
.type('image/svg+xml')
.set('Cache-Control', 'public, max-age=3600')
.send(renderMarketingAssetSvg(kind, slug, size));
});
}
function installStreamRoutes(
app: express.Express,
controlEnabled: boolean
): void {
const streamResponse = (request: Request, response: Response) => {
if (isPerformanceMediaRequest(request, controlEnabled)) {
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
if (downloadStreamFixtureOf(request) === 'local-media') {
streamSlowSeriesDownload(
request,
response,
LOCAL_MEDIA_LIVE_STREAM_OPTIONS
);
return;
}
response.redirect(HLS_STUB);
};
const movieResponse = (request: Request, response: Response) => {
if (isPerformanceMediaRequest(request, controlEnabled)) {
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
if (downloadStreamFixtureOf(request) === 'local-media') {
streamSlowSeriesDownload(
request,
response,
LOCAL_MEDIA_MOVIE_DOWNLOAD_OPTIONS
);
return;
}
response.redirect(HLS_STUB);
};
const seriesResponse = (request: Request, response: Response) => {
if (isPerformanceMediaRequest(request, controlEnabled)) {
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
const fixture = downloadStreamFixtureOf(request);
if (fixture === 'slow-series') {
streamSlowSeriesDownload(request, response);
return;
}
if (fixture === 'local-media') {
streamSlowSeriesDownload(
request,
response,
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS
);
return;
}
response.redirect(HLS_STUB);
};
app.get('/live/:username/:password/:streamId.m3u8', streamResponse);
app.get('/live/:username/:password/:streamId.ts', streamResponse);
app.get('/movie/:username/:password/:streamId.:ext', movieResponse);
app.get('/series/:username/:password/:streamId.:ext', seriesResponse);
app.all(
'/timeshift/:username/:password/:duration/:start/:streamId.ts',
(request, response) => {
if (TIMESHIFT_CLIP_USERS.has(request.params['username'])) {
response
.type('video/mp2t')
.send(
readFileSync(
join(
process.cwd(),
'apps/xtream-mock-server/src/fixtures/live.mpegts'
)
)
);
return;
}
streamResponse(request, response);
}
);
app.all('/streaming/timeshift.php', streamResponse);
}
function downloadStreamFixtureOf(
request: Request
): ScenarioConfig['downloadStreamFixture'] {
const username = String(request.params['username'] ?? '');
const password = String(request.params['password'] ?? '');
return getScenario(username, password).downloadStreamFixture;
}
function isPerformanceMediaRequest(
request: Request,
controlEnabled: boolean
): boolean {
if (!controlEnabled) return false;
const username =
request.params['username'] ?? request.query['username'] ?? '';
const password =
request.params['password'] ?? request.query['password'] ?? '';
return (
username === PERFORMANCE_USERNAME && password === PERFORMANCE_PASSWORD
);
}