false, 'error' => 'PHP DOM/XML extension is not installed.']; } if ($xml === '') { return ['ok' => false, 'error' => 'Empty response']; } libxml_use_internal_errors(true); $dom = new DOMDocument(); $loaded = $dom->loadXML($xml, LIBXML_NOERROR | LIBXML_NOWARNING); $errors = array_map(static fn($e) => trim($e->message), libxml_get_errors()); libxml_clear_errors(); if (!$loaded) { return ['ok' => false, 'error' => $errors[0] ?? 'Invalid XML']; } $xp = new DOMXPath($dom); $programmes = $xp->query('/tv/programme'); $channels = $xp->query('/tv/channel'); $categories = $xp->query('/tv/programme/category'); $subtitles = $xp->query('/tv/programme/sub-title'); $ratings = $xp->query('/tv/programme/rating'); $dates = $xp->query('/tv/programme/date'); $channel_icons = $xp->query('/tv/channel/icon'); $first = null; $last = null; $with_desc = 0; $with_subtitle = 0; $with_category = 0; $with_rating = 0; $with_date = 0; $with_stop = 0; $unique_categories = []; foreach ($programmes as $programme) { $start = $programme->getAttribute('start'); $stop = $programme->getAttribute('stop'); if ($start !== '' && ($first === null || strcmp($start, $first) < 0)) $first = $start; if ($stop !== '' && ($last === null || strcmp($stop, $last) > 0)) $last = $stop; if ($stop !== '') $with_stop++; if (trim($xp->evaluate('string(desc)', $programme)) !== '') $with_desc++; if (trim($xp->evaluate('string(sub-title)', $programme)) !== '') $with_subtitle++; if (trim($xp->evaluate('string(category)', $programme)) !== '') $with_category++; if (trim($xp->evaluate('string(rating/value)', $programme)) !== '') $with_rating++; if (trim($xp->evaluate('string(date)', $programme)) !== '') $with_date++; foreach ($xp->query('category', $programme) as $category) { $value = trim($category->textContent); if ($value !== '') $unique_categories[strtolower($value)] = $value; } } $channel_count = $channels->length; $programme_count = $programmes->length; return [ 'ok' => true, 'channels' => $channel_count, 'programmes' => $programme_count, 'categories' => $categories->length, 'subtitles' => $subtitles->length, 'ratings' => $ratings->length, 'dates' => $dates->length, 'channel_icons' => $channel_icons->length, 'channels_without_icon' => max(0, $channel_count - $channel_icons->length), 'programmes_with_desc' => $with_desc, 'programmes_with_subtitle' => $with_subtitle, 'programmes_with_category' => $with_category, 'programmes_with_rating' => $with_rating, 'programmes_with_date' => $with_date, 'programmes_with_stop' => $with_stop, 'unique_categories' => count($unique_categories), 'avg_programmes_per_channel' => $channel_count > 0 ? round($programme_count / $channel_count, 1) : 0, 'first_start' => $first, 'last_stop' => $last, ]; } function health_parse_m3u(string $body): array { $lines = preg_split('/\r\n|\r|\n/', $body); $streams = 0; $extinf = 0; $http = 0; $https = 0; $duplicates = 0; $seen_urls = []; foreach ($lines as $line) { $line = trim($line); if ($line === '') continue; if (stripos($line, '#EXTINF') === 0) { $extinf++; continue; } if ($line[0] !== '#') { $streams++; if (stripos($line, 'https://') === 0) $https++; if (stripos($line, 'http://') === 0) $http++; if (isset($seen_urls[$line])) $duplicates++; $seen_urls[$line] = true; } } return [ 'streams' => $streams, 'extinf' => $extinf, 'http_streams' => $http, 'https_streams' => $https, 'duplicate_urls' => $duplicates, 'size_bytes' => strlen($body), ]; } function health_format_xmltv_stamp(?string $stamp): string { if (!$stamp) return '—'; $dt = DateTime::createFromFormat('YmdHis O', $stamp); if (!$dt) return $stamp; return $dt->format('d/m/Y H:i'); } function health_probe_payload(): array { $config_path = gridtv_config_path(); $config_exists = file_exists($config_path); $config_valid = false; $config = []; if ($config_exists) { $decoded = json_decode(file_get_contents($config_path), true); if (is_array($decoded)) { $config = $decoded; $config_valid = true; } } $sources = $config_valid ? array_values($config['epg_sources'] ?? []) : []; $first_source = $sources[0] ?? null; $checks = [ 'config_exists' => $config_exists, 'config_valid' => $config_valid, 'php_curl' => extension_loaded('curl'), 'php_xml' => class_exists('DOMDocument'), 'source_configured' => !empty($sources), ]; $details = [ 'group_name' => $config['group_name'] ?? 'GridTV', 'source_count' => count($sources), ]; if ($first_source && !empty($first_source['epg_url'])) { $fetch = gridtv_fetch_url((string) $first_source['epg_url'], 12); $checks['xmltv_reachable'] = $fetch['ok']; $details['xmltv_status'] = $fetch['status']; $details['xmltv_time_total'] = round((float) $fetch['time_total'], 3); if ($fetch['ok']) { $stats = health_parse_xmltv($fetch['body']); $checks['xmltv_valid'] = !empty($stats['ok']); if (!empty($stats['ok'])) { $details['xmltv_channels'] = $stats['channels']; $details['xmltv_programmes'] = $stats['programmes']; } else { $details['xmltv_error'] = $stats['error'] ?? 'Invalid XMLTV'; } } else { $details['xmltv_error'] = $fetch['error'] ?: ('HTTP ' . $fetch['status']); } } else { $checks['xmltv_reachable'] = false; $details['xmltv_error'] = 'No XMLTV source configured'; } $ok = !in_array(false, $checks, true); return [ 'ok' => $ok, 'status' => $ok ? 'ok' : 'degraded', 'timestamp' => gmdate('c'), 'checks' => $checks, 'details' => $details, ]; } function health_build_payload(array $config, string $locale): array { $config_path = gridtv_config_path(); $config_exists = file_exists($config_path); $config_writable = $config_exists ? is_writable($config_path) : is_writable(dirname($config_path)); $sources = array_values($config['epg_sources'] ?? []); $checks = [ [ 'label' => 'Config file', 'ok' => $config_exists, 'detail' => $config_exists ? basename($config_path) . ' found' : 'Missing config.json', ], [ 'label' => 'Config writable', 'ok' => $config_writable, 'detail' => $config_writable ? 'Writable by PHP' : 'Not writable by PHP', ], [ 'label' => 'PHP version', 'ok' => version_compare(PHP_VERSION, '8.0.0', '>='), 'detail' => 'PHP ' . PHP_VERSION, ], [ 'label' => 'php-curl', 'ok' => extension_loaded('curl'), 'detail' => extension_loaded('curl') ? 'Extension loaded' : 'Missing php-curl', ], [ 'label' => 'php-xml / DOM', 'ok' => class_exists('DOMDocument'), 'detail' => class_exists('DOMDocument') ? 'Extension loaded' : 'Missing php-xml / DOM', ], [ 'label' => 'Configured sources', 'ok' => count($sources) > 0, 'detail' => count($sources) . ' source(s) in config', ], ]; $source_reports = []; foreach ($sources as $index => $source) { $epg_url = trim((string) ($source['epg_url'] ?? '')); $m3u_url = trim((string) ($source['m3u_url'] ?? '')); $epg_fetch = $epg_url !== '' ? gridtv_fetch_url($epg_url, 25) : ['ok' => false, 'status' => 0, 'error' => 'Missing URL', 'body' => '', 'content_type' => '', 'final_url' => '', 'time_total' => 0.0]; $epg_stats = $epg_fetch['ok'] ? health_parse_xmltv($epg_fetch['body']) : ['ok' => false, 'error' => $epg_fetch['error'] ?: 'HTTP ' . $epg_fetch['status']]; $epg_fetch_public = $epg_fetch; $epg_fetch_public['body_bytes'] = strlen((string) ($epg_fetch_public['body'] ?? '')); unset($epg_fetch_public['body']); $m3u_fetch = null; $m3u_stats = null; if ($m3u_url !== '') { $m3u_fetch = gridtv_fetch_url($m3u_url, 20); $m3u_stats = $m3u_fetch['ok'] ? health_parse_m3u($m3u_fetch['body']) : null; } $m3u_fetch_public = $m3u_fetch; if (is_array($m3u_fetch_public)) { $m3u_fetch_public['body_bytes'] = strlen((string) ($m3u_fetch_public['body'] ?? '')); unset($m3u_fetch_public['body']); } if (!empty($epg_stats['ok'])) { $epg_stats['first_start_local'] = health_format_xmltv_stamp($epg_stats['first_start'] ?? null); $epg_stats['last_stop_local'] = health_format_xmltv_stamp($epg_stats['last_stop'] ?? null); } $source_reports[] = [ 'name' => $source['name'] ?: 'Source ' . ($index + 1), 'epg_url' => $epg_url, 'm3u_url' => $m3u_url, 'epg_host' => gridtv_extract_host($epg_url), 'm3u_host' => gridtv_extract_host($m3u_url), 'epg_fetch' => $epg_fetch_public, 'epg_stats' => $epg_stats, 'm3u_fetch' => $m3u_fetch_public, 'm3u_stats' => $m3u_stats, ]; } return [ 'summary' => [ 'group_name' => $config['group_name'] ?? 'GridTV', 'source_count' => count($sources), 'locale' => strtoupper($locale), 'config_size' => $config_exists ? gridtv_format_bytes((int) filesize($config_path)) : '0 B', 'config_path' => $config_path, ], 'checks' => $checks, 'sources' => $source_reports, 'helpers' => [ 'status_ok' => 'OK', 'status_bad' => 'Issue', ], ]; } if (isset($_GET['format']) && $_GET['format'] === 'probe') { $payload = health_probe_payload(); http_response_code($payload['ok'] ? 200 : 503); if (isset($_GET['plain']) && $_GET['plain'] === '1') { header('Content-Type: text/plain; charset=UTF-8'); echo $payload['ok'] ? 'OK' : 'KO'; } else { header('Content-Type: application/json; charset=UTF-8'); echo json_encode($payload, JSON_UNESCAPED_SLASHES); } exit; } $config = gridtv_load_config(); gridtv_require_admin($config, 'Health'); [$locale, $L] = gridtv_load_locale($config); if (isset($_GET['format']) && $_GET['format'] === 'json') { header('Content-Type: application/json; charset=UTF-8'); echo json_encode(health_build_payload($config, $locale), JSON_UNESCAPED_SLASHES); exit; } ?> GridTV — Health
Page de diagnostic admin. L’écran apparaît tout de suite, puis les checks XMLTV/M3U se remplissent en arrière-plan.
Loading diagnostics…
Configuration, XMLTV et M3U sont testés en arrière-plan.

Monitoring probe

Use these public endpoints with Uptime Kuma or any HTTP monitoring tool. They return 200 when the instance is healthy and 503 when a critical check fails.

JSON probe
?format=probe
Plain text probe
?format=probe&plain=1
Group
Sources
Locale
Config Size