epg display in bouquet list

This commit is contained in:
DYefremov
2022-06-06 20:33:37 +03:00
parent a4cbe00e96
commit 748b41e31c
5 changed files with 264 additions and 56 deletions

View File

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

View File

@@ -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("<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}"
for j in range(events_size):
_type, _len = struct.unpack(_type_read_str, f.read(_len_read_size))
f.read(10)
n_crc = (_len - 10) // 4
if n_crc > 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:

View File

@@ -147,6 +147,11 @@
<attribute name="label" translatable="yes">Display picons</attribute>
<attribute name="action">app.display_picons</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Display EPG in bouquet list</attribute>
<attribute name="action">app.display_epg</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Alternate layout</attribute>
<attribute name="action">app.set_alternate_layout</attribute>
@@ -356,6 +361,11 @@
<attribute name="label" translatable="yes">Display picons</attribute>
<attribute name="action">app.display_picons</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Display EPG in bouquet list</attribute>
<attribute name="action">app.display_epg</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Alternate layout</attribute>
<attribute name="action">app.set_alternate_layout</attribute>

View File

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

View File

@@ -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'<span size="small" weight="bold">{escape(event.title)}</span>\n'
f'<span size="small" style="italic">{event.time}</span>'))
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: