diff --git a/app/eparser/iptv.py b/app/eparser/iptv.py
index 2c96eaa9..41b8dffb 100644
--- a/app/eparser/iptv.py
+++ b/app/eparser/iptv.py
@@ -3,9 +3,10 @@ import re
from enum import Enum
from urllib.parse import unquote, quote
+from app.commons import log
+from app.eparser.ecommons import BqServiceType, Service
from app.settings import SettingsType
from app.ui.uicommons import IPTV_ICON
-from .ecommons import BqServiceType, Service
# url, description, urlkey, account, usrname, psw, s_type, iconsrc, iconsrc_b, group
NEUTRINO_FAV_ID_FORMAT = "{}::{}::{}::{}::{}::{}::{}::{}::{}::{}"
@@ -22,7 +23,7 @@ class StreamType(Enum):
E_SERVICE_HLS = "8739"
-def parse_m3u(path, s_type, detect_encoding=True):
+def parse_m3u(path, s_type, detect_encoding=True, params=None):
with open(path, "rb") as file:
data = file.read()
encoding = "utf-8"
@@ -37,28 +38,56 @@ def parse_m3u(path, s_type, detect_encoding=True):
encoding = enc.get("encoding", "utf-8")
aggr = [None] * 10
+ s_aggr = aggr[: -3]
services = []
groups = set()
- counter = 0
+ marker_counter = 1
+ sid_counter = 1
name = None
+ picon = None
+ p_id = "1_0_1_0_0_0_0_0_0_0.png"
+ st = BqServiceType.IPTV.name
+ params = params or [0, 0, 0, 0]
for line in str(data, encoding=encoding, errors="ignore").splitlines():
if line.startswith("#EXTINF"):
- name = line[1 + line.index(","):].strip()
+ inf, sep, line = line.partition(" ")
+ if not line:
+ line = inf
+ line, sep, name = line.rpartition(",")
+
+ data = re.split('"', line)
+ size = len(data)
+ if size < 3:
+ continue
+ d = {data[i].lower().strip(" ="): data[i + 1] for i in range(0, len(data) - 1, 2)}
+ picon = d.get("tvg-logo", None)
+
+ grp_name = d.get("group-title", None)
+ if grp_name not in groups:
+ groups.add(grp_name)
+ fav_id = MARKER_FORMAT.format(marker_counter, grp_name, grp_name)
+ marker_counter += 1
+ mr = Service(None, None, None, grp_name, *aggr[0:3], BqServiceType.MARKER.name, *aggr, fav_id, None)
+ services.append(mr)
elif line.startswith("#EXTGRP") and s_type is SettingsType.ENIGMA_2:
grp_name = line.strip("#EXTGRP:").strip()
if grp_name not in groups:
groups.add(grp_name)
- fav_id = MARKER_FORMAT.format(counter, grp_name, grp_name)
- counter += 1
+ fav_id = MARKER_FORMAT.format(marker_counter, grp_name, grp_name)
+ marker_counter += 1
mr = Service(None, None, None, grp_name, *aggr[0:3], BqServiceType.MARKER.name, *aggr, fav_id, None)
services.append(mr)
elif not line.startswith("#"):
url = line.strip()
- fav_id = get_fav_id(url, name, s_type)
- if name and url:
- srv = Service(None, None, IPTV_ICON, name, *aggr[0:3], BqServiceType.IPTV.name, *aggr, fav_id, None)
+ params[0] = sid_counter
+ sid_counter += 1
+ fav_id = get_fav_id(url, name, s_type, params)
+ if all((name, url, fav_id)):
+ srv = Service(None, None, IPTV_ICON, name, *aggr[0:3], st, picon, p_id, *s_aggr, url, fav_id, None)
services.append(srv)
+ else:
+ log("*.m3u* parse error ['{}']: name[{}], url[{}], fav id[{}]".format(path, name, url, fav_id))
return services
@@ -86,12 +115,13 @@ def export_to_m3u(path, bouquet, s_type):
file.writelines(lines)
-def get_fav_id(url, service_name, s_type):
+def get_fav_id(url, service_name, settings_type, params=None, stream_type=None, s_type=1):
""" Returns fav id depending on the profile. """
- if s_type is SettingsType.ENIGMA_2:
- stream_type = StreamType.NONE_TS.value
- return ENIGMA2_FAV_ID_FORMAT.format(stream_type, 1, 0, 0, 0, 0, quote(url), service_name, service_name, None)
- elif s_type is SettingsType.NEUTRINO_MP:
+ if settings_type is SettingsType.ENIGMA_2:
+ stream_type = stream_type or StreamType.NONE_TS.value
+ params = params or (0, 0, 0, 0)
+ return ENIGMA2_FAV_ID_FORMAT.format(stream_type, s_type, *params, quote(url), service_name, service_name, None)
+ elif settings_type is SettingsType.NEUTRINO_MP:
return NEUTRINO_FAV_ID_FORMAT.format(url, "", 0, None, None, None, None, "", "", 1)
diff --git a/app/ui/iptv.glade b/app/ui/iptv.glade
index a723659b..4633124a 100644
--- a/app/ui/iptv.glade
+++ b/app/ui/iptv.glade
@@ -3,7 +3,7 @@
The MIT License (MIT)
-Copyright (c) 2018-2020 Dmitriy Yefremov
+Copyright (c) 2018-2021 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -31,7 +31,7 @@ Author: Dmitriy Yefremov
-
+
@@ -1218,7 +1195,7 @@ Author: Dmitriy Yefremov
- close_config_list_button
+ cancel_config_list_button
diff --git a/app/ui/iptv.py b/app/ui/iptv.py
index f27f7c8c..39774810 100644
--- a/app/ui/iptv.py
+++ b/app/ui/iptv.py
@@ -5,17 +5,18 @@ from urllib.error import HTTPError
from urllib.parse import urlparse, unquote, quote
from urllib.request import Request, urlopen
-from gi.repository import GLib
+from gi.repository import GLib, Gio, GdkPixbuf
-from app.commons import run_idle, run_task
+from app.commons import run_idle, run_task, log
from app.eparser.ecommons import BqServiceType, Service
-from app.eparser.iptv import NEUTRINO_FAV_ID_FORMAT, StreamType, ENIGMA2_FAV_ID_FORMAT, get_fav_id, MARKER_FORMAT
+from app.eparser.iptv import (NEUTRINO_FAV_ID_FORMAT, StreamType, ENIGMA2_FAV_ID_FORMAT, get_fav_id, MARKER_FORMAT,
+ parse_m3u)
from app.settings import SettingsType
from app.tools.yt import YouTubeException, YouTube
-from .dialogs import Action, show_dialog, DialogType, get_dialogs_string, get_message
-from .main_helper import get_base_model, get_iptv_url, on_popup_menu
-from .uicommons import (Gtk, Gdk, TEXT_DOMAIN, UI_RESOURCES_PATH, IPTV_ICON, Column, IS_GNOME_SESSION, KeyboardKey,
- get_yt_icon)
+from app.ui.dialogs import Action, show_dialog, DialogType, get_dialogs_string, get_message
+from app.ui.main_helper import get_base_model, get_iptv_url, on_popup_menu
+from app.ui.uicommons import (Gtk, Gdk, TEXT_DOMAIN, UI_RESOURCES_PATH, IPTV_ICON, Column, IS_GNOME_SESSION,
+ KeyboardKey, get_yt_icon)
_DIGIT_ENTRY_NAME = "digit-entry"
_ENIGMA2_REFERENCE = "{}:0:{}:{:X}:{:X}:{:X}:{:X}:0:0:0"
@@ -399,9 +400,10 @@ class SearchUnavailableDialog:
self._dialog.destroy()
-class IptvListConfigurationDialog:
+class IptvListDialog:
+ """ Base class for working with iptv lists. """
- def __init__(self, transient, services, iptv_rows, bouquet, fav_model, s_type):
+ def __init__(self, transient, s_type):
handlers = {"on_apply": self.on_apply,
"on_response": self.on_response,
"on_stream_type_default_togged": self.on_stream_type_default_togged,
@@ -415,10 +417,6 @@ class IptvListConfigurationDialog:
"on_entry_changed": self.on_entry_changed,
"on_info_bar_close": self.on_info_bar_close}
- self._rows = iptv_rows
- self._services = services
- self._bouquet = bouquet
- self._fav_model = fav_model
self._s_type = s_type
builder = Gtk.Builder()
@@ -429,6 +427,8 @@ class IptvListConfigurationDialog:
self._dialog = builder.get_object("iptv_list_configuration_dialog")
self._dialog.set_transient_for(transient)
+ self._data_box = builder.get_object("iptv_list_data_box")
+ self._start_values_grid = builder.get_object("start_values_grid")
self._info_bar = builder.get_object("list_configuration_info_bar")
self._reference_label = builder.get_object("reference_label")
self._stream_type_check_button = builder.get_object("stream_type_default_check_button")
@@ -444,13 +444,15 @@ class IptvListConfigurationDialog:
self._list_nid_entry = builder.get_object("list_nid_entry")
self._list_namespace_entry = builder.get_object("list_namespace_entry")
self._reset_to_default_switch = builder.get_object("reset_to_default_lists_switch")
- # style
- self._style_provider = Gtk.CssProvider()
- self._style_provider.load_from_path(UI_RESOURCES_PATH + "style.css")
+ # Style
+ style_provider = Gtk.CssProvider()
+ style_provider.load_from_path(UI_RESOURCES_PATH + "style.css")
+ self._default_elems = (self._stream_type_check_button, self._type_check_button, self._sid_auto_check_button,
+ self._tid_check_button, self._nid_check_button, self._namespace_check_button)
self._digit_elems = (self._list_srv_type_entry, self._list_sid_entry, self._list_tid_entry,
self._list_nid_entry, self._list_namespace_entry)
for el in self._digit_elems:
- el.get_style_context().add_provider_for_screen(Gdk.Screen.get_default(), self._style_provider,
+ el.get_style_context().add_provider_for_screen(Gdk.Screen.get_default(), style_provider,
Gtk.STYLE_PROVIDER_PRIORITY_USER)
def show(self):
@@ -494,19 +496,58 @@ class IptvListConfigurationDialog:
self._list_namespace_entry.set_sensitive(not button.get_active())
@run_idle
- def on_reset_to_default(self, item, active):
- item.set_sensitive(not active)
+ def on_reset_to_default(self, item):
self._stream_type_combobox.set_active(1)
self._list_srv_type_entry.set_text("1")
- for el in (self._list_sid_entry, self._list_nid_entry, self._list_tid_entry, self._list_namespace_entry):
+ for el in self._digit_elems[1:]:
el.set_text("0")
- for el in (self._stream_type_check_button, self._type_check_button, self._sid_auto_check_button,
- self._tid_check_button, self._nid_check_button, self._namespace_check_button):
+ for el in self._default_elems:
el.set_active(True)
def on_info_bar_close(self, bar=None, resp=None):
self._info_bar.set_visible(False)
+ def on_apply(self, item):
+ pass
+
+ @run_idle
+ def update_reference(self):
+ if is_data_correct(self._digit_elems):
+ stream_type = get_stream_type(self._stream_type_combobox)
+ self._reference_label.set_text(
+ _ENIGMA2_REFERENCE.format(stream_type, *[int(elem.get_text()) for elem in self._digit_elems]))
+
+ def on_entry_changed(self, entry):
+ if _PATTERN.search(entry.get_text()):
+ entry.set_name(_DIGIT_ENTRY_NAME)
+ else:
+ entry.set_name("GtkEntry")
+ self.update_reference()
+
+ def is_default_values(self):
+ return any(el.get_text() == "0" for el in self._digit_elems[2:])
+
+ def is_all_data_default(self):
+ return all(el.get_active() for el in self._default_elems)
+
+
+class IptvListConfigurationDialog(IptvListDialog):
+
+ def __init__(self, transient, services, iptv_rows, bouquet, fav_model, s_type):
+ super().__init__(transient, s_type)
+
+ self._rows = iptv_rows
+ self._bouquet = bouquet
+ self._fav_model = fav_model
+ self._services = services
+
+ apply_button = Gtk.Button(visible=True,
+ image=Gtk.Image(icon_name="gtk-apply"),
+ always_show_image=True,
+ label=get_message("Apply"))
+ apply_button.connect("clicked", self.on_apply)
+ self._dialog.add_action_widget(apply_button, Gtk.ResponseType.APPLY)
+
@run_idle
def on_apply(self, item):
if not is_data_correct(self._digit_elems):
@@ -514,14 +555,13 @@ class IptvListConfigurationDialog:
return
if self._s_type is SettingsType.ENIGMA_2:
- reset = self._reset_to_default_switch.get_active()
type_default = self._type_check_button.get_active()
tid_default = self._tid_check_button.get_active()
sid_auto = self._sid_auto_check_button.get_active()
nid_default = self._nid_check_button.get_active()
namespace_default = self._namespace_check_button.get_active()
- stream_type = StreamType.NONE_TS.value if reset else get_stream_type(self._stream_type_combobox)
+ stream_type = get_stream_type(self._stream_type_combobox)
srv_type = "1" if type_default else self._list_srv_type_entry.get_text()
tid = "0" if tid_default else "{:X}".format(int(self._list_tid_entry.get_text()))
nid = "0" if nid_default else "{:X}".format(int(self._list_nid_entry.get_text()))
@@ -532,7 +572,7 @@ class IptvListConfigurationDialog:
data, sep, desc = fav_id.partition("http")
data = data.split(":")
- if reset:
+ if self.is_all_data_default():
data[2], data[3], data[4], data[5], data[6] = "10000"
else:
data[0], data[2], data[4], data[5], data[6] = stream_type, srv_type, tid, nid, namespace
@@ -551,19 +591,178 @@ class IptvListConfigurationDialog:
self._info_bar.set_visible(True)
- @run_idle
- def update_reference(self):
- if is_data_correct(self._digit_elems):
- stream_type = get_stream_type(self._stream_type_combobox)
- self._reference_label.set_text(
- _ENIGMA2_REFERENCE.format(stream_type, *[int(elem.get_text()) for elem in self._digit_elems]))
- def on_entry_changed(self, entry):
- if _PATTERN.search(entry.get_text()):
- entry.set_name(_DIGIT_ENTRY_NAME)
- else:
- entry.set_name("GtkEntry")
- self.update_reference()
+class M3uImportDialog(IptvListDialog):
+ """ Import dialog for *.m3u* playlists. """
+
+ def __init__(self, transient, s_type, path, callback):
+ super().__init__(transient, s_type)
+
+ self._callback = callback
+ self._services = None
+ self._url_count = 0
+ self._max_count = 0
+ self._is_download = False
+ self._cancellable = Gio.Cancellable()
+ self._dialog.set_title(get_message("Playlist import"))
+ # Progress
+ self._progress_bar = Gtk.ProgressBar(visible=False, valign="center")
+ self._spinner = Gtk.Spinner(active=False)
+ self._info_label = Gtk.Label(visible=True, ellipsize="end", max_width_chars=30)
+ load_label = Gtk.Label(label=get_message("Loading data..."))
+ self._spinner.bind_property("active", self._spinner, "visible")
+ self._spinner.bind_property("visible", load_label, "visible")
+ self._spinner.bind_property("active", self._start_values_grid, "sensitive", 4)
+
+ progress_box = Gtk.HBox(visible=True, spacing=2)
+ progress_box.add(self._progress_bar)
+ progress_box.pack_end(self._spinner, False, False, 0)
+ progress_box.pack_start(load_label, False, False, 0)
+ # Picons
+ self._picons_switch = Gtk.Switch(visible=True)
+ self._picon_box = Gtk.HBox(visible=False, sensitive=False, spacing=2)
+ self._picon_box.pack_end(self._picons_switch, False, False, 0)
+ self._picon_box.pack_end(Gtk.Label(visible=True, label=get_message("Download picons")), False, False, 0)
+ # Extra box
+ extra_box = Gtk.HBox(visible=True, spacing=2, margin_bottom=5, margin_top=5)
+ extra_box.set_center_widget(progress_box)
+ extra_box.pack_start(self._info_label, False, False, 5)
+ extra_box.pack_end(self._picon_box, True, True, 5)
+
+ frame = Gtk.Frame(visible=True)
+ frame.add(extra_box)
+ self._data_box.add(frame)
+
+ self._apply_button = Gtk.Button(visible=True,
+ image=Gtk.Image(icon_name="insert-link"),
+ always_show_image=True,
+ label=get_message("Import"))
+ self._apply_button.connect("clicked", self.on_apply)
+ self._dialog.add_action_widget(self._apply_button, Gtk.ResponseType.APPLY)
+ self._dialog.connect("delete-event", self.on_close)
+
+ self.get_m3u(path, s_type)
+
+ @run_task
+ def get_m3u(self, path, s_type):
+ try:
+ GLib.idle_add(self._spinner.set_property, "active", True)
+ self._services = parse_m3u(path, s_type)
+ for s in self._services:
+ if s.picon:
+ GLib.idle_add(self._picon_box.set_sensitive, True)
+ break
+ finally:
+ msg = "{} {}".format(get_message("Streams detected:"), len(self._services) if self._services else 0)
+ GLib.idle_add(self._info_label.set_text, msg)
+ GLib.idle_add(self._spinner.set_property, "active", False)
+
+ def on_apply(self, item):
+ if not is_data_correct(self._digit_elems):
+ show_dialog(DialogType.ERROR, self._dialog, "Error. Verify the data!")
+ return
+
+ picons = {}
+ services = self._services
+
+ if not self.is_all_data_default():
+ services = []
+ params = [int(el.get_text()) for el in self._digit_elems]
+ s_type = params[0]
+ params = params[1:]
+ stream_type = get_stream_type(self._stream_type_combobox)
+
+ for i, s in enumerate(self._services, start=params[0]):
+ # Skipping markers.
+ if not s.data_id:
+ services.append(s)
+ continue
+
+ params[0] = i
+ picon_id = "1_0_{:X}_{:X}_{:X}_{:X}_{:04X}0000_0_0_0.png".format(s_type, *params)
+ fav_id = get_fav_id(url=s.data_id,
+ service_name=s.service,
+ settings_type=self._s_type,
+ params=params,
+ stream_type=stream_type,
+ s_type=s_type)
+
+ picons[s.picon] = picon_id
+ services.append(s._replace(picon_id=picon_id, data_id=None, fav_id=fav_id))
+
+ if self._picons_switch.get_active():
+ if self.is_default_values():
+ show_dialog(DialogType.ERROR, self._dialog,
+ "Set values for TID, NID and Namespace for correct naming of the picons!")
+ return
+
+ self.download_picons(picons)
+
+ self._callback(services)
+
+ @run_task
+ def download_picons(self, picons):
+ self._is_download = True
+ GLib.idle_add(self._apply_button.set_sensitive, False)
+ GLib.idle_add(self._progress_bar.set_visible, True)
+
+ self._url_count = len(picons)
+ self._max_count = self._url_count
+ self._cancellable.reset()
+
+ for p in filter(None, picons):
+ if not self._is_download:
+ return
+
+ f = Gio.File.new_for_uri(p)
+ try:
+ GdkPixbuf.Pixbuf.new_from_stream_at_scale_async(f.read(cancellable=self._cancellable), 220, 132, False,
+ self._cancellable,
+ self.on_picon_load_done,
+ picons.get(p, None))
+ except GLib.GError as e:
+ self.update_progress()
+ if e.code != Gio.IOErrorEnum.CANCELLED:
+ log(str("Picon download error:{} {}").format(p, e))
+
+ def on_picon_load_done(self, file, result, user_data):
+ try:
+ pixbuf = GdkPixbuf.Pixbuf.new_from_stream_finish(result)
+ except GLib.GError as e:
+ if e.code != Gio.IOErrorEnum.CANCELLED:
+ log("Loading picon [{}] data error: {}".format(user_data, e))
+ finally:
+ self._info_label.set_text("Processing: {}".format(user_data))
+ self.update_progress()
+
+ def update_progress(self):
+ self._url_count -= 1
+ frac = 1 - self._url_count / self._max_count
+ self._progress_bar.set_fraction(frac)
+
+ if self._url_count == 0:
+ self._progress_bar.set_visible(False)
+ self._progress_bar.set_fraction(0.0)
+ self._apply_button.set_sensitive(True)
+ self._info_label.set_text(get_message("Done!"))
+ self._is_download = False
+
+ def on_response(self, dialog, response):
+ if response == Gtk.ResponseType.APPLY:
+ return True
+
+ if response == Gtk.ResponseType.CANCEL and not self._is_download or not self.on_close():
+ self._dialog.destroy()
+
+ def on_close(self, window=None, event=None):
+ if self._is_download:
+ if show_dialog(DialogType.QUESTION, self._dialog) == Gtk.ResponseType.OK:
+ self._is_download = False
+ self._cancellable.cancel()
+ return False
+ return True
+
+ return False
class YtListImportDialog:
diff --git a/app/ui/main_app_window.py b/app/ui/main_app_window.py
index 75db0bee..311beff8 100644
--- a/app/ui/main_app_window.py
+++ b/app/ui/main_app_window.py
@@ -11,7 +11,7 @@ from gi.repository import GLib, Gio
from app.commons import run_idle, log, run_task, run_with_delay, init_logger
from app.connections import (HttpAPI, download_data, DownloadType, upload_data, test_http, TestException,
HttpApiException, STC_XML_FILE)
-from app.eparser import get_blacklist, write_blacklist, parse_m3u
+from app.eparser import get_blacklist, write_blacklist
from app.eparser import get_services, get_bouquets, write_bouquets, write_services, Bouquets, Bouquet, Service
from app.eparser.ecommons import CAS, Flag, BouquetService
from app.eparser.enigma.bouquets import BqServiceType
@@ -25,7 +25,7 @@ from .backup import BackupDialog, backup_data, clear_data_path
from .dialogs import show_dialog, DialogType, get_chooser_dialog, WaitDialog, get_message
from .download_dialog import DownloadDialog
from .imports import ImportDialog, import_bouquet
-from .iptv import IptvDialog, SearchUnavailableDialog, IptvListConfigurationDialog, YtListImportDialog
+from .iptv import IptvDialog, SearchUnavailableDialog, IptvListConfigurationDialog, YtListImportDialog, M3uImportDialog
from .main_helper import (insert_marker, move_items, rename, ViewTarget, set_flags, locate_in_services,
scroll_to, get_base_model, update_picons_data, copy_picon_reference, assign_picons,
remove_picon, is_only_one_item_selected, gen_bouquets, BqGenType, get_iptv_url, append_picons,
@@ -2153,17 +2153,8 @@ class Application(Gtk.Application):
self.show_error_dialog("No m3u file is selected!")
return
- self._wait_dialog.show()
- self.get_m3u(response)
-
- @run_task
- def get_m3u(self, path):
- try:
- channels = parse_m3u(path, self._s_type)
- if channels and self._bq_selected:
- GLib.idle_add(self.append_imported_services, channels)
- finally:
- GLib.idle_add(self._wait_dialog.hide)
+ if self._bq_selected:
+ M3uImportDialog(self._main_window, self._s_type, response, self.append_imported_services).show()
def append_imported_services(self, services):
bq_services = self._bouquets.get(self._bq_selected)