diff --git a/app/public/index.php b/app/public/index.php index 53c3c3e..2134728 100644 --- a/app/public/index.php +++ b/app/public/index.php @@ -2,6 +2,9 @@ require_once '/var/www/src/Config.php'; require_once '/var/www/src/XmlTv.php'; +require_once '/var/www/src/Services/ChannelService.php'; +require_once '/var/www/src/Services/XmlTvCacheService.php'; +require_once '/var/www/src/Services/ProgrammeService.php'; require_once '/var/www/src/View.php'; $config = new Config( diff --git a/app/src/Services/ChannelService.php b/app/src/Services/ChannelService.php new file mode 100644 index 0000000..a7f36ae --- /dev/null +++ b/app/src/Services/ChannelService.php @@ -0,0 +1,40 @@ +getChannels(); + + $maxNumber = 0; + foreach ($storedChannels as $stored) { + $maxNumber = max($maxNumber, (int)($stored['number'] ?? 0)); + } + + foreach ($cachedChannels as $id => $channel) { + if (!isset($storedChannels[$id])) { + $maxNumber++; + + $storedChannels[$id] = [ + 'uuid' => bin2hex(random_bytes(16)), + 'name' => $channel['name'] ?? $id, + 'icon' => $channel['icon'] ?? '', + 'country' => '', + 'language' => '', + 'enabled' => true, + 'favorite' => false, + 'number' => $maxNumber, + 'group' => 'Unknown', + ]; + } else { + $storedChannels[$id]['name'] = $channel['name'] ?? $storedChannels[$id]['name']; + } + } + + uasort($storedChannels, function ($a, $b) { + return ((int)($a['number'] ?? 999999)) <=> ((int)($b['number'] ?? 999999)); + }); + + $config->saveChannels($storedChannels); + } +} diff --git a/app/src/Services/ProgrammeService.php b/app/src/Services/ProgrammeService.php new file mode 100644 index 0000000..d00f047 --- /dev/null +++ b/app/src/Services/ProgrammeService.php @@ -0,0 +1,191 @@ +timezone = $timezone; + } + + public function load(array $data, ?Config $config = null): array + { + $channelConfig = $config ? $config->getChannels() : []; + $channels = []; + $programmes = []; + + foreach ($data['channels'] ?? [] as $id => $channel) { + $cfg = $channelConfig[$id] ?? []; + + if (($cfg['enabled'] ?? true) !== true) { + continue; + } + + $channels[$id] = [ + 'id' => $id, + 'name' => $cfg['name'] ?? $channel['name'] ?? $id, + 'icon' => $cfg['icon'] ?? $channel['icon'] ?? '', + 'number' => (int)($cfg['number'] ?? 999999), + 'favorite' => (bool)($cfg['favorite'] ?? false), + ]; + + foreach ($channel['programmes'] ?? [] as $p) { + $programmes[$id][] = [ + 'title' => $p['title'] ?? '', + 'desc' => $p['description'] ?? '', + 'category' => $p['category'] ?? '', + 'start' => (new DateTime())->setTimestamp((int)$p['start'])->setTimezone(new DateTimeZone($this->timezone)), + 'stop' => (new DateTime())->setTimestamp((int)$p['stop'])->setTimezone(new DateTimeZone($this->timezone)), + ]; + } + } + + uasort($channels, function ($a, $b) { + return ($a['number'] ?? 999999) <=> ($b['number'] ?? 999999); + }); + + return [$channels, $programmes]; + } + + public function getCurrentProgrammes(array $data, ?Config $config = null): array + { + $channelConfig = $config ? $config->getChannels() : []; + $now = time(); + $items = []; + + foreach ($data['channels'] ?? [] as $id => $channel) { + $cfg = $channelConfig[$id] ?? []; + + if (($cfg['enabled'] ?? true) !== true) { + continue; + } + + foreach ($channel['programmes'] ?? [] as $p) { + $start = (int)($p['start'] ?? 0); + $stop = (int)($p['stop'] ?? 0); + + if ($now >= $start && $now < $stop) { + $duration = max(1, $stop - $start); + $elapsed = max(0, $now - $start); + $percent = min(100, round(($elapsed / $duration) * 100)); + + $items[] = [ + 'channel_id' => $id, + 'channel_name' => $cfg['name'] ?? $channel['name'] ?? $id, + 'channel_icon' => $cfg['icon'] ?? $channel['icon'] ?? '', + 'channel_number' => (int)($cfg['number'] ?? 999999), + 'favorite' => (bool)($cfg['favorite'] ?? false), + 'title' => $p['title'] ?? '', + 'subtitle' => $p['subtitle'] ?? '', + 'description' => $p['description'] ?? '', + 'category' => $p['category'] ?? '', + 'start' => $start, + 'stop' => $stop, + 'percent' => $percent, + 'remaining_minutes' => max(0, (int)ceil(($stop - $now) / 60)), + ]; + + break; + } + } + } + + usort($items, function ($a, $b) { + return $a['channel_number'] <=> $b['channel_number']; + }); + + return $items; + } + + public function getDayProgrammes(Config $config, array $data, string $date, ?string $channelId = null): array + { + $channelConfig = $config->getChannels(); + + $tz = new DateTimeZone($this->timezone); + $dayStart = new DateTime($date . ' 00:00:00', $tz); + $dayEnd = clone $dayStart; + $dayEnd->modify('+1 day'); + + $startTs = $dayStart->getTimestamp(); + $endTs = $dayEnd->getTimestamp(); + + $items = []; + + foreach ($data['channels'] ?? [] as $id => $channel) { + if ($channelId !== null && $id !== $channelId) { + continue; + } + + $cfg = $channelConfig[$id] ?? []; + + if (($cfg['enabled'] ?? true) !== true) { + continue; + } + + $programmes = []; + + foreach ($channel['programmes'] ?? [] as $p) { + $start = (int)($p['start'] ?? 0); + $stop = (int)($p['stop'] ?? 0); + + if ($start < $endTs && $stop > $startTs) { + $visibleStart = max($start, $startTs); + $visibleStop = min($stop, $endTs); + + $startDt = (new DateTime())->setTimestamp($visibleStart)->setTimezone($tz); + $stopDt = (new DateTime())->setTimestamp($visibleStop)->setTimezone($tz); + + $startMinute = ((int)$startDt->format('H') * 60) + (int)$startDt->format('i'); + $stopMinute = ((int)$stopDt->format('H') * 60) + (int)$stopDt->format('i'); + + if ($visibleStop >= $endTs) { + $stopMinute = 1440; + } + + $durationMinutes = max(1, (int)(($visibleStop - $visibleStart) / 60)); + + $programmes[] = [ + 'id' => md5($id . '|' . $start . '|' . $stop . '|' . ($p['title'] ?? '')), + 'title' => $p['title'] ?? '', + 'subtitle' => $p['subtitle'] ?? '', + 'description' => $p['description'] ?? '', + 'category' => $p['category'] ?? '', + 'start' => $start, + 'stop' => $stop, + 'visible_start' => $visibleStart, + 'visible_stop' => $visibleStop, + 'start_minute' => $startMinute, + 'stop_minute' => $stopMinute, + 'duration_minutes' => $durationMinutes, + 'is_current' => time() >= $start && time() < $stop, + 'is_past' => $stop < time(), + ]; + } + } + + if (!$programmes) { + continue; + } + + usort($programmes, function ($a, $b) { + return $a['start'] <=> $b['start']; + }); + + $items[] = [ + 'channel_id' => $id, + 'channel_name' => $cfg['name'] ?? $channel['name'] ?? $id, + 'channel_icon' => $cfg['icon'] ?? $channel['icon'] ?? '', + 'channel_number' => (int)($cfg['number'] ?? 999999), + 'favorite' => (bool)($cfg['favorite'] ?? false), + 'programmes' => $programmes, + ]; + } + + usort($items, function ($a, $b) { + return $a['channel_number'] <=> $b['channel_number']; + }); + + return $items; + } +} diff --git a/app/src/Services/XmlTvCacheService.php b/app/src/Services/XmlTvCacheService.php new file mode 100644 index 0000000..c7c9465 --- /dev/null +++ b/app/src/Services/XmlTvCacheService.php @@ -0,0 +1,132 @@ +file = $file; + $this->timezone = $timezone; + $this->cacheFile = $cacheFile; + } + + public function getCachedData(): array + { + if (!file_exists($this->cacheFile)) { + return $this->rebuildCache(); + } + + $xmlMtime = file_exists($this->file) ? filemtime($this->file) : 0; + $cacheMtime = filemtime($this->cacheFile); + + if ($xmlMtime > $cacheMtime) { + return $this->rebuildCache(); + } + + $data = json_decode(file_get_contents($this->cacheFile), true); + + if (!is_array($data)) { + return $this->rebuildCache(); + } + + return $data; + } + + public function rebuildCache(): array + { + $data = [ + 'updated' => date('c'), + 'source' => $this->file, + 'channels' => [], + ]; + + if (!file_exists($this->file)) { + $this->writeCache($data); + return $data; + } + + $xml = simplexml_load_file($this->file); + + foreach ($xml->channel as $channel) { + $id = (string)$channel['id']; + + $data['channels'][$id] = [ + 'id' => $id, + 'name' => (string)$channel->{'display-name'}, + 'icon' => isset($channel->icon) ? (string)$channel->icon['src'] : '', + 'programmes' => [], + ]; + } + + foreach ($xml->programme as $programme) { + $channelId = (string)$programme['channel']; + + if (!isset($data['channels'][$channelId])) { + continue; + } + + $start = $this->parseTime((string)$programme['start']); + $stop = $this->parseTime((string)$programme['stop']); + + if (!$start || !$stop) { + continue; + } + + $data['channels'][$channelId]['programmes'][] = [ + 'title' => (string)$programme->title, + 'subtitle' => (string)$programme->{'sub-title'}, + 'description' => (string)$programme->desc, + 'category' => (string)$programme->category, + 'start' => $start->getTimestamp(), + 'stop' => $stop->getTimestamp(), + 'duration' => $stop->getTimestamp() - $start->getTimestamp(), + 'year' => (string)$programme->date, + 'rating' => isset($programme->rating->value) ? (string)$programme->rating->value : '', + 'episode' => isset($programme->{'episode-num'}) ? (string)$programme->{'episode-num'} : '', + 'icon' => isset($programme->icon) ? (string)$programme->icon['src'] : '', + 'country' => (string)$programme->country, + ]; + } + + foreach ($data['channels'] as &$channel) { + usort($channel['programmes'], function ($a, $b) { + return $a['start'] <=> $b['start']; + }); + } + + unset($channel); + + $this->writeCache($data); + return $data; + } + + private function writeCache(array $data): void + { + file_put_contents( + $this->cacheFile, + json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) + ); + } + + private function parseTime(string $value): ?DateTime + { + if (!preg_match('/^(\d{14})\s+([+-]\d{4})$/', trim($value), $m)) { + return null; + } + + $dt = DateTime::createFromFormat('YmdHis O', $m[1] . ' ' . $m[2]); + + if (!$dt) { + return null; + } + + $dt->setTimezone(new DateTimeZone($this->timezone)); + return $dt; + } +} diff --git a/app/src/XmlTv.php b/app/src/XmlTv.php index a99699f..668c02d 100644 --- a/app/src/XmlTv.php +++ b/app/src/XmlTv.php @@ -5,6 +5,8 @@ class XmlTv private string $file; private string $cacheFile; private string $timezone; + private XmlTvCacheService $cacheService; + private ProgrammeService $programmeService; public function __construct( string $file, @@ -14,341 +16,45 @@ class XmlTv $this->file = $file; $this->timezone = $timezone; $this->cacheFile = $cacheFile; + $this->cacheService = new XmlTvCacheService($file, $timezone, $cacheFile); + $this->programmeService = new ProgrammeService($timezone); } public function synchronizeChannels(Config $config): void { + $service = new ChannelService(); $data = $this->getCachedData(); - $storedChannels = $config->getChannels(); - $maxNumber = 0; - foreach ($storedChannels as $stored) { - $maxNumber = max($maxNumber, (int)($stored['number'] ?? 0)); - } - - foreach ($data['channels'] ?? [] as $id => $channel) { - if (!isset($storedChannels[$id])) { - $maxNumber++; - - $storedChannels[$id] = [ - 'uuid' => bin2hex(random_bytes(16)), - 'name' => $channel['name'] ?? $id, - 'icon' => $channel['icon'] ?? '', - 'country' => '', - 'language' => '', - 'enabled' => true, - 'favorite' => false, - 'number' => $maxNumber, - 'group' => 'Unknown', - ]; - } else { - $storedChannels[$id]['name'] = $channel['name'] ?? $storedChannels[$id]['name']; - } - } - - uasort($storedChannels, function ($a, $b) { - return ((int)($a['number'] ?? 999999)) <=> ((int)($b['number'] ?? 999999)); - }); - - $config->saveChannels($storedChannels); + $service->synchronizeChannels($data['channels'] ?? [], $config); } public function load(?Config $config = null): array { - $data = $this->getCachedData(); - - $channelConfig = $config ? $config->getChannels() : []; - $channels = []; - $programmes = []; - - foreach ($data['channels'] ?? [] as $id => $channel) { - $cfg = $channelConfig[$id] ?? []; - - if (($cfg['enabled'] ?? true) !== true) { - continue; - } - - $channels[$id] = [ - 'id' => $id, - 'name' => $cfg['name'] ?? $channel['name'] ?? $id, - 'icon' => $cfg['icon'] ?? $channel['icon'] ?? '', - 'number' => (int)($cfg['number'] ?? 999999), - 'favorite' => (bool)($cfg['favorite'] ?? false), - ]; - - foreach ($channel['programmes'] ?? [] as $p) { - $programmes[$id][] = [ - 'title' => $p['title'] ?? '', - 'desc' => $p['description'] ?? '', - 'category' => $p['category'] ?? '', - 'start' => (new DateTime())->setTimestamp((int)$p['start'])->setTimezone(new DateTimeZone($this->timezone)), - 'stop' => (new DateTime())->setTimestamp((int)$p['stop'])->setTimezone(new DateTimeZone($this->timezone)), - ]; - } - } - - uasort($channels, function ($a, $b) { - return ($a['number'] ?? 999999) <=> ($b['number'] ?? 999999); - }); - - return [$channels, $programmes]; + return $this->programmeService->load($this->getCachedData(), $config); } + + public function rebuildCache(): array { - $data = [ - 'updated' => date('c'), - 'source' => $this->file, - 'channels' => [], - ]; - - if (!file_exists($this->file)) { - $this->writeCache($data); - return $data; - } - - $xml = simplexml_load_file($this->file); - - foreach ($xml->channel as $channel) { - $id = (string)$channel['id']; - - $data['channels'][$id] = [ - 'id' => $id, - 'name' => (string)$channel->{'display-name'}, - 'icon' => isset($channel->icon) ? (string)$channel->icon['src'] : '', - 'programmes' => [], - ]; - } - - foreach ($xml->programme as $programme) { - $channelId = (string)$programme['channel']; - - if (!isset($data['channels'][$channelId])) { - continue; - } - - $start = $this->parseTime((string)$programme['start']); - $stop = $this->parseTime((string)$programme['stop']); - - if (!$start || !$stop) { - continue; - } - - $data['channels'][$channelId]['programmes'][] = [ - 'title' => (string)$programme->title, - 'subtitle' => (string)$programme->{'sub-title'}, - 'description' => (string)$programme->desc, - 'category' => (string)$programme->category, - 'start' => $start->getTimestamp(), - 'stop' => $stop->getTimestamp(), - 'duration' => $stop->getTimestamp() - $start->getTimestamp(), - 'year' => (string)$programme->date, - 'rating' => isset($programme->rating->value) ? (string)$programme->rating->value : '', - 'episode' => isset($programme->{'episode-num'}) ? (string)$programme->{'episode-num'} : '', - 'icon' => isset($programme->icon) ? (string)$programme->icon['src'] : '', - 'country' => (string)$programme->country, - ]; - } - - foreach ($data['channels'] as &$channel) { - usort($channel['programmes'], function ($a, $b) { - return $a['start'] <=> $b['start']; - }); - } - - unset($channel); - - $this->writeCache($data); - return $data; + return $this->cacheService->rebuildCache(); } public function getCachedData(): array { - if (!file_exists($this->cacheFile)) { - return $this->rebuildCache(); - } - - $xmlMtime = file_exists($this->file) ? filemtime($this->file) : 0; - $cacheMtime = filemtime($this->cacheFile); - - if ($xmlMtime > $cacheMtime) { - return $this->rebuildCache(); - } - - $data = json_decode(file_get_contents($this->cacheFile), true); - - if (!is_array($data)) { - return $this->rebuildCache(); - } - - return $data; + return $this->cacheService->getCachedData(); } public function getCurrentProgrammes(?Config $config = null): array { - $data = $this->getCachedData(); - $channelConfig = $config ? $config->getChannels() : []; - $now = time(); - $items = []; - - foreach ($data['channels'] ?? [] as $id => $channel) { - $cfg = $channelConfig[$id] ?? []; - - if (($cfg['enabled'] ?? true) !== true) { - continue; - } - - foreach ($channel['programmes'] ?? [] as $p) { - $start = (int)($p['start'] ?? 0); - $stop = (int)($p['stop'] ?? 0); - - if ($now >= $start && $now < $stop) { - $duration = max(1, $stop - $start); - $elapsed = max(0, $now - $start); - $percent = min(100, round(($elapsed / $duration) * 100)); - - $items[] = [ - 'channel_id' => $id, - 'channel_name' => $cfg['name'] ?? $channel['name'] ?? $id, - 'channel_icon' => $cfg['icon'] ?? $channel['icon'] ?? '', - 'channel_number' => (int)($cfg['number'] ?? 999999), - 'favorite' => (bool)($cfg['favorite'] ?? false), - 'title' => $p['title'] ?? '', - 'subtitle' => $p['subtitle'] ?? '', - 'description' => $p['description'] ?? '', - 'category' => $p['category'] ?? '', - 'start' => $start, - 'stop' => $stop, - 'percent' => $percent, - 'remaining_minutes' => max(0, (int)ceil(($stop - $now) / 60)), - ]; - - break; - } - } - } - - usort($items, function ($a, $b) { - return $a['channel_number'] <=> $b['channel_number']; - }); - - return $items; + return $this->programmeService->getCurrentProgrammes($this->getCachedData(), $config); } - private function writeCache(array $data): void - { - file_put_contents( - $this->cacheFile, - json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) - ); - } - private function parseTime(string $value): ?DateTime - { - if (!preg_match('/^(\d{14})\s+([+-]\d{4})$/', trim($value), $m)) { - return null; - } - - $dt = DateTime::createFromFormat('YmdHis O', $m[1] . ' ' . $m[2]); - - if (!$dt) { - return null; - } - - $dt->setTimezone(new DateTimeZone($this->timezone)); - return $dt; - } public function getDayProgrammes(Config $config, string $date, ?string $channelId = null): array { - $data = $this->getCachedData(); - $channelConfig = $config->getChannels(); - - $tz = new DateTimeZone($this->timezone); - $dayStart = new DateTime($date . ' 00:00:00', $tz); - $dayEnd = clone $dayStart; - $dayEnd->modify('+1 day'); - - $startTs = $dayStart->getTimestamp(); - $endTs = $dayEnd->getTimestamp(); - - $items = []; - - foreach ($data['channels'] ?? [] as $id => $channel) { - if ($channelId !== null && $id !== $channelId) { - continue; - } - - $cfg = $channelConfig[$id] ?? []; - - if (($cfg['enabled'] ?? true) !== true) { - continue; - } - - $programmes = []; - - foreach ($channel['programmes'] ?? [] as $p) { - $start = (int)($p['start'] ?? 0); - $stop = (int)($p['stop'] ?? 0); - - if ($start < $endTs && $stop > $startTs) { - - $visibleStart = max($start, $startTs); - $visibleStop = min($stop, $endTs); - - $startDt = (new DateTime())->setTimestamp($visibleStart)->setTimezone($tz); - $stopDt = (new DateTime())->setTimestamp($visibleStop)->setTimezone($tz); - - $startMinute = ((int)$startDt->format('H') * 60) + (int)$startDt->format('i'); - $stopMinute = ((int)$stopDt->format('H') * 60) + (int)$stopDt->format('i'); - - if ($visibleStop >= $endTs) { - $stopMinute = 1440; - } - - $durationMinutes = max(1, (int)(($visibleStop - $visibleStart) / 60)); - - $programmes[] = [ - 'id' => md5($id . '|' . $start . '|' . $stop . '|' . ($p['title'] ?? '')), - 'title' => $p['title'] ?? '', - 'subtitle' => $p['subtitle'] ?? '', - 'description' => $p['description'] ?? '', - 'category' => $p['category'] ?? '', - 'start' => $start, - 'stop' => $stop, - 'visible_start' => $visibleStart, - 'visible_stop' => $visibleStop, - 'start_minute' => $startMinute, - 'stop_minute' => $stopMinute, - 'duration_minutes' => $durationMinutes, - 'is_current' => time() >= $start && time() < $stop, - 'is_past' => $stop < time(), - ]; - } - } - - if (!$programmes) { - continue; - } - - usort($programmes, function ($a, $b) { - return $a['start'] <=> $b['start']; - }); - - $items[] = [ - 'channel_id' => $id, - 'channel_name' => $cfg['name'] ?? $channel['name'] ?? $id, - 'channel_icon' => $cfg['icon'] ?? $channel['icon'] ?? '', - 'channel_number' => (int)($cfg['number'] ?? 999999), - 'favorite' => (bool)($cfg['favorite'] ?? false), - 'programmes' => $programmes, - ]; - } - - usort($items, function ($a, $b) { - return $a['channel_number'] <=> $b['channel_number']; - }); - - return $items; + return $this->programmeService->getDayProgrammes($config, $this->getCachedData(), $date, $channelId); } + }