mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
411 lines
13 KiB
TypeScript
411 lines
13 KiB
TypeScript
import { faker } from '@faker-js/faker';
|
|
import { generateCategories } from './generators/categories.generator.js';
|
|
import {
|
|
buildEpgListing,
|
|
buildLiveStream,
|
|
generateEpgListings,
|
|
generateLiveStreams,
|
|
RawEpgListing,
|
|
RawLiveStream,
|
|
} from './generators/live.generator.js';
|
|
import {
|
|
generateVodStreams,
|
|
generateVodDetails,
|
|
RawVodStream,
|
|
RawVodDetails,
|
|
} from './generators/vod.generator.js';
|
|
import {
|
|
generateSeriesItems,
|
|
generateSeriesInfo,
|
|
RawSeriesItem,
|
|
RawSeriesInfo,
|
|
} from './generators/series.generator.js';
|
|
import { RawCategory } from './generators/categories.generator.js';
|
|
import {
|
|
buildMarketingPortalFixture,
|
|
buildMarketingSeriesInfo,
|
|
buildMarketingVodDetails,
|
|
} from './generators/marketing.generator.js';
|
|
import {
|
|
buildPerformancePortalFixture,
|
|
buildPerformanceSeriesInfo,
|
|
buildPerformanceVodDetails,
|
|
} from './generators/performance.generator.js';
|
|
import { getScenario, ScenarioConfig } from './scenarios.js';
|
|
|
|
export interface PortalData {
|
|
scenario: ScenarioConfig;
|
|
liveCategories: RawCategory[];
|
|
vodCategories: RawCategory[];
|
|
seriesCategories: RawCategory[];
|
|
liveStreams: RawLiveStream[];
|
|
vodStreams: RawVodStream[];
|
|
seriesItems: RawSeriesItem[];
|
|
epgListingsByStreamId: Map<number, RawEpgListing[]>;
|
|
}
|
|
|
|
const portalCache = new Map<string, PortalData>();
|
|
const vodDetailsCache = new Map<string, RawVodDetails>();
|
|
const seriesInfoCache = new Map<string, RawSeriesInfo>();
|
|
|
|
function generatePortalData(username: string, password: string): PortalData {
|
|
const scenario = getScenario(username, password);
|
|
if (scenario.performanceFixture === 'catalog-100k') {
|
|
return {
|
|
scenario,
|
|
...buildPerformancePortalFixture(),
|
|
};
|
|
}
|
|
faker.seed(scenario.seed);
|
|
const {
|
|
categoryCount,
|
|
itemsPerCategory,
|
|
seasonsPerSeries,
|
|
episodesPerSeason,
|
|
} = scenario;
|
|
if (scenario.marketingFixture) {
|
|
const marketingFixture = buildMarketingPortalFixture();
|
|
for (const s of marketingFixture.seriesItems) {
|
|
const cacheKey = detailCacheKey(username, password, s.series_id);
|
|
if (!seriesInfoCache.has(cacheKey)) {
|
|
seriesInfoCache.set(
|
|
cacheKey,
|
|
buildMarketingSeriesInfo(
|
|
s,
|
|
seasonsPerSeries,
|
|
episodesPerSeason
|
|
)
|
|
);
|
|
}
|
|
}
|
|
return {
|
|
scenario,
|
|
...marketingFixture,
|
|
};
|
|
}
|
|
let liveCategories = generateCategories('live', categoryCount.live);
|
|
if (scenario.categoryFixture === 'scroll') {
|
|
liveCategories = liveCategories.map((category, index) => ({
|
|
...category,
|
|
// Coprime step permutes names independently of provider/SQLite IDs.
|
|
category_name: `${index % 4 === 0 ? 'Visible' : 'Hidden'} ${String(
|
|
(index * 137) % categoryCount.live
|
|
).padStart(3, '0')}`,
|
|
}));
|
|
}
|
|
const vodCategories = generateCategories('vod', categoryCount.vod);
|
|
const seriesCategories = generateCategories('series', categoryCount.series);
|
|
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
|
|
let liveStreams = generateLiveStreams(liveCategories, itemsPerCategory);
|
|
const vodStreams = generateVodStreams(vodCategories, itemsPerCategory);
|
|
const seriesItems = generateSeriesItems(seriesCategories, itemsPerCategory);
|
|
if (scenario.epgFixture === 'timezone-focus') {
|
|
const timezoneFixture = buildTimezoneFixture();
|
|
liveCategories = timezoneFixture.liveCategories;
|
|
liveStreams = timezoneFixture.liveStreams;
|
|
timezoneFixture.epgListingsByStreamId.forEach((listings, streamId) => {
|
|
epgListingsByStreamId.set(streamId, listings);
|
|
});
|
|
}
|
|
// Pre-populate series info cache
|
|
if (!scenario.deferSeriesDetails) {
|
|
for (const s of seriesItems) {
|
|
const cacheKey = detailCacheKey(username, password, s.series_id);
|
|
if (!seriesInfoCache.has(cacheKey)) {
|
|
seriesInfoCache.set(
|
|
cacheKey,
|
|
generateSeriesInfo(s, seasonsPerSeries, episodesPerSeason)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
scenario,
|
|
liveCategories,
|
|
vodCategories,
|
|
seriesCategories,
|
|
liveStreams,
|
|
vodStreams,
|
|
seriesItems,
|
|
epgListingsByStreamId,
|
|
};
|
|
}
|
|
|
|
export function getPortalData(username: string, password: string): PortalData {
|
|
const key = `${username}:${password}`;
|
|
if (!portalCache.has(key)) {
|
|
portalCache.set(key, generatePortalData(username, password));
|
|
}
|
|
return portalCache.get(key)!;
|
|
}
|
|
|
|
export function getVodDetails(
|
|
username: string,
|
|
password: string,
|
|
vodId: number
|
|
): RawVodDetails | null {
|
|
const cacheKey = detailCacheKey(username, password, vodId);
|
|
if (!vodDetailsCache.has(cacheKey)) {
|
|
const data = getPortalData(username, password);
|
|
const stream = data.vodStreams.find((v) => v.stream_id === vodId);
|
|
if (!stream) return null;
|
|
if (data.scenario.vodDetailsFixture === 'empty-metadata') {
|
|
vodDetailsCache.set(cacheKey, { info: [] });
|
|
return vodDetailsCache.get(cacheKey) ?? null;
|
|
}
|
|
vodDetailsCache.set(
|
|
cacheKey,
|
|
data.scenario.performanceFixture === 'catalog-100k'
|
|
? buildPerformanceVodDetails(stream)
|
|
: data.scenario.marketingFixture
|
|
? buildMarketingVodDetails(stream)
|
|
: generateVodDetails(stream)
|
|
);
|
|
}
|
|
return vodDetailsCache.get(cacheKey) ?? null;
|
|
}
|
|
|
|
export function getSeriesInfo(
|
|
username: string,
|
|
password: string,
|
|
seriesId: number
|
|
): RawSeriesInfo | null {
|
|
const cacheKey = detailCacheKey(username, password, seriesId);
|
|
if (!seriesInfoCache.has(cacheKey)) {
|
|
const data = getPortalData(username, password);
|
|
const series = data.seriesItems.find((s) => s.series_id === seriesId);
|
|
if (!series) return null;
|
|
seriesInfoCache.set(
|
|
cacheKey,
|
|
data.scenario.performanceFixture === 'catalog-100k'
|
|
? buildPerformanceSeriesInfo(series)
|
|
: data.scenario.marketingFixture
|
|
? buildMarketingSeriesInfo(
|
|
series,
|
|
data.scenario.seasonsPerSeries,
|
|
data.scenario.episodesPerSeason
|
|
)
|
|
: generateSeriesInfo(
|
|
series,
|
|
data.scenario.seasonsPerSeries,
|
|
data.scenario.episodesPerSeason
|
|
)
|
|
);
|
|
}
|
|
return seriesInfoCache.get(cacheKey) ?? null;
|
|
}
|
|
|
|
export function getEpgListings(
|
|
username: string,
|
|
password: string,
|
|
streamId: number
|
|
): RawEpgListing[] {
|
|
const data = getPortalData(username, password);
|
|
if (!data.epgListingsByStreamId.has(streamId)) {
|
|
data.epgListingsByStreamId.set(streamId, generateEpgListings(streamId));
|
|
}
|
|
return data.epgListingsByStreamId.get(streamId)!;
|
|
}
|
|
|
|
export function getShortEpgListings(
|
|
username: string,
|
|
password: string,
|
|
streamId: number,
|
|
limit = 12
|
|
): RawEpgListing[] {
|
|
const listings = getEpgListings(username, password, streamId);
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const currentIndex = listings.findIndex((listing) => {
|
|
const stopTimestamp = Number.parseInt(listing.stop_timestamp, 10);
|
|
return Number.isFinite(stopTimestamp) && stopTimestamp >= now;
|
|
});
|
|
|
|
const startIndex = currentIndex >= 0 ? currentIndex : 0;
|
|
return listings.slice(startIndex, startIndex + Math.max(1, limit));
|
|
}
|
|
|
|
export function resetAll(): void {
|
|
portalCache.clear();
|
|
vodDetailsCache.clear();
|
|
seriesInfoCache.clear();
|
|
}
|
|
|
|
/** Test-only cache sizes; never exposes credential-bearing keys or fixture data. */
|
|
export function getDetailCacheCardinalityForTesting(): Readonly<{
|
|
vodDetails: number;
|
|
seriesInfo: number;
|
|
}> {
|
|
return {
|
|
vodDetails: vodDetailsCache.size,
|
|
seriesInfo: seriesInfoCache.size,
|
|
};
|
|
}
|
|
|
|
function detailCacheKey(
|
|
username: string,
|
|
password: string,
|
|
itemId: number
|
|
): string {
|
|
return `${username}:${password}:${itemId}`;
|
|
}
|
|
|
|
function buildTimezoneFixture(): Pick<
|
|
PortalData,
|
|
'liveCategories' | 'liveStreams' | 'epgListingsByStreamId'
|
|
> {
|
|
const primaryCategoryId = '101';
|
|
const secondaryCategoryId = '102';
|
|
const liveCategories: RawCategory[] = [
|
|
{
|
|
category_id: primaryCategoryId,
|
|
category_name: 'EPG Focus',
|
|
parent_id: 0,
|
|
},
|
|
{
|
|
category_id: secondaryCategoryId,
|
|
category_name: 'Overflow Live',
|
|
parent_id: 0,
|
|
},
|
|
];
|
|
|
|
const liveStreams: RawLiveStream[] = [
|
|
buildLiveStream({
|
|
num: 1,
|
|
name: 'Timezone News',
|
|
streamId: 10_000,
|
|
categoryId: primaryCategoryId,
|
|
tvArchive: 1,
|
|
tvArchiveDuration: 3,
|
|
}),
|
|
buildLiveStream({
|
|
num: 2,
|
|
name: 'Night Sports',
|
|
streamId: 10_001,
|
|
categoryId: primaryCategoryId,
|
|
}),
|
|
buildLiveStream({
|
|
num: 3,
|
|
name: 'Archive Cinema',
|
|
streamId: 10_002,
|
|
categoryId: primaryCategoryId,
|
|
}),
|
|
buildLiveStream({
|
|
num: 4,
|
|
name: 'Overflow Live 1',
|
|
streamId: 10_003,
|
|
categoryId: secondaryCategoryId,
|
|
}),
|
|
buildLiveStream({
|
|
num: 5,
|
|
name: 'Overflow Live 2',
|
|
streamId: 10_004,
|
|
categoryId: secondaryCategoryId,
|
|
}),
|
|
];
|
|
|
|
const epgListingsByStreamId = new Map<number, RawEpgListing[]>();
|
|
epgListingsByStreamId.set(10_000, buildTimezoneNewsEpg(10_000));
|
|
epgListingsByStreamId.set(10_001, []);
|
|
epgListingsByStreamId.set(10_002, []);
|
|
|
|
return {
|
|
liveCategories,
|
|
liveStreams,
|
|
epgListingsByStreamId,
|
|
};
|
|
}
|
|
|
|
function buildTimezoneNewsEpg(streamId: number): RawEpgListing[] {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const roundedNow = now - (now % (15 * 60));
|
|
const nextUtcMidnight = getFutureUtcMidnight(now);
|
|
const rawStringOffsetSeconds = -2 * 60 * 60;
|
|
const channelId = `channel-${streamId}.mock`;
|
|
|
|
const listings = [
|
|
{
|
|
id: `${streamId}-past`,
|
|
title: 'Earlier Bulletin',
|
|
description:
|
|
'Past schedule item used to anchor current-program detection.',
|
|
startTimestamp: roundedNow - 75 * 60,
|
|
stopTimestamp: roundedNow - 15 * 60,
|
|
},
|
|
{
|
|
id: `${streamId}-current`,
|
|
title: 'Global Headlines',
|
|
description:
|
|
'Current program for list-row and detail EPG assertions.',
|
|
startTimestamp: roundedNow - 15 * 60,
|
|
stopTimestamp: roundedNow + 15 * 60,
|
|
},
|
|
{
|
|
id: `${streamId}-next`,
|
|
title: 'Market Wrap',
|
|
description:
|
|
'Immediate next program for short-EPG preview assertions.',
|
|
startTimestamp: roundedNow + 15 * 60,
|
|
stopTimestamp: roundedNow + 45 * 60,
|
|
},
|
|
{
|
|
id: `${streamId}-later`,
|
|
title: 'Overnight Update',
|
|
description:
|
|
'Later same-day program for selected-channel list coverage.',
|
|
startTimestamp: roundedNow + 45 * 60,
|
|
stopTimestamp: roundedNow + 75 * 60,
|
|
},
|
|
{
|
|
id: `${streamId}-boundary-1`,
|
|
title: 'Late Edition',
|
|
description:
|
|
'Boundary program spanning UTC midnight for timezone edge cases.',
|
|
startTimestamp: nextUtcMidnight - 30 * 60,
|
|
stopTimestamp: nextUtcMidnight + 30 * 60,
|
|
},
|
|
{
|
|
id: `${streamId}-boundary-2`,
|
|
title: 'After Midnight',
|
|
description:
|
|
'Post-midnight program used for next-day navigation checks.',
|
|
startTimestamp: nextUtcMidnight + 30 * 60,
|
|
stopTimestamp: nextUtcMidnight + 90 * 60,
|
|
},
|
|
];
|
|
|
|
return listings
|
|
.sort((left, right) => left.startTimestamp - right.startTimestamp)
|
|
.map((listing) =>
|
|
buildEpgListing({
|
|
id: listing.id,
|
|
epgId: channelId,
|
|
title: listing.title,
|
|
description: listing.description,
|
|
startTimestamp: listing.startTimestamp,
|
|
stopTimestamp: listing.stopTimestamp,
|
|
channelId,
|
|
rawStringOffsetSeconds,
|
|
})
|
|
);
|
|
}
|
|
|
|
function getFutureUtcMidnight(nowSeconds: number): number {
|
|
const nowDate = new Date(nowSeconds * 1000);
|
|
let midnightSeconds = Math.floor(
|
|
Date.UTC(
|
|
nowDate.getUTCFullYear(),
|
|
nowDate.getUTCMonth(),
|
|
nowDate.getUTCDate() + 1,
|
|
0,
|
|
0,
|
|
0
|
|
) / 1000
|
|
);
|
|
|
|
while (midnightSeconds - nowSeconds < 3 * 60 * 60) {
|
|
midnightSeconds += 24 * 60 * 60;
|
|
}
|
|
|
|
return midnightSeconds;
|
|
}
|