[ 'timeout' => 120, 'follow_location' => 1, 'max_redirects' => 5, 'user_agent' => 'TVGuide EPG Updater/1.0', ], 'https' => [ 'timeout' => 120, 'follow_location' => 1, 'max_redirects' => 5, 'user_agent' => 'TVGuide EPG Updater/1.0', ], ]); $input = @fopen($url, 'rb', false, $context); if ($input === false) { return false; } $output = @fopen($target, 'wb'); if ($output === false) { fclose($input); return false; } $bytes = stream_copy_to_stream($input, $output); fclose($input); fclose($output); return $bytes !== false && $bytes > 0; } function detectCompression(string $url, string $file): string { $path = strtolower( parse_url($url, PHP_URL_PATH) ?? '' ); if (str_ends_with($path, '.xz')) { return 'xz'; } if ( str_ends_with($path, '.gz') || str_ends_with($path, '.gzip') ) { return 'gz'; } $handle = fopen($file, 'rb'); $header = $handle ? fread($handle, 6) : ''; if ($handle) { fclose($handle); } if (str_starts_with($header, "\xFD\x37\x7A\x58\x5A\x00")) { return 'xz'; } if (str_starts_with($header, "\x1F\x8B")) { return 'gz'; } return 'xml'; } function unpackFile( string $source, string $target, string $compression ): bool { if ($compression === 'xml') { return copy($source, $target); } $command = match ($compression) { 'xz' => sprintf( 'xz -dc %s > %s 2>&1', escapeshellarg($source), escapeshellarg($target) ), 'gz' => sprintf( 'gzip -dc %s > %s 2>&1', escapeshellarg($source), escapeshellarg($target) ), default => null, }; if ($command === null) { return false; } exec($command, $output, $status); return $status === 0 && file_exists($target) && filesize($target) > 0; } function isValidXmlTv(string $file): bool { if (!file_exists($file) || filesize($file) === 0) { return false; } libxml_use_internal_errors(true); $xml = simplexml_load_file($file); libxml_clear_errors(); if (!$xml) { return false; } return $xml->getName() === 'tv'; } function removeOldWorkFiles(): void { foreach (glob(EPG_WORK_DIR . '/*') ?: [] as $file) { if (is_file($file)) { @unlink($file); } } } $lockHandle = fopen(EPG_LOCK_FILE, 'c'); if ($lockHandle === false) { logMessage('Lock-Datei konnte nicht geöffnet werden.', true); exit(1); } if (!flock($lockHandle, LOCK_EX | LOCK_NB)) { logMessage( 'Ein EPG-Update läuft bereits. Dieser Lauf wird beendet.' ); fclose($lockHandle); exit(0); } $startedAt = microtime(true); try { logMessage('EPG-Update gestartet.'); $config = new Config( '/var/www/config/config.json', '/var/www/config/channels.json' ); $epg = $config->get('epg', []); $urls = $epg['urls'] ?? []; if (!$urls && !empty($epg['url'])) { $urls = [$epg['url']]; } if (!$urls) { throw new RuntimeException( 'Keine epg.urls in config.json gesetzt.' ); } if (!is_dir(EPG_WORK_DIR)) { mkdir(EPG_WORK_DIR, 0775, true); } removeOldWorkFiles(); $xmlFiles = []; foreach (array_values($urls) as $index => $url) { $downloadFile = EPG_WORK_DIR . "/source-{$index}.download"; $xmlFile = EPG_WORK_DIR . "/source-{$index}.xml"; logMessage("Lade Quelle " . ($index + 1) . ": {$url}"); if (!downloadFile($url, $downloadFile)) { logMessage( "Download fehlgeschlagen oder leer: {$url}", true ); continue; } $compression = detectCompression( $url, $downloadFile ); logMessage( sprintf( 'Download abgeschlossen: %.2f MB, Format: %s', filesize($downloadFile) / 1024 / 1024, strtoupper($compression) ) ); if (!unpackFile( $downloadFile, $xmlFile, $compression )) { logMessage( "Datei konnte nicht entpackt werden: {$url}", true ); continue; } if (!isValidXmlTv($xmlFile)) { logMessage( "XMLTV-Datei ist ungültig: {$url}", true ); continue; } $xmlFiles[] = $xmlFile; } if (!$xmlFiles) { throw new RuntimeException( 'Keine gültigen XMLTV-Dateien geladen.' ); } /* * Zuerst in eine temporäre Datei mergen. * Die bestehende guide.xml bleibt bei einem Fehler unangetastet. */ $temporaryTarget = EPG_WORK_DIR . '/guide-' . getmypid() . '.xml'; logMessage( count($xmlFiles) . ' gültige Quelle(n), Merge gestartet.' ); $merge = new XmlTvMergeService(); $merge->merge($xmlFiles, $temporaryTarget); if (!isValidXmlTv($temporaryTarget)) { throw new RuntimeException( 'Die zusammengeführte XMLTV-Datei ist ungültig.' ); } /* * Atomarer Austausch innerhalb desselben Dateisystems. */ if (!rename($temporaryTarget, EPG_TARGET)) { throw new RuntimeException( 'guide.xml konnte nicht atomar ersetzt werden.' ); } logMessage( sprintf( 'guide.xml aktualisiert: %.2f MB', filesize(EPG_TARGET) / 1024 / 1024 ) ); $cacheService = new XmlTvCacheService( EPG_TARGET, $config->get('timezone', 'Europe/Vienna'), EPG_CACHE ); $cacheData = $cacheService->rebuildCache(); $channelCount = count($cacheData['channels'] ?? []); $programmeCount = 0; foreach ($cacheData['channels'] ?? [] as $channel) { $programmeCount += count($channel['programmes'] ?? []); } $duration = microtime(true) - $startedAt; logMessage( sprintf( 'EPG-Update erfolgreich: %d Sender, %d Sendungen, %.1f Sekunden.', $channelCount, $programmeCount, $duration ) ); exit(0); } catch (Throwable $exception) { $duration = microtime(true) - $startedAt; logMessage( sprintf( 'EPG-Update fehlgeschlagen nach %.1f Sekunden: %s', $duration, $exception->getMessage() ), true ); exit(1); } finally { flock($lockHandle, LOCK_UN); fclose($lockHandle); }