363 lines
7.6 KiB
PHP
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);
|
|
}
|