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 @@
+
+
+
+
+
+
+
+
+
+
+
+
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
+ 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: