diff --git a/Dockerfile b/Dockerfile index c09d25e..d3c1f0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,13 @@ FROM php:8.4-apache -RUN echo "memory_limit=512M" > /usr/local/etc/php/conf.d/tvguide.ini +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + xz-utils \ + gzip \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN echo "memory_limit=512M" \ + > /usr/local/etc/php/conf.d/tvguide.ini COPY --from=composer:latest /usr/bin/composer /usr/bin/composer diff --git a/app/bin/update-epg.php b/app/bin/update-epg.php index 8cd1814..f64bbca 100644 --- a/app/bin/update-epg.php +++ b/app/bin/update-epg.php @@ -2,84 +2,361 @@ require_once '/var/www/src/bootstrap.php'; -$config = new Config( - '/var/www/config/config.json', - '/var/www/config/channels.json' -); +const EPG_WORK_DIR = '/var/www/epg/work'; +const EPG_TARGET = '/var/www/epg/guide.xml'; +const EPG_CACHE = '/var/www/cache/guide.json'; +const EPG_LOCK_FILE = '/var/www/epg/update.lock'; +const EPG_LOG_FILE = '/var/www/logs/epg-update.log'; -$epg = $config->get('epg', []); -$urls = $epg['urls'] ?? []; +function logMessage(string $message, bool $error = false): void +{ + $line = sprintf( + "[%s] %s\n", + date('Y-m-d H:i:s'), + $message + ); -if (!$urls && !empty($epg['url'])) { - $urls = [$epg['url']]; + echo $line; + + if ($error) { + fwrite(STDERR, $line); + } + + $logDirectory = dirname(EPG_LOG_FILE); + + if (!is_dir($logDirectory)) { + mkdir($logDirectory, 0775, true); + } + + file_put_contents( + EPG_LOG_FILE, + $line, + FILE_APPEND | LOCK_EX + ); } -if (!$urls) { - fwrite(STDERR, "Keine epg.urls in config.json gesetzt.\n"); +function downloadFile(string $url, string $target): bool +{ + $context = stream_context_create([ + 'http' => [ + '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); } -$workDir = '/var/www/epg/work'; -$target = '/var/www/epg/guide.xml'; - -if (!is_dir($workDir)) { - mkdir($workDir, 0775, true); +if (!flock($lockHandle, LOCK_EX | LOCK_NB)) { + logMessage( + 'Ein EPG-Update läuft bereits. Dieser Lauf wird beendet.' + ); + fclose($lockHandle); + exit(0); } -$xmlFiles = []; +$startedAt = microtime(true); -foreach ($urls as $index => $url) { - $base = $workDir . '/source-' . $index; - $xzFile = $base . '.xz'; - $xmlFile = $base . '.xml'; +try { + logMessage('EPG-Update gestartet.'); - echo "Lade EPG: {$url}\n"; + $config = new Config( + '/var/www/config/config.json', + '/var/www/config/channels.json' + ); - $data = file_get_contents($url); + $epg = $config->get('epg', []); + $urls = $epg['urls'] ?? []; - if ($data === false || trim($data) === '') { - fwrite(STDERR, "Download fehlgeschlagen oder leer: {$url}\n"); - continue; + if (!$urls && !empty($epg['url'])) { + $urls = [$epg['url']]; } - file_put_contents($xzFile, $data); - - echo "Entpacke XZ...\n"; - - $command = 'xz -dc ' . escapeshellarg($xzFile) . ' > ' . escapeshellarg($xmlFile); - exec($command, $output, $code); - - if ($code !== 0 || !file_exists($xmlFile) || filesize($xmlFile) === 0) { - fwrite(STDERR, "XZ konnte nicht entpackt werden: {$url}\n"); - continue; + if (!$urls) { + throw new RuntimeException( + 'Keine epg.urls in config.json gesetzt.' + ); } - if (!@simplexml_load_file($xmlFile)) { - fwrite(STDERR, "XML ungültig: {$url}\n"); - continue; + if (!is_dir(EPG_WORK_DIR)) { + mkdir(EPG_WORK_DIR, 0775, true); } - $xmlFiles[] = $xmlFile; -} + 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 + ); -if (!$xmlFiles) { - fwrite(STDERR, "Keine gültigen XMLTV-Dateien geladen.\n"); exit(1); +} finally { + flock($lockHandle, LOCK_UN); + fclose($lockHandle); } - -echo "Merge XMLTV...\n"; - -$merge = new XmlTvMergeService(); -$merge->merge($xmlFiles, $target); - -echo "guide.xml aktualisiert.\n"; - -$cache = new XmlTvCacheService( - $target, - $config->get('timezone', 'Europe/Vienna'), - '/var/www/cache/guide.json' -); - -$cache->rebuildCache(); - -echo "guide.json und guide.version aktualisiert.\n"; diff --git a/docker-compose.yaml b/docker-compose.yaml index d998725..f7053ff 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,3 +16,33 @@ services: - ./epg:/var/www/epg - ./logos:/var/www/html/logos - ./posters:/var/www/html/posters + - ./logs:/var/www/logs + + epg-scheduler: + build: . + container_name: tvguide-epg-scheduler + restart: unless-stopped + + environment: + EPG_UPDATE_INTERVAL: "21600" + + volumes: + - ./app/src:/var/www/src + - ./app/config:/var/www/config + - ./app/cache:/var/www/cache + - ./app/bin:/var/www/bin + - ./vendor:/var/www/vendor + - ./epg:/var/www/epg + - ./logs:/var/www/logs + + command: + - /bin/sh + - -c + - | + echo "TVGuide EPG-Scheduler gestartet." + echo "Intervall: $${EPG_UPDATE_INTERVAL:-21600} Sekunden" + + while true; do + php /var/www/bin/update-epg.php + sleep "$${EPG_UPDATE_INTERVAL:-21600}" + done