feat(stalker): full Live TV channel list for complete search, count badges, and all-channels grid (#1209)

Load the complete ITV channel list once per portal (Ministra get_all_channels with a paged crawl fallback) and use it for: full local search across every channel, per-genre category count badges, an all-channels paginated grid on Live TV entry, and eager bulk EPG so row previews appear without playing a channel. Censored (adult) genres absent from get_all_channels fall back to the legacy paged flow and show no badge. Includes mock-server support, unit + E2E coverage, and architecture docs.
This commit is contained in:
4gray authored and GitHub committed 2026-07-23 23:31:49 +02:00
1 parent e53adcbeaf
commit b3e130aa65
56 files changed
+3044 -63

No files matched your search

+2
View File
@@ -38,6 +38,7 @@ Then in IPTVnator, add a new Stalker portal:
| `00:1A:79:00:00:03` | **minimal** | 2 categories, 5 items — edge case testing (empty states, single items) |
| `00:1A:79:00:00:04` | **is-series** | 60% of VOD items have `is_series=1` — tests the Ministra lazy-season flow |
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
## Configuration
@@ -65,6 +66,7 @@ All endpoints are served at `GET /portal.php?action=<action>&...` matching the r
| `get_categories` | Category list filtered by `type` (itv/vod/series) |
| `get_genres` | Genre list (mirrors categories) |
| `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons |
| `get_all_channels` | Complete ITV channel list in one response (`type=itv` only); excludes censored (adult) genres; disabled in the `legacy-pagination` scenario |
| `create_link` | Returns a real public HLS stream URL for playback |
| `favorites` | Add / remove / get favorites (in-memory, resets on restart) |
| `get_short_epg` | Current-and-upcoming EPG window for a channel (`ch_id`, `size`) |
@@ -9,6 +9,8 @@ export interface RawCategory {
id: string;
title: string;
alias: string;
/** Ministra adult-genre flag ('1' = censored). */
censored?: string;
}
export interface RawChannel {
@@ -171,6 +173,16 @@ export function generatePortalData(config: ScenarioConfig): GeneratedPortalData
// ------ ITV categories + channels ------
data.itvCategories = generateCategories('itv', config.categoryCount.itv);
// Real Ministra portals mark adult genres as censored and EXCLUDE their
// channels from get_all_channels / the "*" listing; the channels are only
// served by paging the specific genre. Mirror that with one extra
// censored category so clients can exercise the fallback path.
data.itvCategories.push({
id: '1099',
title: 'For adults',
alias: 'for_adults',
censored: '1',
});
let channelIndex = 0;
for (const cat of data.itvCategories) {
const channels = generateChannels(cat.id, config.itemsPerCategory, channelIndex);
@@ -0,0 +1,46 @@
import { Request, Response } from 'express';
import { getPortalData } from '../data-store.js';
import { getScenario } from '../scenarios.js';
import { extractMac } from './get-categories.handler.js';
/**
* Stalker/Ministra get_all_channels — returns the COMPLETE ITV channel list
* in a single response (no pagination). This is what STB clients use to build
* their local channel list.
*
* Only type=itv is supported, matching the reference Ministra middleware.
* Scenarios with supportsGetAllChannels=false mimic legacy portals and answer
* with an error payload so clients fall back to paginated get_ordered_list.
*/
export function handleGetAllChannels(req: Request, res: Response): void {
const mac = extractMac(req);
const type = (req.query['type'] as string) ?? 'itv';
const scenario = getScenario(mac);
if (scenario.supportsGetAllChannels === false || type !== 'itv') {
res.json({ js: { error: 'Unknown action: get_all_channels' } });
return;
}
const data = getPortalData(mac);
// Like real Ministra portals: censored (adult) genres are excluded from
// the bulk channel list — clients must page those genres explicitly.
const censoredIds = new Set(
data.itvCategories
.filter((cat) => cat.censored === '1')
.map((cat) => cat.id)
);
const allChannels = [...data.channels.entries()]
.filter(([categoryId]) => !censoredIds.has(categoryId))
.flatMap(([, channels]) => channels);
res.json({
js: {
data: allChannels,
total_items: allChannels.length,
max_page_items: allChannels.length,
cur_page: 0,
selected_item: 0,
},
});
}
@@ -26,7 +26,7 @@ export function handleGetGenres(req: Request, res: Response): void {
id: cat.id,
title: cat.title,
alias: cat.alias,
censored: '0',
censored: cat.censored ?? '0',
}));
res.json({ js: genres });
@@ -34,8 +34,17 @@ export function handleGetOrderedList(req: Request, res: Response): void {
if (type === 'itv') {
if (categoryId === '*') {
for (const items of data.channels.values()) {
allItems.push(...items);
// The "*" listing excludes censored (adult) genres, matching
// real portals; their channels are only served by genre id.
const censoredIds = new Set(
data.itvCategories
.filter((cat) => cat.censored === '1')
.map((cat) => cat.id)
);
for (const [catId, items] of data.channels.entries()) {
if (!censoredIds.has(catId)) {
allItems.push(...items);
}
}
} else {
allItems = data.channels.get(categoryId) ?? [];
@@ -1,6 +1,7 @@
import { Request, Response } from 'express';
import { handleHandshake } from '../handlers/handshake.handler.js';
import { handleDoAuth } from '../handlers/do-auth.handler.js';
import { handleGetAllChannels } from '../handlers/get-all-channels.handler.js';
import { handleGetCategories } from '../handlers/get-categories.handler.js';
import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js';
import { handleGetSeasons } from '../handlers/get-seasons.handler.js';
@@ -39,6 +40,9 @@ export default function dispatchPortalAction(req: Request, res: Response): void
handleGetOrderedList(req, res);
}
break;
case 'get_all_channels':
handleGetAllChannels(req, res);
break;
case 'create_link':
handleCreateLink(req, res);
break;
@@ -15,6 +15,12 @@ export interface ScenarioConfig {
isSeriesFraction: number;
/** Fraction of VOD items that have embedded series[] array */
embeddedSeriesFraction: number;
/**
* Whether the portal implements the ITV `get_all_channels` action.
* Defaults to true; set false to force clients onto paginated
* `get_ordered_list` crawling (legacy portals).
*/
supportsGetAllChannels?: boolean;
}
/**
@@ -88,6 +94,19 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
isSeriesFraction: 0,
embeddedSeriesFraction: 0.5, // 50% of VOD items have embedded series[]
},
'00:1a:79:00:00:06': {
name: 'legacy-pagination',
description:
'Portal without get_all_channels — clients must crawl get_ordered_list pages',
seed: 6006,
categoryCount: { itv: 6, radio: 4, vod: 4, series: 4 },
itemsPerCategory: 40,
seasonsPerSeries: 2,
episodesPerSeason: 5,
isSeriesFraction: 0,
embeddedSeriesFraction: 0,
supportsGetAllChannels: false,
},
};
/**