import { PortalActivityType, StalkerPortalItem, } from './stalker-portal-item.interface'; const SQLITE_UTC_TIMESTAMP_PATTERN = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/; /** * Extract a stable ID from an untyped Stalker portal item. * * Stalker portals use different id fields depending on the content type * (`id`, `stream_id`, `series_id`, `movie_id`). This function returns * the first non-empty one, or a generated fallback based on the playlist * id and index. */ export function extractStalkerItemId( item: StalkerPortalItem | Record, fallbackPrefix = '', fallbackIndex = 0 ): string { const raw = item as Record; const id = raw['id'] ?? raw['stream_id'] ?? raw['series_id'] ?? raw['movie_id']; const idStr = String(id ?? '').trim(); return idStr || `${fallbackPrefix}-${fallbackIndex}`; } /** * Extract a display title from a Stalker portal item. */ export function extractStalkerItemTitle( item: StalkerPortalItem | Record ): string { const raw = item as Record; return String(raw['title'] ?? raw['o_name'] ?? raw['name'] ?? 'Unknown'); } /** * Extract a poster/cover URL from a Stalker portal item. */ export function extractStalkerItemPoster( item: StalkerPortalItem | Record ): string { const raw = item as Record; return String(raw['cover'] ?? raw['logo'] ?? raw['poster_url'] ?? ''); } /** Portals spell the series marker as `true`, `1`, or `'1'`. */ export function isStalkerSeriesFlag(value: unknown): boolean { return value === true || value === 1 || value === '1'; } /** * Does this item behave like a series? Beyond the explicit flag, an * embedded-VOD ("vclub") item announces its episodes through a non-empty * `series` array while staying in the VOD category. */ export function isStalkerSeriesItem(item: { is_series?: unknown; series?: unknown; }): boolean { return ( isStalkerSeriesFlag(item?.is_series) || (Array.isArray(item?.series) && item.series.length > 0) ); } /** * Determine the normalised activity type of a Stalker item. * * - `itv` / `live` / radio → `'live'` * - `series` or `is_series` equal to `true`, `1`, or `'1'` → `'series'` * - everything else → `'movie'` * * Deliberately blind to the `series` episode array: embedded-VOD items live * in the VOD section and must keep routing there, so they stay `'movie'` * here even though TMDB knows them as shows. Metadata lookups need the other * answer — see {@link isStalkerSeriesItem}. */ export function extractStalkerItemType( item: StalkerPortalItem | Record ): PortalActivityType { const raw = item as Record; const categoryId = String(raw['category_id'] ?? '').toLowerCase(); const streamType = String(raw['stream_type'] ?? '').toLowerCase(); if ( categoryId === 'itv' || streamType === 'live' || isStalkerRadioItem(raw) ) { return 'live'; } if (categoryId === 'series' || isStalkerSeriesFlag(raw['is_series'])) { return 'series'; } return 'movie'; } /** * Detect radio station records from Stalker favorites/recently-viewed payloads. */ export function isStalkerRadioItem( item: StalkerPortalItem | Record ): boolean { const raw = item as Record; const radio = raw['radio']; const radioFlag = radio === true || radio === 1 || String(radio ?? '') .trim() .toLowerCase() === 'true' || String(radio ?? '').trim() === '1'; const categoryId = String(raw['category_id'] ?? '') .trim() .toLowerCase(); const streamType = String(raw['stream_type'] ?? '') .trim() .toLowerCase(); const cmd = String(raw['cmd'] ?? '') .trim() .toLowerCase(); return ( radioFlag || categoryId === 'radio' || streamType === 'radio' || cmd.includes('://radio/') ); } /** * Normalise a raw date value (epoch number, numeric string, or ISO string) * into a consistent ISO 8601 string. */ export function normalizeStalkerDate(value: unknown): string { if (typeof value === 'number') { if (!Number.isFinite(value)) return ''; const ms = value > 10_000_000_000 ? value : value * 1000; return new Date(ms).toISOString(); } if (typeof value === 'string') { const trimmed = value.trim(); if (/^\d+$/.test(trimmed)) { const numeric = Number(trimmed); if (Number.isFinite(numeric)) { const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000; return new Date(ms).toISOString(); } } const sqliteUtcIso = normalizeSqliteUtcTimestamp(trimmed); if (sqliteUtcIso) { return sqliteUtcIso; } const parsed = Date.parse(trimmed); if (!Number.isNaN(parsed)) { return new Date(parsed).toISOString(); } } return ''; } /** * Check whether a raw item from `Playlist.favorites` matches the given itemId. * * Used to filter stalker favorites/recently-viewed arrays when removing items. */ export function stalkerItemMatchesId( raw: unknown, targetId: string, fallbackPrefix = '', fallbackIndex = 0 ): boolean { const item = (raw ?? {}) as Record; const rawId = extractStalkerItemId(item, fallbackPrefix, fallbackIndex); return rawId === targetId; } function normalizeSqliteUtcTimestamp(value: string): string | null { const match = SQLITE_UTC_TIMESTAMP_PATTERN.exec(value); if (!match) { return null; } const [, year, month, day, hours, minutes, seconds, milliseconds = '0'] = match; const normalizedMs = milliseconds.padEnd(3, '0').slice(0, 3); return new Date( Date.UTC( Number(year), Number(month) - 1, Number(day), Number(hours), Number(minutes), Number(seconds), Number(normalizedMs) ) ).toISOString(); }