Files
2026-07-10 14:14:41 +02:00

363 lines
7.6 KiB
PHP

<?php
require_once '/var/www/src/bootstrap.php';
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';
function logMessage(string $message, bool $error = false): void
{
$line = sprintf(
"[%s] %s\n",
date('Y-m-d H:i:s'),
$message
);
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
);
}
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);
}
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);
}