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; } const portalCache = new Map(); const vodDetailsCache = new Map(); const seriesInfoCache = new Map(); 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(); 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(); 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; }