mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
* feat(portal): season thumbnails in the season dropdown + PR #1628 follow-ups Follow-ups to the season posters shipped in #1628: - The >6-seasons dropdown (`SeasonTabsComponent`) now carries a 28×42 season thumbnail at the start of each menu row that has a poster and in the closed trigger for the selected season, fed by a new `seasonPosters` input from the season container and the fullscreen episode panel. Rows without a poster get no placeholder, a failed image is dropped, and the pill row stays text-only as the design review decided. - The fullscreen season strip's episode count uses its own `PORTALS.EPISODE_COUNT_ONE/OTHER` keys instead of borrowing the download manager's; all 18 locales filled through the i18n merger from their existing `DOWNLOADS.EPISODE_COUNT_*` translations. - The Stalker mock's serve targets no longer pin `PORT` (an nx:run-commands `env` entry overrides the shell), and `main.ts` resolves `PORT`, then the Playwright-side `MOCK_PORT` alias, then 3210 — so `MOCK_PORT=3310` now relocates the whole E2E run. The Xtream mock honours `XTREAM_MOCK_PORT` the same way. - `resolveAutoSelectedSeason` gets a direct spec covering every branch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(xtream-mock): mint marketing asset URLs on the port the server bound Greptile P1 on #1633: the listener honoured `XTREAM_MOCK_PORT`, but `marketingAssetOrigin()` still read `PORT` alone, so a run relocated only through the alias sent every poster/backdrop/logo/episode URL to 3211. One resolver (`resolveXtreamMockPortString` in `mock-port.ts`: `PORT`, then `XTREAM_MOCK_PORT`, then 3211) now feeds the environment parser, the marketing asset origin and the demo-guide origin fallback. A spec pins the precedence and that `marketingAssetUrl` follows the bound port. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
366 lines
12 KiB
TypeScript
366 lines
12 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';
|
|
// 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 (request.params['username'] === 'epg') {
|
|
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
|
|
);
|
|
}
|