= htmlspecialchars($programme['stop']->format('H:i')) ?>
[], 'programmes' => [], 'error' => 'PHP DOM/XML extension is not installed']; } libxml_use_internal_errors(true); $dom = new DOMDocument(); if (!$dom->loadXML($xml, LIBXML_NOERROR | LIBXML_NOWARNING)) { return ['channels' => [], 'programmes' => [], 'error' => 'Invalid XMLTV response']; } $xp = new DOMXPath($dom); $channels = []; foreach ($xp->query('/tv/channel') as $channel) { $id = $channel->getAttribute('id'); $name = trim($xp->evaluate('string(display-name[1])', $channel)); $icon = trim($xp->evaluate('string(icon/@src)', $channel)); $channels[$id] = ['id' => $id, 'name' => $name ?: $id, 'icon' => $icon]; } $start_day = new DateTimeImmutable($target_day . ' 00:00:00'); $end_day = $start_day->modify('+1 day'); $rows = []; foreach ($xp->query('/tv/programme') as $programme) { $channel_id = $programme->getAttribute('channel'); $start = DateTimeImmutable::createFromFormat('YmdHis O', $programme->getAttribute('start')); $stop = DateTimeImmutable::createFromFormat('YmdHis O', $programme->getAttribute('stop')); if (!$start || !$stop) continue; if ($stop <= $start_day || $start >= $end_day) continue; $title = trim($xp->evaluate('string(title)', $programme)); $subtitle = trim($xp->evaluate('string(sub-title)', $programme)); $desc = trim($xp->evaluate('string(desc)', $programme)); $rating = trim($xp->evaluate('string(rating/value)', $programme)); $cats = []; foreach ($xp->query('category', $programme) as $cat) { $value = trim($cat->textContent); if ($value !== '') $cats[strtolower($value)] = $value; } $rows[$channel_id][] = [ 'start' => $start, 'stop' => $stop, 'title' => $title, 'subtitle' => $subtitle, 'desc' => $desc, 'rating' => $rating, 'categories' => array_values($cats), ]; } uasort($channels, static function ($a, $b) { preg_match('/^\d+/', $a['name'], $ma); preg_match('/^\d+/', $b['name'], $mb); $na = isset($ma[0]) ? (int) $ma[0] : 9999; $nb = isset($mb[0]) ? (int) $mb[0] : 9999; return $na <=> $nb ?: strcmp($a['name'], $b['name']); }); foreach ($rows as &$programmes) { usort($programmes, static fn($a, $b) => $a['start'] <=> $b['start']); } return ['channels' => $channels, 'programmes' => $rows, 'error' => '']; } function export_window_definitions(string $target_day): array { return [ 'full' => [ 'label' => '00h-24h', 'start' => new DateTimeImmutable($target_day . ' 00:00:00'), 'end' => new DateTimeImmutable($target_day . ' 23:59:59'), ], 'morning' => [ 'label' => 'Morning', 'start' => new DateTimeImmutable($target_day . ' 06:00:00'), 'end' => new DateTimeImmutable($target_day . ' 11:59:59'), ], 'afternoon' => [ 'label' => 'Afternoon', 'start' => new DateTimeImmutable($target_day . ' 12:00:00'), 'end' => new DateTimeImmutable($target_day . ' 17:59:59'), ], 'evening' => [ 'label' => 'Evening', 'start' => new DateTimeImmutable($target_day . ' 18:00:00'), 'end' => new DateTimeImmutable($target_day . ' 23:59:59'), ], 'prime' => [ 'label' => 'Prime time', 'start' => new DateTimeImmutable($target_day . ' 20:00:00'), 'end' => new DateTimeImmutable($target_day . ' 23:59:59'), ], ]; } function export_filter_programmes(array $programmes, DateTimeImmutable $start, DateTimeImmutable $end): array { return array_values(array_filter($programmes, static fn($p) => $p['stop'] > $start && $p['start'] < $end)); } function export_hour_slots(DateTimeImmutable $start, DateTimeImmutable $end): array { $slots = []; $cursor = $start; while ($cursor < $end) { $slot_end = $cursor->modify('+1 hour'); if ($slot_end > $end) $slot_end = $end; $slots[] = ['start' => $cursor, 'end' => $slot_end]; $cursor = $slot_end; } return $slots; } function export_programme_weight(array $programme): int { $duration = max(1, (int) round(($programme['stop']->getTimestamp() - $programme['start']->getTimestamp()) / 60)); $weight = min($duration, 240); if (!empty($programme['subtitle'])) $weight += 10; if (!empty($programme['categories'])) $weight += 8; if (!empty($programme['desc'])) $weight += min(40, (int) floor(strlen($programme['desc']) / 20)); return $weight; } function export_programme_duration(array $programme): int { return max(1, (int) round(($programme['stop']->getTimestamp() - $programme['start']->getTimestamp()) / 60)); } function export_programme_is_series(array $programme): bool { return !empty($programme['subtitle']); } function export_programme_has_category(array $programme, array $needles): bool { foreach ($programme['categories'] as $category) { $value = strtolower($category); foreach ($needles as $needle) { if (str_contains($value, strtolower($needle))) return true; } } return false; } function export_take_entries(array $entries, callable $filter, int $limit = 4): array { $picked = []; foreach ($entries as $entry) { if (!$filter($entry)) continue; $picked[] = $entry; if (count($picked) >= $limit) break; } return $picked; } function export_build_magazine(array $channels): array { $all = []; foreach ($channels as $channel) { foreach ($channel['programmes'] as $programme) { $all[] = [ 'channel_name' => $channel['name'], 'channel_icon' => $channel['icon'], 'programme' => $programme, 'weight' => export_programme_weight($programme), ]; } } usort($all, static function ($a, $b) { return $b['weight'] <=> $a['weight'] ?: $a['programme']['start'] <=> $b['programme']['start'] ?: strcmp($a['channel_name'], $b['channel_name']); }); $highlights = array_slice($all, 0, 5); $series = export_take_entries($all, static fn($entry) => export_programme_is_series($entry['programme']), 4); $movies = export_take_entries($all, static fn($entry) => !export_programme_is_series($entry['programme']) && export_programme_duration($entry['programme']) >= 80, 4); $late_picks = export_take_entries($all, static fn($entry) => (int) $entry['programme']['start']->format('H') >= 21, 4); $docs = export_take_entries($all, static fn($entry) => export_programme_has_category($entry['programme'], ['document', 'biopic', 'history', 'histoire']), 4); $by_channel = []; foreach ($channels as $channel) { $by_channel[] = [ 'name' => $channel['name'], 'icon' => $channel['icon'], 'programmes' => array_slice($channel['programmes'], 0, 6), ]; } $cards = [ [ 'title' => 'Ce soir a la tele', 'kicker' => 'Selection de la redaction', 'items' => array_slice($highlights, 0, 4), ], [ 'title' => 'Series en diffusion', 'kicker' => 'Episodes et rendez-vous', 'items' => $series ?: array_slice($highlights, 0, 4), ], [ 'title' => 'Films de la selection', 'kicker' => 'Longs formats', 'items' => $movies ?: array_slice($highlights, 0, 4), ], [ 'title' => 'Docs et recits de vie', 'kicker' => 'Documentaires / biopics', 'items' => $docs ?: $late_picks ?: array_slice($highlights, 0, 4), ], ]; return ['highlights' => $highlights, 'channels' => $by_channel, 'cards' => $cards]; } $sources = array_values($config['epg_sources'] ?? []); $source_index = max(0, min((int) ($_GET['source'] ?? 0), max(count($sources) - 1, 0))); $source = $sources[$source_index] ?? null; $target_day = preg_match('/^\d{4}-\d{2}-\d{2}$/', (string) ($_GET['day'] ?? '')) ? (string) $_GET['day'] : (new DateTimeImmutable('today'))->format('Y-m-d'); $layout_input = isset($_GET['layout']) ? (string) $_GET['layout'] : 'cards'; $limit_input = isset($_GET['limit']) ? (string) $_GET['limit'] : '8'; $window_input = isset($_GET['window']) ? (string) $_GET['window'] : 'full'; $layout = in_array($layout_input, ['cards', 'timeline', 'magazine'], true) ? $layout_input : 'cards'; $limit = in_array($limit_input, ['4', '8', 'all'], true) ? $limit_input : '8'; $windows = export_window_definitions($target_day); $window_key = array_key_exists($window_input, $windows) ? $window_input : 'full'; $window = $windows[$window_key]; $slots = export_hour_slots($window['start'], $window['end']->modify('+1 second')); $fetch = $source ? gridtv_fetch_url((string) $source['epg_url'], 25) : ['ok' => false, 'error' => 'No source configured', 'body' => '']; $parsed = $fetch['ok'] ? export_parse_xmltv_programmes($fetch['body'], $target_day) : ['channels' => [], 'programmes' => [], 'error' => $fetch['error'] ?: 'Unable to fetch XMLTV']; $visible_channels = []; foreach ($parsed['channels'] as $channel_id => $channel) { $filtered = export_filter_programmes($parsed['programmes'][$channel_id] ?? [], $window['start'], $window['end']->modify('+1 second')); if (!$filtered) continue; $visible_channels[$channel_id] = $channel + ['programmes' => $filtered]; } if ($limit !== 'all') { $visible_channels = array_slice($visible_channels, 0, (int) $limit, true); } $day_label = (new DateTimeImmutable($target_day))->format('l d F Y'); $layout_label = $layout === 'cards' ? 'Readable cards' : ($layout === 'timeline' ? 'Dense timeline' : 'TV magazine'); $magazine = export_build_magazine($visible_channels); ?>