mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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>
368 lines
13 KiB
TypeScript
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
|
|
);
|
|
}
|