Automated EPG Download
This commit is contained in:
+9
-1
@@ -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
|
||||
|
||||
+336
-59
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user