diff --git a/app/settings.py b/app/settings.py index af6c02de..a6b53391 100644 --- a/app/settings.py +++ b/app/settings.py @@ -155,6 +155,12 @@ class PlayStreamsMode(IntEnum): M3U = 2 +class EpgSource(IntEnum): + HTTP = 0 # HTTP API -> WebIf + DAT = 1 # epg.dat file + XML = 2 # XML TV + + class Settings: __INSTANCE = None __VERSION = 2 @@ -532,6 +538,30 @@ class Settings: def epg_options(self, value): self._cp_settings["epg_options"] = value + @property + def epg_source(self): + return EpgSource(self._cp_settings.get("epg_source", EpgSource.HTTP)) + + @epg_source.setter + def epg_source(self, value): + self._cp_settings["epg_source"] = value + + @property + def epg_update_interval(self): + return self._cp_settings.get("epg_update_interval", 5) + + @epg_update_interval.setter + def epg_update_interval(self, value): + self._cp_settings["epg_update_interval"] = value + + @property + def epg_xml_source(self): + return self._cp_settings.get("epg_xml_source", "") + + @epg_xml_source.setter + def epg_xml_source(self, value): + self._cp_settings["epg_xml_source"] = value + # *********** FTP ************ # @property diff --git a/app/tools/epg.py b/app/tools/epg.py index 4d06ca05..b1f34a33 100644 --- a/app/tools/epg.py +++ b/app/tools/epg.py @@ -27,12 +27,21 @@ """ Module for working with epg.dat file. """ +import abc import os +import shutil import struct +import sys from collections import namedtuple from datetime import datetime, timezone +from tempfile import NamedTemporaryFile +from urllib.parse import urlparse from xml.dom.minidom import parse, Node, Document +import xml.etree.ElementTree as ET +import requests + +from app.commons import log, run_task from app.eparser.ecommons import BqServiceType, BouquetService ENCODING = "utf-8" @@ -44,6 +53,18 @@ except ModuleNotFoundError: else: DETECT_ENCODING = True +EpgEvent = namedtuple("EpgEvent", ["title", "time", "desc", "event_data"]) +EpgEvent.__new__.__defaults__ = ("N/A", "N/A", "N/A", None) # For Python3 < 3.7 + + +class Reader(metaclass=abc.ABCMeta): + + @abc.abstractmethod + def download(self, clb=None): pass + + @abc.abstractmethod + def get_current_events(self, ids: set) -> dict: pass + class EPG: """ Base EPG class. """ @@ -51,7 +72,7 @@ class EPG: # datetime.datetime.toordinal(1858,11,17) => 678576 ZERO_DAY = 678576 - EpgEvent = namedtuple("EpgEvent", ["id", "data", "start", "duration", "title", "desc", "ext_desc"]) + Event = namedtuple("EpgEvent", ["id", "data", "start", "duration", "title", "desc", "ext_desc"]) class EventData: """ Event data representation class. """ @@ -86,7 +107,7 @@ class EPG: return EPG.get_from_bcd(self.raw_data[7]) * 3600 + EPG.get_from_bcd( self.raw_data[8]) * 60 + EPG.get_from_bcd(self.raw_data[9]) - class Reader: + class DatReader(Reader): """ The epd.dat file reading class. The read algorithm was taken from the eEPGCache::load() function from this source: @@ -98,6 +119,12 @@ class EPG: self._refs = {} self._desc = {} + def download(self, clb=None): + pass + + def get_current_events(self, ids: set) -> dict: + pass + def get_refs(self): return self._refs.keys() @@ -135,7 +162,7 @@ class EPG: elif desc_type == 78: # Extended event descriptor -> 0x4e -> 78 ext_desc = data[9:].decode(encoding, errors="ignore") if data[7] and data[8] < 32 else None - return EPG.EpgEvent(e_id, evd, start, duration, title, desc, ext_desc) + return EPG.Event(e_id, evd, start, duration, title, desc, ext_desc) def get_events(self, ref): return self._refs.get(ref, {}) @@ -190,6 +217,152 @@ class EPG: return ((value & 0xF0) >> 4) * 10 + (value & 0xF) +class XmlTvReader(Reader): + PR_TAG = "programme" + CH_TAG = "channel" + DSP_NAME_TAG = "display-name" + ICON_TAG = "icon" + TITLE_TAG = "title" + DESC_TAG = "desc" + + TIME_FORMAT_STR = "%Y%m%d%H%M%S %z" + + Service = namedtuple("Service", ["id", "name", "logo", "events"]) + Event = namedtuple("EpgEvent", ["start", "duration", "title", "desc"]) + + def __init__(self, path, url): + self._path = path + self._url = url + self._ids = {} + + @run_task + def download(self, clb=None): + """ Downloads and processes an XMLTV file. """ + res = urlparse(self._url) + if not all((res.scheme, res.netloc)): + log(f"{self.__class__.__name__} [download] error: Invalid URL {self._url}") + return + + with requests.get(url=self._url, stream=True) as request: + if request.reason == "OK": + suf = self._url[self._url.rfind("."):] + if suf not in (".gz", ".xz", ".lzma"): + log(f"{self.__class__.__name__} [download] error: Unsupported file extension.") + return + + data_len = request.headers.get("content-length") + + with NamedTemporaryFile(suffix=suf) as tf: + downloaded = 0 + data_len = int(data_len) + log("Downloading XMLTV file...") + for data in request.iter_content(chunk_size=1024): + downloaded += len(data) + tf.write(data) + done = int(50 * downloaded / data_len) + sys.stdout.write(f"\rDownloading XMLTV file [{'=' * done}{' ' * (50 - done)}]") + sys.stdout.flush() + tf.seek(0) + sys.stdout.write("\n") + + os.makedirs(os.path.dirname(self._path), exist_ok=True) + + if suf.endswith(".gz"): + try: + shutil.copyfile(tf.name, self._path) + except OSError as e: + log(f"{self.__class__.__name__} [download *.gz] error: {e}") + elif self._url.endswith((".xz", ".lzma")): + import lzma + + try: + with lzma.open(tf, "rb") as lzf: + shutil.copyfileobj(lzf, self._path) + except (lzma.LZMAError, OSError) as e: + log(f"{self.__class__.__name__} [download *.xz] error: {e}") + else: + log(f"{self.__class__.__name__} [download] error: {request.reason}") + + if clb: + clb() + + def get_current_events(self, names: set) -> dict: + events = {} + + dt = datetime.utcnow() + utc = dt.timestamp() + offset = datetime.now() - dt + + for srv in filter(lambda s: s.name in names, self._ids.values()): + ev = list(filter(lambda s: s.start < utc, srv.events)) + if ev: + ev = ev[-1] + start = datetime.fromtimestamp(ev.start) + offset + end_time = datetime.fromtimestamp(ev.duration) + offset + tm = f"{start.strftime('%H:%M')} - {end_time.strftime('%H:%M')}" + events[srv.name] = EpgEvent(ev.title, tm, ev.desc, ev) + + return events + + def parse(self): + """ Parses XML. """ + try: + import gzip + + with gzip.open(self._path, "rb") as gzf: + log("Processing XMLTV data...") + list(map(self.process_node, ET.iterparse(gzf))) + log("XMLTV data parsing is complete.") + except OSError as e: + log(f"{self.__class__.__name__} [parse] error: {e}") + + def process_node(self, node): + event, element = node + if element.tag == self.CH_TAG: + ch_id = element.get("id", None) + name, logo = None, None + for c in element: + if c.tag == self.DSP_NAME_TAG: + name = c.text + elif c.tag == self.ICON_TAG: + logo = c.get("src", None) + self._ids[ch_id] = self.Service(ch_id, name, logo, []) + elif element.tag == self.PR_TAG: + channel = self._ids.get(element.get(self.CH_TAG, None), None) + if channel: + events = channel[-1] + start = element.get("start", None) + if start: + start = self.get_utc_time(start) + + stop = element.get("stop", None) + if stop: + stop = self.get_utc_time(stop) + + title, desc = None, None + for c in element: + if c.tag == self.TITLE_TAG: + title = c.text + elif c.tag == self.DESC_TAG: + desc = c.text + + if all((start, stop, title)): + events.append(self.Event(start, stop, title, desc)) + + def to_epg_dat(self): + """ Converts and saves imported data to 'epg.dat' file. """ + raise ValueError("Not implemented yet!") + + @staticmethod + def get_utc_time(time_str): + """ Returns the UTC time in seconds. """ + t, sep, delta = time_str.partition(" ") + t = datetime(*map(int, (t[:4], t[4:6], t[6:8], t[8:10], t[10:12], t[12:]))).timestamp() + if delta: + t -= (3600 * int(delta) // 100) + return t + + class ChannelsParser: _COMMENT = "File was created in DemonEditor" diff --git a/app/ui/epg/__init__.py b/app/ui/epg/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/ui/epg_dialog.glade b/app/ui/epg/dialog.glade similarity index 100% rename from app/ui/epg_dialog.glade rename to app/ui/epg/dialog.glade diff --git a/app/ui/epg.py b/app/ui/epg/epg.py similarity index 87% rename from app/ui/epg.py rename to app/ui/epg/epg.py index 289042ca..7a35c330 100644 --- a/app/ui/epg.py +++ b/app/ui/epg/epg.py @@ -33,7 +33,6 @@ import os import re import shutil import urllib.request -from collections import namedtuple from datetime import datetime from enum import Enum from urllib.error import HTTPError, URLError @@ -44,15 +43,12 @@ from gi.repository import GLib from app.commons import run_idle, run_task, run_with_delay from app.connections import download_data, DownloadType, HttpAPI from app.eparser.ecommons import BouquetService, BqServiceType -from app.settings import SEP -from app.tools.epg import EPG, ChannelsParser +from app.settings import SEP, EpgSource +from app.tools.epg import EPG, ChannelsParser, EpgEvent, XmlTvReader from app.ui.dialogs import get_message, show_dialog, DialogType, get_builder from app.ui.timers import TimerTool -from .main_helper import on_popup_menu, update_entry_data -from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, Column, EPG_ICON, KeyboardKey, IS_GNOME_SESSION, Page - -EpgEvent = namedtuple("EpgEvent", ["title", "time", "desc", "event_data"]) -EpgEvent.__new__.__defaults__ = ("N/A", "N/A", "N/A", None) # For Python3 < 3.7 +from ..main_helper import on_popup_menu, update_entry_data +from ..uicommons import Gtk, Gdk, UI_RESOURCES_PATH, Column, EPG_ICON, KeyboardKey, IS_GNOME_SESSION, Page class RefsSource(Enum): @@ -64,40 +60,121 @@ class EpgCache(dict): def __init__(self, app): super().__init__() self._current_bq = None + self._reader = None + self._settings = app.app_settings + self._src = self._settings.epg_source self._app = app self._app.connect("bouquet-changed", self.on_bouquet_changed) self._app.connect("profile-changed", self.on_profile_changed) self.init() + @run_task def init(self): - GLib.timeout_add_seconds(3, self.update_epg_data, priority=GLib.PRIORITY_LOW) + if self._src is EpgSource.XML: + url = self._settings.epg_xml_source + gz_file = f"{self._settings.profile_data_path}epg{os.sep}epg.gz" + self._reader = XmlTvReader(gz_file, url) + + if os.path.isfile(gz_file): + # Difference calculation between the current time and file modification. + dif = datetime.now() - datetime.fromtimestamp(os.path.getmtime(gz_file)) + # We will update daily. -> Temporarily!!! + self._reader.download(self._reader.parse) if dif.days > 0 else self._reader.parse() + else: + self._reader.download(self._reader.parse) + elif self._src is EpgSource.DAT: + self._reader = EPG.DatReader(f"{self._settings.profile_data_path}epg{os.sep}epg.dat") + self._reader.download() + + GLib.timeout_add_seconds(self._settings.epg_update_interval, self.update_epg_data, priority=GLib.PRIORITY_LOW) def on_bouquet_changed(self, app, bq): self._current_bq = bq - def on_profile_changed(self, app, bq): + def on_profile_changed(self, app, p): self.clear() def update_epg_data(self): - api = self._app.http_api - bq = self._app.current_bouquet_files.get(self._current_bq, None) + if self._src is EpgSource.HTTP: + api = self._app.http_api + bq = self._app.current_bouquet_files.get(self._current_bq, None) - if bq and api: - req = quote(f'FROM BOUQUET "userbouquet.{bq}.{self._current_bq.split(":")[-1]}"') - api.send(HttpAPI.Request.EPG_NOW, f'1:7:1:0:0:0:0:0:0:0:{req}', self.update_data) + if bq and api: + req = quote(f'FROM BOUQUET "userbouquet.{bq}.{self._current_bq.split(":")[-1]}"') + api.send(HttpAPI.Request.EPG_NOW, f'1:7:1:0:0:0:0:0:0:0:{req}', self.update_http_data) + elif self._src is EpgSource.XML: + self.update_xml_data() return self._app.display_epg - @run_idle - def update_data(self, epg): + def update_http_data(self, epg): for e in (EpgTool.get_event(e, False) for e in epg.get("event_list", []) if e.get("e2eventid", "").isdigit()): self[e.event_data.get("e2eventservicename", "")] = e + def update_xml_data(self): + services = self._app.current_services + names = {services[s].service for s in self._app.current_bouquets.get(self._current_bq, [])} + for name, e in self._reader.get_current_events(names).items(): + self[name] = e + def get_current_event(self, service_name): return self.get(service_name, EpgEvent()) +class EpgSettingsPopover(Gtk.Popover): + + def __init__(self, app, **kwarg): + super().__init__(**kwarg) + self._app = app + self._app.connect("profile-changed", self.on_profile_changed) + + handlers = {"on_save": self.on_save, + "on_close": lambda b: self.popdown()} + builder = get_builder(f"{UI_RESOURCES_PATH}epg{SEP}settings.glade", handlers) + self.add(builder.get_object("main_box")) + + self._http_src_button = builder.get_object("http_src_button") + self._xml_src_button = builder.get_object("xml_src_button") + self._dat_src_button = builder.get_object("dat_src_button") + self._interval_button = builder.get_object("interval_button") + self._url_entry = builder.get_object("url_entry") + self._dat_path_box = builder.get_object("dat_path_box") + + self.init() + + def init(self): + settings = self._app.app_settings + src = settings.epg_source + if src is EpgSource.HTTP: + self._http_src_button.set_active(True) + elif src is EpgSource.XML: + self._xml_src_button.set_active(True) + else: + self._dat_src_button.set_active(True) + + self._interval_button.set_value(settings.epg_update_interval) + self._url_entry.set_text(settings.epg_xml_source) + self._dat_path_box.set_active_id(settings.epg_dat_path) + + def on_save(self, button): + settings = self._app.app_settings + if self._http_src_button.get_active(): + settings.epg_source = EpgSource.HTTP + elif self._xml_src_button.get_active(): + settings.epg_source = EpgSource.XML + else: + settings.epg_source = EpgSource.DAT + + settings.epg_update_interval = self._interval_button.get_value() + settings.epg_xml_source = self._url_entry.get_text() + settings.epg_dat_path = self._dat_path_box.get_active_id() + self.popdown() + + def on_profile_changed(self, app, p): + self.init() + + class EpgTool(Gtk.Box): def __init__(self, app, *args, **kwargs): super().__init__(*args, **kwargs) @@ -110,7 +187,7 @@ class EpgTool(Gtk.Box): "on_epg_filter_changed": self.on_epg_filter_changed, "on_epg_filter_toggled": self.on_epg_filter_toggled} - builder = get_builder(UI_RESOURCES_PATH + "epg.glade", handlers) + builder = get_builder(f"{UI_RESOURCES_PATH}epg{SEP}tab.glade", handlers) self._view = builder.get_object("epg_view") self._model = builder.get_object("epg_model") @@ -243,7 +320,7 @@ class EpgDialog: self._refs_source = RefsSource.SERVICES self._download_xml_is_active = False - builder = get_builder(f"{UI_RESOURCES_PATH}epg_dialog.glade", handlers) + builder = get_builder(f"{UI_RESOURCES_PATH}epg{SEP}dialog.glade", handlers) self._dialog = builder.get_object("epg_dialog_window") self._dialog.set_transient_for(self._app.app_window) @@ -348,7 +425,7 @@ class EpgDialog: refs = None if self._enable_dat_filter: try: - epg_reader = EPG.Reader(f"{self._epg_dat_path_entry.get_text()}epg.dat") + epg_reader = EPG.DatReader(f"{self._epg_dat_path_entry.get_text()}epg.dat") epg_reader.read() refs = epg_reader.get_refs() except (OSError, ValueError) as e: diff --git a/app/ui/epg/settings.glade b/app/ui/epg/settings.glade new file mode 100644 index 00000000..17b3e1f8 --- /dev/null +++ b/app/ui/epg/settings.glade @@ -0,0 +1,356 @@ + + + + + + + + + + + 3 + 60 + 3 + 1 + 10 + + + True + False + 10 + 10 + 5 + 5 + vertical + 2 + + + True + False + center + Source: + + + False + True + 0 + + + + + True + False + vertical + 5 + + + True + False + expand + + + WebIf + True + True + False + True + False + dat_src_button + + + False + True + 0 + + + + + XML TV + True + True + False + True + False + dat_src_button + + + False + True + 1 + + + + + *.dat file + True + False + True + False + True + False + http_src_button + + + False + True + 2 + + + + + False + True + 0 + + + + + True + False + 5 + + + True + False + Update interval (sec): + + + False + True + 0 + + + + + True + True + 4 + interval_adjustment + 1 + True + 3 + + + False + True + 1 + + + + + False + True + 1 + + + + + True + False + False + vertical + 5 + + + + True + False + start + Url to *.xml.gz file: + + + False + True + 0 + + + + + True + True + network-transmit-receive-symbolic + False + url + + + False + True + 1 + + + + + True + False + 5 + + + True + False + Download: + + + False + True + 0 + + + + + True + False + False + end + 0 + daily + + Daily + + + + True + True + 1 + + + + + False + True + 2 + + + + + False + True + 2 + + + + + True + False + False + vertical + 5 + + + + True + False + start + STB path: + + + False + True + 3 + + + + + True + False + 0 + /etc/enigma2 + + /etc/enigma2/ + /media/hdd/ + /media/usb/ + /media/mmc/ + /media/cf/ + + + + False + True + 4 + + + + + False + True + 4 + + + + + True + True + 1 + + + + + True + False + 5 + 5 + 5 + True + expand + + + Save + True + True + True + + + + True + True + 1 + + + + + Close + True + True + True + + + + True + True + 1 + + + + + False + True + 16 + + + + diff --git a/app/ui/epg.glade b/app/ui/epg/tab.glade similarity index 100% rename from app/ui/epg.glade rename to app/ui/epg/tab.glade diff --git a/app/ui/main.glade b/app/ui/main.glade index fa26cdc9..763c689d 100644 --- a/app/ui/main.glade +++ b/app/ui/main.glade @@ -3282,7 +3282,25 @@ Author: Dmitriy Yefremov - + + False + False + True + EPG source + + + True + False + insert-text-symbolic + + + + + False + True + end + 4 + diff --git a/app/ui/main.py b/app/ui/main.py index f0b0be90..cdae756e 100644 --- a/app/ui/main.py +++ b/app/ui/main.py @@ -50,7 +50,7 @@ from app.settings import (SettingsType, Settings, SettingsException, SettingsRea IS_DARWIN, PlayStreamsMode, IS_LINUX) from app.tools.media import Recorder from app.ui.control import ControlTool -from app.ui.epg import EpgDialog, EpgTool, EpgCache +from app.ui.epg.epg import EpgCache, EpgSettingsPopover, EpgDialog, EpgTool from app.ui.ftp import FtpClientBox from app.ui.logs import LogsClient from app.ui.playback import PlayerBox @@ -530,6 +530,9 @@ class Application(Gtk.Application): self._epg_cache = None fav_service_column = builder.get_object("fav_service_column") fav_service_column.set_cell_data_func(builder.get_object("fav_service_renderer"), self.fav_service_data_func) + self._epg_menu_button = builder.get_object("epg_menu_button") + self._epg_menu_button.connect("realize", lambda b: b.set_popover(EpgSettingsPopover(self))) + self.bind_property("is_enigma", self._epg_menu_button, "sensitive") # Hiding for Neutrino. self.bind_property("is_enigma", builder.get_object("services_button_box"), "visible") # Setting the last size of the window if it was saved. @@ -2638,7 +2641,7 @@ class Application(Gtk.Application): self.update_profile_label() is_enigma = self._s_type is SettingsType.ENIGMA_2 self.set_property("is-enigma", is_enigma) - self.update_stack_elements_visibility(is_enigma) + self.update_elements_visibility(is_enigma) def update_profiles(self): self._profile_combo_box.remove_all() @@ -2646,7 +2649,7 @@ class Application(Gtk.Application): self._profile_combo_box.append(p, p) @run_idle - def update_stack_elements_visibility(self, is_enigma=False): + def update_elements_visibility(self, is_enigma=False): self._stack_services_frame.set_visible(self._settings.get("show_bouquets", True)) self._stack_satellite_box.set_visible(self._settings.get("show_satellites", True)) self._stack_picon_box.set_visible(self._settings.get("show_picons", True)) @@ -2928,8 +2931,9 @@ class Application(Gtk.Application): action.set_state(value) set_display = bool(value) self._settings.display_epg = set_display - self._display_epg = set_display + self._epg_menu_button.set_visible(set_display) self._epg_cache = EpgCache(self) if set_display else None + self._display_epg = set_display def on_epg_list_configuration(self, action, value=None): if self._s_type is not SettingsType.ENIGMA_2: