├── .gitignore ├── LICENSE ├── README.md ├── _config.yml ├── app ├── __init__.py ├── commons.py ├── connections.py ├── eparser │ ├── __init__.py │ ├── ecommons.py │ ├── enigma │ │ ├── __init__.py │ │ ├── blacklist.py │ │ ├── bouquets.py │ │ ├── lamedb.py │ │ └── streamrelay.py │ ├── iptv.py │ ├── neutrino │ │ ├── __init__.py │ │ ├── bouquets.py │ │ ├── nxml.py │ │ └── services.py │ └── satxml.py ├── settings.py ├── tools │ ├── __init__.py │ ├── epg.py │ ├── media.py │ ├── mpv.py │ ├── picons.py │ ├── satellites.py │ ├── vlc.py │ └── yt.py └── ui │ ├── __init__.py │ ├── app_menu.ui │ ├── backup.py │ ├── backup_dialog.glade │ ├── bootlogo.py │ ├── control.glade │ ├── control.py │ ├── dialogs.glade │ ├── dialogs.py │ ├── epg │ ├── __init__.py │ ├── dialog.glade │ ├── epg.py │ ├── settings.glade │ └── tab.glade │ ├── extensions │ ├── __init__.py │ └── management.py │ ├── ftp.glade │ ├── ftp.py │ ├── icons │ └── hicolor │ │ ├── 96x96 │ │ └── apps │ │ │ └── demon-editor.png │ │ └── scalable │ │ └── apps │ │ └── demon-editor.svg │ ├── imports.glade │ ├── imports.py │ ├── iptv.glade │ ├── iptv.py │ ├── lang │ ├── be │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── de │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── es │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── it │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── nl │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── pl │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── pt │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── ru │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ ├── tr │ │ └── LC_MESSAGES │ │ │ └── demon-editor.mo │ └── zh_CN │ │ └── LC_MESSAGES │ │ └── demon-editor.mo │ ├── logs.glade │ ├── logs.py │ ├── m3u.glade │ ├── mac_style.css │ ├── main.glade │ ├── main.py │ ├── main_helper.py │ ├── picons.glade │ ├── picons.py │ ├── playback.glade │ ├── playback.py │ ├── recordings.glade │ ├── recordings.py │ ├── search.py │ ├── service_details_dialog.glade │ ├── service_details_dialog.py │ ├── settings_dialog.glade │ ├── settings_dialog.py │ ├── style.css │ ├── tasks.py │ ├── telnet.glade │ ├── telnet.py │ ├── timers.glade │ ├── timers.py │ ├── transmitter.glade │ ├── transmitter.py │ ├── uicommons.py │ ├── win_style.css │ └── xml │ ├── __init__.py │ ├── dialogs.glade │ ├── dialogs.py │ ├── edit.py │ ├── editor.glade │ └── update.glade ├── build ├── BUILDING.md ├── BUILD_WIN.md ├── linux │ ├── ALTLinux spec │ │ ├── demon-editor-2.0-development-startfix.patch │ │ └── demon-editor.spec │ ├── build-deb.sh │ └── deb │ │ ├── DEBIAN │ │ ├── README.source │ │ ├── control │ │ ├── copyright │ │ └── demon-editor-docs.docs │ │ └── usr │ │ ├── bin │ │ └── demon-editor │ │ └── share │ │ ├── applications │ │ └── demon-editor.desktop │ │ ├── demoneditor │ │ └── start.py │ │ └── icons │ │ └── hicolor │ │ ├── 96x96 │ │ └── apps │ │ │ └── demon-editor.png │ │ └── scalable │ │ └── apps │ │ └── demon-editor.svg ├── mac │ ├── DemonEditor.spec │ ├── icon.icns │ └── start.py └── win │ ├── DemonEditor.spec │ ├── icon.ico │ └── start.py ├── demon-editor.desktop ├── extensions ├── README.md └── __init__.py ├── po ├── be │ └── demon-editor.po ├── build.sh ├── de │ └── demon-editor.po ├── es │ └── demon-editor.po ├── it │ └── demon-editor.po ├── nl │ └── demon-editor.po ├── pl │ └── demon-editor.po ├── pt │ └── demon-editor.po ├── ru │ └── demon-editor.po ├── tr │ └── demon-editor.po └── zh_CN │ └── demon-editor.po └── start.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *__pycache__ 4 | .idea 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2025 Dmitriy Yefremov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DemonEditor 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![platform](https://img.shields.io/badge/platform-linux%20|%20macos-lightgrey) 3 | ### Enigma2 channel and satellite list editor for GNU/Linux. 4 | Experimental support of Neutrino-MP or others on the same basis (BPanther, etc). 5 | 6 | ## Main features of the program 7 | * Editing bouquets, channels, satellites. 8 | [](https://user-images.githubusercontent.com/7511379/141680963-9b8eb6cc-c712-46b2-aefe-19769e21a7d5.png) 9 | * Import function. 10 | [](https://user-images.githubusercontent.com/7511379/141681059-68bc1b55-6fab-436c-aa73-ef24e2e5113b.png) 11 | * Backup function. 12 | [](https://user-images.githubusercontent.com/7511379/141681104-ed9b5d35-25de-426f-b9bb-2a6e4db022bb.png) 13 | * Support of picons. 14 | [](https://user-images.githubusercontent.com/7511379/141681115-957c63a3-4113-422d-bb27-2d96b1463cd1.png) 15 | * Importing services, downloading picons and updating satellites from the Web. 16 | [](https://user-images.githubusercontent.com/7511379/141681075-28f18ea5-e456-4e84-bf64-1b7d9a95324d.png) 17 | [](https://user-images.githubusercontent.com/7511379/141681040-b1ad190a-6bc2-4741-bb42-1fb219a0fcab.png) 18 | * Extended support of IPTV. 19 | * Import to bouquet(Neutrino WEBTV) from m3u. 20 | * Export of bouquets with IPTV services in m3u. 21 | * Assignment of EPG from DVB or XML for IPTV services (Enigma2 only). 22 | [](https://user-images.githubusercontent.com/7511379/141681187-fae4e784-c9e0-43df-b499-4d38e83d6560.png) 23 | * Playback of IPTV or other streams directly from the bouquet list. 24 | [](https://user-images.githubusercontent.com/7511379/141681129-98f78cdc-9a98-46ef-b738-618a327634d4.png) 25 | * Control panel (via HTTP API). 26 | [](https://user-images.githubusercontent.com/7511379/141684475-4511ea4f-b152-42d5-b9c8-f3e1e9a160d0.png) 27 | * Ability to view EPG and manage timers (via HTTP API). 28 | * Simple FTP client (experimental). 29 | [](https://user-images.githubusercontent.com/7511379/141681165-5679c331-72e7-4044-b365-dcdb30b1433c.png) 30 | 31 | **To increase program functionality you can use [extensions](https://github.com/DYefremov/demoneditor-extensions).** 32 | 33 | #### Keyboard shortcuts 34 | * **Ctrl + X** - only in bouquet list. 35 | * **Ctrl + C** - only in services list. 36 | * **Ctrl + Insert** - copies the selected channels from the main list to the bouquet 37 | beginning or inserts (creates) a new bouquet. 38 | * **Ctrl + BackSpace** - copies the selected channels from the main list to the bouquet end. 39 | * **Ctrl + E** - edit. 40 | * **Ctrl + R, F2** - rename. 41 | * **Ctrl + Alt + R** - rename for bouquet. 42 | * **Ctrl + S, T** in Satellites edit tool for create satellite or transponder. 43 | * **Ctrl + L** - parental lock. 44 | * **Ctrl + H** - hide/skip. 45 | * **Space** - select/deselect. 46 | * **Left/Right** - remove selection. 47 | * **Ctrl + Up, Down, PageUp, PageDown, Home, End**- move selected items in the list. 48 | * **Ctrl + O** - (re)load user data from current dir. 49 | * **Ctrl + D** - load data from receiver. 50 | * **Ctrl + U/B** - upload data/bouquets to receiver. 51 | * **Ctrl + I** - extra info, details. 52 | * **Ctrl + F** - show search bar. 53 | * **Ctrl + Shift + F** - show/hide filter bar. 54 | * **Ctrl + T** - show/hide built-in Telnet client. 55 | * **Ctrl + Shift + L** - show/hide logging panel. 56 | * **Shift + P** - start play IPTV or other stream in the bouquet list. 57 | * **Shift + Z** - switch(**zap**) the channel(works when the HTTP API is enabled, Enigma2 only). 58 | * **Shift + W** - switch to the channel and watch in the program. 59 | 60 | For **multiple** selection with the mouse, press and hold the **Ctrl** key! 61 | 62 | ## Minimum requirements 63 | *Python >= 3.6, GTK+ >= 3.22, python3-gi, python3-gi-cairo, python3-requests.* 64 | 65 | ***Optional:** python3-pil, python3-chardet, ffmpeg.* 66 | ## Installation and Launch 67 | * ### Linux 68 | To start the program, in most cases it is enough to download the [archive](https://github.com/DYefremov/DemonEditor/archive/master.zip), unpack 69 | and run it by double clicking on DemonEditor.desktop in the root directory, 70 | or launching from the console with the command:```./start.py``` 71 | Extra folders can be deleted, excluding the *app* folder and root files like *DemonEditor.desktop* and *start.py*! 72 | 73 | To create a simple **debian package**, you can use the *build-deb.sh.* You can also download a ready-made *.deb package from the [releases](https://github.com/DYefremov/DemonEditor/releases) page. 74 | Users of **LTS** versions of [Ubuntu](https://ubuntu.com/) or distributions based on them can use [PPA](https://launchpad.net/~dmitriy-yefremov/+archive/ubuntu/demon-editor) repository. 75 | A ready-made [package](https://aur.archlinux.org/packages/demoneditor-bin) is also available for [Arch Linux](https://archlinux.org/) users in the [AUR](https://aur.archlinux.org/) repository. 76 | * ### macOS 77 | **This program can be run on macOS.** 78 | To run the program on macOS, you need to install [Homebrew](https://brew.sh/). 79 | Then install the required components via terminal: 80 | ```brew install python3 gtk+3 pygobject3 adwaita-icon-theme gtksourceview3``` 81 | 82 | ```pip3 install requests telnetlib-313-and-up --break-system-packages``` 83 | 84 | *Optional:* ```brew install pillow python-chardet ffmpeg``` 85 | 86 | Launch is similar to Linux. 87 | 88 | You can also download the ready-made package as a ***.dmg** file from the [releases](https://github.com/DYefremov/DemonEditor/releases) page. 89 | Recommended copy the package to the **Application** directory. 90 | Perhaps in the security settings it will be necessary to allow the launch of this application! 91 | 92 | * ### MS Windows 93 | **Windows users can also run this program.** 94 | One way is to use the [MSYS2](https://www.msys2.org/) platform. You can use [this](https://github.com/DYefremov/DemonEditor/blob/master/build/BUILD_WIN.md) quick guide. 95 | In addition, you can download a ready-made build (**64-bit**) from the [releases](https://github.com/DYefremov/DemonEditor/releases) page. 96 | 97 | **All builds may contain components distributed under the GPL [v3](http://www.gnu.org/licenses/gpl-3.0.html) or lower license. 98 | By downloading and using this packages you agree to the terms of this [license](http://www.gnu.org/licenses/gpl-3.0.html) and the possible inconvenience associated with this!** 99 | 100 | THIS SOFTWARE COMES WITH ABSOLUTELY NO WARRANTY. 101 | AUTHOR IS NOT LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY CONNECTION WITH THIS SOFTWARE. 102 | 103 | ## Important 104 | Support for DVB-T/T2 and DVB-C channels for Neutrino is not fully implemented and has an experimental status. 105 | 106 | Main supported *lamedb* format is version **4**. Versions **3** and **5** has only **experimental** support! For version **3** is only read mode available. When saving, version **4** format is used instead. 107 | 108 | When using the multiple import feature, from *lamedb* will be taken data **only for channels that are in the selected bouquets!** 109 | If you need full set of the data, including *[satellites, terrestrial, cables].xml* (current files will be overwritten), 110 | just load your data via *"File/Open"* and press *"Save"*. When importing separate bouquet files, only those services 111 | (excluding IPTV) that are in the **current open lamedb** (main list of services) will be imported. 112 | 113 | **The built-in Telnet client does not support ANSI escape sequences!** 114 | 115 | For streams playback, this app supports [VLC](https://www.videolan.org/vlc/), [MPV](https://mpv.io/) and [GStreamer](https://gstreamer.freedesktop.org/). Depending on your distro, you may need to install additional packages and libraries. 116 | #### Command line arguments: 117 | * **-l** - write logs to file. 118 | * **-d on/off** - turn on/off debug mode. Allows to display more information in the logs. 119 | 120 | ## License 121 | Licensed under the [MIT](LICENSE) license. 122 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | title: DemonEditor 3 | description: Enigma2 channel and satellite list editor. 4 | show_downloads: false -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/__init__.py -------------------------------------------------------------------------------- /app/commons.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from functools import wraps 4 | from threading import Thread, Timer 5 | 6 | from gi.repository import GLib 7 | 8 | _LOG_FILE = "demon-editor.log" 9 | LOG_DATE_FORMAT = "%d-%m-%y %H:%M:%S" 10 | LOGGER_NAME = "main_logger" 11 | LOG_FORMAT = "%(asctime)s %(message)s" 12 | 13 | 14 | def init_logger(): 15 | logging.Logger(LOGGER_NAME) 16 | logging.basicConfig(level=logging.INFO, 17 | format=LOG_FORMAT, 18 | datefmt=LOG_DATE_FORMAT, 19 | handlers=[logging.FileHandler(_LOG_FILE), logging.StreamHandler()]) 20 | log("Logging is enabled.", level=logging.INFO) 21 | 22 | 23 | def log(message, level=logging.ERROR, debug=False, fmt_message="{}"): 24 | """ The main logging function. """ 25 | logger = logging.getLogger(LOGGER_NAME) 26 | if debug: 27 | from traceback import format_exc 28 | logger.log(level, fmt_message.format(format_exc())) 29 | else: 30 | logger.log(level, message) 31 | 32 | 33 | def run_idle(func): 34 | """ Runs a function with a lower priority """ 35 | 36 | @wraps(func) 37 | def wrapper(*args, **kwargs): 38 | GLib.idle_add(func, *args, **kwargs) 39 | 40 | return wrapper 41 | 42 | 43 | def run_task(func): 44 | """ Runs function in separate thread """ 45 | 46 | @wraps(func) 47 | def wrapper(*args, **kwargs): 48 | task = Thread(target=func, args=args, kwargs=kwargs, daemon=True) 49 | task.start() 50 | 51 | return wrapper 52 | 53 | 54 | def run_with_delay(timeout=5): 55 | """ Starts the function with a delay. 56 | 57 | If the previous timer still works, it will canceled! 58 | """ 59 | 60 | def run_with(func): 61 | timer = None 62 | 63 | @wraps(func) 64 | def wrapper(*args, **kwargs): 65 | nonlocal timer 66 | if timer and timer.is_alive(): 67 | timer.cancel() 68 | 69 | def run(): 70 | GLib.idle_add(func, *args, **kwargs, priority=GLib.PRIORITY_LOW) 71 | 72 | timer = Timer(interval=timeout, function=run) 73 | timer.start() 74 | 75 | return wrapper 76 | 77 | return run_with 78 | 79 | 80 | def get_size_from_bytes(size): 81 | """ Simple convert function from bytes to other units like K, M or G. """ 82 | try: 83 | b = float(size) 84 | except ValueError: 85 | return size 86 | else: 87 | kb, mb, gb = 1024.0, 1048576.0, 1073741824.0 88 | 89 | if b < kb: 90 | return str(b) 91 | elif kb <= b < mb: 92 | return f"{b / kb:.1f} K" 93 | elif mb <= b < gb: 94 | return f"{b / mb:.1f} M" 95 | elif gb <= b: 96 | return f"{b / gb:.1f} G" 97 | 98 | 99 | class DefaultDict(defaultdict): 100 | """ Extended to support functions with params as default factory. """ 101 | 102 | def __missing__(self, key): 103 | if self.default_factory: 104 | value = self[key] = self.default_factory(key) 105 | return value 106 | return super().__missing__(key) 107 | 108 | def get(self, key, default=None): 109 | return self[key] 110 | 111 | 112 | if __name__ == "__main__": 113 | pass 114 | -------------------------------------------------------------------------------- /app/eparser/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2023 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | from app.commons import run_task 29 | from app.settings import SettingsType 30 | from .ecommons import Service, Satellite, Transponder, Bouquet, Bouquets, is_transponder_valid 31 | from .enigma.blacklist import get_blacklist, write_blacklist 32 | from .enigma.bouquets import BouquetsWriter, BouquetsReader 33 | from .enigma.lamedb import get_services as get_enigma_services, write_services as write_enigma_services 34 | from .iptv import parse_m3u 35 | from .neutrino.bouquets import get_bouquets as get_neutrino_bouquets, write_bouquets as write_neutrino_bouquets 36 | from .neutrino.services import get_services as get_neutrino_services, write_services as write_neutrino_services 37 | from .satxml import get_satellites, write_satellites 38 | 39 | 40 | def get_services(data_path, s_type, format_version): 41 | if s_type is SettingsType.ENIGMA_2: 42 | return get_enigma_services(data_path, format_version) 43 | elif s_type is SettingsType.NEUTRINO_MP: 44 | return get_neutrino_services(data_path) 45 | 46 | 47 | @run_task 48 | def write_services(path, channels, s_type, format_version): 49 | if s_type is SettingsType.ENIGMA_2: 50 | write_enigma_services(path, channels, format_version) 51 | elif s_type is SettingsType.NEUTRINO_MP: 52 | write_neutrino_services(path, channels) 53 | 54 | 55 | def get_bouquets(path, s_type): 56 | if s_type is SettingsType.ENIGMA_2: 57 | return BouquetsReader(path).get() 58 | elif s_type is SettingsType.NEUTRINO_MP: 59 | return get_neutrino_bouquets(path) 60 | 61 | 62 | def write_bouquet(path, bq, s_type): 63 | if s_type is SettingsType.ENIGMA_2: 64 | writer = BouquetsWriter(path, None) 65 | writer.write_bouquet(f"{path}userbouquet.{bq.name}.{bq.type}", bq.name, bq.services) 66 | elif s_type is SettingsType.NEUTRINO_MP: 67 | from .neutrino.bouquets import write_bouquet 68 | write_bouquet(path, bq) 69 | 70 | 71 | def write_bouquets(path, bouquets, s_type, force_bq_names=False, blacklist=None): 72 | if s_type is SettingsType.ENIGMA_2: 73 | BouquetsWriter(path, bouquets, force_bq_names, blacklist).write() 74 | elif s_type is SettingsType.NEUTRINO_MP: 75 | write_neutrino_bouquets(path, bouquets) 76 | 77 | 78 | if __name__ == "__main__": 79 | pass 80 | -------------------------------------------------------------------------------- /app/eparser/ecommons.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2025 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | """ Common elements module. """ 30 | from collections import namedtuple 31 | from enum import Enum 32 | 33 | from app.commons import log 34 | 35 | Service = namedtuple("Service", ["flags_cas", "transponder_type", "coded", "service", "locked", "hide", "package", 36 | "service_type", "picon", "picon_id", "ssid", "freq", "rate", "pol", "fec", 37 | "system", "pos", "data_id", "fav_id", "transponder"]) 38 | 39 | 40 | # ***************** Bouquets *******************# 41 | 42 | class BqServiceType(Enum): 43 | DEFAULT = "DEFAULT" 44 | IPTV = "IPTV" 45 | MARKER = "MARKER" # 64 46 | SPACE = "SPACE" # 832 [hidden marker] 47 | ALT = "ALT" # Service with alternatives 48 | BOUQUET = "BOUQUET" # Sub bouquet. 49 | 50 | @classmethod 51 | def _missing_(cls, value): 52 | return cls.DEFAULT 53 | 54 | 55 | Bouquet = namedtuple("Bouquet", ["name", "type", "services", "locked", "hidden", "file"]) 56 | Bouquet.__new__.__defaults__ = (None, BqServiceType.DEFAULT, [], None, None, None) # For Python3 < 3.7 57 | Bouquets = namedtuple("Bouquets", ["name", "type", "bouquets"]) 58 | BouquetService = namedtuple("BouquetService", ["name", "type", "data", "num"]) 59 | 60 | # *************** *.xml [Satellites, Terrestrial, Cable] ***************** # 61 | 62 | Satellite = namedtuple("Satellite", ["name", "flags", "position", "transponders"]) 63 | Terrestrial = namedtuple("Terrestrial", ["name", "flags", "countrycode", "transponders"]) 64 | Cable = namedtuple("Cable", ["name", "flags", "satfeed", "countrycode", "transponders"]) 65 | 66 | Transponder = namedtuple("Transponder", ["frequency", "symbol_rate", "polarization", "fec_inner", "system", 67 | "modulation", "pls_mode", "pls_code", "is_id", "t2mi_plp_id"]) 68 | TerTransponder = namedtuple("TerTransponder", ["centre_frequency", "system", "bandwidth", "constellation", 69 | "code_rate_hp", "code_rate_lp", "guard_interval", "transmission_mode", 70 | "hierarchy_information", "inversion", "plp_id"]) 71 | CableTransponder = namedtuple("CableTransponder", ["frequency", "symbol_rate", "fec_inner", "modulation"]) 72 | 73 | 74 | class TrType(Enum): 75 | """ Transponders type """ 76 | Satellite = "s" 77 | Terrestrial = "t" 78 | Cable = "c" 79 | ATSC = "a" 80 | 81 | 82 | class BqType(Enum): 83 | """ Bouquet type. """ 84 | BOUQUET = "bouquet" 85 | TV = "tv" 86 | RADIO = "radio" 87 | WEBTV = "webtv" 88 | MARKER = "marker" 89 | 90 | @classmethod 91 | def _missing_(cls, value): 92 | return cls.TV 93 | 94 | 95 | class Flag(Enum): 96 | """ Service flags 97 | 98 | K - last bit (1) 99 | H - second from end (10) 100 | P - third (100) 101 | N - sixth (100000) 102 | """ 103 | KEEP = 1 # Do not automatically update the services parameters. 104 | HIDE = 2 105 | PIDS = 4 # Always use the cached instead of current pids. 106 | LOCK = 8 107 | NEW = 40 # Marked as new at the last scan 108 | 109 | @staticmethod 110 | def is_hide(value: int): 111 | return value & 1 << 1 112 | 113 | @staticmethod 114 | def is_keep(value: int): 115 | return value & 1 << 0 116 | 117 | @staticmethod 118 | def is_pids(value: int): 119 | return value & 1 << 2 120 | 121 | @staticmethod 122 | def is_new(value: int): 123 | return value & 1 << 5 124 | 125 | @staticmethod 126 | def parse(value: str) -> int: 127 | """ Returns an int representation of the flag value. 128 | 129 | The flag value is usually represented by the number [int], 130 | but can also be appear in hex format. 131 | """ 132 | if len(value) < 3: 133 | return 0 134 | 135 | value = value[2:] 136 | if value.isdigit(): 137 | return int(value) 138 | return int(value, 16) 139 | 140 | 141 | class Pids(Enum): 142 | VIDEO = "c:00" 143 | AUDIO = "c:01" 144 | TELETEXT = "c:02" 145 | PCR = "c:03" 146 | AC3 = "c:04" 147 | VIDEO_TYPE = "c:05" 148 | AUDIO_CHANNEL = "c:06" 149 | BIT_STREAM_DELAY = "c:07" # in ms 150 | PCM_DELAY = "c:08" # in ms 151 | SUBTITLE = "c:09" 152 | 153 | 154 | class Inversion(Enum): 155 | Off = "0" 156 | On = "1" 157 | Auto = "2" 158 | 159 | @classmethod 160 | def _missing_(cls, value): 161 | return cls.Auto 162 | 163 | 164 | class Pilot(Enum): 165 | Off = "0" 166 | On = "1" 167 | Auto = "2" 168 | 169 | @classmethod 170 | def _missing_(cls, value): 171 | return cls.Auto 172 | 173 | 174 | class SystemCable(Enum): 175 | """ System of cable service """ 176 | ANNEX_A = "0" 177 | ANNEX_C = "1" 178 | 179 | 180 | ROLL_OFF = {"0": "35%", "1": "25%", "2": "20%", "3": "Auto"} 181 | 182 | POLARIZATION = {"0": "H", "1": "V", "2": "L", "3": "R"} 183 | 184 | PLS_MODE = {"0": "Root", "1": "Gold", "2": "Combo"} 185 | 186 | FEC = {"0": "Auto", "1": "1/2", "2": "2/3", "3": "3/4", "4": "5/6", "5": "7/8", "6": "8/9", "7": "3/5", "8": "4/5", 187 | "9": "9/10", "10": "1/2", "11": "2/3", "12": "3/4", "13": "5/6", "14": "7/8", "15": "8/9", "16": "3/5", 188 | "17": "4/5", "18": "9/10", "19": "1/2", "20": "2/3", "21": "3/4", "22": "5/6", "23": "7/8", "24": "8/9", 189 | "25": "3/5", "26": "4/5", "27": "9/10", "28": "Auto"} 190 | 191 | FEC_DEFAULT = {"0": "Auto", "1": "1/2", "2": "2/3", "3": "3/4", "4": "5/6", "5": "7/8", "6": "8/9", "7": "3/5", 192 | "8": "4/5", "9": "9/10", "10": "6/7", "15": "None"} 193 | 194 | SYSTEM = {"0": "DVB-S", "1": "DVB-S2"} 195 | 196 | MODULATION = {"0": "Auto", "1": "QPSK", "2": "8PSK", "4": "16APSK", "5": "32APSK"} 197 | 198 | SERVICE_TYPE = {"-2": "Data", "1": "TV", "2": "Radio", "3": "Data", "10": "Radio", "22": "TV (H264)", 199 | "25": "TV (HD)", "31": "TV (UHD)"} 200 | 201 | # Terrestrial 202 | BANDWIDTH = {"0": "8MHz", "1": "7MHz", "2": "6MHz", "3": "Auto", "4": "5MHz", "5": "1/712MHz", "6": "10MHz"} 203 | 204 | CONSTELLATION = {"0": "QPSK", "1": "16-QAM", "2": "64-QAM", "3": "Auto"} 205 | 206 | T_MODULATION = {"0": "QPSK", "1": "QAM16", "2": "QAM64", "3": "Auto", "4": "QAM256"} 207 | 208 | TRANSMISSION_MODE = {"0": "2k", "1": "8k", "2": "Auto", "3": "4k", "4": "1k", "5": "16k", "6": "32k"} 209 | 210 | GUARD_INTERVAL = {"0": "1/32", "1": "1/16", "2": "1/8", "3": "1/4", "4": "Auto", "5": "1/128", "6": "19/128", 211 | "7": "19/256"} 212 | 213 | HIERARCHY = {"0": "None", "1": "1", "2": "2", "3": "4", "4": "Auto"} 214 | 215 | T_FEC = {"0": "1/2", "1": "2/3", "2": "3/4", "3": "5/6", "4": "7/8", "5": "Auto", "6": "6/7", "7": "8/9"} 216 | 217 | T_SYSTEM = {"0": "DVB-T", "1": "DVB-T2", "-1": "DVB-T/T2"} 218 | 219 | # Cable 220 | C_MODULATION = {"0": "QPSK", "1": "QAM16", "2": "QAM32", "3": "QAM64", "4": "QAM128", "5": "QAM256", "6": "Auto"} 221 | 222 | # ATSC 223 | A_MODULATION = {"0": "Auto", "1": "QAM16", "2": "QAM32", "3": "QAM64", "4": "QAM128", "5": "QAM256", "6": "8VSB", 224 | "7": "16VSB"} 225 | 226 | # CAS 227 | CAS = {"C:26": "BISS", "C:0B": "Conax", "C:06": "Irdeto", "C:18": "Nagravision", "C:05": "Viaccess", "C:01": "SECA", 228 | "C:0E": "PowerVu", "C:4A": "DRE-Crypt", "C:7B": "DRE-Crypt", "C:56": "Verimatrix", "C:09": "VideoGuard", 229 | "C:4AFC": "Panaccess"} 230 | 231 | # 'on' attribute 0070(hex) = 112(int) = ONID(ONID-TID on www.lyngsat.com) 232 | PROVIDER = {112: "HTB+", 253: "Tricolor TV"} 233 | 234 | 235 | # ************* subsidiary functions **************** 236 | 237 | def get_key_by_value(dc: dict, value): 238 | """ Returns key from dict by value """ 239 | for k, v in dc.items(): 240 | if v == value: 241 | return k 242 | 243 | 244 | def get_value_by_name(en, name): 245 | """ Returns value by name from enums """ 246 | for n in en: 247 | if n.name == name: 248 | return n.value 249 | 250 | 251 | def is_transponder_valid(tr: Transponder): 252 | """ Checks transponder validity. """ 253 | try: 254 | int(tr.frequency) 255 | int(tr.symbol_rate) 256 | tr.pls_mode is None or int(tr.pls_mode) 257 | tr.pls_code is None or int(tr.pls_code) 258 | tr.is_id is None or int(tr.is_id) 259 | tr.t2mi_plp_id is None or int(tr.t2mi_plp_id) 260 | except (TypeError, ValueError) as e: 261 | log(f"Transponder validation error: {e}\n{tr}") 262 | return False 263 | 264 | if tr.polarization not in POLARIZATION: 265 | return False 266 | if tr.fec_inner not in FEC: 267 | return False 268 | if tr.system not in SYSTEM: 269 | return False 270 | if tr.modulation not in MODULATION: 271 | return False 272 | 273 | return True 274 | -------------------------------------------------------------------------------- /app/eparser/enigma/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/eparser/enigma/__init__.py -------------------------------------------------------------------------------- /app/eparser/enigma/blacklist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2021 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | """ This module used for parsing blacklist file 30 | 31 | Parent Lock/Unlock 32 | """ 33 | from contextlib import suppress 34 | 35 | __FILE_NAME = "blacklist" 36 | 37 | 38 | def get_blacklist(path): 39 | with suppress(FileNotFoundError): 40 | with open(path + __FILE_NAME, "r", encoding="utf-8") as file: 41 | # filter empty values and "\n" 42 | return {*list(filter(None, (x.strip() for x in file.readlines())))} 43 | return {} 44 | 45 | 46 | def write_blacklist(path, channels): 47 | with open(path + __FILE_NAME, "w", encoding="utf-8") as file: 48 | if channels: 49 | file.writelines("\n".join(channels)) 50 | 51 | 52 | if __name__ == "__main__": 53 | pass 54 | -------------------------------------------------------------------------------- /app/eparser/enigma/streamrelay.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2024 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | """ Additional module to use stream relay functionality. 30 | 31 | Reads/Writes 'whitelist_streamrelay' file. 32 | """ 33 | import os.path 34 | from contextlib import suppress 35 | 36 | from app.commons import log 37 | 38 | _FILE_NAME = "whitelist_streamrelay" 39 | 40 | 41 | class StreamRelay(dict): 42 | """ Class to hold/process service references used by a stream relay. """ 43 | 44 | def refresh(self, path): 45 | self.clear() 46 | f_path = f"{path}{_FILE_NAME}" 47 | if os.path.isfile(f_path): 48 | log("Updating stream relay cache...") 49 | with suppress(FileNotFoundError): 50 | with open(f"{path}{_FILE_NAME}", "r", encoding="utf-8") as file: 51 | refs = filter(None, (x.rstrip("\n") for x in file.readlines())) 52 | self.update(self.get_ref_data(ref) for ref in refs) 53 | 54 | def get_ref_data(self, ref): 55 | """ Returns tuple from FAV ID and ref or ref and None for comments. """ 56 | data = ref.split(":") 57 | if len(data) == 11: 58 | if "http" in data[-1]: 59 | return ref.replace("%3a", "%3A"), ref 60 | return f"{data[3]}:{data[4]}:{data[5]}:{data[6]}", ref 61 | return ref, None 62 | 63 | def save(self, path): 64 | """ Saves current refs to a file. 65 | 66 | If no refs is present, delites current relay file. 67 | """ 68 | f_name = f"{path}{_FILE_NAME}" 69 | if len(self): 70 | with open(f_name, "w", encoding="utf-8") as file: 71 | file.writelines([f"{v if v else k}\n\n" for k, v in self.items()]) 72 | else: 73 | if os.path.exists(f_name): 74 | os.remove(f_name) 75 | 76 | 77 | if __name__ == "__main__": 78 | pass 79 | -------------------------------------------------------------------------------- /app/eparser/iptv.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2025 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | """ Module for IPTV and streams support """ 30 | import re 31 | from enum import Enum 32 | from urllib.parse import unquote, quote 33 | 34 | from app.commons import log 35 | from app.eparser.ecommons import BqServiceType, Service 36 | from app.settings import SettingsType 37 | from app.ui.uicommons import IPTV_ICON 38 | 39 | # url, description, urlkey, account, usrname, psw, s_type, iconsrc, iconsrc_b, group 40 | NEUTRINO_FAV_ID_FORMAT = "{}::{}::{}::{}::{}::{}::{}::{}::{}::{}" 41 | ENIGMA2_FAV_ID_FORMAT = " {}:{}:{:X}:{:X}:{:X}:{:X}:{:X}:0:0:0:{}:{}\n#DESCRIPTION {}\n" 42 | MARKER_FORMAT = " 1:64:{}:0:0:0:0:0:0:0::{}\n#DESCRIPTION {}\n" 43 | PICON_FORMAT = "{}_{}_{:X}_{:X}_{:X}_{:X}_{:X}_0_0_0.png" 44 | 45 | ENCODING_BLACKLIST = {"MacRoman"} 46 | 47 | 48 | class StreamType(Enum): 49 | DVB_TS = "1" 50 | NONE_TS = "4097" 51 | NONE_REC_1 = "5001" 52 | NONE_REC_2 = "5002" 53 | E_SERVICE_URI = "8193" 54 | E_SERVICE_HLS = "8739" 55 | UNKNOWN = "0" 56 | 57 | @classmethod 58 | def _missing_(cls, value): 59 | return cls.UNKNOWN 60 | 61 | 62 | def parse_m3u(path, s_type, detect_encoding=True, params=None): 63 | """ Parses *m3u* file and returns tuple with EPG src URLs and services list. """ 64 | pattern = re.compile(r'(\S+)="(.*?)"') 65 | 66 | with open(path, "rb") as file: 67 | data = file.read() 68 | encoding = "utf-8" 69 | 70 | if detect_encoding: 71 | try: 72 | import chardet 73 | except ModuleNotFoundError: 74 | pass 75 | else: 76 | enc = chardet.detect(data) 77 | encoding = enc.get("encoding", "utf-8") 78 | encoding = "utf-8" if encoding in ENCODING_BLACKLIST else encoding 79 | 80 | aggr = [None] * 10 81 | s_aggr = aggr[: -3] 82 | epg_src = None 83 | group = None 84 | groups = set() 85 | services = [] 86 | marker_counter = 1 87 | sid_counter = 1 88 | name = None 89 | picon = None 90 | p_id = "1_0_1_0_0_0_0_0_0_0.png" 91 | st = BqServiceType.IPTV.name 92 | params = params or [0, 0, 0, 0] 93 | m_name = BqServiceType.MARKER.name 94 | 95 | for line in str(data, encoding=encoding, errors="ignore").splitlines(): 96 | if line.startswith("#EXTM3U"): 97 | data = dict(pattern.findall(line)) 98 | epg_src = data.get("x-tvg-url", data.get("url-tvg", None)) 99 | epg_src = epg_src.split(",") if epg_src else None 100 | if line.startswith("#EXTINF"): 101 | line, sep, name = line.rpartition(",") 102 | data = dict(pattern.findall(line)) 103 | name = data.get("tvg-name", name) 104 | picon = data.get("tvg-logo", None) 105 | epg_id = data.get("tvg-id", None) 106 | 107 | if s_type is SettingsType.ENIGMA_2: 108 | group = data.get("group-title", None) 109 | elif line.startswith("#EXTGRP") and s_type is SettingsType.ENIGMA_2: 110 | group = line.strip("#EXTGRP:").strip() 111 | elif not line.startswith("#") and "://" in line: 112 | url = line.strip() 113 | params[0] = sid_counter 114 | sid_counter += 1 115 | fav_id = get_fav_id(url, name, s_type, params) 116 | 117 | if s_type is SettingsType.ENIGMA_2: 118 | p_id = get_picon_id(params) 119 | if group not in groups: 120 | # Some playlists have "random" of group names. 121 | # We will take only the first one we found on the list! 122 | groups.add(group) 123 | m_id = MARKER_FORMAT.format(marker_counter, group, group) 124 | marker_counter += 1 125 | services.append(Service(None, None, None, group, *aggr[0:3], m_name, *aggr, m_id, None)) 126 | 127 | if all((name, url, fav_id)): 128 | services.append(Service(epg_id, None, IPTV_ICON, name, *aggr[0:2], group, 129 | st, picon, p_id, *s_aggr, url, fav_id, None)) 130 | else: 131 | log(f"*.m3u* parse error ['{path}']: name[{name}], url[{url}], fav id[{fav_id}]") 132 | 133 | return epg_src, services 134 | 135 | 136 | def export_to_m3u(path, bouquet, s_type, url=None): 137 | pattern = re.compile(".*:(http.*).*") if s_type is SettingsType.ENIGMA_2 else re.compile("(http.*?)::::.*") 138 | lines = ["#EXTM3U\n"] 139 | current_grp = None 140 | 141 | for s in bouquet.services: 142 | srv_type = s.type 143 | if srv_type is BqServiceType.IPTV: 144 | res = re.match(pattern, s.data) 145 | if not res: 146 | continue 147 | lines.append(f"#EXTINF:-1,{s.name}\n") 148 | lines.append(current_grp) if current_grp else None 149 | u = res.group(1) 150 | if s_type is SettingsType.ENIGMA_2: 151 | index = u.rfind(":") 152 | lines.append(f"{unquote(u[:index] if index > 0 else u)}\n") 153 | else: 154 | lines.append(f"{u}\n") 155 | elif srv_type is BqServiceType.MARKER: 156 | current_grp = f"#EXTGRP:{s.name}\n" 157 | elif srv_type is BqServiceType.DEFAULT and url: 158 | lines.append(f"#EXTINF:-1,{s.name}\n") 159 | lines.append(current_grp) if current_grp else None 160 | lines.append(f"{url}{s.data}\n") 161 | 162 | with open(f"{path}{bouquet.name}.m3u", "w", encoding="utf-8") as file: 163 | file.writelines(lines) 164 | 165 | 166 | def get_fav_id(url, name, settings_type, params=None, st_type=None, s_id=0, srv_type=1, force_quote=True): 167 | """ Returns fav id depending on the settings type. """ 168 | if settings_type is SettingsType.ENIGMA_2: 169 | st_type = st_type or StreamType.NONE_TS.value 170 | params = params or (0, 0, 0, 0) 171 | url = quote(url) if force_quote else url 172 | return ENIGMA2_FAV_ID_FORMAT.format(st_type, s_id, srv_type, *params, url, name, name, None) 173 | elif settings_type is SettingsType.NEUTRINO_MP: 174 | return NEUTRINO_FAV_ID_FORMAT.format(url, "", 0, None, None, None, None, "", "", 1) 175 | 176 | 177 | def get_picon_id(params=None, st_type=None, s_id=0, srv_type=1): 178 | st_type = st_type or StreamType.NONE_TS.value 179 | params = params or (0, 0, 0, 0) 180 | return PICON_FORMAT.format(st_type, s_id, srv_type, *params) 181 | 182 | 183 | if __name__ == "__main__": 184 | pass 185 | -------------------------------------------------------------------------------- /app/eparser/neutrino/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2021 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | SP = "_:::_" 29 | KSP = "_::_" 30 | API_VER = "4" 31 | 32 | 33 | def get_attributes(data): 34 | return {el[0]: el[1] for el in (e.split(KSP) for e in data.split(SP))} 35 | 36 | 37 | def get_xml_attributes(attr): 38 | attrs = attr.attributes 39 | return {t: attrs[t].value for t in attrs.keys()} 40 | -------------------------------------------------------------------------------- /app/eparser/neutrino/bouquets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2024 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | import os 30 | 31 | from app.commons import log 32 | from app.eparser.iptv import NEUTRINO_FAV_ID_FORMAT 33 | from app.eparser.neutrino import KSP, SP, get_xml_attributes, get_attributes, API_VER 34 | from app.eparser.neutrino.nxml import XmlHandler, NeutrinoDocument 35 | from ..ecommons import Bouquets, Bouquet, BouquetService, BqServiceType, PROVIDER, BqType 36 | 37 | _FILE = "bouquets.xml" 38 | _U_FILE = "ubouquets.xml" 39 | _W_FILE = "webtv_usr.xml" 40 | _WEB_TV_NAME = "[Web TV]" 41 | 42 | _COMMENT = " File was created in DemonEditor. Enjoy watching! " 43 | 44 | 45 | def get_bouquets(path): 46 | return (parse_bouquets(path + _FILE, "Providers", BqType.BOUQUET.value), 47 | parse_bouquets(path + _U_FILE, "FAV", BqType.TV.value), 48 | parse_webtv(path + _W_FILE, "WEBTV", BqType.WEBTV.value)) 49 | 50 | 51 | def parse_bouquets(file, name, bq_type): 52 | bouquets = Bouquets(name=name, type=bq_type, bouquets=[]) 53 | if not os.path.exists(file): 54 | return bouquets 55 | 56 | dom = XmlHandler.parse(file) 57 | 58 | for elem in dom.getElementsByTagName("Bouquet"): 59 | if elem.hasAttributes(): 60 | bq_attrs = get_xml_attributes(elem) 61 | bq_name = bq_attrs.get("name", "") 62 | hidden = bq_attrs.get("hidden", "0") 63 | locked = bq_attrs.get("locked", "0") 64 | services = [] 65 | 66 | for srv_elem in elem.getElementsByTagName("S"): 67 | if srv_elem.hasAttributes(): 68 | s_attrs = get_xml_attributes(srv_elem) 69 | if "i" in s_attrs: 70 | ssid = s_attrs.get("i", "0") 71 | on = s_attrs.get("on", "0") 72 | tr_id = s_attrs.get("t", "0") 73 | fav_id = f"{tr_id}:{on}:{ssid}" 74 | services.append(BouquetService(None, BqServiceType.DEFAULT, fav_id, 0)) 75 | elif "u" in s_attrs: 76 | services.append(get_webtv_service(s_attrs)) 77 | else: 78 | log(f"Parse bouquets [Neutrino] error: Unknown service type. -> {s_attrs}") 79 | 80 | bouquets[2].append(Bouquet(name=bq_name, 81 | type=bq_type, 82 | services=services, 83 | locked=locked == "1", 84 | hidden=hidden == "1", 85 | file=SP.join(f"{k}{KSP}{v}" for k, v in bq_attrs.items()))) 86 | 87 | if BqType(bq_type) is BqType.BOUQUET: 88 | for bq in bouquets.bouquets: 89 | if bq.services: 90 | key = int(bq.services[0].data.split(":")[1], 16) 91 | if key not in PROVIDER: 92 | pos, sep, name = bq.name.partition("]") 93 | PROVIDER[key] = name 94 | 95 | return bouquets 96 | 97 | 98 | def parse_webtv(path, name, bq_type): 99 | bouquets = Bouquets(name=name, type=bq_type, bouquets=[]) 100 | if not os.path.exists(path): 101 | return bouquets 102 | 103 | dom = XmlHandler.parse(path) 104 | # Display name. 105 | name = None 106 | for e in dom.childNodes: 107 | if e.nodeType == e.ELEMENT_NODE: 108 | name = e.getAttribute("name") 109 | break 110 | 111 | services = [] 112 | for elem in dom.getElementsByTagName("webtv"): 113 | if elem.hasAttributes(): 114 | web_attrs = get_xml_attributes(elem) 115 | services.append(get_webtv_service(web_attrs)) 116 | 117 | bouquet = Bouquet(name=name or _WEB_TV_NAME, type=bq_type, services=services, locked=None, hidden=None, file=None) 118 | bouquets[2].append(bouquet) 119 | 120 | return bouquets 121 | 122 | 123 | def get_webtv_service(web_attrs): 124 | title = web_attrs.get("title", web_attrs.get("n", "")) 125 | fav_id = NEUTRINO_FAV_ID_FORMAT.format(web_attrs.get("url", web_attrs.get("u", )), 126 | web_attrs.get("description", ""), 127 | web_attrs.get("urlkey", None), 128 | web_attrs.get("account", None), 129 | web_attrs.get("usrname", None), 130 | web_attrs.get("psw", None), 131 | web_attrs.get("type", None), 132 | web_attrs.get("iconsrc", None), 133 | web_attrs.get("iconsrc_b", None), 134 | web_attrs.get("group", None)) 135 | return BouquetService(name=title, type=BqServiceType.IPTV, data=fav_id, num=0) 136 | 137 | 138 | def write_bouquets(path, bouquets): 139 | for bq in bouquets: 140 | bq_type = BqType(bq.type) 141 | if bq_type is BqType.WEBTV: 142 | write_webtv(path + _W_FILE, bq) 143 | else: 144 | write_bouquet(path + (_FILE if bq_type is BqType.BOUQUET else _U_FILE), bq) 145 | 146 | 147 | def write_bouquet(file, bouquet): 148 | doc = NeutrinoDocument() 149 | root = doc.createElement("zapit") 150 | root.setAttribute("api", API_VER) 151 | doc.appendChild(root) 152 | comment = doc.createComment(_COMMENT) 153 | doc.appendChild(comment) 154 | 155 | for bq in bouquet.bouquets: 156 | attrs = get_attributes(bq.file) if bq.file else {} 157 | attrs["name"] = bq.name 158 | if bq.hidden: 159 | attrs["hidden"] = "1" 160 | else: 161 | attrs.pop("hidden", None) 162 | if bq.locked: 163 | attrs["locked"] = "1" 164 | else: 165 | attrs.pop("locked", None) 166 | 167 | bq_elem = doc.createElement("Bouquet") 168 | for k, v in attrs.items(): 169 | bq_elem.setAttribute(k, v) 170 | 171 | root.appendChild(bq_elem) 172 | 173 | for srv in bq.services: 174 | srv_elem = doc.createElement("S") 175 | srv_elem.setAttribute("n", srv.service) 176 | s_type = BqServiceType(srv.service_type) 177 | 178 | if s_type is BqServiceType.DEFAULT: 179 | tr_id, on, ssid = srv.fav_id.split(":") 180 | srv_elem.setAttribute("i", ssid) 181 | srv_elem.setAttribute("t", tr_id) 182 | srv_elem.setAttribute("on", on) 183 | srv_elem.setAttribute("frq", srv.freq) 184 | srv_elem.setAttribute("s", get_attributes(srv.flags_cas).get("position", "0")) 185 | elif s_type is BqServiceType.IPTV: 186 | s_data = srv.fav_id.split("::") 187 | if s_data: 188 | srv_elem.setAttribute("n", srv.service) 189 | srv_elem.setAttribute("u", s_data[0]) 190 | else: 191 | log(f"Write bouquet [Neutrino] error: Unsupported service type. -> {s_type.value}") 192 | 193 | bq_elem.appendChild(srv_elem) 194 | 195 | doc.write_xml(file) 196 | 197 | 198 | def write_webtv(file, bouquet): 199 | doc = NeutrinoDocument() 200 | root = doc.createElement("webtvs") 201 | doc.appendChild(root) 202 | comment = doc.createComment(_COMMENT) 203 | doc.appendChild(comment) 204 | 205 | for bq in bouquet.bouquets: 206 | root.setAttribute("name", bq.name or _WEB_TV_NAME) 207 | for srv in bq.services: 208 | url, description, urlkey, account, usrname, psw, s_type, iconsrc, iconsrc_b, group = srv.fav_id.split("::") 209 | srv_elem = doc.createElement("webtv") 210 | srv_elem.setAttribute("title", srv.service) 211 | srv_elem.setAttribute("url", url) 212 | 213 | if description != "None": 214 | srv_elem.setAttribute("description", description) 215 | if urlkey != "None": 216 | srv_elem.setAttribute("urlkey", urlkey) 217 | if account != "None": 218 | srv_elem.setAttribute("account", account) 219 | if usrname != "None": 220 | srv_elem.setAttribute("usrname", usrname) 221 | if psw != "None": 222 | srv_elem.setAttribute("psw", psw) 223 | if s_type != "None": 224 | srv_elem.setAttribute("type", s_type) 225 | if iconsrc != "None": 226 | srv_elem.setAttribute("iconsrc", iconsrc) 227 | if iconsrc_b != "None": 228 | srv_elem.setAttribute("iconsrc_b", iconsrc_b) 229 | if group != "None": 230 | srv_elem.setAttribute("group", group) 231 | 232 | root.appendChild(srv_elem) 233 | 234 | doc.write_xml(file) 235 | 236 | 237 | if __name__ == "__main__": 238 | pass 239 | -------------------------------------------------------------------------------- /app/eparser/neutrino/nxml.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2021 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | """ Additional module for working with Neutrino xml files. """ 30 | import re 31 | from xml.dom.minidom import parseString, Document, Element, Node 32 | from xml.parsers.expat import ExpatError 33 | 34 | from app.commons import log 35 | 36 | 37 | class XmlHandler: 38 | """ Utility class for handling Neutrino xml files. """ 39 | __slots__ = () 40 | 41 | ERROR_MESSAGE = "The file [{}] is not formatted correctly or contains invalid characters! Cause: {}" 42 | 43 | @staticmethod 44 | def parse(path): 45 | """ Parses a file into the DOM by filename. """ 46 | try: 47 | return parseString(open(path, "r", encoding="utf-8", errors="ignore").read()) 48 | except ExpatError as e: 49 | # Some neutrino configuration files may contain text data with invalid character ['&']. 50 | # https://www.w3.org/TR/xml/#syntax 51 | # Apparently there is an error in Neutrino itself and the document is not initially formed correctly. 52 | log(XmlHandler.ERROR_MESSAGE.format(path, e)) 53 | 54 | return XmlHandler.preprocess(path) 55 | 56 | @staticmethod 57 | def preprocess(path): 58 | """ Pre-processing xml [for '&' symbol] for correct parsing. """ 59 | with open(path, "r", encoding="utf-8", errors="ignore") as f: 60 | pat = re.compile("&([^;\\W]*([^;\\w]|$))") 61 | log("Processing the file '{}'...".format(path)) 62 | try: 63 | dom = parseString(re.sub(pat, "&", f.read())) 64 | except ExpatError as e: 65 | msg = XmlHandler.ERROR_MESSAGE.format(path, e) 66 | log(msg) 67 | raise ValueError(e) 68 | else: 69 | log("Done!") 70 | return dom 71 | 72 | 73 | class NeutrinoDocument(Document): 74 | 75 | def createElement(self, tag_name): 76 | e = NElement(tag_name) 77 | e.ownerDocument = self 78 | return e 79 | 80 | def write_xml(self, path): 81 | self.writexml(open(path, "w", encoding="utf-8"), addindent=" ", newl="\n", encoding="UTF-8") 82 | 83 | 84 | class NElement(Element): 85 | 86 | def writexml(self, writer, indent="", add_indent="", new_line=""): 87 | """ Overridden specifically for neutrino for more correct [' -> optional] xml attrs generation. """ 88 | writer.write(indent + "<" + self.tagName) 89 | attrs = self._get_attributes() 90 | 91 | for a_name in attrs.keys(): 92 | writer.write(" %s=\"" % a_name) 93 | self.write_data(writer, attrs[a_name].value) 94 | writer.write("\"") 95 | if self.childNodes: 96 | writer.write(">") 97 | if len(self.childNodes) == 1 and self.childNodes[0].nodeType in (Node.TEXT_NODE, Node.CDATA_SECTION_NODE): 98 | self.childNodes[0].writexml(writer, '', '', '') 99 | else: 100 | writer.write(new_line) 101 | for node in self.childNodes: 102 | node.writexml(writer, indent + add_indent, add_indent, new_line) 103 | writer.write(indent) 104 | writer.write("%s" % (self.tagName, new_line)) 105 | else: 106 | writer.write("/>%s" % new_line) 107 | 108 | @staticmethod 109 | def write_data(writer, data): 110 | """ Writes data chars to writer.""" 111 | if data: 112 | data = data.replace("&", "&").replace("<", "<").replace("\"", """).replace(">", ">") 113 | data = data.replace("'", "'") 114 | writer.write(data) 115 | -------------------------------------------------------------------------------- /app/eparser/neutrino/services.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2021 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | from collections import defaultdict 30 | 31 | from app.commons import log 32 | from app.eparser.ecommons import (Service, POLARIZATION, FEC, SYSTEM, SERVICE_TYPE, PROVIDER, T_SYSTEM, TrType, 33 | SystemCable) 34 | from app.eparser.neutrino import get_xml_attributes, SP, KSP, get_attributes, API_VER 35 | from app.eparser.neutrino.nxml import XmlHandler, NeutrinoDocument 36 | 37 | _FILE = "services.xml" 38 | 39 | 40 | def write_services(path, services): 41 | NeutrinoServiceWriter(path, services).write() 42 | 43 | 44 | def get_services(path): 45 | return NeutrinoServicesReader(path).get_services() 46 | 47 | 48 | class NeutrinoServiceWriter: 49 | 50 | def __init__(self, path, services): 51 | self._path = path + _FILE 52 | self._services = services 53 | 54 | self._api = API_VER 55 | self._doc = NeutrinoDocument() 56 | self._root = self._doc.createElement("zapit") 57 | self._root.setAttribute("api", self._api) 58 | self._doc.appendChild(self._root) 59 | self._doc.appendChild(self._doc.createComment(" File was created in DemonEditor. Enjoy watching! ")) 60 | 61 | def write(self): 62 | srvs = defaultdict(list) 63 | for s in self._services: 64 | srvs[s.transponder_type].append(s) 65 | self.append_services(srvs.get(TrType.Satellite.value), "sat") 66 | self.append_services(srvs.get(TrType.Terrestrial.value), "terrestrial") 67 | self.append_services(srvs.get(TrType.Cable.value), "cable") 68 | 69 | self._doc.write_xml(self._path) 70 | self._doc.unlink() 71 | 72 | def append_services(self, services, s_type): 73 | if not services: 74 | return 75 | 76 | sats = defaultdict(list) 77 | for srv in services: 78 | sats[srv[0]].append(srv) 79 | 80 | for sat in sats: 81 | sat_elem = self._doc.createElement(s_type) 82 | attrs = get_attributes(sat) 83 | for k, v in attrs.items(): 84 | sat_elem.setAttribute(k, v) 85 | 86 | self._root.appendChild(sat_elem) 87 | 88 | transponders = defaultdict(list) 89 | for srv in sats.get(sat): 90 | transponders[srv[-1]].append(srv) 91 | 92 | for tr in transponders: 93 | tr_elem = self._doc.createElement("TS") 94 | for k, v in get_attributes(tr).items(): 95 | tr_elem.setAttribute(k, v) 96 | sat_elem.appendChild(tr_elem) 97 | 98 | for srv in transponders.get(tr): 99 | srv_elem = self._doc.createElement("S") 100 | s_attrs = get_attributes(srv.data_id) 101 | api = s_attrs.pop("api", self._api) 102 | if api != self._api: 103 | self._root.setAttribute("api", api) 104 | 105 | for k, v in s_attrs.items(): 106 | srv_elem.setAttribute(k, v) 107 | 108 | tr_elem.appendChild(srv_elem) 109 | 110 | 111 | class NeutrinoServicesReader: 112 | 113 | def __init__(self, path): 114 | self._path = path + _FILE 115 | self._attrs = None 116 | self._tr = None 117 | self._api = "4" 118 | self._services = [] 119 | 120 | def get_services(self): 121 | dom = XmlHandler.parse(self._path) 122 | 123 | for root in dom.getElementsByTagName("zapit"): 124 | if root.hasAttributes(): 125 | api = root.attributes["api"] 126 | self._api = api.value if api else self._api 127 | 128 | for elem in root.getElementsByTagName("sat"): 129 | if elem.hasAttributes(): 130 | sat_attrs = get_xml_attributes(elem) 131 | sat_pos = 0 132 | try: 133 | sat_pos = int(sat_attrs.get("position", "0")) 134 | sat_pos = "{:0.1f}{}".format(abs(sat_pos / 10), "W" if sat_pos < 0 else "E") 135 | except ValueError as e: 136 | log("Neutrino parsing error [parse sat position]: {}".format(e)) 137 | sat = SP.join("{}{}{}".format(k, KSP, v) for k, v in sat_attrs.items()) 138 | for tr_elem in elem.getElementsByTagName("TS"): 139 | if tr_elem.hasAttributes(): 140 | self.parse_sat_transponder(sat, sat_pos, tr_elem) 141 | 142 | # Terrestrial DVB-T[2]. 143 | for elem in root.getElementsByTagName("terrestrial"): 144 | if elem.hasAttributes(): 145 | terr_attrs = get_xml_attributes(elem) 146 | terr = SP.join("{}{}{}".format(k, KSP, v) for k, v in terr_attrs.items()) 147 | 148 | for tr_elem in elem.getElementsByTagName("TS"): 149 | if tr_elem.hasAttributes(): 150 | self.parse_ct_transponder(terr, tr_elem, TrType.Terrestrial) 151 | 152 | # Cable. 153 | for elem in root.getElementsByTagName("cable"): 154 | if elem.hasAttributes(): 155 | cable_attrs = get_xml_attributes(elem) 156 | cable = SP.join("{}{}{}".format(k, KSP, v) for k, v in cable_attrs.items()) 157 | 158 | for tr_elem in elem.getElementsByTagName("TS"): 159 | if tr_elem.hasAttributes(): 160 | self.parse_ct_transponder(cable, tr_elem, TrType.Cable) 161 | 162 | return self._services 163 | 164 | def parse_sat_transponder(self, sat, sat_pos, tr_elem): 165 | tr_attr = get_xml_attributes(tr_elem) 166 | tr = SP.join("{}{}{}".format(k, KSP, v) for k, v in tr_attr.items()) 167 | tr_id = tr_attr.get("id", "0").lstrip("0") 168 | on = tr_attr.get("on", "0") 169 | freq = tr_attr.get("frq", "0") 170 | rate = tr_attr.get("sr", "0") 171 | fec = tr_attr.get("fec", "0") 172 | 173 | pol = POLARIZATION.get(tr_attr.get("pol", "0")) 174 | # Formatting displayed values. 175 | try: 176 | freq = "{}".format(int(freq) // 1000) 177 | rate = "{}".format(int(rate) // 1000) 178 | except ValueError as e: 179 | log("Neutrino parsing error [parse_transponder]: {}".format(e)) 180 | 181 | for srv_elem in tr_elem.getElementsByTagName("S"): 182 | if srv_elem.hasAttributes(): 183 | at = get_xml_attributes(srv_elem) 184 | at["api"] = self._api 185 | ssid, name, s_type, sys = at.get("i", "0"), at.get("n", ""), at.get("t", "3"), at.get("s", "0") 186 | data_id = SP.join("{}{}{}".format(k, KSP, v) for k, v in at.items()) 187 | fav_id = "{}:{}:{}".format(tr_id, on.lstrip("0"), ssid.lstrip("0")) 188 | picon_id = "{}{}{}.png".format(tr_id, on, ssid) 189 | prv = PROVIDER.get(int(on, 16), "") 190 | st = SERVICE_TYPE.get(str(int(s_type, 16)), SERVICE_TYPE.get("-2")) 191 | 192 | srv = Service(sat, TrType.Satellite.value, None, name, None, None, prv, st, None, picon_id, ssid, freq, 193 | rate, pol, FEC.get(fec), SYSTEM.get(sys), sat_pos, data_id, fav_id, tr) 194 | self._services.append(srv) 195 | 196 | def parse_ct_transponder(self, terr, tr_elem, tr_type): 197 | attrs = get_xml_attributes(tr_elem) 198 | tr = SP.join("{}{}{}".format(k, KSP, v) for k, v in attrs.items()) 199 | tr_id, on, freq = attrs.get("id", "0").lstrip("0"), attrs.get("on", "0"), attrs.get("frq", "0") 200 | 201 | for srv_elem in tr_elem.getElementsByTagName("S"): 202 | if srv_elem.hasAttributes(): 203 | s_at = get_xml_attributes(srv_elem) 204 | s_at["api"] = self._api 205 | ssid, name, s_type, sys = s_at.get("i", "0"), s_at.get("n", ""), s_at.get("t", "3"), s_at.get("s", "0") 206 | data_id = SP.join("{}{}{}".format(k, KSP, v) for k, v in s_at.items()) 207 | fav_id = "{}:{}:{}".format(tr_id, on.lstrip("0"), ssid.lstrip("0")) 208 | picon_id = "{}{}{}.png".format(tr_id, on, ssid) 209 | prv = PROVIDER.get(int(on, 16), "") 210 | st = SERVICE_TYPE.get(str(int(s_type, 16)), SERVICE_TYPE.get("-2")) 211 | 212 | if tr_type is TrType.Terrestrial: 213 | sys = T_SYSTEM.get(sys) 214 | pos = "T" 215 | elif tr_type is TrType.Cable: 216 | sys = SystemCable(sys).name 217 | pos = "C" 218 | else: 219 | log("Parse transponder error: Not supported type [{}]".format(tr_type)) 220 | break 221 | 222 | srv = Service(terr, tr_type.value, None, name, None, None, prv, st, None, picon_id, ssid, 223 | freq, "0", None, None, sys, pos, data_id, fav_id, tr) 224 | self._services.append(srv) 225 | 226 | 227 | if __name__ == "__main__": 228 | pass 229 | -------------------------------------------------------------------------------- /app/eparser/satxml.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2023 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | """ Module for working with *.xml files. 30 | 31 | For more info see comments. 32 | """ 33 | import xml.etree.ElementTree as ETree 34 | 35 | from .ecommons import Satellite, Terrestrial, Cable, Transponder, TerTransponder, CableTransponder 36 | 37 | _SAT_COMMENT = ("\tFile was created in DemonEditor.\n\n" 38 | "Usable flags are:\n" 39 | " 1: Network Scan\n" 40 | " 2: use BAT\n" 41 | " 4: use ONIT\n" 42 | " 8: skip NITs of known networks\n" 43 | " This is a bitmap and combinations can be used.\n\n" 44 | "Transponder parameters:\n" 45 | "\tpolarization: 0 - Horizontal, 1 - Vertical, 2 - Left Circular, 3 - Right Circular\n" 46 | "\tfec_inner: 0 - Auto, 1 - 1/2, 2 - 2/3, 3 - 3/4, 4 - 5/6, 5 - 7/8, 6 - 8/9, 7 - 3/5,\n" 47 | "\t8 - 4/5, 9 - 9/10, 15 - None\n" 48 | "\tmodulation: 0 - Auto, 1 - QPSK, 2 - 8PSK, 4 - 16APSK, 5 - 32APSK\n" 49 | "\trolloff: 0 - 0.35, 1 - 0.25, 2 - 0.20, 3 - Auto\n" 50 | "\tpilot: 0 - Off, 1 - On, 2 - Auto\n" 51 | "\tinversion: 0 = Off, 1 = On, 2 = Auto (default)\n" 52 | "\tsystem: 0 = DVB-S, 1 = DVB-S2\n" 53 | "\tis_id: 0 - 255\n" 54 | "\tpls_mode: 0 - Root, 1 - Gold, 2 - Combo\n" 55 | "\tpls_code: 0 - 262142\n\n") 56 | 57 | _TERRESTRIAL_COMMENT = ("\tFile was created in DemonEditor.\n\n" 58 | "Usable flags are:\n" 59 | " 1: Network Scan\n" 60 | " 2: use BAT\n" 61 | " 4: use ONIT\n" 62 | " 8: skip NITs of known networks\n" 63 | " This is a bitmap and combinations can be used.\n\n") 64 | 65 | _CABLE_COMMENT = ("\tFile was created in DemonEditor.\n\n" 66 | "Transponder parameters:\n" 67 | "\tmodulation:\n" 68 | "\t3: QAM64\n" 69 | "\t5: QAM256\n") 70 | 71 | 72 | def get_satellites(path): 73 | """ Returns data [Satellite] list from *.xml. """ 74 | return [Satellite(e.get("name", None), 75 | e.get("flags", None), 76 | e.get("position", None) or "0", 77 | get_sat_transponders(e)) for e in ETree.parse(path).iter("sat")] 78 | 79 | 80 | def get_sat_transponders(elem): 81 | """ Returns satellite transponders list. """ 82 | return [Transponder(e.get("frequency", "0"), 83 | e.get("symbol_rate", "0"), 84 | e.get("polarization", None), 85 | e.get("fec_inner", None), 86 | e.get("system", None), 87 | e.get("modulation", None), 88 | e.get("pls_mode", None), 89 | e.get("pls_code", None), 90 | e.get("is_id", None), 91 | e.get("t2mi_plp_id", None)) for e in elem.iter("transponder")] 92 | 93 | 94 | def get_terrestrial(path): 95 | """ Returns data [Terrestrial] list from *.xml. """ 96 | return [Terrestrial(e.get("name", None), 97 | e.get("flags", None), 98 | e.get("countrycode", None), 99 | [get_ter_transponder(e) for e in e.iter("transponder")] 100 | ) for e in ETree.parse(path).iter("terrestrial")] 101 | 102 | 103 | def get_ter_transponder(elem): 104 | """ Returns terrestrial transponder. """ 105 | return TerTransponder(elem.get("centre_frequency", "0"), 106 | elem.get("system", None), 107 | elem.get("bandwidth", None), 108 | elem.get("constellation", None), 109 | elem.get("code_rate_hp", None), 110 | elem.get("code_rate_lp", None), 111 | elem.get("guard_interval", None), 112 | elem.get("transmission_mode", None), 113 | elem.get("hierarchy_information", None), 114 | elem.get("inversion", None), 115 | elem.get("plp_id", None)) 116 | 117 | 118 | def get_cable(path): 119 | """ Returns data [Cable] list from *.xml. """ 120 | return [Cable(e.get("name", None), 121 | e.get("flags", None), 122 | e.get("satfeed", None), 123 | e.get("countrycode", None), 124 | get_cable_transponders(e)) for e in ETree.parse(path).iter("cable")] 125 | 126 | 127 | def get_cable_transponders(elem): 128 | """ Returns cable transponders list. """ 129 | return [CableTransponder(e.get("frequency", "0"), 130 | e.get("symbol_rate", "0"), 131 | e.get("fec_inner", None), 132 | e.get("modulation", None)) for e in elem.iter("transponder")] 133 | 134 | 135 | def write_satellites(satellites, data_path, encoding="UTF-8"): 136 | """ Creates satellites.xml file. """ 137 | write_xml("satellites", "sat", satellites, data_path, _SAT_COMMENT, encoding) 138 | 139 | 140 | def write_terrestrial(terrestrial, data_path, encoding="UTF-8"): 141 | """ Creates terrestrial.xml file. """ 142 | write_xml("locations", "terrestrial", terrestrial, data_path, _TERRESTRIAL_COMMENT, encoding) 143 | 144 | 145 | def write_cable(cables, data_path, encoding="UTF-8"): 146 | """ Creates cables.xml file. """ 147 | write_xml("cables", "cable", cables, data_path, _CABLE_COMMENT, encoding) 148 | 149 | 150 | def write_xml(root_name, sub_name, data, data_path, comment="", encoding="UTF-8"): 151 | """ Creates *.xml files. """ 152 | xml = ETree.Element(root_name) 153 | [write_element(sub_name, "transponder", t, xml) for t in data] 154 | 155 | tree = ETree.ElementTree(xml) 156 | indent(tree.getroot()) 157 | 158 | with open(data_path, "wb") as f: 159 | # To put comment on top. 160 | f.write(f'\n\n\n'.encode("utf-8")) 161 | tree.write(f, encoding=encoding) 162 | 163 | 164 | def write_element(e_name, ch_name, e_data, root): 165 | """ Writes element with sub elements. 166 | 167 | @param e_name: Element name. 168 | @param ch_name: Child element name. 169 | @param e_data: Element data -> defaultdict 170 | @param root: Parent of the element. 171 | """ 172 | t = e_data._asdict() 173 | subs = t.pop("transponders") 174 | root_sub = ETree.SubElement(root, e_name, {k: v for k, v in t.items() if v}) 175 | [ETree.SubElement(root_sub, ch_name, {k: v for k, v in tr._asdict().items() if v}) for tr in subs] 176 | 177 | 178 | def indent(elem, parent=None, index=-1, level=0, space=" "): 179 | """ Appends whitespace to the subtree to indent the tree visually. 180 | 181 | Since the minimum supported version < 3.9, we will use our own implementation. 182 | """ 183 | for i, sub in enumerate(elem): 184 | indent(sub, elem, i, level + 1) 185 | if parent: 186 | if index == 0: 187 | parent.text = f"\n{space * level}" 188 | else: 189 | parent[index - 1].tail = f"\n{space * level}" 190 | 191 | if index == len(parent) - 1: 192 | elem.tail = f"\n{space * (level - 1)}" 193 | 194 | 195 | def get_pos_str(pos: int) -> str: 196 | """ Converts satellite position int value to readable string. """ 197 | return f"{abs(pos / 10):0.1f}{'W' if pos < 0 else 'E'}" 198 | 199 | 200 | if __name__ == "__main__": 201 | pass 202 | -------------------------------------------------------------------------------- /app/tools/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/tools/__init__.py -------------------------------------------------------------------------------- /app/ui/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/ui/backup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2024 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | import os 30 | import shutil 31 | import tempfile 32 | import time 33 | import zipfile 34 | from datetime import datetime 35 | from enum import Enum 36 | from pathlib import Path 37 | 38 | from app.commons import run_idle, get_size_from_bytes 39 | from app.settings import SettingsType, SEP 40 | from app.ui.dialogs import show_dialog, DialogType, get_builder 41 | from app.ui.main_helper import append_text_to_tview, show_info_bar_message 42 | from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, MOD_MASK, HeaderBar 43 | 44 | KEEP_DATA = {"satellites.xml", 45 | "terrestrial.xml", 46 | "cables.xml", 47 | "whitelist", 48 | "whitelist_streamrelay"} 49 | 50 | 51 | class RestoreType(Enum): 52 | BOUQUETS = 0 53 | ALL = 1 54 | 55 | 56 | class BackupDialog: 57 | def __init__(self, transient, settings, callback): 58 | handlers = {"on_restore_bouquets": self.on_restore_bouquets, 59 | "on_restore_all": self.on_restore_all, 60 | "on_remove": self.on_remove, 61 | "on_view_popup_menu": self.on_view_popup_menu, 62 | "on_info_button_toggled": self.on_info_button_toggled, 63 | "on_info_bar_close": self.on_info_bar_close, 64 | "on_cursor_changed": self.on_cursor_changed, 65 | "on_resize": self.on_resize, 66 | "on_key_release": self.on_key_release} 67 | 68 | builder = get_builder(UI_RESOURCES_PATH + "backup_dialog.glade", handlers) 69 | 70 | self._settings = settings 71 | self._s_type = settings.setting_type 72 | self._data_path = self._settings.profile_data_path 73 | self._backup_path = self._settings.profile_backup_path or f"{self._data_path}backup{os.sep}" 74 | self._open_data_callback = callback 75 | self._dialog_window = builder.get_object("dialog_window") 76 | self._dialog_window.set_transient_for(transient) 77 | self._model = builder.get_object("main_list_store") 78 | self._main_view = builder.get_object("main_view") 79 | self._text_view = builder.get_object("text_view") 80 | self._info_check_button = builder.get_object("info_check_button") 81 | self._info_bar = builder.get_object("info_bar") 82 | self._message_label = builder.get_object("message_label") 83 | self._file_count_label = builder.get_object("file_count_label") 84 | 85 | if self._settings.use_header_bar: 86 | header_bar = HeaderBar() 87 | self._dialog_window.set_titlebar(header_bar) 88 | 89 | button_box = builder.get_object("main_button_box") 90 | button_box.set_margin_top(0) 91 | button_box.set_margin_bottom(0) 92 | button_box.set_margin_left(0) 93 | button_box.reparent(header_bar) 94 | 95 | ch_button = builder.get_object("info_check_button") 96 | ch_button.set_margin_right(0) 97 | h_bar = builder.get_object("header_bar") 98 | h_bar.remove(ch_button) 99 | h_bar.set_visible(False) 100 | header_bar.pack_end(ch_button) 101 | 102 | # Setting the last size of the dialog window if it was saved 103 | window_size = self._settings.get("backup_tool_window_size") 104 | if window_size: 105 | self._dialog_window.resize(*window_size) 106 | 107 | self.init_data() 108 | 109 | def show(self): 110 | self._dialog_window.show() 111 | 112 | @run_idle 113 | def init_data(self): 114 | if os.path.isdir(self._backup_path): 115 | for file in filter(lambda x: x.endswith(".zip"), os.listdir(self._backup_path)): 116 | p = Path(os.path.join(self._backup_path, file)) 117 | if p.is_file(): 118 | self._model.append((p.stem, get_size_from_bytes(p.stat().st_size))) 119 | else: 120 | os.makedirs(os.path.dirname(self._backup_path), exist_ok=True) 121 | 122 | self._file_count_label.set_text(str(len(self._model))) 123 | 124 | def on_restore_bouquets(self, item): 125 | self.restore(RestoreType.BOUQUETS) 126 | 127 | def on_restore_all(self, item): 128 | self.restore(RestoreType.ALL) 129 | 130 | def on_remove(self, item): 131 | model, paths = self._main_view.get_selection().get_selected_rows() 132 | if not paths: 133 | show_dialog(DialogType.ERROR, self._dialog_window, "No selected item!") 134 | return 135 | 136 | if show_dialog(DialogType.QUESTION, self._dialog_window) == Gtk.ResponseType.CANCEL: 137 | return 138 | 139 | itrs_to_delete = [] 140 | try: 141 | for itr in map(model.get_iter, paths): 142 | file_name = model.get_value(itr, 0) 143 | os.remove(f"{self._backup_path}{file_name}.zip") 144 | itrs_to_delete.append(itr) 145 | except FileNotFoundError as e: 146 | self.show_info_message(str(e), Gtk.MessageType.ERROR) 147 | else: 148 | list(map(model.remove, itrs_to_delete)) 149 | 150 | self._file_count_label.set_text(str(len(self._model))) 151 | 152 | def on_view_popup_menu(self, menu, event): 153 | if event.get_event_type() == Gdk.EventType.BUTTON_PRESS and event.button == Gdk.BUTTON_SECONDARY: 154 | menu.popup(None, None, None, None, event.button, event.time) 155 | 156 | def on_info_button_toggled(self, button): 157 | if button.get_active(): 158 | self.on_cursor_changed(self._main_view) 159 | 160 | @run_idle 161 | def show_info_message(self, text, message_type): 162 | show_info_bar_message(self._info_bar, self._message_label, text, message_type) 163 | 164 | def on_info_bar_close(self, bar=None, resp=None): 165 | self._info_bar.set_visible(False) 166 | 167 | def on_cursor_changed(self, view): 168 | if not self._info_check_button.get_active(): 169 | return 170 | 171 | model, paths = view.get_selection().get_selected_rows() 172 | if paths: 173 | try: 174 | file_name = self._backup_path + model.get_value(model.get_iter(paths[0]), 0) + ".zip" 175 | created = time.ctime(os.path.getctime(file_name)) 176 | self._text_view.get_buffer().set_text( 177 | f"Created: {created}\n********** Files: **********\n") 178 | with zipfile.ZipFile(file_name) as zip_file: 179 | for name in zip_file.namelist(): 180 | append_text_to_tview(name + "\n", self._text_view) 181 | except FileNotFoundError as e: 182 | self.show_info_message(str(e), Gtk.MessageType.ERROR) 183 | else: 184 | self._text_view.get_buffer().set_text("") 185 | 186 | def restore(self, restore_type): 187 | model, paths = self._main_view.get_selection().get_selected_rows() 188 | if not paths: 189 | show_dialog(DialogType.ERROR, self._dialog_window, "No selected item!") 190 | return 191 | 192 | if len(paths) > 1: 193 | show_dialog(DialogType.ERROR, self._dialog_window, "Please, select only one item!") 194 | return 195 | 196 | if show_dialog(DialogType.QUESTION, self._dialog_window) == Gtk.ResponseType.CANCEL: 197 | return 198 | 199 | file_name = model.get_value(model.get_iter(paths[0]), 0) 200 | full_file_name = f"{self._backup_path}{file_name}.zip" 201 | 202 | try: 203 | if restore_type is RestoreType.ALL: 204 | clear_data_path(self._data_path) 205 | shutil.unpack_archive(full_file_name, self._data_path) 206 | elif restore_type is RestoreType.BOUQUETS: 207 | tmp_dir = tempfile.gettempdir() + SEP + file_name 208 | cond = (".tv", ".radio") if self._s_type is SettingsType.ENIGMA_2 else "bouquets.xml" 209 | shutil.unpack_archive(full_file_name, tmp_dir) 210 | for file in filter(lambda f: f.endswith(cond), os.listdir(self._data_path)): 211 | os.remove(os.path.join(self._data_path, file)) 212 | for file in filter(lambda f: f.endswith(cond), os.listdir(tmp_dir)): 213 | shutil.move(os.path.join(tmp_dir, file), self._data_path + file) 214 | shutil.rmtree(tmp_dir) 215 | except FileNotFoundError as e: 216 | self.show_info_message(str(e), Gtk.MessageType.ERROR) 217 | else: 218 | self.show_info_message("Done!", Gtk.MessageType.INFO) 219 | self._open_data_callback(self._data_path) 220 | 221 | def on_resize(self, window): 222 | if self._settings: 223 | self._settings.add("backup_tool_window_size", window.get_size()) 224 | 225 | def on_key_release(self, view, event): 226 | """ Handling keystrokes """ 227 | key_code = event.hardware_keycode 228 | if not KeyboardKey.value_exist(key_code): 229 | return 230 | key = KeyboardKey(key_code) 231 | ctrl = event.state & MOD_MASK 232 | 233 | if key is KeyboardKey.DELETE: 234 | self.on_remove(view) 235 | elif ctrl and key is KeyboardKey.E: 236 | self.restore(RestoreType.ALL) 237 | elif ctrl and key is KeyboardKey.R: 238 | self.restore(RestoreType.BOUQUETS) 239 | 240 | 241 | def backup_data(path, backup_path, move=True, keep=None): 242 | """ Creating data backup from a folder at the specified path 243 | 244 | Returns full path to the compressed file. 245 | """ 246 | keep = keep or KEEP_DATA 247 | backup_path = f"{backup_path}{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}{SEP}" 248 | os.makedirs(os.path.dirname(backup_path), exist_ok=True) 249 | os.makedirs(os.path.dirname(path), exist_ok=True) 250 | # Backup files in data dir. 251 | for file in filter(lambda f: os.path.isfile(os.path.join(path, f)), os.listdir(path)): 252 | src, dst = os.path.join(path, file), backup_path + file 253 | shutil.move(src, dst) if move and file not in keep else shutil.copy(src, dst) 254 | # Compressing to zip and delete remaining files. 255 | zip_file = shutil.make_archive(backup_path.rstrip(SEP), "zip", backup_path) 256 | shutil.rmtree(backup_path) 257 | 258 | return zip_file 259 | 260 | 261 | def restore_data(src, dst): 262 | """ Unpacks backup data. """ 263 | clear_data_path(dst) 264 | shutil.unpack_archive(src, dst) 265 | 266 | 267 | def clear_data_path(path): 268 | """ Clearing data at the specified path excluding *.xml file. """ 269 | for file in filter(lambda f: f not in KEEP_DATA and os.path.isfile(os.path.join(path, f)), os.listdir(path)): 270 | os.remove(os.path.join(path, file)) 271 | 272 | 273 | if __name__ == "__main__": 274 | pass 275 | -------------------------------------------------------------------------------- /app/ui/dialogs.glade: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | False 38 | True 39 | True 40 | system-help 41 | normal 42 | DemonEditor 43 | 3.13.0 Beta 44 | 2018-2025 Dmitriy Yefremov 45 | 46 | Enigma2 channel and satellite list editor. 47 | https://dyefremov.github.io/DemonEditor/ 48 | Это приложение распространяется без каких-либо гарантий. 49 | Подробнее в <a href="http://opensource.org/licenses/mit-license.php">The MIT License (MIT)</a>. 50 | Dmitriy Yefremov 51 | 52 | translator-credits 53 | Program logo: <a href="http://ihad.tv">mfgeg</a> 54 | demon-editor 55 | True 56 | mit-x11 57 | 58 | 59 | 60 | 61 | 62 | False 63 | vertical 64 | 2 65 | 66 | 67 | False 68 | 50 69 | 50 70 | expand 71 | 72 | 73 | False 74 | False 75 | 0 76 | 77 | 78 | 79 | 80 | 81 | 82 | {use_header} 83 | Transponder 84 | False 85 | {title} 86 | False 87 | True 88 | center 89 | 320 90 | True 91 | utility 92 | True 93 | True 94 | 95 | 96 | Cancel 97 | True 98 | True 99 | True 100 | center 101 | 102 | 103 | 104 | 105 | OK 106 | True 107 | True 108 | True 109 | center 110 | 111 | 112 | 113 | 114 | 115 | False 116 | 5 117 | 5 118 | vertical 119 | 120 | 121 | False 122 | center 123 | end 124 | 125 | 126 | False 127 | False 128 | 0 129 | 130 | 131 | 132 | 133 | True 134 | True 135 | 5 136 | 5 137 | 5 138 | 5 139 | document-edit-symbolic 140 | False 141 | False 142 | False 143 | 144 | 145 | False 146 | True 147 | 0 148 | 149 | 150 | 151 | 152 | 153 | input_dialog_cancel_button 154 | input_dialog_ok_button 155 | 156 | 157 | 158 | False 159 | False 160 | True 161 | center-on-parent 162 | True 163 | splashscreen 164 | True 165 | True 166 | False 167 | 168 | 169 | 100 170 | True 171 | False 172 | 5 173 | 5 174 | vertical 175 | 176 | 177 | 150 178 | 45 179 | True 180 | False 181 | True 182 | 183 | 184 | False 185 | True 186 | 0 187 | 188 | 189 | 190 | 191 | True 192 | False 193 | 10 194 | 10 195 | Loading data... 196 | 197 | 198 | False 199 | True 200 | 1 201 | 202 | 203 | 204 | 205 | 208 | 209 | 210 | -------------------------------------------------------------------------------- /app/ui/dialogs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2024 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | """ Common module for showing dialogs """ 30 | import gettext 31 | import xml.etree.ElementTree as ET 32 | from enum import Enum 33 | from functools import lru_cache 34 | from pathlib import Path 35 | 36 | from app.commons import run_idle 37 | from app.settings import SEP, IS_WIN, USE_HEADER_BAR 38 | from .uicommons import Gtk, UI_RESOURCES_PATH, TEXT_DOMAIN 39 | 40 | 41 | class BaseDialog(Gtk.Dialog): 42 | """ Base dialog class for editing DVB (-> *.xml) data. """ 43 | DEFAULT_BUTTONS = (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK) 44 | 45 | def __init__(self, parent, title, buttons=None, *args, **kwargs): 46 | super().__init__(transient_for=parent, 47 | title=translate(title), 48 | modal=True, 49 | resizable=False, 50 | default_width=255, 51 | skip_taskbar_hint=True, 52 | skip_pager_hint=True, 53 | destroy_with_parent=True, 54 | use_header_bar=USE_HEADER_BAR, 55 | window_position=Gtk.WindowPosition.CENTER_ON_PARENT, 56 | buttons=buttons or self.DEFAULT_BUTTONS, 57 | *args, **kwargs) 58 | 59 | 60 | class Dialog(Enum): 61 | MESSAGE = """ 62 | 63 | 64 | 65 | 66 | {use_header} 67 | False 68 | True 69 | 255 70 | True 71 | dialog 72 | True 73 | True 74 | {message_type} 75 | {buttons_type} 76 | 77 | 78 | """ 79 | 80 | 81 | class Action(Enum): 82 | EDIT = 0 83 | ADD = 1 84 | 85 | 86 | class DialogType(Enum): 87 | INPUT = "input" 88 | CHOOSER = "chooser" 89 | ERROR = "error" 90 | QUESTION = "question" 91 | INFO = "info" 92 | ABOUT = "about" 93 | WAIT = "wait" 94 | 95 | def __str__(self): 96 | return self.value 97 | 98 | 99 | class WaitDialog: 100 | def __init__(self, transient, text=None): 101 | builder, dialog = get_dialog_from_xml(DialogType.WAIT, transient) 102 | self._dialog = dialog 103 | self._dialog.set_transient_for(transient) 104 | self._label = builder.get_object("wait_dialog_label") 105 | self._default_text = text or self._label.get_text() 106 | 107 | def show(self, text=None): 108 | self.set_text(text) 109 | self._dialog.show() 110 | 111 | @run_idle 112 | def set_text(self, text): 113 | self._label.set_text(translate(text or self._default_text)) 114 | 115 | @run_idle 116 | def hide(self): 117 | self._dialog.hide() 118 | 119 | @run_idle 120 | def destroy(self): 121 | self._dialog.destroy() 122 | 123 | 124 | def show_dialog(dialog_type, transient, text=None, settings=None, action_type=None, file_filter=None, buttons=None, 125 | title=None, create_dir=False): 126 | """ Shows dialogs by name. """ 127 | if dialog_type in (DialogType.INFO, DialogType.ERROR): 128 | return get_message_dialog(transient, dialog_type, Gtk.ButtonsType.OK, text) 129 | elif dialog_type is DialogType.CHOOSER and settings: 130 | return get_file_chooser_dialog(transient, text, settings, action_type, file_filter, buttons, title, create_dir) 131 | elif dialog_type is DialogType.INPUT: 132 | return get_input_dialog(transient, text) 133 | elif dialog_type is DialogType.QUESTION: 134 | action = action_type if action_type else Gtk.ButtonsType.OK_CANCEL 135 | return get_message_dialog(transient, DialogType.QUESTION, action, text or "Are you sure?") 136 | elif dialog_type is DialogType.ABOUT: 137 | return get_about_dialog(transient) 138 | 139 | 140 | def get_chooser_dialog(transient, settings, name, patterns, title=None, file_filter=None): 141 | if not file_filter: 142 | file_filter = Gtk.FileFilter() 143 | file_filter.set_name(name) 144 | for p in patterns: 145 | file_filter.add_pattern(p) 146 | 147 | return show_dialog(dialog_type=DialogType.CHOOSER, 148 | transient=transient, 149 | settings=settings, 150 | action_type=Gtk.FileChooserAction.OPEN, 151 | file_filter=file_filter, 152 | title=title) 153 | 154 | 155 | def get_file_chooser_dialog(transient, text, settings, action_type, file_filter, buttons=None, title=None, dirs=False): 156 | action_type = Gtk.FileChooserAction.SELECT_FOLDER if action_type is None else action_type 157 | dialog = Gtk.FileChooserNative.new(translate(title) if title else "", transient, action_type) 158 | dialog.set_create_folders(dirs) 159 | dialog.set_modal(True) 160 | 161 | if file_filter is not None: 162 | dialog.add_filter(file_filter) 163 | 164 | dialog.set_current_folder(settings.profile_data_path) 165 | response = dialog.run() 166 | 167 | if response == Gtk.ResponseType.ACCEPT: 168 | path = Path(dialog.get_filename() or dialog.get_current_folder()) 169 | if path.is_dir(): 170 | response = "{}{}".format(path.resolve(), SEP) 171 | elif path.is_file(): 172 | response = str(path.resolve()) 173 | dialog.destroy() 174 | 175 | return response 176 | 177 | 178 | def get_input_dialog(transient, text): 179 | builder, dialog = get_dialog_from_xml(DialogType.INPUT, transient, use_header=USE_HEADER_BAR) 180 | entry = builder.get_object("input_entry") 181 | entry.set_text(text if text else "") 182 | response = dialog.run() 183 | txt = entry.get_text() 184 | dialog.destroy() 185 | 186 | return txt if response == Gtk.ResponseType.OK else Gtk.ResponseType.CANCEL 187 | 188 | 189 | def get_message_dialog(transient, message_type, buttons_type, text): 190 | builder = Gtk.Builder() 191 | builder.set_translation_domain(TEXT_DOMAIN) 192 | dialog_str = Dialog.MESSAGE.value.format(use_header=0, message_type=message_type, buttons_type=int(buttons_type)) 193 | builder.add_from_string(dialog_str) 194 | dialog = builder.get_object("message_dialog") 195 | dialog.set_transient_for(transient) 196 | dialog.set_markup(translate(text)) 197 | response = dialog.run() 198 | dialog.destroy() 199 | 200 | return response 201 | 202 | 203 | def get_about_dialog(transient): 204 | builder, dialog = get_dialog_from_xml(DialogType.ABOUT, transient) 205 | dialog.set_transient_for(transient) 206 | response = dialog.run() 207 | dialog.destroy() 208 | 209 | return response 210 | 211 | 212 | def get_dialog_from_xml(dialog_type, transient, use_header=0, title=""): 213 | dialog_name = dialog_type.value + "_dialog" 214 | builder = Gtk.Builder() 215 | builder.set_translation_domain(TEXT_DOMAIN) 216 | dialog_str = get_dialogs_string(UI_RESOURCES_PATH + "dialogs.glade").format(use_header=use_header, title=title) 217 | builder.add_objects_from_string(dialog_str, (dialog_name,)) 218 | dialog = builder.get_object(dialog_name) 219 | dialog.set_transient_for(transient) 220 | 221 | return builder, dialog 222 | 223 | 224 | def translate(message): 225 | """ returns translated message """ 226 | return gettext.dgettext(TEXT_DOMAIN, message) 227 | 228 | 229 | @lru_cache(maxsize=5) 230 | def get_dialogs_string(path, tag="property"): 231 | if IS_WIN: 232 | return translate_xml(path, tag) 233 | else: 234 | with open(path, "r", encoding="utf-8") as f: 235 | return "".join(f) 236 | 237 | 238 | def get_builder(path, handlers=None, use_str=False, objects=None, tag="property"): 239 | """ Creates and returns a Gtk.Builder instance. """ 240 | builder = Gtk.Builder() 241 | builder.set_translation_domain(TEXT_DOMAIN) 242 | 243 | if use_str: 244 | if objects: 245 | builder.add_objects_from_string(get_dialogs_string(path, tag).format(use_header=USE_HEADER_BAR), objects) 246 | else: 247 | builder.add_from_string(get_dialogs_string(path, tag).format(use_header=USE_HEADER_BAR)) 248 | else: 249 | if objects: 250 | builder.add_objects_from_string(get_dialogs_string(path, tag), objects) 251 | else: 252 | builder.add_from_string(get_dialogs_string(path, tag)) 253 | 254 | builder.connect_signals(handlers or {}) 255 | 256 | return builder 257 | 258 | 259 | def translate_xml(path, tag="property"): 260 | """ Used to translate GUI from * .glade files in MS Windows. 261 | 262 | More info: https://gitlab.gnome.org/GNOME/gtk/-/issues/569 263 | """ 264 | et = ET.parse(path) 265 | root = et.getroot() 266 | for e in root.iter(): 267 | if e.tag == tag and e.attrib.get("translatable", None) == "yes": 268 | e.text = translate(e.text) 269 | elif e.tag == "item" and e.attrib.get("translatable", None) == "yes": 270 | e.text = translate(e.text) 271 | 272 | return ET.tostring(root, encoding="unicode", method="xml") 273 | 274 | 275 | if __name__ == "__main__": 276 | pass 277 | -------------------------------------------------------------------------------- /app/ui/epg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/epg/__init__.py -------------------------------------------------------------------------------- /app/ui/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/extensions/__init__.py -------------------------------------------------------------------------------- /app/ui/icons/hicolor/96x96/apps/demon-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/icons/hicolor/96x96/apps/demon-editor.png -------------------------------------------------------------------------------- /app/ui/lang/be/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/be/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/de/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/de/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/es/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/es/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/it/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/it/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/nl/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/nl/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/pl/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/pl/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/pt/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/pt/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/ru/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/ru/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/tr/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/tr/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/lang/zh_CN/LC_MESSAGES/demon-editor.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/lang/zh_CN/LC_MESSAGES/demon-editor.mo -------------------------------------------------------------------------------- /app/ui/logs.glade: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | True 39 | False 40 | 0.49000000953674316 41 | none 42 | 43 | 44 | True 45 | False 46 | 47 | 48 | True 49 | False 50 | 10 51 | 10 52 | 5 53 | 5 54 | vertical 55 | 5 56 | 57 | 58 | True 59 | False 60 | 5 61 | 5 62 | 5 63 | 5 64 | 5 65 | 66 | 67 | True 68 | True 69 | True 70 | Clear 71 | center 72 | center 73 | 74 | 75 | 76 | True 77 | False 78 | gtk-clear 79 | 80 | 81 | 82 | 83 | False 84 | True 85 | 0 86 | 87 | 88 | 89 | 90 | True 91 | True 92 | True 93 | Close 94 | True 95 | 96 | 97 | 98 | True 99 | False 100 | gtk-close 101 | 102 | 103 | 104 | 105 | False 106 | True 107 | end 108 | 1 109 | 110 | 111 | 112 | 113 | False 114 | True 115 | 0 116 | 117 | 118 | 119 | 120 | True 121 | True 122 | in 123 | 124 | 125 | True 126 | True 127 | False 128 | 5 129 | 5 130 | 5 131 | 5 132 | 133 | 134 | 135 | 136 | True 137 | True 138 | 1 139 | 140 | 141 | 144 | 145 | 146 | 149 | 150 | 151 | 152 | 153 | True 154 | False 155 | 2 156 | Logs 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | -------------------------------------------------------------------------------- /app/ui/logs.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2022 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | import logging 30 | 31 | from gi.repository import GLib 32 | 33 | from app.commons import LOGGER_NAME, LOG_FORMAT, LOG_DATE_FORMAT 34 | from app.ui.dialogs import get_builder 35 | from app.ui.main_helper import append_text_to_tview 36 | from app.ui.uicommons import Gtk, UI_RESOURCES_PATH 37 | 38 | 39 | class LogsClient(Gtk.Box): 40 | """ Logger GUI client. """ 41 | 42 | class LogHandler(logging.Handler): 43 | def __init__(self, view): 44 | logging.Handler.__init__(self) 45 | self._view = view 46 | self.setFormatter(logging.Formatter(fmt=LOG_FORMAT, datefmt=LOG_DATE_FORMAT)) 47 | 48 | def handle(self, rec: logging.LogRecord): 49 | GLib.idle_add(append_text_to_tview, f"{self.format(rec)}\n", self._view) 50 | 51 | def __init__(self, app, *args, **kwargs): 52 | super().__init__(*args, **kwargs) 53 | self._app = app 54 | 55 | handlers = {"on_clear": self.on_clear, "on_close": self.on_close} 56 | builder = get_builder(UI_RESOURCES_PATH + "logs.glade", handlers) 57 | 58 | self._log_view = builder.get_object("log_view") 59 | self.pack_start(builder.get_object("log_frame"), True, True, 0) 60 | 61 | logger = logging.getLogger(LOGGER_NAME) 62 | logger.addHandler(LogsClient.LogHandler(self._log_view)) 63 | 64 | self.show() 65 | 66 | def on_clear(self, button): 67 | GLib.idle_add(self._log_view.get_buffer().set_text, "") 68 | 69 | def on_close(self, button): 70 | self._app.change_action_state("on_logs_show", GLib.Variant.new_boolean(False)) 71 | 72 | 73 | if __name__ == "__main__": 74 | pass 75 | -------------------------------------------------------------------------------- /app/ui/mac_style.css: -------------------------------------------------------------------------------- 1 | * { 2 | background-clip: padding-box; 3 | -GtkScrolledWindow-scrollbar-spacing: 0; 4 | -GtkToolItemGroup-expander-size: 11; 5 | -GtkWidget-text-handle-width: 20; 6 | -GtkWidget-text-handle-height: 20; 7 | -GtkDialog-button-spacing: 12; 8 | -GtkDialog-action-area-border: 6; 9 | } 10 | 11 | entry { 12 | min-height: 2.0em; 13 | padding: 0.2em; 14 | } 15 | 16 | entry > image { 17 | padding-left: 0.3em; 18 | padding-right: 0.3em; 19 | } 20 | 21 | button { 22 | min-height: 1.2em; 23 | min-width: 1.5em; 24 | padding-top: 0.3em; 25 | padding-bottom: 0.3em; 26 | } 27 | 28 | button:active, button:checked { 29 | color: @theme_selected_fg_color; 30 | background-image: linear-gradient(@theme_selected_bg_color, @theme_selected_bg_color); 31 | } 32 | 33 | combobox { 34 | min-height: 2.2em; 35 | } 36 | 37 | spinbutton { 38 | min-height: 1.5em; 39 | } 40 | 41 | toolbutton { 42 | padding: 0.1em; 43 | } 44 | 45 | spinner { 46 | padding-left: 1em; 47 | padding-right: 1em; 48 | } 49 | 50 | infobar { 51 | min-height: 2em; 52 | } 53 | 54 | revealer > box > button { 55 | padding: 0.2em; 56 | } 57 | 58 | switch slider { 59 | min-height: 1.5em; 60 | min-width: 1.5em; 61 | } 62 | 63 | .font > box { 64 | min-height: 1.5em; 65 | padding-top: 0.1em; 66 | padding-bottom: 0.1em; 67 | } 68 | 69 | .dialog-action-area button { 70 | margin-bottom: 0.6em; 71 | } 72 | -------------------------------------------------------------------------------- /app/ui/search.py: -------------------------------------------------------------------------------- 1 | """ This is helper module for search features """ 2 | from app.commons import run_with_delay 3 | 4 | 5 | class SearchProvider: 6 | def __init__(self, view, entry, down_button, up_button, columns=None): 7 | self._paths = [] 8 | self._current_index = -1 9 | self._max_indexes = 0 10 | self._view = view 11 | self._entry = entry 12 | self._up_button = up_button 13 | self._down_button = down_button 14 | self._columns = columns 15 | 16 | entry.connect("changed", self.on_search) 17 | self._down_button.connect("clicked", self.on_search_down) 18 | self._up_button.connect("clicked", self.on_search_up) 19 | 20 | def search(self, text): 21 | self._current_index = -1 22 | self._paths.clear() 23 | model = self._view.get_model() 24 | selection = self._view.get_selection() 25 | if not selection: 26 | return 27 | 28 | selection.unselect_all() 29 | if not text: 30 | return 31 | 32 | text = text.upper() 33 | for r in model: 34 | data = [r[i] for i in self._columns] if self._columns else r[:] 35 | if next((s for s in data if text in str(s).upper()), False): 36 | path = r.path 37 | selection.select_path(r.path) 38 | self._paths.append(path) 39 | 40 | self._max_indexes = len(self._paths) - 1 41 | if self._max_indexes > 0: 42 | self.on_search_down() 43 | 44 | self.update_navigation_buttons() 45 | 46 | def scroll_to(self, index): 47 | self._view.scroll_to_cell(self._paths[index], None) 48 | self.update_navigation_buttons() 49 | 50 | def on_search_down(self, button=None): 51 | if self._current_index < self._max_indexes: 52 | self._current_index += 1 53 | self.scroll_to(self._current_index) 54 | 55 | def on_search_up(self, button=None): 56 | if self._current_index > -1: 57 | self._current_index -= 1 58 | self.scroll_to(self._current_index) 59 | 60 | def update_navigation_buttons(self): 61 | self._up_button.set_sensitive(self._current_index > 0) 62 | self._down_button.set_sensitive(self._current_index < self._max_indexes) 63 | 64 | @run_with_delay(1) 65 | def on_search(self, entry): 66 | self.search(entry.get_text()) 67 | 68 | def on_search_toggled(self, action, value=None): 69 | self._entry.grab_focus() if action.get_active() else self._entry.set_text("") 70 | 71 | 72 | if __name__ == "__main__": 73 | pass 74 | -------------------------------------------------------------------------------- /app/ui/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | -GtkDialog-action-area-border: 6; 3 | } 4 | 5 | #digit-entry { 6 | border-color: Red; 7 | } 8 | 9 | #status-bar-button { 10 | padding-top: 1px; 11 | padding-bottom: 1px; 12 | padding-left: 3px; 13 | padding-right: 3px; 14 | margin: 1px; 15 | } 16 | 17 | #task-button { 18 | padding: 0; 19 | } 20 | 21 | #header-button { 22 | padding-top: 0; 23 | padding-bottom: 0; 24 | } 25 | 26 | #header-entry { 27 | min-height: 0; 28 | } 29 | 30 | #header-stack-switcher > button { 31 | padding-top: 0; 32 | padding-bottom: 0; 33 | } 34 | 35 | buttonbox { 36 | padding: 0; 37 | } 38 | 39 | paned > separator { 40 | background-repeat: no-repeat; 41 | background-position: center; 42 | } 43 | 44 | paned.horizontal > separator { 45 | background-size: 2px 24px; 46 | } 47 | 48 | paned.vertical > separator { 49 | background-size: 24px 2px; 50 | } 51 | 52 | .red-button { 53 | background-image: none; 54 | background-color: red; 55 | } 56 | 57 | .green-button { 58 | background-image: none; 59 | background-color: green; 60 | } 61 | 62 | .yellow-button { 63 | background-image: none; 64 | background-color: yellow; 65 | } 66 | 67 | .blue-button { 68 | background-image: none; 69 | background-color: blue; 70 | } 71 | 72 | .time-entry { 73 | padding: 0px; 74 | margin: 0px; 75 | } 76 | 77 | .group {} 78 | 79 | .group :first-child { 80 | border-top-right-radius: 0; 81 | border-bottom-right-radius: 0; 82 | } 83 | 84 | .group :last-child { 85 | border-top-left-radius: 0; 86 | border-bottom-left-radius: 0; 87 | border-left-width: 0; 88 | } 89 | 90 | .group :not(:first-child):not(:last-child) { 91 | border-radius: 0; 92 | border-left-width: 0; 93 | border-right-width: 1px; 94 | } 95 | 96 | .stack-switcher > button > label { 97 | padding-left: 5px; 98 | padding-right: 5px; 99 | min-width: 50px; 100 | } 101 | 102 | .stack-switcher > button.text-button { 103 | padding-left: 5px; 104 | padding-right: 5px; 105 | min-width: 50px; 106 | } 107 | 108 | .playback { 109 | background-color: #000000; 110 | color: #ffffff; 111 | } 112 | -------------------------------------------------------------------------------- /app/ui/tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2022 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | from app.ui.dialogs import translate 28 | from .uicommons import Gtk, GLib 29 | 30 | 31 | class BGTaskWidget(Gtk.Box): 32 | """ Widget for displaying and running background tasks. """ 33 | 34 | TASK_LIMIT = 1 35 | 36 | def __init__(self, app, text, target, *args): 37 | super().__init__(spacing=2, orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.CENTER) 38 | self._app = app 39 | 40 | self._label = Gtk.Label(translate(text)) 41 | self.pack_start(self._label, False, False, 0) 42 | 43 | self._spinner = Gtk.Spinner(active=True) 44 | self.pack_start(self._spinner, False, False, 0) 45 | 46 | close_button = Gtk.Button.new_from_icon_name("window-close", Gtk.IconSize.MENU) 47 | close_button.set_relief(Gtk.ReliefStyle.NONE) 48 | close_button.set_valign(Gtk.Align.CENTER) 49 | close_button.set_tooltip_text(translate("Cancel")) 50 | close_button.set_name("task-button") 51 | close_button.connect("clicked", lambda b: self._app.emit("task-cancel", self)) 52 | self.pack_start(close_button, False, False, 0) 53 | 54 | self.show_all() 55 | 56 | # Just prototype. -> It may not work properly! 57 | # TODO: Different options need to be tested. Possibly with normal threads. 58 | from concurrent.futures import ThreadPoolExecutor 59 | 60 | self._executor = ThreadPoolExecutor(max_workers=self.TASK_LIMIT) 61 | future = self._executor.submit(target, *args) 62 | future.add_done_callback(lambda f: GLib.idle_add(self._app.emit, "task-done", self)) 63 | 64 | @property 65 | def text(self): 66 | return self._label.get_text() 67 | 68 | @text.setter 69 | def text(self, value): 70 | self._label.set_text(value) 71 | 72 | @property 73 | def tooltip(self): 74 | return self.get_tooltip_text() 75 | 76 | @tooltip.setter 77 | def tooltip(self, value): 78 | self.set_tooltip_text(value) 79 | 80 | def cancel(self): 81 | self._executor.shutdown(wait=False) 82 | self._app.emit("task-canceled", None) 83 | 84 | 85 | if __name__ == '__main__': 86 | pass 87 | -------------------------------------------------------------------------------- /app/ui/telnet.glade: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | Normal 39 | False 40 | 41 | 42 | 43 | 44 | tag_table 45 | 46 | 47 | True 48 | False 49 | 0.49000000953674316 50 | none 51 | 52 | 53 | True 54 | False 55 | 56 | 57 | True 58 | False 59 | 10 60 | 10 61 | 10 62 | 5 63 | vertical 64 | 5 65 | 66 | 67 | True 68 | False 69 | 5 70 | 5 71 | 5 72 | 5 73 | 74 | 75 | True 76 | True 77 | True 78 | Connect 79 | 80 | 81 | 82 | True 83 | False 84 | gtk-connect 85 | 86 | 87 | 88 | 89 | False 90 | True 91 | 0 92 | 93 | 94 | 95 | 96 | True 97 | True 98 | Disconnect 99 | 100 | 101 | 102 | True 103 | False 104 | gtk-disconnect 105 | 106 | 107 | 108 | 109 | False 110 | True 111 | 1 112 | 113 | 114 | 115 | 116 | True 117 | True 118 | True 119 | Clear 120 | center 121 | center 122 | 123 | 124 | 125 | True 126 | False 127 | gtk-clear 128 | 129 | 130 | 131 | 132 | False 133 | True 134 | 2 135 | 136 | 137 | 138 | 139 | False 140 | True 141 | 0 142 | 143 | 144 | 145 | 146 | True 147 | True 148 | in 149 | 150 | 151 | textview-large 152 | True 153 | True 154 | char 155 | 5 156 | 5 157 | text_buffer 158 | True 159 | GTK_INPUT_HINT_WORD_COMPLETION | GTK_INPUT_HINT_NONE 160 | True 161 | 162 | 163 | 164 | 165 | 166 | 167 | True 168 | True 169 | 1 170 | 171 | 172 | 173 | 174 | 177 | 178 | 179 | 180 | 181 | True 182 | False 183 | 2 184 | Telnet 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /app/ui/telnet.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2025 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | import re 30 | import socket 31 | from collections import deque 32 | 33 | from gi.repository import GLib 34 | 35 | from app.commons import run_task, run_idle, log 36 | from app.connections import ExtTelnet 37 | from app.ui.uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, MOD_MASK 38 | 39 | 40 | class TelnetClient(Gtk.Box): 41 | """ Very simple telnet client. """ 42 | _COLOR_PATTERN = re.compile("\x1b.*?m") # Color info 43 | _ERASING_PATTERN = re.compile("\x1b.*?K") # Erase to right 44 | _APP_MODE_PATTERN = re.compile("\x1b.*?(1h)|(1l)") # h - on, l - off 45 | _ALL_PATTERN = re.compile(r'(\x1b\[|\x9b)[0-?]*[@-~]') 46 | _NOT_SUPPORTED = {"mc", "mcedit", "vi", "nano"} 47 | 48 | def __init__(self, app, *args, **kwargs): 49 | super().__init__(*args, **kwargs) 50 | self._app = app 51 | self._app.connect("profile-changed", self.on_profile_changed) 52 | 53 | self._tn = None 54 | self._app_mode = False 55 | self._commands = deque(maxlen=10) 56 | 57 | self._handlers = {"on_clear": self.on_clear, 58 | "on_text_view_realize": self.on_text_view_realize, 59 | "on_view_key_press": self.on_view_key_press, 60 | "on_connect": self.on_connect, 61 | "on_disconnect": self.on_disconnect} 62 | 63 | builder = Gtk.Builder() 64 | builder.add_from_file(UI_RESOURCES_PATH + "telnet.glade") 65 | builder.connect_signals(self._handlers) 66 | 67 | self._text_view = builder.get_object("text_view") 68 | self._buf = builder.get_object("text_buffer") 69 | self._end_tag = builder.get_object("end_tag") 70 | self._connect_button = builder.get_object("connect_button") 71 | self._connect_button.bind_property("visible", builder.get_object("disconnect_button"), "visible", 4) 72 | 73 | main_frame = builder.get_object("telnet_frame") 74 | provider = Gtk.CssProvider() 75 | provider.load_from_path(UI_RESOURCES_PATH + "style.css") 76 | main_frame.get_style_context().add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) 77 | 78 | self.pack_start(main_frame, True, True, 0) 79 | self.show() 80 | 81 | def on_profile_changed(self, app, data): 82 | self.on_clear() 83 | self.on_disconnect() 84 | self.on_connect() 85 | 86 | def on_text_view_realize(self, view): 87 | self.on_connect() 88 | 89 | @run_task 90 | def on_connect(self, item=None): 91 | try: 92 | GLib.idle_add(self._connect_button.set_visible, False) 93 | settings = self._app.app_settings 94 | user, password, timeout = settings.user, settings.password, settings.telnet_timeout 95 | self._tn = ExtTelnet(self.append_output, host=settings.host, port=settings.telnet_port, timeout=timeout) 96 | 97 | if user != "": 98 | self._tn.read_until(b"login: ") 99 | self._tn.write(user.encode("utf-8") + b"\n") 100 | if password != "": 101 | self._tn.read_until(b"Password: ") 102 | self._tn.write(password.encode("utf-8") + b"\n") 103 | 104 | self._tn.interact() 105 | except (OSError, EOFError, socket.timeout, ConnectionRefusedError) as e: 106 | log(f"{self.__class__.__name__}: {e}") 107 | self._app.show_info_message(str(e), Gtk.MessageType.ERROR) 108 | finally: 109 | GLib.idle_add(self._connect_button.set_visible, True) 110 | 111 | @run_task 112 | def on_disconnect(self, item=None): 113 | if self._tn: 114 | GLib.idle_add(self._connect_button.set_visible, True) 115 | self._tn.close() 116 | 117 | def on_command_done(self, entry): 118 | command = entry.get_text() 119 | entry.set_text("") 120 | if command and self._tn: 121 | self._tn.write(command.encode("ascii") + b"\r") 122 | 123 | def on_clear(self, item=None): 124 | self._buf.delete(self._buf.get_start_iter(), self._buf.get_end_iter()) 125 | 126 | def on_view_key_press(self, view, event): 127 | """ Handling keystrokes on press. """ 128 | if event.keyval == Gdk.KEY_Return: 129 | self.do_command() 130 | return True 131 | 132 | key_code = event.hardware_keycode 133 | if not KeyboardKey.value_exist(key_code): 134 | return None 135 | 136 | key = KeyboardKey(key_code) 137 | ctrl = event.state & MOD_MASK 138 | if ctrl and key is KeyboardKey.C: 139 | if self._tn and self._tn.sock: 140 | self._tn.write(b"\x03") # interrupt 141 | 142 | # Last commands navigation. 143 | if key is KeyboardKey.UP: 144 | self.delete_last_command() 145 | if self._commands: 146 | cmd = self._commands.pop() 147 | self._commands.appendleft(cmd) 148 | self._buf.insert_at_cursor(cmd, -1) 149 | return True 150 | elif key is KeyboardKey.DOWN: 151 | self.delete_last_command() 152 | if self._commands: 153 | cmd = self._commands.popleft() 154 | self._commands.append(cmd) 155 | self._buf.insert_at_cursor(cmd, -1) 156 | return True 157 | return False 158 | 159 | def delete_last_command(self): 160 | end = self._buf.get_end_iter() 161 | if end.ends_tag(self._end_tag): 162 | return 163 | 164 | if end.backward_to_tag_toggle(self._end_tag): 165 | self._buf.delete(self._buf.get_end_iter(), end) 166 | 167 | def do_command(self): 168 | count = self._buf.get_line_count() 169 | begin = self._buf.get_iter_at_line(count) 170 | end = self._buf.get_end_iter() 171 | command = [] 172 | 173 | while end.backward_to_tag_toggle(self._end_tag): 174 | command.append(self._buf.get_text(end, begin, False)) 175 | break 176 | else: # if buf is empty 177 | command.append(self._buf.get_text(begin, end, False)) 178 | 179 | # To preventing duplication of the command in the buf. 180 | self._buf.delete(end, begin) 181 | 182 | if command and self._tn.sock: 183 | cmd = command[0] 184 | if cmd in self._NOT_SUPPORTED: 185 | self._app.show_info_message(f"'{cmd}' is not supported by this client.", Gtk.MessageType.ERROR) 186 | else: 187 | self._tn.write(cmd.encode("ascii") + b"\r") 188 | self._commands.append(cmd) 189 | 190 | @run_idle 191 | def append_output(self, txt): 192 | t = txt.decode("ascii", errors="ignore") 193 | 194 | ap = re.search(self._APP_MODE_PATTERN, t) 195 | if ap: 196 | on, of = ap.group(1), ap.group(2) 197 | if on: 198 | self._app_mode = True 199 | elif of: 200 | self._app_mode = False 201 | self.on_clear() 202 | 203 | t = re.sub(self._ALL_PATTERN, "", t) # Removing [replacing] ascii escape sequences. 204 | 205 | if self._app_mode: 206 | start, end = self._buf.get_start_iter(), self._buf.get_end_iter() 207 | count = self._buf.get_line_count() 208 | new_lines = t.split("\r\n") 209 | ext_lines = self._buf.get_text(start, end, True).split("\r\n") 210 | if count < len(new_lines): 211 | self._buf.set_text(re.sub(self._ERASING_PATTERN, "", t)) 212 | else: 213 | for i, line in enumerate(new_lines): 214 | if line: 215 | ext_lines[i] = re.sub(self._ERASING_PATTERN, "", line) 216 | self._buf.set_text("\r\n".join(ext_lines)) 217 | else: 218 | self._buf.insert_at_cursor(t, -1) 219 | 220 | insert = self._buf.get_insert() 221 | self._text_view.scroll_to_mark(insert, 0.0, True, 0.0, 1.0) 222 | self._buf.apply_tag(self._end_tag, self._buf.get_start_iter(), self._buf.get_end_iter()) 223 | 224 | 225 | if __name__ == "__main__": 226 | pass 227 | -------------------------------------------------------------------------------- /app/ui/transmitter.glade: -------------------------------------------------------------------------------- 1 | 2 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | False 39 | False 40 | mouse 41 | True 42 | True 43 | True 44 | False 45 | center 46 | True 47 | 48 | 49 | 50 | 51 | 52 | True 53 | False 54 | 1 55 | 56 | 57 | True 58 | True 59 | True 60 | Previous stream in the list 61 | center 62 | center 63 | 1 64 | 1 65 | 1 66 | 67 | 68 | 69 | True 70 | False 71 | gtk-media-previous 72 | 73 | 74 | 75 | 76 | False 77 | True 78 | 0 79 | 80 | 81 | 82 | 83 | True 84 | True 85 | True 86 | Next stream in the list 87 | center 88 | center 89 | 1 90 | 1 91 | 92 | 93 | 94 | True 95 | False 96 | gtk-media-next 97 | 98 | 99 | 100 | 101 | False 102 | True 103 | 1 104 | 105 | 106 | 107 | 108 | True 109 | True 110 | Drag or paste the link here 111 | 2 112 | 2 113 | 1 114 | 1 115 | gtk-paste 116 | 117 | 118 | 119 | 120 | 121 | False 122 | True 123 | 2 124 | 125 | 126 | 127 | 128 | True 129 | True 130 | True 131 | Play 132 | center 133 | center 134 | 1 135 | 1 136 | 137 | 138 | 139 | True 140 | False 141 | gtk-media-play 142 | 143 | 144 | 145 | 146 | False 147 | True 148 | 3 149 | 150 | 151 | 152 | 153 | True 154 | True 155 | True 156 | Stop playback 157 | center 158 | center 159 | 1 160 | 1 161 | 162 | 163 | 164 | True 165 | False 166 | gtk-media-stop 167 | 168 | 169 | 170 | 171 | False 172 | True 173 | 4 174 | 175 | 176 | 177 | 178 | True 179 | True 180 | True 181 | Remove added links in the playlist 182 | center 183 | center 184 | 1 185 | 1 186 | 1 187 | 188 | 189 | 190 | True 191 | False 192 | gtk-clear 193 | 194 | 195 | 196 | 197 | False 198 | True 199 | 6 200 | 201 | 202 | 205 | 206 | 207 | 208 | 209 | True 210 | False 211 | view-restore 212 | 213 | 214 | True 215 | False 216 | 217 | 218 | Show/Hide 219 | True 220 | False 221 | show_image 222 | False 223 | 224 | 225 | 226 | 227 | 228 | demon-editor 229 | True 230 | 231 | 232 | 233 | 234 | -------------------------------------------------------------------------------- /app/ui/transmitter.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from urllib.parse import urlparse 3 | 4 | import gi 5 | from gi.repository import GLib 6 | 7 | from app.commons import log 8 | from app.connections import HttpAPI 9 | from app.tools.yt import YouTube 10 | from app.ui.dialogs import get_builder 11 | from app.ui.iptv import get_yt_icon 12 | from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH 13 | 14 | 15 | class LinksTransmitter: 16 | """ The main class for the "send to" function. 17 | 18 | It used for direct playback of media links by the enigma2 media player. 19 | """ 20 | __STREAM_PREFIX = "4097:0:1:0:0:0:0:0:0:0:" 21 | 22 | def __init__(self, http_api, app_window, settings): 23 | handlers = {"on_popup_menu": self.on_popup_menu, 24 | "on_status_icon_activate": self.on_status_icon_activate, 25 | "on_url_changed": self.on_url_changed, 26 | "on_url_activate": self.on_url_activate, 27 | "on_drag_data_received": self.on_drag_data_received, 28 | "on_previous": self.on_previous, 29 | "on_next": self.on_next, 30 | "on_stop": self.on_stop, 31 | "on_clear": self.on_clear, 32 | "on_play": self.on_play} 33 | 34 | self._http_api = http_api 35 | self._app_window = app_window 36 | self._is_status_icon = True 37 | 38 | builder = get_builder(UI_RESOURCES_PATH + "transmitter.glade", handlers) 39 | 40 | self._main_window = builder.get_object("main_window") 41 | self._url_entry = builder.get_object("url_entry") 42 | self._tool_bar = builder.get_object("tool_bar") 43 | self._popup_menu = builder.get_object("staus_popup_menu") 44 | self._restore_menu_item = builder.get_object("restore_menu_item") 45 | self._status_active = None 46 | self._status_passive = None 47 | self._yt = YouTube.get_instance(settings) 48 | 49 | try: 50 | gi.require_version("AppIndicator3", "0.1") 51 | from gi.repository import AppIndicator3 52 | except (ImportError, ValueError) as e: 53 | log("{}: Load library error: {}".format(__class__.__name__, e)) 54 | self._tray = builder.get_object("status_icon") 55 | else: 56 | self._is_status_icon = False 57 | self._status_active = AppIndicator3.IndicatorStatus.ACTIVE 58 | self._status_passive = AppIndicator3.IndicatorStatus.PASSIVE 59 | 60 | category = AppIndicator3.IndicatorCategory.APPLICATION_STATUS 61 | path = Path(UI_RESOURCES_PATH + "/icons/hicolor/scalable/apps/demon-editor.svg") 62 | path = str(path.resolve()) if path.is_file() else "demon-editor" 63 | self._tray = AppIndicator3.Indicator.new("DemonEditor", path, category) 64 | self._tray.set_status(self._status_active) 65 | self._tray.set_secondary_activate_target(builder.get_object("show_menu_item")) 66 | self._tray.set_menu(self._popup_menu) 67 | 68 | style_provider = Gtk.CssProvider() 69 | style_provider.load_from_path(UI_RESOURCES_PATH + "style.css") 70 | self._url_entry.get_style_context().add_provider_for_screen(Gdk.Screen.get_default(), style_provider, 71 | Gtk.STYLE_PROVIDER_PRIORITY_USER) 72 | 73 | def show(self, show): 74 | if self._is_status_icon: 75 | self._tray.set_visible(show) 76 | elif self._status_active: 77 | self._tray.set_status(self._status_active if show else self._status_passive) 78 | if not show: 79 | self.hide() 80 | 81 | def hide(self): 82 | self._main_window.hide() 83 | 84 | def on_popup_menu(self, menu, button, time): 85 | menu.popup(None, None, None, None, button, time) 86 | 87 | def on_status_icon_activate(self, window): 88 | visible = window.get_visible() 89 | window.hide() if visible else window.show() 90 | self._app_window.present() if visible else self._app_window.iconify() 91 | 92 | def on_url_changed(self, entry): 93 | entry.set_name("GtkEntry" if self.is_url(entry.get_text()) else "digit-entry") 94 | 95 | def on_url_activate(self, entry): 96 | gen = self.activate_url(entry.get_text()) 97 | GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW) 98 | 99 | def on_drag_data_received(self, entry, drag_context, x, y, data, info, time): 100 | url = data.get_text() 101 | GLib.idle_add(entry.set_text, url) 102 | gen = self.activate_url(url) 103 | GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW) 104 | 105 | def activate_url(self, url): 106 | self._url_entry.set_name("GtkEntry") 107 | self._url_entry.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, None) 108 | 109 | if self.is_url(url): 110 | self._tool_bar.set_sensitive(False) 111 | yt_id = YouTube.get_yt_id(url) 112 | yield True 113 | 114 | if yt_id: 115 | self._url_entry.set_icon_from_pixbuf(Gtk.EntryIconPosition.SECONDARY, get_yt_icon("youtube", 32)) 116 | links, title = self._yt.get_yt_link(yt_id, url) 117 | yield True 118 | if links: 119 | url = links[sorted(links, key=lambda x: int(x.rstrip("p")), reverse=True)[0]] 120 | else: 121 | self.on_done(links) 122 | return 123 | else: 124 | self._url_entry.set_icon_from_stock(Gtk.EntryIconPosition.SECONDARY, None) 125 | 126 | self._http_api.send(HttpAPI.Request.PLAY, url, self.on_done, self.__STREAM_PREFIX) 127 | yield True 128 | 129 | def on_done(self, res): 130 | """ Play callback """ 131 | res = res.get("e2state", None) if res else res 132 | self._url_entry.set_name("GtkEntry" if res else "digit-entry") 133 | GLib.idle_add(self._tool_bar.set_sensitive, True) 134 | 135 | def on_previous(self, item): 136 | self._http_api.send(HttpAPI.Request.PLAYER_PREV, None, self.on_done) 137 | 138 | def on_next(self, item): 139 | self._http_api.send(HttpAPI.Request.PLAYER_NEXT, None, self.on_done) 140 | 141 | def on_play(self, item): 142 | self._http_api.send(HttpAPI.Request.PLAYER_PLAY, None, self.on_done) 143 | 144 | def on_stop(self, item): 145 | self._http_api.send(HttpAPI.Request.PLAYER_STOP, None, self.on_done) 146 | 147 | def on_clear(self, item): 148 | """ Remove added links in the playlist. """ 149 | GLib.idle_add(self._tool_bar.set_sensitive, False) 150 | self._http_api.send(HttpAPI.Request.PLAYER_LIST, None, self.clear_playlist) 151 | 152 | def clear_playlist(self, res): 153 | GLib.idle_add(self._tool_bar.set_sensitive, not res) 154 | if "error_code" in res: 155 | log("Error clearing playlist. There may be no http connection.") 156 | self.on_done(res) 157 | return 158 | 159 | for ref in res: 160 | GLib.idle_add(self._tool_bar.set_sensitive, False) 161 | self._http_api.send(HttpAPI.Request.PLAYER_REMOVE, 162 | ref.get("e2servicereference", ""), 163 | self.on_done, 164 | self.__STREAM_PREFIX) 165 | 166 | @staticmethod 167 | def is_url(text): 168 | """ Simple url checking. """ 169 | result = urlparse(text) 170 | return result.scheme and result.netloc 171 | 172 | 173 | if __name__ == "__main__": 174 | pass 175 | -------------------------------------------------------------------------------- /app/ui/uicommons.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2018-2023 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | 29 | import locale 30 | import os 31 | from enum import Enum, IntEnum 32 | from functools import lru_cache 33 | 34 | import gi 35 | 36 | gi.require_version("Gtk", "3.0") 37 | gi.require_version("Gdk", "3.0") 38 | from gi.repository import Gtk, Gdk, GLib 39 | 40 | from app.settings import Settings, SettingsException, IS_DARWIN, IS_LINUX, GTK_PATH 41 | 42 | # Setting mod mask for keyboard depending on platform 43 | MOD_MASK = Gdk.ModifierType.MOD2_MASK if IS_DARWIN else Gdk.ModifierType.CONTROL_MASK 44 | # Paths. 45 | BASE_PATH = "app/ui/" 46 | EX_PATH = "/usr/share/demoneditor/app/ui/" if IS_LINUX else "ui/" 47 | # Path to *.glade files. 48 | UI_RESOURCES_PATH = BASE_PATH if os.path.exists(BASE_PATH) else EX_PATH 49 | # Translation. 50 | LANG_PATH = UI_RESOURCES_PATH + "lang" 51 | TEXT_DOMAIN = "demon-editor" 52 | 53 | NOTIFY_IS_INIT = False 54 | APP_FONT = None 55 | 56 | try: 57 | settings = Settings.get_instance() 58 | except SettingsException: 59 | pass 60 | else: 61 | os.environ["LANGUAGE"] = settings.language 62 | st = Gtk.Settings().get_default() 63 | APP_FONT = st.get_property("gtk-font-name") 64 | st.set_property("gtk-application-prefer-dark-theme", settings.dark_mode) 65 | 66 | if settings.is_themes_support: 67 | st.set_property("gtk-theme-name", settings.theme) 68 | st.set_property("gtk-icon-theme-name", settings.icon_theme) 69 | else: 70 | if not IS_LINUX: 71 | if IS_DARWIN: 72 | s_path = f"{GTK_PATH + '/' + UI_RESOURCES_PATH if GTK_PATH else UI_RESOURCES_PATH}mac_style.css" 73 | else: 74 | s_path = f"{UI_RESOURCES_PATH}win_style.css" 75 | 76 | style_provider = Gtk.CssProvider() 77 | style_provider.load_from_path(s_path) 78 | Gtk.StyleContext.add_provider_for_screen(Gdk.Screen.get_default(), style_provider, 79 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) 80 | 81 | if IS_LINUX: 82 | if UI_RESOURCES_PATH == BASE_PATH: 83 | locale.bindtextdomain(TEXT_DOMAIN, LANG_PATH) 84 | # Init notify 85 | try: 86 | gi.require_version("Notify", "0.7") 87 | from gi.repository import Notify 88 | except (ImportError, ValueError): 89 | pass # NOP 90 | else: 91 | NOTIFY_IS_INIT = Notify.init("DemonEditor") 92 | elif IS_DARWIN: 93 | import gettext 94 | 95 | if GTK_PATH: 96 | LANG_PATH = GTK_PATH + "/share/locale" 97 | gettext.bindtextdomain(TEXT_DOMAIN, LANG_PATH) 98 | # For launching from the bundle. 99 | if os.getcwd() == "/" and GTK_PATH: 100 | os.chdir(GTK_PATH) 101 | else: 102 | locale.setlocale(locale.LC_NUMERIC, "C") 103 | 104 | # Icons. 105 | theme = Gtk.IconTheme.get_default() 106 | theme.append_search_path(UI_RESOURCES_PATH + "icons") 107 | 108 | 109 | def get_icon(name, size, default=None): 110 | try: 111 | return theme.load_icon(name, size, 0) if theme.lookup_icon(name, size, 0) else default 112 | except GLib.Error: 113 | return default 114 | 115 | 116 | _IMAGE_MISSING = get_icon("image-missing", 16) 117 | CODED_ICON = get_icon("emblem-readonly", 16, _IMAGE_MISSING) 118 | LOCKED_ICON = get_icon("changes-prevent-symbolic", 16, _IMAGE_MISSING) 119 | HIDE_ICON = get_icon("go-jump", 16, _IMAGE_MISSING) 120 | TV_ICON = get_icon("tv-symbolic", 16, _IMAGE_MISSING) 121 | IPTV_ICON = get_icon("emblem-shared", 16, _IMAGE_MISSING) 122 | LINK_ICON = get_icon("emblem-symbolic-link", 16, _IMAGE_MISSING) 123 | FOLDER_ICON = get_icon("folder-symbolic" if IS_DARWIN else "folder", 16, _IMAGE_MISSING) 124 | EPG_ICON = get_icon("gtk-index", 16, _IMAGE_MISSING) 125 | DEFAULT_ICON = get_icon("emblem-default", 16, get_icon("emblem-default-symbolic", 16, _IMAGE_MISSING)) 126 | 127 | 128 | @lru_cache(maxsize=1) 129 | def get_yt_icon(icon_name, size=24): 130 | """ Getting YouTube icon. 131 | 132 | If the icon is not found in the icon themes, the "Info" icon is returned by default! 133 | """ 134 | default_theme = Gtk.IconTheme.get_default() 135 | if default_theme.has_icon(icon_name): 136 | return default_theme.load_icon(icon_name, size, 0) 137 | 138 | n_theme = Gtk.IconTheme.new() 139 | import glob 140 | 141 | for theme_name in map(os.path.basename, filter(os.path.isdir, glob.glob("/usr/share/icons/*"))): 142 | n_theme.set_custom_theme(theme_name) 143 | if n_theme.has_icon(icon_name): 144 | return n_theme.load_icon(icon_name, size, 0) 145 | 146 | return default_theme.load_icon("emblem-important-symbolic", size, 0) 147 | 148 | 149 | def show_notification(message, timeout=10000, urgency=1): 150 | """ Shows notification. 151 | 152 | @param message: text to display 153 | @param timeout: milliseconds 154 | @param urgency: 0 - low, 1 - normal, 2 - critical 155 | """ 156 | if IS_DARWIN: 157 | # Since NSUserNotification has been deprecated, osascript will be used. 158 | os.system("""osascript -e 'display notification "{}" with title "DemonEditor"'""".format(message)) 159 | elif NOTIFY_IS_INIT: 160 | notify = Notify.Notification.new("DemonEditor", message, "demon-editor") 161 | notify.set_urgency(urgency) 162 | notify.set_timeout(timeout) 163 | notify.show() 164 | 165 | 166 | class HeaderBar(Gtk.HeaderBar): 167 | """ Custom header bar widget. """ 168 | 169 | def __init__(self, **kwargs): 170 | super().__init__(**kwargs) 171 | self.set_visible(True) 172 | self.set_show_close_button(True) 173 | 174 | if IS_DARWIN: 175 | self.set_decoration_layout("close,minimize,maximize") 176 | 177 | 178 | class Page(Enum): 179 | """ Main stack widget page. """ 180 | INFO = "info" 181 | SERVICES = "services" 182 | SATELLITE = "satellite" 183 | PICONS = "picons" 184 | EPG = "epg" 185 | TIMERS = "timers" 186 | RECORDINGS = "recordings" 187 | FTP = "ftp" 188 | CONTROL = "control" 189 | 190 | 191 | class ViewTarget(Enum): 192 | """ Used for set target view. """ 193 | BOUQUET = 0 194 | FAV = 1 195 | SERVICES = 2 196 | IPTV = 3 197 | 198 | 199 | class BqGenType(Enum): 200 | """ Bouquet generation type. """ 201 | SAT = 0 202 | EACH_SAT = 1 203 | PACKAGE = 2 204 | EACH_PACKAGE = 3 205 | TYPE = 4 206 | EACH_TYPE = 5 207 | 208 | 209 | class Column(IntEnum): 210 | """ Column nums in the views """ 211 | # Main view 212 | SRV_CAS_FLAGS = 0 213 | SRV_STANDARD = 1 214 | SRV_CODED = 2 215 | SRV_SERVICE = 3 216 | SRV_LOCKED = 4 217 | SRV_HIDE = 5 218 | SRV_PACKAGE = 6 219 | SRV_TYPE = 7 220 | SRV_PICON = 8 221 | SRV_PICON_ID = 9 222 | SRV_SSID = 10 223 | SRV_FREQ = 11 224 | SRV_RATE = 12 225 | SRV_POL = 13 226 | SRV_FEC = 14 227 | SRV_SYSTEM = 15 228 | SRV_POS = 16 229 | SRV_DATA_ID = 17 230 | SRV_FAV_ID = 18 231 | SRV_TRANSPONDER = 19 232 | SRV_TOOLTIP = 20 233 | SRV_BACKGROUND = 21 234 | # FAV view 235 | FAV_NUM = 0 236 | FAV_CODED = 1 237 | FAV_SERVICE = 2 238 | FAV_LOCKED = 3 239 | FAV_HIDE = 4 240 | FAV_TYPE = 5 241 | FAV_POS = 6 242 | FAV_ID = 7 243 | FAV_PICON = 8 244 | FAV_TOOLTIP = 9 245 | FAV_BACKGROUND = 10 246 | # Bouquets view 247 | BQ_NAME = 0 248 | BQ_LOCKED = 1 249 | BQ_HIDDEN = 2 250 | BQ_TYPE = 3 251 | # Alternatives view 252 | ALT_NUM = 0 253 | ALT_PICON = 1 254 | ALT_SERVICE = 2 255 | ALT_TYPE = 3 256 | ALT_POS = 4 257 | ALT_FAV_ID = 5 258 | ALT_ID = 6 259 | ALT_ITER = 7 260 | # Recordings view 261 | REC_SERVICE = 0 262 | REC_TITLE = 1 263 | REC_TIME = 2 264 | REC_LEN = 3 265 | REC_FILE = 4 266 | REC_DESC = 5 267 | # IPTV view 268 | IPTV_SERVICE = 0 269 | IPTV_TYPE = 1 270 | IPTV_PICON = 2 271 | IPTV_REF = 3 272 | IPTV_URL = 4 273 | IPTV_FAV_ID = 5 274 | IPTV_PICON_ID = 6 275 | IPTV_TOOLTIP = 7 276 | # EPG view 277 | EPG_SERVICE = 0 278 | EPG_TITLE = 1 279 | EPG_START = 2 280 | EPG_END = 3 281 | EPG_LENGTH = 4 282 | EPG_DESC = 5 283 | EPG_DATA = 6 284 | 285 | def __index__(self): 286 | """ Overridden to get the index in slices directly """ 287 | return self.value 288 | 289 | 290 | # *************** Keyboard keys *************** # 291 | 292 | class BaseKeyboardKey(Enum): 293 | @classmethod 294 | def value_exist(cls, value): 295 | return value in (val.value for val in cls.__members__.values()) 296 | 297 | 298 | if IS_LINUX: 299 | class KeyboardKey(BaseKeyboardKey): 300 | """ The raw(hardware) codes [Linux] of the keyboard keys. """ 301 | E = 26 302 | R = 27 303 | T = 28 304 | P = 33 305 | S = 39 306 | F = 41 307 | X = 53 308 | C = 54 309 | V = 55 310 | W = 25 311 | Z = 52 312 | INSERT = 118 313 | HOME = 110 314 | END = 115 315 | UP = 111 316 | DOWN = 116 317 | PAGE_UP = 112 318 | PAGE_DOWN = 117 319 | LEFT = 113 320 | RIGHT = 114 321 | F2 = 68 322 | F4 = 70 323 | F5 = 71 324 | F7 = 73 325 | SPACE = 65 326 | DELETE = 119 327 | BACK_SPACE = 22 328 | RETURN = 36 329 | CTRL_L = 37 330 | CTRL_R = 105 331 | # Laptop codes 332 | HOME_KP = 79 333 | END_KP = 87 334 | PAGE_UP_KP = 81 335 | PAGE_DOWN_KP = 89 336 | 337 | elif IS_DARWIN: 338 | class KeyboardKey(BaseKeyboardKey): 339 | """ The raw(hardware) codes [macOS] of the keyboard keys. """ 340 | F = 3 341 | E = 14 342 | R = 15 343 | T = 17 344 | P = 35 345 | S = 1 346 | H = 4 347 | L = 37 348 | X = 7 349 | C = 8 350 | V = 9 351 | W = 13 352 | Z = 6 353 | INSERT = -1 354 | HOME = -1 355 | END = -1 356 | UP = 126 357 | DOWN = 125 358 | PAGE_UP = -1 359 | PAGE_DOWN = -1 360 | LEFT = 123 361 | RIGHT = 123 362 | F2 = 120 363 | F4 = 118 364 | F5 = 96 365 | F7 = 98 366 | SPACE = 49 367 | DELETE = 51 368 | BACK_SPACE = 76 369 | RETURN = 36 370 | CTRL_L = 55 371 | CTRL_R = 55 372 | # Laptop codes. 373 | HOME_KP = -1 374 | END_KP = -1 375 | PAGE_UP_KP = -1 376 | PAGE_DOWN_KP = -1 377 | 378 | else: 379 | class KeyboardKey(BaseKeyboardKey): 380 | """ The raw(hardware) codes [Windows] of the keyboard keys. """ 381 | E = 69 382 | R = 82 383 | T = 84 384 | P = 80 385 | S = 83 386 | F = 70 387 | X = 88 388 | C = 67 389 | V = 86 390 | W = 87 391 | Z = 90 392 | INSERT = 45 393 | HOME = 36 394 | END = 35 395 | UP = 38 396 | DOWN = 40 397 | PAGE_UP = 33 398 | PAGE_DOWN = 34 399 | LEFT = 37 400 | RIGHT = 39 401 | F2 = 113 402 | F4 = 115 403 | F5 = 116 404 | F7 = 118 405 | SPACE = 32 406 | DELETE = 46 407 | BACK_SPACE = 8 408 | RETURN = 13 409 | CTRL_L = 17 410 | CTRL_R = 163 411 | # Laptop codes. 412 | HOME_KP = -1 413 | END_KP = -1 414 | PAGE_UP_KP = -1 415 | PAGE_DOWN_KP = -1 416 | 417 | # Keys for move in lists. KEY_KP_(NAME) for laptop! 418 | MOVE_KEYS = {KeyboardKey.UP, KeyboardKey.PAGE_UP, 419 | KeyboardKey.DOWN, KeyboardKey.PAGE_DOWN, 420 | KeyboardKey.HOME, KeyboardKey.END, 421 | KeyboardKey.HOME_KP, KeyboardKey.END_KP, 422 | KeyboardKey.PAGE_UP_KP, KeyboardKey.PAGE_DOWN_KP} 423 | 424 | if __name__ == "__main__": 425 | pass 426 | -------------------------------------------------------------------------------- /app/ui/win_style.css: -------------------------------------------------------------------------------- 1 | * { 2 | -GtkDialog-action-area-border: 12; 3 | } 4 | 5 | switch { 6 | margin-right: 2px; 7 | } 8 | 9 | spinbutton entry { 10 | min-height: 16px; 11 | } 12 | 13 | button > image { 14 | padding: 2px; 15 | } 16 | 17 | grid > button { 18 | padding-left: 15px; 19 | padding-right: 15px; 20 | } 21 | 22 | popover .view { 23 | background-color: transparent; 24 | } 25 | 26 | headerbar .titlebutton > image { 27 | padding: 0; 28 | } 29 | -------------------------------------------------------------------------------- /app/ui/xml/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/app/ui/xml/__init__.py -------------------------------------------------------------------------------- /build/BUILDING.md: -------------------------------------------------------------------------------- 1 | ## Building DemonEditor 2 | This directory contains build scripts and additional files for various platforms and distributions. 3 | 4 | ### Supported platforms 5 | * GNU/Linux 6 | * macOS 7 | * MS Windows -------------------------------------------------------------------------------- /build/BUILD_WIN.md: -------------------------------------------------------------------------------- 1 | ## Launch 2 | The best way to run this program from source is using of [MSYS2](https://www.msys2.org/) platform. 3 | 1. Download and install the platform as described [here](https://www.msys2.org/) up to point 4. 4 | 2. Launch **mingw64** shell. 5 | ![mingw64](https://user-images.githubusercontent.com/7511379/161400639-898ceb10-7de8-4557-bde1-25fe32bdfb03.png) 6 | 3. Run first `pacman -Suy` After that, you may need to restart the terminal and re-run the update command. 7 | 4. Install minimal required packages: 8 | `pacman -S mingw-w64-x86_64-gtk3 mingw-w64-x86_64-python3 mingw-w64-x86_64-python3-gobject mingw-w64-x86_64-python3-pip mingw-w64-x86_64-python3-requests` 9 | Optional: `pacman -S mingw-w64-x86_64-python3-pillow` 10 | To support streams playback, install the following packages (the list may not be complete): 11 | * For [GStreamer](https://gstreamer.freedesktop.org/) `pacman -S mingw-w64-x86_64-gst-libav mingw-w64-x86_64-gst-plugins-bad mingw-w64-x86_64-gst-plugins-base mingw-w64-x86_64-gst-plugins-good mingw-w64-x86_64-gstreamer` 12 | * For [MPV](https://mpv.io/) `pacman -S mingw-w64-x86_64-mpv`, 13 | To reduce installation size or try the latest changes, we can install the *libmpv* [build](https://github.com/shinchiro/mpv-winbuild-cmake/releases) (**mpv-dev**-x86_64-v3-*.7z) by [shinchiro](https://github.com/shinchiro). 14 | * Download and extract 7z archive. 15 | * Copy libmpv-2.dll to *C:\msys64\mingw64\bin* 16 | * libmpv.dll.a to *C:\msys64\mingw64\lib* 17 | and folder *include\mpv to *C:\msys64\mingw64\include* path. 18 | 19 | 5. Download and unzip the archive with sources from preferred branch (e.g. [master](https://github.com/DYefremov/DemonEditor/archive/refs/heads/master.zip)) in to folder where MSYS2 is installed. E.g: `c:\msys64\home\username\` 20 | 6. Run mingw64 shell. Go to the folder where the program was unpacked. E.g: `cd DemonEditor/` 21 | And run: `./start.py` 22 | 23 | ## Building a package 24 | To build a standalone package, we can use [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/). 25 | 1. Launch mingw64 shell. 26 | 2. Install PyInstaller via pip: `pip3 install pyinstaller` 27 | 3. Go to the folder where the program was unpacked. E.g: `c:\msys64\home\username\DemonEditor\` 28 | 4. Сopy and replace the files from the /build/win/ folder to the root . 29 | 5. Go to the folder with the program in the running terminal: `cd DemonEditor/` 30 | 6. Give the following command: `pyinstaller.exe DemonEditor.spec` 31 | 7. Wait until the operation end. In the dist folder you will find a ready-made build. 32 | 33 | ### Appearance 34 | To change the look we can use third party [Gtk3 themes and Icon sets](https://www.gnome-look.org). 35 | To set the default theme: 36 | 1. Сreate a folder "`\etc\gtk-3.0\`" in the root of the finished build folder. 37 | 2. Create a _settings.ini_ file in this folder with the following content: 38 | ``` 39 | [Settings] 40 | gtk-icon-theme-name = Adwaita 41 | gtk-theme-name = Windows-10 42 | ``` 43 | In this case, we are using the default icon theme "Adwaita" and the [third party theme](https://github.com/B00merang-Project/Windows-10) "Windows-10". 44 | Themes and icon sets should be located in the `share\themes` and `share\icons` folders respectively. 45 | To fine-tune the default theme you use, you can use the _win_style.css_ file in the `ui` folder. 46 | You can find more info about changing the appearance of Gtk applications on the Web yourself. 47 | -------------------------------------------------------------------------------- /build/linux/ALTLinux spec/demon-editor-2.0-development-startfix.patch: -------------------------------------------------------------------------------- 1 | diff -Nru demon-editor-2.0-development-orig/DemonEditor.desktop demon-editor-2.0-development/DemonEditor.desktop 2 | --- demon-editor-2.0-development-orig/DemonEditor.desktop 2021-10-14 21:32:56.000000000 +0300 3 | +++ demon-editor-2.0-development/DemonEditor.desktop 2021-09-29 13:19:24.000000000 +0300 4 | @@ -6,8 +6,8 @@ 5 | Comment[be]=Рэдактар спіса каналаў і спадарожнікаў для Enigma2 6 | Comment[de]=Programm- und Satellitenlisten-Editor für Enigma2 7 | Icon=demon-editor 8 | -Exec=bash -c 'cd $(dirname %k) && ./start.py' 9 | +Exec=demon-editor 10 | Terminal=false 11 | Type=Application 12 | -Categories=Utility;Application; 13 | +Categories=Utility; 14 | StartupNotify=false 15 | diff -Nru demon-editor-2.0-development-orig/start.py demon-editor-2.0-development/start.py 16 | --- demon-editor-2.0-development-orig/start.py 2021-10-14 21:32:56.000000000 +0300 17 | +++ demon-editor-2.0-development/start.py 2021-09-29 13:19:24.000000000 +0300 18 | @@ -1,29 +1,4 @@ 19 | #!/usr/bin/env python3 20 | -import os 21 | +from app.ui.main import start_app 22 | 23 | - 24 | -def update_icon(): 25 | - need_update = False 26 | - icon_name = "DemonEditor.desktop" 27 | - 28 | - with open(icon_name, "r") as f: 29 | - lines = f.readlines() 30 | - for i, line in enumerate(lines): 31 | - if line.startswith("Icon="): 32 | - icon_path = line.lstrip("Icon=") 33 | - current_path = "{}/app/ui/icons/hicolor/96x96/apps/demon-editor.png".format(os.getcwd()) 34 | - if icon_path != current_path: 35 | - need_update = True 36 | - lines[i] = "Icon={}\n".format(current_path) 37 | - break 38 | - 39 | - if need_update: 40 | - with open(icon_name, "w") as f: 41 | - f.writelines(lines) 42 | - 43 | - 44 | -if __name__ == "__main__": 45 | - from app.ui.main import start_app 46 | - 47 | - update_icon() 48 | - start_app() 49 | +start_app() 50 | -------------------------------------------------------------------------------- /build/linux/ALTLinux spec/demon-editor.spec: -------------------------------------------------------------------------------- 1 | Name: demon-editor 2 | Version: 2.0 3 | Release: slava0 4 | BuildArch: noarch 5 | Summary: Enigma2 channel and satellite list editor 6 | Url: https://github.com/DYefremov/DemonEditor 7 | License: MIT 8 | Group: Other 9 | Source: %name-%version-development.tar.gz 10 | Patch0: %name-%version-development-startfix.patch 11 | AutoReq: no 12 | Requires: python3 python3-module-requests python3-module-pygobject3 python3-module-chardet libmpv1 13 | BuildRequires: python3-dev python3-module-mpl_toolkits 14 | 15 | %description 16 | Enigma2 channel and satellites list editor for GNU/Linux. 17 | 18 | Experimental support of Neutrino-MP or others on the same basis (BPanther, etc). 19 | Focused on the convenience of working in lists from the keyboard. The mouse is also fully supported (Drag and Drop etc). 20 | 21 | Main features of the program: 22 | Editing bouquets, channels, satellites. 23 | Import function. 24 | Backup function. 25 | Support of picons. 26 | Importing services, downloading picons and updating satellites from the Web. 27 | Extended support of IPTV. 28 | Import to bouquet(Neutrino WEBTV) from m3u. 29 | Export of bouquets with IPTV services in m3u. 30 | Assignment of EPGs from DVB or XML for IPTV services (only Enigma2, experimental). 31 | Playback of IPTV or other streams directly from the bouquet list. 32 | Control panel with the ability to view EPG and manage timers (via HTTP API, experimental). 33 | Simple FTP client (experimental). 34 | 35 | %prep 36 | %setup -n %name-%version-development 37 | %patch0 -p1 38 | 39 | %install 40 | %__install -d %buildroot%_datadir/demoneditor/app 41 | %__install -m644 app/*py %buildroot%_datadir/demoneditor/app 42 | %__install -d %buildroot%_datadir/demoneditor/app/eparser 43 | %__install -m644 app/eparser/*py %buildroot%_datadir/demoneditor/app/eparser 44 | %__install -d %buildroot%_datadir/demoneditor/app/eparser/enigma 45 | %__install -m644 app/eparser/enigma/*py %buildroot%_datadir/demoneditor/app/eparser/enigma 46 | %__install -d %buildroot%_datadir/demoneditor/app/eparser/neutrino 47 | %__install -m644 app/eparser/neutrino/*py %buildroot%_datadir/demoneditor/app/eparser/neutrino 48 | %__install -d %buildroot%_datadir/demoneditor/app/tools 49 | %__install -m644 app/tools/*py %buildroot%_datadir/demoneditor/app/tools 50 | %__install -d %buildroot%_datadir/demoneditor/app/ui 51 | %__install -m644 app/ui/*py %buildroot%_datadir/demoneditor/app/ui 52 | %__install -m644 app/ui/*glade %buildroot%_datadir/demoneditor/app/ui 53 | %__install -m644 app/ui/*css %buildroot%_datadir/demoneditor/app/ui 54 | %__install -m644 app/ui/*ui %buildroot%_datadir/demoneditor/app/ui 55 | %__install -m755 start.py %buildroot%_datadir/demoneditor 56 | 57 | %__install -d %buildroot%_iconsdir/hicolor/96x96/apps 58 | %__install -d %buildroot%_iconsdir/hicolor/scalable/apps 59 | %__install -m644 app/ui/icons/hicolor/96x96/apps/%name.* %buildroot%_iconsdir/hicolor/96x96/apps 60 | %__install -m644 app/ui/icons/hicolor/scalable/apps%name.* -d %buildroot%_iconsdir/hicolor/scalable/apps 61 | 62 | %__install -d %buildroot%_datadir/locale 63 | cp -r app/ui/lang/* %buildroot%_datadir/locale 64 | 65 | %__install -d %buildroot%_bindir 66 | echo "#!/bin/bash 67 | python3 %_datadir/demoneditor/start.py $1" > %buildroot%_bindir/%name 68 | chmod 755 %buildroot%_bindir/%name 69 | 70 | %__install -d %buildroot%_desktopdir 71 | %__install -m644 DemonEditor.desktop %buildroot%_desktopdir/DemonEditor.desktop 72 | 73 | %find_lang %name 74 | 75 | %files -f %name.lang 76 | %doc deb/DEBIAN/README.source 77 | %_bindir/%name 78 | %_datadir/demoneditor 79 | %_iconsdir/*/*/*/%name.* 80 | %_desktopdir/DemonEditor.desktop 81 | 82 | %changelog 83 | * Wed Sep 29 2021 Viacheslav Dikonov 1.0.10-slava0 84 | - ALTLinux package 85 | 86 | 87 | -------------------------------------------------------------------------------- /build/linux/build-deb.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | VER="3.13.0_Beta" 3 | B_PATH="dist/DemonEditor" 4 | DEB_PATH="$B_PATH/usr/share/demoneditor" 5 | 6 | mkdir -p $B_PATH 7 | cp -TRv deb $B_PATH 8 | 9 | rsync -arv ../../app/ui/lang/* "$B_PATH/usr/share/locale" 10 | rsync --exclude=app/ui/lang --exclude=app/ui/icons --exclude=__pycache__ -arv ../../app $DEB_PATH 11 | rsync --exclude=__pycache__ -arv ../../extensions $DEB_PATH 12 | 13 | cd dist 14 | fakeroot dpkg-deb -Zxz --build DemonEditor 15 | mv DemonEditor.deb DemonEditor_$VER.deb 16 | 17 | rm -R DemonEditor 18 | -------------------------------------------------------------------------------- /build/linux/deb/DEBIAN/README.source: -------------------------------------------------------------------------------- 1 | demon-editor for Debian 2 | ---------------------- 3 | DemonEditor 4 | Enigma2 channel and satellite list editor for GNU/Linux. 5 | 6 | Experimental support of Neutrino-MP or others on the same basis (BPanther, etc). 7 | Focused on the convenience of working in lists from the keyboard. The mouse is also fully supported (Drag and Drop etc). 8 | 9 | Main features of the program: 10 | Editing bouquets, channels, satellites. 11 | Import function. 12 | Backup function. 13 | Support of picons. 14 | Importing services, downloading picons and updating satellites from the Web. 15 | Extended support of IPTV. 16 | Import to bouquet(Neutrino WEBTV) from m3u. 17 | Export of bouquets with IPTV services in m3u. 18 | Assignment of EPGs from DVB or XML for IPTV services (only Enigma2, experimental). 19 | Playback of IPTV or other streams directly from the bouquet list. 20 | Control panel with the ability to view EPG and manage timers (via HTTP API, experimental). 21 | Simple FTP client (experimental). 22 | 23 | Keyboard shortcuts: 24 | Ctrl + Insert - copies the selected channels from the main list to the the bouquet beginning or inserts (creates) a new bouquet. 25 | Ctrl + BackSpace - copies the selected channels from the main list to the bouquet end. 26 | Ctrl + X - only in bouquet list. Ctrl + C - only in services list. 27 | Clipboard is "rubber". There is an accumulation before the insertion! 28 | Ctrl + E - edit. 29 | Ctrl + R, F2 - rename. 30 | Ctrl + S, T in Satellites edit tool for create satellite or transponder. 31 | Ctrl + L - parental lock. 32 | Ctrl + H - hide/skip. 33 | Ctrl + P - start play IPTV or other stream in the bouquet list. 34 | Ctrl + Z - switch (zap) the channel (works when the HTTP API is enabled, Enigma2 only). 35 | Ctrl + W - switch to the channel and watch in the program. 36 | Space - select/deselect. 37 | Left/Right - remove selection. 38 | Ctrl + Up, Down, PageUp, PageDown, Home, End - move selected items in the list. 39 | Ctrl + O - (re)load user data from current dir. 40 | Ctrl + D - load data from receiver. 41 | Ctrl + U/B upload data/bouquets to receiver. 42 | Ctrl + F - show/hide search bar. 43 | Ctrl + Shift + F - show/hide filter bar. 44 | 45 | For multiple selection with the mouse, press and hold the Ctrl key! 46 | 47 | Minimum requirements: 48 | Python >= 3.6, GTK+ >= 3.22, python3-gi, python3-gi-cairo, python3-requests. 49 | 50 | Important: 51 | Terrestrial(DVB-T/T2) and cable(DVB-C) channels are only supported for Enigma2! 52 | Main supported *lamedb* format is version **4**. Versions **3** and **5** has only **experimental** support! 53 | For version **3** is only read mode available. When saving, version **4** format is used instead! 54 | 55 | When using the multiple import feature, from *lamedb* will be taken data **only for channels that are in the 56 | selected bouquets!** If you need full set of the data, including *[satellites, terrestrial, cables].xml* (current files will be overwritten), 57 | just load your data via *"File/Open"* and press *"Save"*. When importing separate bouquet files, only those services 58 | (excluding IPTV) that are in the **current open lamedb** (main list of services) will be imported. 59 | 60 | For streams playback, this app supports VLC, MPV and GStreamer. 61 | Depending on your distro, you may need to install additional packages and libraries. 62 | 63 | -------------------------------------------------------------------------------- /build/linux/deb/DEBIAN/control: -------------------------------------------------------------------------------- 1 | Package: demon-editor 2 | Version: 3.13.0-Beta 3 | Section: utils 4 | Priority: optional 5 | Architecture: all 6 | Essential: no 7 | Depends: python3 (>= 3.6), 8 | python3-requests, 9 | python3-gi, 10 | python3-gi-cairo, 11 | gir1.2-notify-0.7, 12 | p7zip-full 13 | Recommends: ffmpeg, 14 | libmpv1, 15 | python3-chardet, 16 | libgtksourceview (>= 3.0) 17 | Maintainer: Dmitriy Yefremov 18 | Homepage: https://dyefremov.github.io/DemonEditor 19 | Description: Enigma2 channel and satellite list editor 20 | Editing bouquets, channels, satellites, importing services, 21 | downloading picons and updating satellites from the Web, 22 | extended support of IPTV, assignment of EPG from DVB or 23 | XML for IPTV services, playback of IPTV or other streams 24 | directly from the bouquet list, control panel (via HTTP API), 25 | ability to view EPG and manage timers (via HTTP API), 26 | simple FTP client (experimental). 27 | -------------------------------------------------------------------------------- /build/linux/deb/DEBIAN/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Contact: Dmitriy Yefremov 3 | Source: https://github.com/DYefremov/DemonEditor 4 | 5 | Files: * 6 | MIT License 7 | 8 | Copyright (c) 2018-2025 Dmitriy Yefremov 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | -------------------------------------------------------------------------------- /build/linux/deb/DEBIAN/demon-editor-docs.docs: -------------------------------------------------------------------------------- 1 | README.source 2 | -------------------------------------------------------------------------------- /build/linux/deb/usr/bin/demon-editor: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python3 /usr/share/demoneditor/start.py $@ 3 | -------------------------------------------------------------------------------- /build/linux/deb/usr/share/applications/demon-editor.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Name=DemonEditor 4 | GenericName=Enigma2 bouquets editor 5 | GenericName[be]=Рэдактар букетаў Enigma2 6 | GenericName[de]=Enigma2 Bouquet-Editor 7 | GenericName[es]=Editor de ramos de Enigma2 8 | GenericName[it]=Editor di bouquet Enigma2 9 | GenericName[nl]=Enigma2 boeket editor 10 | GenericName[pl]=Edytor bukietów Enigma2 11 | GenericName[pt]=Editor de buquês Enigma2 12 | GenericName[ru]=Редактор букетов Enigma2 13 | GenericName[tr]=Enigma2 buket düzenleyici 14 | GenericName[zh_CN]=Enigma2频道编辑器 15 | Comment=Channel and satellite list editor for Enigma2 16 | Comment[be]=Рэдактар спісу каналаў і супутнікаў для Enigma2 17 | Comment[de]=Kanal- und Satellitenlisten-Editor für Enigma2 18 | Comment[es]=Editor de lista de canales y satélites para Enigma2 19 | Comment[it]=Editor di elenchi di canali e satelliti per Enigma2 20 | Comment[nl]=Kanaal- en satellietlijsteditor voor Enigma2 21 | Comment[pl]=Edytor list kanałów i satelitów dla Enigma2 22 | Comment[pt]=Editor de lista de canais e satélites para Enigma2 23 | Comment[ru]=Редактор списка каналов и спутников для Enigma2 24 | Comment[tr]=Enigma2 için Kanal ve uydu listesi düzenleyici 25 | Comment[zh_CN]=Enigma2频道和卫星列表编辑器 26 | Icon=demon-editor 27 | Exec=/usr/bin/demon-editor 28 | Terminal=false 29 | Type=Application 30 | Categories=Utility;Application; 31 | StartupNotify=false 32 | -------------------------------------------------------------------------------- /build/linux/deb/usr/share/demoneditor/start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from app.ui.main import start_app 3 | 4 | start_app() 5 | -------------------------------------------------------------------------------- /build/linux/deb/usr/share/icons/hicolor/96x96/apps/demon-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/build/linux/deb/usr/share/icons/hicolor/96x96/apps/demon-editor.png -------------------------------------------------------------------------------- /build/mac/DemonEditor.spec: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | import distutils.util 4 | 5 | EXE_NAME = 'start.py' 6 | DIR_PATH = os.getcwd() 7 | COMPILING_PLATFORM = distutils.util.get_platform() 8 | PATH_EXE = [os.path.join(DIR_PATH, EXE_NAME)] 9 | STRIP = True 10 | BUILD_DATE = datetime.datetime.now().strftime("%y%m%d") 11 | 12 | block_cipher = None 13 | 14 | excludes = ['app.tools.mpv', 15 | 'gi.repository.Gst', 16 | 'gi.repository.GstBase', 17 | 'gi.repository.GstVideo', 18 | 'youtube_dl', 19 | 'tkinter'] 20 | 21 | ui_files = [('app/ui/*.glade', 'ui'), 22 | ('app/ui/*.css', 'ui'), 23 | ('app/ui/*.ui', 'ui'), 24 | ('app/ui/epg/*.glade', 'ui/epg'), 25 | ('app/ui/xml/*.glade', 'ui/xml'), 26 | ('app/ui/lang*', 'share/locale'), 27 | ('app/ui/icons*', 'share/icons'), 28 | ('extensions/*', 'extensions') 29 | ] 30 | 31 | a = Analysis([EXE_NAME], 32 | pathex=PATH_EXE, 33 | binaries=None, 34 | datas=ui_files, 35 | hiddenimports=['fileinput', 'uuid', 'asyncio'], 36 | hookspath=[], 37 | runtime_hooks=[], 38 | hooksconfig={ 39 | "gi": { 40 | "languages": ["en", "be", "es", "it", "nl", 41 | "pl", "pt", "ru", "tr", "zh_CN"], 42 | "module-versions": { 43 | "Gtk": "3.0" 44 | }, 45 | }, 46 | }, 47 | excludes=excludes, 48 | win_no_prefer_redirects=False, 49 | win_private_assemblies=False, 50 | cipher=block_cipher) 51 | 52 | pyz = PYZ(a.pure, 53 | a.zipped_data, 54 | cipher=block_cipher) 55 | 56 | exe = EXE(pyz, 57 | a.scripts, 58 | exclude_binaries=True, 59 | name='DemonEditor', 60 | debug=False, 61 | strip=STRIP, 62 | upx=True, 63 | console=False) 64 | 65 | coll = COLLECT(exe, 66 | a.binaries, 67 | a.zipfiles, 68 | a.datas, 69 | strip=STRIP, 70 | upx=True, 71 | name='DemonEditor') 72 | 73 | app = BUNDLE(coll, 74 | name='DemonEditor.app', 75 | icon='icon.icns', 76 | bundle_identifier=None, 77 | info_plist={ 78 | 'NSPrincipalClass': 'NSApplication', 79 | 'CFBundleName': 'DemonEditor', 80 | 'CFBundleDisplayName': 'DemonEditor', 81 | 'CFBundleGetInfoString': "Enigma2 channel and satellite editor", 82 | 'LSApplicationCategoryType': 'public.app-category.utilities', 83 | 'LSMinimumSystemVersion': '10.13', 84 | 'CFBundleShortVersionString': f"3.13.0.{BUILD_DATE} Beta", 85 | 'NSHumanReadableCopyright': u"Copyright © 2018-2025, Dmitriy Yefremov", 86 | 'NSRequiresAquaSystemAppearance': 'false', 87 | 'NSHighResolutionCapable': 'true' 88 | }) 89 | -------------------------------------------------------------------------------- /build/mac/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/build/mac/icon.icns -------------------------------------------------------------------------------- /build/mac/start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | if __name__ == "__main__": 4 | from multiprocessing import set_start_method 5 | from app.ui.main import start_app 6 | 7 | set_start_method("fork") # For compatibility [Python > 3.7] 8 | start_app() 9 | -------------------------------------------------------------------------------- /build/win/DemonEditor.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | EXE_NAME = 'start.py' 4 | DIR_PATH = os.getcwd() 5 | PATH_EXE = [os.path.join(DIR_PATH, EXE_NAME)] 6 | 7 | block_cipher = None 8 | 9 | 10 | excludes = ['app.tools.mpv', 11 | 'gi.repository.Gst', 12 | 'gi.repository.GstBase', 13 | 'gi.repository.GstVideo', 14 | 'youtube_dl', 15 | 'tkinter'] 16 | 17 | 18 | ui_files = [('app\\ui\\*.glade', 'ui'), 19 | ('app\\ui\\*.css', 'ui'), 20 | ('app\\ui\\*.ui', 'ui'), 21 | ('app\\ui\\epg\\*.glade', 'ui\\epg'), 22 | ('app\\ui\\xml\\*.glade', 'ui\\xml'), 23 | ('app\\ui\\lang*', 'share\\locale'), 24 | ('app\\ui\\icons*', 'share\\icons'), 25 | ('extensions\\*', 'extensions') 26 | ] 27 | 28 | 29 | a = Analysis([EXE_NAME], 30 | pathex=PATH_EXE, 31 | binaries=[], 32 | datas=ui_files, 33 | hiddenimports=['fileinput', 'uuid', 'ctypes.wintypes', 'asyncio'], 34 | hookspath=[], 35 | runtime_hooks=[], 36 | hooksconfig={ 37 | "gi": { 38 | "languages": ["en", "be", "es", "it", "nl", 39 | "pl", "pt", "ru", "tr", "zh_CN"], 40 | "module-versions": { 41 | "Gtk": "3.0", 42 | "GtkSource": "3", 43 | }, 44 | }, 45 | }, 46 | excludes=excludes, 47 | win_no_prefer_redirects=False, 48 | win_private_assemblies=False, 49 | cipher=block_cipher, 50 | noarchive=False) 51 | pyz = PYZ(a.pure, a.zipped_data, 52 | cipher=block_cipher) 53 | exe = EXE(pyz, 54 | a.scripts, 55 | [], 56 | exclude_binaries=True, 57 | name='DemonEditor', 58 | debug=False, 59 | bootloader_ignore_signals=False, 60 | contents_directory='.', 61 | strip=False, 62 | upx=True, 63 | console=False, 64 | icon='icon.ico') 65 | coll = COLLECT(exe, 66 | a.binaries, 67 | a.zipfiles, 68 | a.datas, 69 | strip=False, 70 | upx=True, 71 | upx_exclude=[], 72 | name='DemonEditor') 73 | -------------------------------------------------------------------------------- /build/win/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DYefremov/DemonEditor/ef931bcd75e202352d39c5943e45e6851debe969/build/win/icon.ico -------------------------------------------------------------------------------- /build/win/start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import ssl 4 | 5 | if __name__ == "__main__": 6 | from multiprocessing import freeze_support 7 | from app.ui.main import start_app 8 | 9 | os.environ["PYTHONUTF8"] = "1" 10 | # TODO There needs to be a more "correct" way. 11 | ssl._create_default_https_context = ssl._create_unverified_context 12 | 13 | freeze_support() 14 | start_app() 15 | -------------------------------------------------------------------------------- /demon-editor.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Name=DemonEditor 4 | GenericName=Enigma2 bouquets editor 5 | GenericName[be]=Рэдактар букетаў Enigma2 6 | GenericName[de]=Enigma2 Bouquet-Editor 7 | GenericName[es]=Editor de ramos de Enigma2 8 | GenericName[it]=Editor di bouquet Enigma2 9 | GenericName[nl]=Enigma2 boeket editor 10 | GenericName[pl]=Edytor bukietów Enigma2 11 | GenericName[pt]=Editor de buquês Enigma2 12 | GenericName[ru]=Редактор букетов Enigma2 13 | GenericName[tr]=Enigma2 buket düzenleyici 14 | GenericName[zh_CN]=Enigma2频道编辑器 15 | Comment=Channel and satellite list editor for Enigma2 16 | Comment[be]=Рэдактар спісу каналаў і супутнікаў для Enigma2 17 | Comment[de]=Kanal- und Satellitenlisten-Editor für Enigma2 18 | Comment[es]=Editor de lista de canales y satélites para Enigma2 19 | Comment[it]=Editor di elenchi di canali e satelliti per Enigma2 20 | Comment[nl]=Kanaal- en satellietlijsteditor voor Enigma2 21 | Comment[pl]=Edytor list kanałów i satelitów dla Enigma2 22 | Comment[pt]=Editor de lista de canais e satélites para Enigma2 23 | Comment[ru]=Редактор списка каналов и спутников для Enigma2 24 | Comment[tr]=Enigma2 için Kanal ve uydu listesi düzenleyici 25 | Comment[zh_CN]=Enigma2频道和卫星列表编辑器 26 | Icon=demon-editor 27 | Exec=bash -c 'cd $(dirname %k) && ./start.py' 28 | Terminal=false 29 | Type=Application 30 | Categories=Utility;Application; 31 | StartupNotify=false 32 | -------------------------------------------------------------------------------- /extensions/README.md: -------------------------------------------------------------------------------- 1 | Extension packages must be located in the following paths: 2 | ``` app/ui/extensions```, ``` your data path/tools/extensions ```. 3 | 4 | For builds: 5 | ``` Program Root\extensions ``` 6 | 7 | Extensions and examples can be found [here](https://github.com/DYefremov/demoneditor-extensions). 8 | The possibilities of extending the API, as well as the creation and publication of the necessary extensions, can be discussed there. 9 | 10 | ### Pull requests for extensions are not accepted here! 11 | -------------------------------------------------------------------------------- /extensions/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The MIT License (MIT) 4 | # 5 | # Copyright (c) 2023 Dmitriy Yefremov 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | # 25 | # Author: Dmitriy Yefremov 26 | # 27 | 28 | import json 29 | import logging 30 | import os 31 | from pathlib import Path 32 | 33 | CONFIG_PATH = f"{Path.home()}{os.sep}.config{os.sep}demon-editor{os.sep}extensions{os.sep}" 34 | 35 | 36 | class Singleton(type): 37 | _INSTANCE = None 38 | 39 | def __call__(cls, *args, **kwargs): 40 | if not cls._INSTANCE: 41 | cls._INSTANCE = type.__call__(cls, *args, **kwargs) 42 | return cls._INSTANCE 43 | 44 | 45 | class BaseExtension(metaclass=Singleton): 46 | """ Base extension (plugin) class. """ 47 | # The label that will be displayed in the "Tools" menu. 48 | LABEL = "Base extension" 49 | VERSION = "1.0" 50 | # Additional flags. 51 | EMBEDDED = False 52 | SWITCHABLE = False 53 | 54 | _LOGGER_NAME = "main_logger" 55 | 56 | def __init__(self, app): 57 | # Current application instance. 58 | # It can be used all public methods, properties or signals. 59 | self.app = app 60 | self._config_path = f"{CONFIG_PATH}{self.__class__.__name__}{os.sep}config" 61 | 62 | self.log(f"Extension initialized...") 63 | 64 | def exec(self): 65 | """ Triggers an action for the given extension. 66 | 67 | E.g. shows a dialog or runs an external script. 68 | """ 69 | self.app.show_info_message(f"Hello from {self.__class__.__name__} class!") 70 | 71 | def stop(self): 72 | """ Stops (terminates) the task or the extension itself. """ 73 | self.log("Terminating a task...") 74 | 75 | def log(self, message, level=logging.ERROR): 76 | """ Shows log messages. """ 77 | logging.getLogger(self._LOGGER_NAME).log(level, f"[{self.__class__.__name__}] {message}") 78 | 79 | def reset_config(self): 80 | path = Path(self._config_path) 81 | if path.is_file(): 82 | path.unlink() 83 | 84 | @property 85 | def config(self) -> dict: 86 | if not Path(self._config_path).is_file(): 87 | return {} 88 | 89 | with open(self._config_path, "r", encoding="utf-8") as config_file: 90 | try: 91 | return json.load(config_file) 92 | except ValueError as e: 93 | self.log(f"Configuration load error: {e}") 94 | return {} 95 | 96 | @config.setter 97 | def config(self, value: dict): 98 | Path(self._config_path).parent.mkdir(parents=True, exist_ok=True) 99 | with open(self._config_path, "w", encoding="utf-8") as config_file: 100 | json.dump(value, config_file, indent=" ") 101 | 102 | 103 | if __name__ == "__main__": 104 | pass 105 | -------------------------------------------------------------------------------- /po/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | #xgettext --keyword=translatable --sort-output -L Glade -o po/demon-editor.po app/ui/main_window.glade 3 | 4 | for dir in */; 5 | do 6 | msgfmt $dir* -o ../app/ui/lang/${dir%/}/LC_MESSAGES/demon-editor.mo 7 | done -------------------------------------------------------------------------------- /start.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | 5 | def update_icon(): 6 | need_update = False 7 | icon_name = "demon-editor.desktop" 8 | 9 | with open(icon_name, "r", encoding="utf-8") as f: 10 | lines = f.readlines() 11 | for i, line in enumerate(lines): 12 | if line.startswith("Icon="): 13 | icon_path = line.lstrip("Icon=") 14 | current_path = f"{os.getcwd()}/app/ui/icons/hicolor/96x96/apps/demon-editor.png" 15 | if icon_path != current_path: 16 | need_update = True 17 | lines[i] = f"Icon={current_path}\n" 18 | break 19 | 20 | if need_update: 21 | with open(icon_name, "w", encoding="utf-8") as f: 22 | f.writelines(lines) 23 | 24 | 25 | if __name__ == "__main__": 26 | from app.ui.main import start_app 27 | 28 | update_icon() 29 | start_app() 30 | --------------------------------------------------------------------------------