mirror of
https://github.com/DYefremov/DemonEditor.git
synced 2026-03-04 03:21:38 +01:00
epg display in bouquet list
This commit is contained in:
@@ -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:
|
||||
|
||||
190
app/tools/epg.py
190
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("<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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user