├── .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) 
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>%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 |
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 |
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 |
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 | 
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 |
--------------------------------------------------------------------------------