From 748b41e31cd24cc1a228694e41f0b9fea79bf623 Mon Sep 17 00:00:00 2001 From: DYefremov Date: Mon, 6 Jun 2022 20:33:37 +0300 Subject: [PATCH] epg display in bouquet list --- app/connections.py | 14 ++-- app/tools/epg.py | 190 ++++++++++++++++++++++++++++++++++++--------- app/ui/app_menu.ui | 10 +++ app/ui/epg.py | 55 +++++++++++-- app/ui/main.py | 51 +++++++++++- 5 files changed, 264 insertions(+), 56 deletions(-) diff --git a/app/connections.py b/app/connections.py index 6d61a699..f54d06f0 100644 --- a/app/connections.py +++ b/app/connections.py @@ -383,14 +383,8 @@ def download_data(*, settings, download_type=DownloadType.ALL, callback=log, fil ftp.download_picons(settings.picons_path, picons_path, callback, files_filter) # epg.dat if download_type is DownloadType.EPG: - stb_path = settings.services_path - epg_options = settings.epg_options - if epg_options: - stb_path = epg_options.get("epg_dat_stb_path", stb_path) - save_path = epg_options.get("epg_dat_path", save_path) - - ftp.cwd(stb_path) - ftp.download_files(save_path, "epg.dat", callback) + ftp.cwd(settings.epg_dat_path) + ftp.download_files(f"{settings.profile_data_path}epg{os.sep}", "epg.dat", callback) callback("*** Done. ***") @@ -628,6 +622,7 @@ class HttpAPI: VOL = "vol?set=set" # EPG EPG = "epgservice?sRef=" + EPG_NOW = "epgnow?bRef=" # Timer TIMER = "" TIMER_LIST = "timerlist" @@ -671,6 +666,7 @@ class HttpAPI: Request.POWER, Request.VOL, Request.EPG, + Request.EPG_NOW, Request.TIMER, Request.RECORDINGS, Request.N_ZAP} @@ -784,7 +780,7 @@ class HttpAPI: elif req_type is HttpAPI.Request.PLAYER_LIST: return [{el.tag: el.text for el in el.iter()} for el in ETree.fromstring(f.read().decode("utf-8")).iter("e2file")] - elif req_type is HttpAPI.Request.EPG: + elif req_type is HttpAPI.Request.EPG or req_type is HttpAPI.Request.EPG_NOW: return {"event_list": [{el.tag: el.text for el in el.iter()} for el in ETree.fromstring(f.read().decode("utf-8")).iter("e2event")]} elif req_type is HttpAPI.Request.TIMER_LIST: diff --git a/app/tools/epg.py b/app/tools/epg.py index 5c5f8aa2..4d06ca05 100644 --- a/app/tools/epg.py +++ b/app/tools/epg.py @@ -27,53 +27,167 @@ """ Module for working with epg.dat file. """ +import os import struct -from datetime import datetime +from collections import namedtuple +from datetime import datetime, timezone from xml.dom.minidom import parse, Node, Document from app.eparser.ecommons import BqServiceType, BouquetService +ENCODING = "utf-8" +DETECT_ENCODING = False +try: + import chardet +except ModuleNotFoundError: + pass +else: + DETECT_ENCODING = True + class EPG: + """ Base EPG class. """ + # DVB/EPG count days with a 'modified Julian calendar' where day 1 is 17 November 1858. + # datetime.datetime.toordinal(1858,11,17) => 678576 + ZERO_DAY = 678576 + + EpgEvent = namedtuple("EpgEvent", ["id", "data", "start", "duration", "title", "desc", "ext_desc"]) + + class EventData: + """ Event data representation class. """ + __slots__ = ["raw_data", "crc", "size", "type"] + + def __init__(self, size=0, e_type=0): + self.raw_data = None + self.crc = None + self.size = size + self.type = e_type + + def get_event_id(self): + return self.raw_data[0] << 8 | self.raw_data[1] + + def get_start_time(self): + """ Returns start time [sec.]. """ + # Date + start_date = datetime.fromordinal((self.raw_data[2] << 8 | self.raw_data[3]) + EPG.ZERO_DAY).timestamp() + # Time + tm_hour = EPG.get_from_bcd(self.raw_data[4]) + tm_min = EPG.get_from_bcd(self.raw_data[5]) + tm_sec = EPG.get_from_bcd(self.raw_data[6]) + # UTC. + s_time = start_date + tm_hour * 3600 + tm_min * 60 + tm_sec + # Time zone correction. + s_time += datetime.now(timezone.utc).astimezone().utcoffset().seconds + + return s_time + + def get_duration(self): + """ Returns duration [sec.].""" + 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: + """ The epd.dat file reading class. + + The read algorithm was taken from the eEPGCache::load() function from this source: + https://github.com/OpenPLi/enigma2/blob/44d9b92f5260c7de1b3b3a1b9a9cbe0f70ca4bf0/lib/dvb/epgcache.cpp#L1300 + """ + + def __init__(self, path): + self._path = path + self._refs = {} + self._desc = {} + + def get_refs(self): + return self._refs.keys() + + def get_services(self): + return self._refs + + def get_event(self, evd): + title, desc, ext_desc = None, None, None + e_id, start, duration = evd.get_event_id(), evd.get_start_time(), evd.get_duration() + + for c in evd.crc: + data = self._desc.get(c, None) + if not data: + continue + + encoding = ENCODING + if DETECT_ENCODING: + # May be slow. + encoding = chardet.detect(data).get("encoding", "utf-8") or encoding + + desc_type = data[0] + if desc_type == 77: # Short event descriptor -> 0x4d -> 77 + size = data[6] + txt = data[7:-1].decode(encoding, errors="ignore") + t_len = len(txt) + st = 0 + + if size and size < t_len: + st = abs(size - t_len) + + if size < 32: + title = txt + else: + desc = txt[st:] + 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) + + def get_events(self, ref): + return self._refs.get(ref, {}) + + def read(self): + with open(self._path, mode="rb") as f: + crc = struct.unpack("I", f.read(4))[0] + if crc != int(0x98765432): + raise ValueError("Epg file has incorrect byte order!") + + header = f.read(13).decode() + if header == "ENIGMA_EPG_V7": + epg_ver = 7 + elif header == "ENIGMA_EPG_V8": + epg_ver = 8 + else: + raise ValueError("Unsupported format of epd.dat file!") + + channels_count = struct.unpack("I", f.read(4))[0] + _len_read_size = 3 if epg_ver == 8 else 2 + _type_read_str = f"{'H' if epg_ver == 8 else 'B'}B" + + for i in range(channels_count): + sid, nid, tsid, events_size = struct.unpack("IIII", f.read(16)) + service_id = f"{sid:X}:{tsid:X}:{nid:X}" + events = {} + + for j in range(events_size): + _type, _len = struct.unpack(_type_read_str, f.read(_len_read_size)) + event = EPG.EventData(size=_len, e_type=_type) + event.raw_data = f.read(10) + + n_crc = (_len - 10) // 4 + if n_crc > 0: + event.crc = [struct.unpack("I", f.read(4))[0] for n in range(n_crc)] + events[event.get_event_id()] = event + + self._refs[service_id] = events + + for i in range(struct.unpack("I", f.read(4))[0]): + _id, ref_count = struct.unpack("II", f.read(8)) + header = struct.unpack("BB", f.read(2)) + _bytes = header[1] + 2 + f.seek(-2, os.SEEK_CUR) + self._desc[_id] = f.read(_bytes) @staticmethod - def get_epg_refs(path): - """ The read algorithm was taken from the eEPGCache::load() function from this source: - https://github.com/OpenPLi/enigma2/blob/develop/lib/dvb/epgcache.cpp#L955 - """ - refs = set() - - with open(path, mode="rb") as f: - crc = struct.unpack(" 0: - [f.read(4) for n in range(n_crc)] - - refs.add(service_id) - - return refs + def get_from_bcd(value: int): + """ Converts a BCD to an integer. """ + if ((value & 0xF0) >= 0xA0) or ((value & 0xF) >= 0xA): + return -1 + return ((value & 0xF0) >> 4) * 10 + (value & 0xF) class ChannelsParser: diff --git a/app/ui/app_menu.ui b/app/ui/app_menu.ui index 4ecd69c5..5de4ff69 100644 --- a/app/ui/app_menu.ui +++ b/app/ui/app_menu.ui @@ -147,6 +147,11 @@ Display picons app.display_picons + + Display EPG in bouquet list + app.display_epg + action-disabled + Alternate layout app.set_alternate_layout @@ -356,6 +361,11 @@ Display picons app.display_picons + + Display EPG in bouquet list + app.display_epg + action-disabled + Alternate layout app.set_alternate_layout diff --git a/app/ui/epg.py b/app/ui/epg.py index ca83fee9..289042ca 100644 --- a/app/ui/epg.py +++ b/app/ui/epg.py @@ -33,6 +33,7 @@ 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 @@ -50,12 +51,53 @@ 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 + class RefsSource(Enum): SERVICES = 0 XML = 1 +class EpgCache(dict): + def __init__(self, app): + super().__init__() + self._current_bq = None + self._app = app + self._app.connect("bouquet-changed", self.on_bouquet_changed) + self._app.connect("profile-changed", self.on_profile_changed) + + self.init() + + def init(self): + GLib.timeout_add_seconds(3, 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): + self.clear() + + def update_epg_data(self): + 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) + + return self._app.display_epg + + @run_idle + def update_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 get_current_event(self, service_name): + return self.get(service_name, EpgEvent()) + + class EpgTool(Gtk.Box): def __init__(self, app, *args, **kwargs): super().__init__(*args, **kwargs) @@ -129,19 +171,20 @@ class EpgTool(Gtk.Box): @run_idle def update_epg_data(self, epg): self._model.clear() - list(map(self._model.append, (self.get_event_row(e) for e in epg.get("event_list", [])))) + list(map(self._model.append, (self.get_event(e) for e in epg.get("event_list", [])))) self._app.wait_dialog.hide() - def get_event_row(self, event): + @staticmethod + def get_event(event, show_day=True): title = event.get("e2eventtitle", "") or "" desc = event.get("e2eventdescription", "") or "" start = int(event.get("e2eventstart", "0")) start_time = datetime.fromtimestamp(start) end_time = datetime.fromtimestamp(start + int(event.get("e2eventduration", "0"))) - time = f"{start_time.strftime('%A, %H:%M')} - {end_time.strftime('%H:%M')}" + ev_time = f"{start_time.strftime('%A, %H:%M' if show_day else '%H:%M')} - {end_time.strftime('%H:%M')}" - return title, time, desc, event + return EpgEvent(title, ev_time, desc, event) def on_epg_filter_changed(self, entry): self._filter_model.refilter() @@ -305,7 +348,9 @@ class EpgDialog: refs = None if self._enable_dat_filter: try: - refs = EPG.get_epg_refs(self._epg_dat_path_entry.get_text() + "epg.dat") + epg_reader = EPG.Reader(f"{self._epg_dat_path_entry.get_text()}epg.dat") + epg_reader.read() + refs = epg_reader.get_refs() except (OSError, ValueError) as e: self.show_info_message(f"Read data error: {e}", Gtk.MessageType.ERROR) return diff --git a/app/ui/main.py b/app/ui/main.py index b9f0a1b0..f0b0be90 100644 --- a/app/ui/main.py +++ b/app/ui/main.py @@ -32,6 +32,7 @@ from collections import Counter from contextlib import suppress from datetime import datetime from functools import lru_cache +from html import escape from itertools import chain from urllib.parse import urlparse, unquote @@ -49,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 +from app.ui.epg import EpgDialog, EpgTool, EpgCache from app.ui.ftp import FtpClientBox from app.ui.logs import LogsClient from app.ui.playback import PlayerBox @@ -270,6 +271,8 @@ class Application(Gtk.Application): # Signals. GObject.signal_new("profile-changed", self, GObject.SIGNAL_RUN_LAST, GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,)) + GObject.signal_new("bouquet-changed", self, GObject.SIGNAL_RUN_LAST, + GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,)) GObject.signal_new("fav-changed", self, GObject.SIGNAL_RUN_LAST, GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,)) GObject.signal_new("fav-clicked", self, GObject.SIGNAL_RUN_LAST, @@ -522,6 +525,11 @@ class Application(Gtk.Application): self.connect("profile-changed", self.init_iptv) self.connect("iptv-service-added", self.on_iptv_service_added) self.connect("iptv-service-edited", self.on_iptv_service_edited) + # EPG. + self._display_epg = False + 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) # 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. @@ -632,6 +640,10 @@ class Application(Gtk.Application): self.bind_property("is-enigma", sa, "enabled") # Display picons. self.set_state_action("display_picons", self.set_display_picons, self._settings.display_picons) + # Display EPG. + sa = self.set_state_action("display_epg", self.set_display_epg, self._settings.display_epg) + self.change_action_state("display_epg", GLib.Variant.new_boolean(self._settings.display_epg)) + self.bind_property("is_enigma", sa, "enabled") # Alternate layout. sa = self.set_state_action("set_alternate_layout", self.set_use_alt_layout, self._settings.alternate_layout) sa.connect("change-state", self.on_layout_change) @@ -1059,6 +1071,21 @@ class Application(Gtk.Application): renderer.set_property("pixbuf", picon) + def fav_service_data_func(self, column, renderer, model, itr, data): + if self._display_epg and self._s_type is SettingsType.ENIGMA_2: + srv_name = model.get_value(itr, Column.FAV_SERVICE) + if model.get_value(itr, Column.FAV_TYPE) in self._marker_types: + return True + + event = self._epg_cache.get_current_event(srv_name) + if event: + # https://docs.gtk.org/Pango/pango_markup.html + renderer.set_property("markup", (f'{escape(srv_name)}\n\n' + f'{escape(event.title)}\n' + f'{event.time}')) + return False + return True + def view_selection_func(self, *args): """ Used to control selection via drag and drop in views [via _select_enabled field]. @@ -2486,6 +2513,7 @@ class Application(Gtk.Application): self._bouquets_view.expand_row(path, column) if len(path) > 1: + self.emit("bouquet-changed", self._bq_selected) gen = self.update_bouquet_services(model, path) GLib.idle_add(lambda: next(gen, False)) @@ -2894,7 +2922,14 @@ class Application(Gtk.Application): gen = self.remove_favs(response, self._fav_model) GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW) - # ****************** EPG **********************# + # ****************** EPG ********************** # + + def set_display_epg(self, action, value): + action.set_state(value) + set_display = bool(value) + self._settings.display_epg = set_display + self._display_epg = set_display + self._epg_cache = EpgCache(self) if set_display else None def on_epg_list_configuration(self, action, value=None): if self._s_type is not SettingsType.ENIGMA_2: @@ -3318,11 +3353,11 @@ class Application(Gtk.Application): else: self.show_error_message("This type of settings is not supported!") - def get_service_ref(self, path): + def get_service_ref(self, path, show_error=True): row = self._fav_model[path][:] srv_type, fav_id = row[Column.FAV_TYPE], row[Column.FAV_ID] - if srv_type in self._marker_types: + if srv_type in self._marker_types and show_error: self.show_error_message("Not allowed in this context!") return @@ -4168,6 +4203,10 @@ class Application(Gtk.Application): def current_bouquets(self): return self._bouquets + @property + def current_bouquet_files(self): + return self._bq_file + @property def picons(self): return self._picons @@ -4211,6 +4250,10 @@ class Application(Gtk.Application): def page(self): return self._page + @property + def display_epg(self): + return self._display_epg + def start_app(): try: