Compare commits

...

848 Commits

Author SHA1 Message Date
DYefremov
b45dda8ada picon[cz] downloader improvement
* Added caching [as tmp] of the permalinks file.
2026-04-21 01:26:32 +03:00
DYefremov
e1577d8e0c add progressbar style 2026-04-19 22:15:13 +03:00
DYefremov
a184a7cc7f wait dialog style change 2026-04-19 22:10:44 +03:00
DYefremov
6a4ca77009 wait dialog refactoring 2026-04-17 01:14:31 +03:00
DYefremov
c1ed748a91 add custom progress bar class 2026-04-17 00:56:53 +03:00
DYefremov
d6791a9c89 remove warning on satellite page 2026-04-10 10:58:58 +03:00
DYefremov
1f0411fb3d run task -> migrate to Gio [test] 2026-04-09 15:12:17 +03:00
DYefremov
27a1838980 minor playback adjustment
* Changed window behavior for a separate mode.
2026-04-06 22:52:56 +03:00
DYefremov
c1bfb482e1 xmltv reader -> minor cleanup 2026-03-14 17:04:17 +03:00
DYefremov
411e012f5c bg task migration to Gio [test] 2026-03-13 15:51:18 +03:00
DYefremov
a7bc32d7ae xmltv reader -> download adjustment 2026-03-13 15:33:39 +03:00
DYefremov
ef3f69ece1 translations update -> [be, de, ru] 2026-03-03 13:35:26 +03:00
DYefremov
344f4905fc playback setting msg correction 2026-03-03 13:05:13 +03:00
DYefremov
51f36d14ec small refactoring -> [get dialogs string] 2026-03-03 12:21:02 +03:00
DYefremov
f2b31b2ac4 enable editing by double-clicking
* Enabled ability to open the editing dialog by double-clicking in the lists for Bouquets tab.
2026-02-26 13:49:33 +03:00
DYefremov
118734d7fb minor fix for EPG tab settings popover 2026-02-26 11:35:05 +03:00
DYefremov
d7b7f6571b copyright update 2026-02-23 12:10:32 +03:00
DYefremov
9728843b0a msg adjustment 2026-02-23 12:09:50 +03:00
DYefremov
033ac70c8a minor adjustment of favorites remove 2026-02-22 19:19:38 +03:00
DYefremov
85247e8307 version update -> 3.14.4 2026-02-22 16:49:06 +03:00
DYefremov
d38a896685 service edit dialog improvement
* Auto set the namespace value when the satellite position changes.
2026-02-22 15:53:25 +03:00
DYefremov
880908c163 service dialog refactoring 2026-02-22 15:14:32 +03:00
DYefremov
ec94d5ef46 fix creation transponder data for a new channel 2026-02-22 14:27:20 +03:00
DYefremov
49f9863922 version update -> 3.14.3 2026-02-18 16:19:04 +03:00
DYefremov
6d4249cf1e add *.patch for win build 2026-02-18 16:14:02 +03:00
DYefremov
0fc0ef1d3e win build start file update 2026-02-18 15:48:40 +03:00
DYefremov
c587f2bcdc win build logo update 2026-02-18 15:46:17 +03:00
DYefremov
5cd8c68589 add splash screen to win build 2026-02-15 22:37:16 +03:00
DYefremov
61690db0ee minor msg adjustment 2026-02-15 21:50:07 +03:00
DYefremov
fcc2b6b6a8 add app termination
* Added app termination while background tasks are still running.
2026-02-15 13:48:26 +03:00
DYefremov
a5412cd2b3 refactoring of keyboard key handling 2026-02-14 12:58:13 +03:00
DYefremov
a47a7417c2 remove favs refactoring 2026-02-14 10:08:09 +03:00
DYefremov
bdac77e88c picons downloader improvement
* Added subprocess creationflags.
2026-02-02 15:19:01 +03:00
DYefremov
8ab79a2937 *.spec files adjustment 2026-02-01 23:51:17 +03:00
DYefremov
8dc880577f bootlogo manager improvements
* Added subprocess flags to improve startup on Windows.
2026-01-31 12:38:13 +03:00
DYefremov
b1829651d3 it *.mo file update 2026-01-30 20:35:10 +03:00
mapi68
7339872de6 translations update -> it (#239) 2026-01-30 20:33:23 +03:00
DYefremov
8155643098 version update -> 3.14.2 2026-01-26 22:27:21 +03:00
audi06_19
87a1cde859 translations update -> tr (#237) 2026-01-25 18:08:04 +03:00
DYefremov
a591d31d01 translations update -> [be, de, ru] 2026-01-18 22:16:34 +03:00
DYefremov
e863c41117 minor fix 2026-01-18 21:55:26 +03:00
DYefremov
811539ae19 enable extension manager permanently 2026-01-18 16:09:21 +03:00
DYefremov
bc7327a6d5 extension manager adjustment 2026-01-11 00:27:43 +03:00
DYefremov
0c114964f2 fav channels numbering adjustment (#236) 2026-01-01 12:36:16 +03:00
DYefremov
b8a3e5e4c1 *.spec files adjustment 2025-11-15 12:21:14 +03:00
DYefremov
1d16e9e220 version update -> 3.14.1 2025-11-14 13:25:02 +03:00
DYefremov
c96b464cbc prevent double data loading
* Fixes double data loading when opening an external folder from the start page.
2025-11-14 13:22:51 +03:00
DYefremov
43821e6f50 add t2mi pid value support (#232) 2025-11-10 11:56:53 +03:00
DYefremov
e0e642db5a sk *.mo file update 2025-11-10 09:11:06 +03:00
EnoSat
2266fd4d3d Slovak translations update (#234) 2025-11-10 09:09:14 +03:00
EnoSat
6b8145c674 Add Slovak translations to desktop entry (#233) 2025-11-09 21:07:50 +03:00
DYefremov
fa89ab8608 add Slovak translation 2025-11-09 19:32:04 +03:00
EnoSat
da70b0fb18 Slovak language (#231)
Added Slovak localization.
2025-11-09 18:57:38 +03:00
DYefremov
6932465cfd version update -> 3.14.0 2025-11-02 12:08:58 +03:00
DYefremov
303fe0b1ae README update 2025-11-02 11:14:38 +03:00
DYefremov
f8dec3140c add data loading progress update 2025-11-02 11:08:59 +03:00
DYefremov
60e1e4f5e6 migration to the progress bar on data load
* It fixes long data loading on some configs.
2025-11-02 10:28:21 +03:00
DYefremov
a99d6e26db small refactoring of data update 2025-11-02 09:35:29 +03:00
DYefremov
ecf5b6399c yt-dlp init adjustment 2025-11-01 17:07:53 +03:00
DYefremov
3c04a00230 adjust getting yt icon 2025-10-08 21:35:42 +03:00
DYefremov
527a52c87b enable keep of extracted data 2025-10-01 23:22:57 +03:00
DYefremov
4bcd126947 add upload bouquets func 2025-10-01 13:56:46 +03:00
DYefremov
1a3617a6d4 improved service status bar
* Added current datta path display.
2025-09-27 15:50:50 +03:00
DYefremov
d6d7b105ec enabled data saving for ext path 2025-09-27 09:27:49 +03:00
DYefremov
eab34c5ecc small ui adjustment 2025-09-27 09:25:08 +03:00
DYefremov
3cef063aa4 upload adjustment 2025-09-26 22:09:13 +03:00
DYefremov
958320e573 separated data upload for the services tab 2025-09-23 13:13:10 +03:00
DYefremov
394b7c4c01 separated data load/upload for the satellite tab 2025-09-21 13:44:20 +03:00
DYefremov
00f492b0a2 enable multiple selection for tc views 2025-09-21 13:35:36 +03:00
DYefremov
0e0abdcf8e kos web source adjustment 2025-09-10 00:34:41 +03:00
DYefremov
037b917d3e it *.mo file update 2025-08-30 12:42:38 +03:00
mapi68
0bc85cb5fa Italian translation update (#230) 2025-08-30 12:39:56 +03:00
DYefremov
8bf6427bbd enabled data transfer from ext path 2025-08-30 00:09:31 +03:00
audi06_19
f85c1d2e0d Turkish translation update (#229) 2025-08-25 20:16:46 +03:00
DYefremov
03e2fc96ec add tooltip 2025-08-22 09:51:26 +03:00
DYefremov
f0d58c0fb4 translations update -> [be, de, ru] 2025-08-22 09:47:06 +03:00
DYefremov
1fc10f0119 add scroll to new added channel 2025-08-19 23:53:07 +03:00
DYefremov
a21f6faab2 add support for creating terrestrial and cable channels 2025-08-19 23:33:56 +03:00
DYefremov
d78cee1241 enable support for creating a new channel 2025-08-18 14:20:14 +03:00
DYefremov
145bd75776 fix internal yt client 2025-08-18 10:36:13 +03:00
DYefremov
6765fd5db7 version update -> 3.13.2 2025-08-07 22:02:38 +03:00
DYefremov
53616f95b0 сhanged output of XMLTV file loading progress
* Fixes XMLTV file loading for Windows build.
2025-08-07 21:18:59 +03:00
DYefremov
137b5acde5 BUILD_WIN.md update 2025-08-07 20:28:00 +03:00
DYefremov
17f705a4e3 it *.mo file update 2025-08-05 19:37:09 +03:00
mapi68
d68a215e2a Update Italian language (#227) 2025-08-05 19:34:03 +03:00
audi06_19
d8f67380e5 Turkish translation update (#226) 2025-08-04 07:52:53 +03:00
DYefremov
9965f3e3a5 version update -> 3.13.1 2025-08-02 10:03:15 +03:00
DYefremov
2bb0faa19e enabled delete key for alternatives 2025-08-02 09:56:53 +03:00
DYefremov
9c5cf8cebb add paste menu item for alternatives 2025-08-01 22:17:19 +03:00
DYefremov
fb20e82572 enabled paste from buffer for alternatives 2025-08-01 09:33:36 +03:00
DYefremov
ffdf5d8ce2 translations update -> [be, de, ru] 2025-07-31 00:01:57 +03:00
DYefremov
8b6f860459 add warning message
* Added warning message about errors when loading bouquets.
2025-07-30 22:26:56 +03:00
DYefremov
c747cf1275 add error counting during bouquets loading 2025-07-30 22:02:02 +03:00
DYefremov
7a71ebd188 loading bouquets refactoring
* Added data loading error skipping if bouquet file is missing.
2025-07-30 20:48:43 +03:00
DYefremov
c4847766bb enabled dnd from IPTV list to alternatives 2025-07-28 17:19:02 +03:00
DYefremov
73a611dc3c alternatives improvement
* Added display of IPTV services.
2025-07-28 00:35:06 +03:00
DYefremov
ef931bcd75 copyright update 2025-06-03 20:21:26 +03:00
DYefremov
f173587dab bootlogo manager improvement
* Added custom STB path setting.
2025-05-31 23:30:02 +03:00
DYefremov
9a0b362b91 telnetlib usage refactoring (#218) 2025-05-30 15:59:26 +03:00
DYefremov
51acb171d5 it *.mo file update 2025-05-16 12:37:44 +03:00
mapi68
b57adb43ba Italian translation update (#225)
* Add files via upload

* Add files via upload

* correct typo
2025-05-16 12:35:13 +03:00
audi06_19
bcea538c4e Turkish translation update (#224) 2025-05-11 21:34:35 +03:00
DYefremov
77281271c8 minor improvement
* Enabled name cache for multiepg mode.
2025-05-06 09:50:10 +03:00
DYefremov
5c94912f21 version update -> 3.13.0 2025-05-03 16:22:55 +03:00
DYefremov
e8f33cbee9 translations update -> [be, de, ru] 2025-05-02 18:49:01 +03:00
DYefremov
aa2b06ea27 add filtering by coding presence 2025-05-02 16:16:05 +03:00
DYefremov
5576bd8112 enabled custom ports (#221)
*  Enabled custom ports for FTP and Telnet.
2025-04-29 22:12:26 +03:00
DYefremov
551c9d5722 connection test adjustment 2025-04-29 21:45:57 +03:00
DYefremov
f6518f1ee5 enabled port values checking 2025-04-29 21:09:19 +03:00
DYefremov
20b534f723 changed type for ports 2025-04-29 21:07:10 +03:00
Thorsten Klein
82a954e1a4 Accept if lines in lamedb do not start with provider (p:) (#223)
* Accept if lines in `lamedb` do not start with provider (`p:`)

* minor adjustment
2025-04-28 22:42:21 +03:00
DYefremov
67446f0898 cas info display adjustment 2025-03-15 11:56:02 +03:00
DYefremov
39a092cb57 add cas value 2025-03-15 11:54:17 +03:00
DYefremov
4d81937779 it *.mo file update 2025-02-11 08:18:50 +03:00
audi06_19
68dc48cdbe Turkish translation update (#220) 2025-02-11 08:16:09 +03:00
mapi68
1a6be14949 translation update -> [it] (#219) 2025-02-11 08:15:47 +03:00
DYefremov
7295ec90c0 translations update -> [be, de, ru] 2025-02-08 21:25:30 +03:00
DYefremov
3a98b497c8 fix save of *.xml path changes (#217) 2025-01-21 11:43:34 +03:00
DYefremov
12bb1f0601 minor correction for EPG dialog 2025-01-19 22:25:40 +03:00
DYefremov
eebe953ac2 markers reading adjustment 2025-01-12 19:53:58 +03:00
DYefremov
741bea29e6 enabled additional name cache for EPG (#189) 2025-01-05 18:13:50 +03:00
DYefremov
97041e5799 name cache init 2025-01-04 00:58:42 +03:00
DYefremov
5ee4e18346 add EPG name cache option 2025-01-03 22:38:43 +03:00
DYefremov
de508fbfc2 add "tvg-id" parsing for *.m3u import 2025-01-02 13:06:08 +03:00
DYefremov
36aebe7f19 add deep name comparing for EPG dialog (#209) 2024-12-27 00:26:13 +03:00
DYefremov
5ac9053944 version update -> 3.12.0 2024-12-19 00:42:43 +03:00
DYefremov
ce6819d539 file naming for oscam picon converter 2024-12-19 00:38:55 +03:00
DYefremov
b13c2f321c path init for picon converter 2024-12-18 21:05:36 +03:00
DYefremov
015b6b1ccd bootlogo fmt change 2024-12-07 14:31:39 +03:00
DYefremov
911279ce09 basic support for converting to *.tpl 2024-12-05 18:06:57 +03:00
DYefremov
71ddd12541 improved picon conversion tab
* conversion for the selected bouquet
  * oscam picons ui prototype
2024-12-02 23:20:20 +03:00
DYefremov
4867b1b648 picon tab refactoring 2024-12-01 16:02:42 +03:00
DYefremov
25fba17b9c version update -> 3.11.3 2024-11-28 22:45:38 +03:00
DYefremov
f77a55eadd fix assign ref data (#209) 2024-11-28 22:37:32 +03:00
DYefremov
b6e73e5e7a pos sort for picons downloader 2024-11-26 21:08:09 +03:00
DYefremov
780bda1f12 data read adjustment 2024-11-26 20:37:20 +03:00
DYefremov
a4a44692e2 fix warn 2024-11-26 20:18:46 +03:00
DYefremov
6db03b6cac version update -> 3.11.2 2024-11-10 00:00:03 +03:00
DYefremov
a94c53a9c9 fix extra service name for bouquet 2024-11-09 23:36:06 +03:00
DYefremov
b012fccd1a minor code cleanup 2024-11-03 14:53:24 +03:00
DYefremov
4062d206b8 minor code adjustment 2024-11-03 13:50:22 +03:00
DYefremov
a1f656fbca add additional reload data cmds 2024-11-01 14:40:05 +03:00
DYefremov
84afaee1d0 add encoding blacklist for *.m3u import 2024-10-29 22:25:43 +03:00
DYefremov
08619dd182 refactoring of getting service reference
* fix wrong duplicated services filtering #208
2024-10-26 13:13:55 +03:00
DYefremov
04f27eff88 hide error status on new data open 2024-10-26 10:15:15 +03:00
DYefremov
6e706dec2d README update 2024-10-25 20:52:34 +03:00
DYefremov
3bf787b9fb version update -> 3.11.1 2024-10-03 20:49:36 +03:00
audi06_19
3b1bb80d3c Turkish translation update (#207) 2024-09-30 07:44:12 +03:00
DYefremov
05fa5eaf11 kos web source correction 2024-09-28 16:49:24 +03:00
DYefremov
b558a17d9d deb control update 2024-09-20 08:36:25 +03:00
DYefremov
0ee248a24f README update 2024-09-19 13:06:33 +03:00
DYefremov
3a368427fd web import options correction 2024-09-19 13:03:01 +03:00
DYefremov
384c30ea18 add additional events 2024-08-29 20:38:36 +03:00
DYefremov
05cf047127 export to *.m3u correction 2024-08-18 20:44:44 +03:00
DYefremov
621b090a1a it *.mo file update 2024-08-16 10:02:39 +03:00
mapi68
a8d3f39442 translation update -> [it] 2024-08-16 10:00:48 +03:00
DYefremov
02c261b4dd translations update -> [be, de, ru] 2024-08-15 12:59:54 +03:00
DYefremov
5c3532db65 add confirm dialog to bootlogo manager 2024-08-15 12:33:07 +03:00
DYefremov
fda9780de9 README update 2024-08-14 21:49:41 +03:00
DYefremov
6c5bd5d576 add support for *.mvi transfer for bootlogo manager 2024-08-14 21:03:09 +03:00
DYefremov
9c5b7a3901 add file selection for bootlogo manager 2024-08-13 16:20:51 +03:00
DYefremov
b7f312a35d add settings menu for bootlogo 2024-08-12 23:34:45 +03:00
DYefremov
9401b2a7f7 add format selection for bootlogo 2024-08-11 16:31:43 +03:00
DYefremov
682fa341d0 add load bootlogo image from local file 2024-08-10 11:32:30 +03:00
DYefremov
c9daa8a599 small refactoring 2024-08-10 10:54:31 +03:00
DYefremov
94d3d0d9ac loading logo from the box 2024-08-10 00:16:09 +03:00
DYefremov
2189997122 add boot logo manager prototype 2024-08-09 13:48:45 +03:00
DYefremov
8397efa324 streamrelay adjustment 2024-08-05 21:24:43 +03:00
DYefremov
d21f9410cd streamrelay support improvement 2024-08-04 18:33:54 +03:00
DYefremov
be9b3178e0 version update -> 3.11.0 2024-08-02 22:47:53 +03:00
DYefremov
2a8ddc093c minor fix for kos web source 2024-08-02 22:04:53 +03:00
DYefremov
fa1ec4cdcf save streamrelay change 2024-08-01 07:14:24 +03:00
DYefremov
384da95988 add streamrelay to context menu 2024-08-01 00:46:16 +03:00
DYefremov
960541b56a add basic streamrelay support (#199)
* marking services used with streamrelay in the fav list with an additional icon
2024-07-31 22:46:04 +03:00
DYefremov
396d10a805 extension manager ui adjustment 2024-07-27 23:41:07 +03:00
DYefremov
30e1c63a47 extension manager ui adjustment 2024-07-26 14:23:25 +03:00
DYefremov
ef7e35378d backup dialog ui adjustment 2024-07-26 14:18:54 +03:00
DYefremov
0a1bbab7d0 web import dialog redesign 2024-07-12 00:10:58 +03:00
DYefremov
65502018a0 add split satellites by band for web import (#204) 2024-07-02 19:28:03 +03:00
DYefremov
cc20042001 web import adjustments 2024-07-02 15:51:12 +03:00
DYefremov
50c2e831ce extension manager adjustment 2024-07-02 14:38:19 +03:00
DYefremov
ea91c39769 fix bouquet hiding status 2024-06-20 15:32:47 +03:00
DYefremov
3dab8ef7b7 version update -> 3.10.2 2024-06-17 16:16:41 +03:00
DYefremov
dd1a543e5c allowed data extraction from startup page 2024-06-16 18:12:32 +03:00
DYefremov
0966489024 fix reading some bouquets (#202)
* additional format for reading bouquet file name
2024-06-16 18:06:22 +03:00
DYefremov
052187359d version update -> 3.10.1 2024-06-08 17:44:11 +03:00
DYefremov
6ca6867ea9 small adjustment (#201) 2024-06-08 17:38:06 +03:00
DYefremov
d9cdc6458c fix services duplication (#201)
* Fixes service double loading when changing profile on start page.
2024-06-08 15:48:55 +03:00
DYefremov
70b9851324 minor ui correction
* IPTV list config dialog adjustment for macOS
2024-05-17 22:52:46 +03:00
DYefremov
2a3b558d83 adjustment for LyngSat web source
* default values for ONID-TID if not present (#200)
2024-05-09 19:13:52 +03:00
DYefremov
21ea841f34 version update -> 3.10.0 2024-05-07 18:53:52 +03:00
DYefremov
1db0ce3fc5 README update 2024-05-07 18:42:01 +03:00
DYefremov
2804a9bc54 change playback keyboard shortcuts 2024-05-07 18:41:36 +03:00
DYefremov
8976f42974 enabled playback mode for the main services list (#198) 2024-05-07 17:56:00 +03:00
DYefremov
8330104f3c add last config load flag 2024-03-29 22:22:05 +03:00
DYefremov
3ede2e2b07 fix -> mark not present in bouquets (#197) 2024-03-29 20:35:48 +03:00
DYefremov
dd796c0f88 version update -> 3.9.2-b 2024-03-16 17:58:52 +03:00
DYefremov
f3a432c002 add optional keep for backup
* prevent *.xml deletion (#196)
2024-03-11 00:38:25 +03:00
DYefremov
4c1cdc4850 increase pos width for sat dialog 2024-03-06 20:53:14 +03:00
DYefremov
a062d74e0e BUILD_WIN.md update 2024-03-05 22:53:41 +03:00
DYefremov
7bf36c8d6d minor fix to add a timer 2024-03-04 13:38:36 +03:00
DYefremov
6aad0344c8 timer dialog adjustment 2024-03-03 19:47:54 +03:00
DYefremov
bb4665d180 default emblem adjustment 2024-03-03 11:53:57 +03:00
DYefremov
a455c4569d version update -> 3.9.1-b 2024-03-03 10:05:23 +03:00
DYefremov
8d5af301fb dialogs ui adjustments 2024-03-02 13:08:54 +03:00
DYefremov
f342b99769 update *.desktop file for deb package 2024-03-02 12:29:42 +03:00
DYefremov
a0612e6a98 separate data loading (#196) 2024-03-02 11:57:20 +03:00
DYefremov
60b8f7642d lamedb5 reading correction (#194) 2024-03-01 22:19:34 +03:00
DYefremov
8730cbdb7c README update 2024-02-26 22:19:01 +03:00
mapi68
cefb96ea20 Update languages in demon-editor.desktop (#195) 2024-02-26 10:39:50 +03:00
DYefremov
b383c2572a minor fixes for EPG cache 2024-02-25 14:02:36 +03:00
DYefremov
ee2fcf8082 changed some icons for picons tab 2024-02-25 13:35:37 +03:00
DYefremov
44234fa534 set "Save as" element visibility 2024-02-22 22:58:53 +03:00
DYefremov
4bb66b5cc1 version update -> 3.9.0-b 2024-02-22 15:33:59 +03:00
DYefremov
3b1ecbfbbf get webtv name for neutrino 2024-02-22 15:28:44 +03:00
DYefremov
a7f7a59c8a enable ctrl+u shortcut globally 2024-02-21 22:08:51 +03:00
DYefremov
9068028662 it *.mo file update 2024-02-21 10:14:08 +03:00
mapi68
40fbc7809f Italian translation update (#193)
* Update demon-editor.po

* small corrections
2024-02-21 10:10:26 +03:00
DYefremov
6397c2c7f3 translations update -> [be, de, ru] 2024-02-20 17:14:21 +03:00
DYefremov
81e8e30682 add manual port configuration for *.m3u export 2024-02-20 15:24:46 +03:00
DYefremov
9b4c6ab14a fix *.m3u export for neutrino 2024-02-20 14:00:35 +03:00
DYefremov
cad1437c33 small fix 2024-02-18 23:36:41 +03:00
DYefremov
4799a0d464 add additional bouquet name checking (#192) 2024-02-17 19:04:55 +03:00
DYefremov
ce890353a4 win *.spec file update 2024-02-17 13:27:34 +03:00
DYefremov
506e07e3f4 fix multi EPG request 2024-02-17 00:44:49 +03:00
DYefremov
e7a61e3f05 minor ui correction 2024-02-15 11:35:17 +03:00
DYefremov
274abec3b8 add EPG warning messages 2024-02-15 11:22:57 +03:00
DYefremov
77a5f55522 fix extensions loading -> python 3.12 2024-02-14 22:38:39 +03:00
DYefremov
a7f334682f minor corrections for EPG tab 2024-02-14 14:41:18 +03:00
DYefremov
a9b9e5865e minor fix for m3u export 2024-02-13 09:58:20 +03:00
DYefremov
d59458a84b export to m3u improvement
* multiple bouquets export
  * grouping
2024-02-12 23:58:12 +03:00
DYefremov
076bcb0cce removed sub bouquets warning 2024-02-11 20:09:59 +03:00
DYefremov
8cbf03eb51 improved *m3u import
* added split by groups
2024-02-11 18:33:35 +03:00
DYefremov
abed7bf9cb bouquet name gen refactoring 2024-02-11 16:30:40 +03:00
DYefremov
388e748673 adjustment of EPG cache init 2024-02-10 23:55:23 +03:00
DYefremov
ba3a3ae0aa add EPG cache import on init 2024-02-10 22:10:52 +03:00
DYefremov
9bb4f6d75d fix XMLTV double load 2024-02-10 21:05:47 +03:00
DYefremov
06242ce611 add data size check for XMLTV download 2024-02-10 20:47:46 +03:00
DYefremov
e4b1c98b2a improvements for EPG tab and cache 2024-02-10 19:08:22 +03:00
DYefremov
6a8426e6ef enabled recording and playback for the current IPTV service 2024-02-07 12:52:56 +03:00
DYefremov
583927f1b1 epg data loading improvements
* separate load of xmltv files
  * cache properties for readers
2024-01-31 15:48:02 +03:00
DYefremov
185a9b0082 fix epg event data 2024-01-28 22:37:04 +03:00
DYefremov
ee7eb01af5 add EPG source support to m3u import 2024-01-28 13:58:29 +03:00
DYefremov
07c8034393 fix bouquet file name for new config 2024-01-24 09:07:20 +03:00
DYefremov
8ec73bc0f9 add event to EPG tool 2024-01-23 15:15:12 +03:00
DYefremov
940b28ff6d data items state adjustment 2024-01-22 23:47:41 +03:00
DYefremov
316260703c save as for satellites tab 2024-01-22 23:14:46 +03:00
DYefremov
e6c7b6572c minor clean 2024-01-22 22:55:13 +03:00
DYefremov
0e50cf4927 add extraction support for EPG tab 2024-01-22 14:19:43 +03:00
DYefremov
f55dff1618 toolbar items state update refactoring 2024-01-22 13:47:07 +03:00
DYefremov
669916a5a9 add m3u export dialog 2024-01-21 23:03:56 +03:00
DYefremov
4154f4d2f5 display info messages refactoring 2024-01-20 19:04:20 +03:00
DYefremov
f3d133b7a3 m3u parsing improvement 2024-01-20 15:32:45 +03:00
DYefremov
a432da60e5 add m3u ui file 2024-01-19 23:40:45 +03:00
DYefremov
d34128b701 add short service info to picon explorer 2024-01-19 18:00:41 +03:00
DYefremov
d16d0e62e2 small refactoring 2024-01-19 17:24:12 +03:00
DYefremov
1a6270478a small revert 2024-01-18 00:10:05 +03:00
DYefremov
4144e37049 m3u elements redesign 2024-01-18 00:00:36 +03:00
DYefremov
61faced24d fix EPG request 2024-01-11 17:31:40 +03:00
DYefremov
add73558f3 update -> about 2024-01-11 14:23:28 +03:00
DYefremov
259f20794c minor corrections for EPG tab 2024-01-11 14:16:38 +03:00
DYefremov
afdb9a3c6b EPG data load optimization 2024-01-10 14:34:26 +03:00
DYefremov
8581e7be1f fix some warnings 2024-01-08 17:45:44 +03:00
DYefremov
5496aec95f enabled filtering hotkey for recordings tab 2024-01-08 17:11:10 +03:00
DYefremov
5441bb0c02 enable upload button for EPG tab 2024-01-06 12:40:04 +03:00
DYefremov
3991d2935f support for opening local xmltv files 2024-01-06 00:12:48 +03:00
DYefremov
c42115ba7d add a separate data opening event 2024-01-05 15:59:00 +03:00
DYefremov
929987b2f1 adjustments for EPG tab 2024-01-05 15:03:36 +03:00
DYefremov
9a0bead39c loading services by first selection of the tab 2024-01-04 14:09:57 +03:00
DYefremov
d18e9c116b custom repo support for extension manager 2024-01-02 12:58:22 +03:00
DYefremov
e477e54dcd EPG file update on XML src change 2023-12-31 10:50:05 +03:00
DYefremov
48b9cb23eb fix EPG filtering when missing values 2023-12-30 12:52:06 +03:00
DYefremov
5db027c1cf add settings popover for EPG tab 2023-12-29 15:16:58 +03:00
DYefremov
128fd8a792 version update -> 3.9.0-a 2023-12-29 14:18:44 +03:00
DYefremov
32043b7df0 XMLTV display support for EPG tab 2023-12-29 14:09:13 +03:00
DYefremov
bb847cd94c fix getting current events for xmltv 2023-12-29 13:02:40 +03:00
DYefremov
64b836363b EPG cache optimization 2023-12-28 23:05:01 +03:00
DYefremov
5f34652905 add support for multiple xmltv sources 2023-12-23 19:28:45 +03:00
DYefremov
32078bc7e3 EPG cache refactoring 2023-12-21 01:01:08 +03:00
DYefremov
9c582c26db it *.mo file update 2023-12-19 10:51:51 +03:00
mapi68
aa5addb280 Italian translation update 2023-12-19 10:47:05 +03:00
DYefremov
2d8ae1bbe2 fav selection refactoring 2023-12-15 18:07:02 +03:00
DYefremov
5405d3a9a8 EPG tab toolbar redesign 2023-11-20 21:36:44 +03:00
DYefremov
d91cbc395c allowed uncompressed *.xml for EPG src link (#189) 2023-11-17 11:00:21 +03:00
DYefremov
e44aedebad fix multiple selection for IPTV tab 2023-11-13 12:47:27 +03:00
DYefremov
9ccbd46b71 sat dialogs ui adjustment
* some corrections for macOS
2023-11-09 17:51:15 +03:00
DYefremov
3f0e9e44a9 changed URL extraction for IPTV (#187) 2023-11-05 17:17:49 +03:00
DYefremov
32a847fc5d fix single bouquet import 2023-11-05 14:02:38 +03:00
DYefremov
795ad51098 bouquets parsing refactoring
* allowed custom  file names for bouquets (#185)
2023-10-16 11:11:57 +03:00
audi06_19
64827a60d8 Turkish translation update (#184) 2023-10-08 20:07:45 +03:00
DYefremov
b98d55ffb6 EPG dialog header redesign 2023-10-06 22:00:20 +03:00
DYefremov
aa21862303 version update 2023-09-29 22:24:15 +03:00
DYefremov
f205e4fa66 minor fix for extension manager
* version update on download
2023-09-22 17:57:05 +03:00
DYefremov
4ffecb9ce4 skip getting picon for the marker 2023-09-22 16:47:06 +03:00
DYefremov
ecc95e9c82 it *.mo file update 2023-09-20 10:48:10 +03:00
mapi68
4c5cbcb514 Italian translation update (#183) 2023-09-20 10:19:08 +03:00
DYefremov
8bd24e9642 translations update [be, de, ru] 2023-09-20 00:27:25 +03:00
DYefremov
64234c26e0 pids pattern adjustment 2023-09-20 00:07:35 +03:00
DYefremov
0b1d31fb8a EPG dialog redesign 2023-09-10 15:10:00 +03:00
DYefremov
a638a6b1fc minor corrections 2023-09-10 14:53:32 +03:00
DYefremov
917e184486 sat dialogs adjustment 2023-07-25 16:44:31 +03:00
DYefremov
bc17a6720a allowed *.xml for backup 2023-07-22 20:42:49 +03:00
DYefremov
838cdbd350 webtv parsing refactoring 2023-07-21 19:08:51 +03:00
DYefremov
e795b21499 add IPTV support for favorites in Neutrino 2023-07-20 23:10:18 +03:00
DYefremov
2482e1a424 fix reading lamedb5 2023-07-20 12:16:28 +03:00
DYefremov
3fd9e3ce9d Dutch translation update 2023-07-14 21:57:30 +03:00
DYefremov
67524f7ede Portuguese translation update 2023-07-14 21:57:05 +03:00
DYefremov
721a585eb9 Spanish translation update 2023-07-14 21:55:47 +03:00
DYefremov
45e210dade minor fix 2023-07-14 08:28:06 +03:00
DYefremov
1d98803763 version update 2023-07-13 22:58:25 +03:00
DYefremov
568a68916c timers tab adjustment 2023-07-13 22:49:15 +03:00
DYefremov
89ed1c0715 IPTV dialog ui adjustment 2023-07-13 22:19:43 +03:00
DYefremov
a1ca26c47e EPG tab ui adjustment 2023-07-13 22:04:08 +03:00
DYefremov
27392d6000 extension manager redesign 2023-07-13 21:23:18 +03:00
DYefremov
f5ebd06fc0 backup dialog redesign 2023-07-13 21:01:48 +03:00
DYefremov
7b933188f8 fix selection 2023-07-07 23:46:15 +03:00
DYefremov
ec5a89e716 import dialogs redesign 2023-07-07 23:35:36 +03:00
DYefremov
f8b1c29638 service details dialog redesign 2023-07-07 21:33:26 +03:00
DYefremov
cdbee2b429 IPTV dialogs redesign 2023-07-06 17:19:15 +03:00
DYefremov
ad748bf14c FTP tab redesign 2023-07-05 17:36:49 +03:00
DYefremov
a7ca2ed0fc timers tab redesign 2023-07-05 15:03:32 +03:00
DYefremov
e32042af35 EPG tab redesign 2023-07-05 14:37:10 +03:00
DYefremov
40d2ac9ecf recordings tab redesign 2023-07-05 09:52:40 +03:00
DYefremov
324b7bf103 minor playback fix 2023-07-05 09:51:53 +03:00
DYefremov
3dd3b1df34 control tab redesign 2023-07-04 15:58:27 +03:00
DYefremov
bbd0b07540 telnet tool redesign 2023-07-03 23:19:05 +03:00
DYefremov
9a37280d14 logging tool redesign 2023-07-03 21:22:59 +03:00
DYefremov
1b83c0f7f6 picons tab redesign 2023-07-03 19:41:03 +03:00
DYefremov
c83310b98e satellite dialogs redesign 2023-07-03 00:15:25 +03:00
DYefremov
6a4bd4e5da satellites tab redesign 2023-07-02 22:16:07 +03:00
DYefremov
bca50a5d30 bouquets tab redesign 2023-06-30 19:00:04 +03:00
DYefremov
45b856946d common path default for picons 2023-06-30 10:03:16 +03:00
DYefremov
3fcc96cd02 settings dialog redesign 2023-06-29 21:26:02 +03:00
DYefremov
0894cb5a47 *.spec files update 2023-06-25 18:08:32 +03:00
DYefremov
1ac6537496 win style adjustment 2023-06-23 23:37:16 +03:00
DYefremov
285ea66197 lazy filtering 2023-06-23 23:33:50 +03:00
DYefremov
1edb313e2c minor filter optimization 2023-06-19 23:20:53 +03:00
DYefremov
c9755e0116 win style update 2023-06-18 12:30:41 +03:00
DYefremov
367a4a2e36 alternate title option for Windows 2023-06-14 17:31:29 +03:00
DYefremov
fc65ee29ee minor changes in settings dialog 2023-06-14 17:18:26 +03:00
DYefremov
f0e9684f51 version update 2023-06-14 12:49:51 +03:00
DYefremov
7084eec407 translations update 2023-06-14 12:16:23 +03:00
DYefremov
ef79dac603 skipping services with empty params for kos 2023-06-13 23:04:09 +03:00
audi06_19
89ee80e4ef Update: Turkish translation update (#181) 2023-06-10 19:08:07 +03:00
DYefremov
73df75a519 switching to yt-dlp 2023-06-06 17:41:23 +03:00
DYefremov
7ed0d6d355 playback adjustment for macOS 2023-06-06 11:39:16 +03:00
DYefremov
b4a0a72db3 debug message output adjustment 2023-06-06 10:05:49 +03:00
DYefremov
63e0f1ea14 deb script adjustment 2023-06-06 09:49:36 +03:00
DYefremov
d446240d91 minor adjustment for picons tab 2023-06-03 20:40:25 +03:00
DYefremov
3d0f010798 fix data reading (#180) 2023-06-03 14:00:16 +03:00
DYefremov
4fc4cb7e2a it *.mo file update 2023-06-02 18:53:32 +03:00
mapi68
80147b4cc0 Italian translation update (#179) 2023-06-02 18:49:26 +03:00
DYefremov
6270c03376 small translations update 2023-06-01 22:45:11 +03:00
DYefremov
2285211100 minor fix for picons download 2023-06-01 22:16:47 +03:00
DYefremov
642bca81c2 stream playback adjustments 2023-05-30 11:07:16 +03:00
DYefremov
198cb3867d status icons for extension manager 2023-05-25 18:57:42 +03:00
DYefremov
d0db68acb4 ver column for extension manager 2023-05-25 15:39:56 +03:00
DYefremov
43544a9df3 extension manager improvement
* loading/removing via status toggle
2023-05-23 20:41:57 +03:00
DYefremov
ebf6454181 yt API correction 2023-05-22 20:54:41 +03:00
DYefremov
ea09cef837 version update
* -> 3.7.0 Alpha
2023-05-22 13:47:42 +03:00
DYefremov
d7853c31ff playback improvement 2023-05-22 13:43:47 +03:00
DYefremov
3dcc942c25 playback code refactoring 2023-05-22 00:41:24 +03:00
DYefremov
ffdd98d406 extension manager minor improvements 2023-05-18 23:56:36 +03:00
DYefremov
4d9ae8c23a pl *.mo file update 2023-05-16 19:00:50 +03:00
lareq
e2c97169fb Polish translation update (#177) 2023-05-16 18:54:27 +03:00
DYefremov
414fd22f71 extension manager improvement 2023-05-15 21:38:51 +03:00
DYefremov
4ff7129750 translate refactoring 2023-05-13 13:31:42 +03:00
DYefremov
1546baab30 small playback refactoring 2023-05-13 09:19:35 +03:00
DYefremov
444df51706 deb build update 2023-05-09 22:38:59 +03:00
DYefremov
5e84656c20 it *.mo file update 2023-05-09 21:01:45 +03:00
DYefremov
245d10fb03 remove/download extensions support 2023-05-07 23:55:03 +03:00
DYefremov
b9f2e5cb3a add extension manager dialog 2023-05-07 00:53:36 +03:00
DYefremov
c040c1145c fix http test for neutrino 2023-05-06 10:21:05 +03:00
DYefremov
92a91cd995 add play button 2023-05-04 23:51:50 +03:00
DYefremov
6255b60453 loading screen to playback
* some playback refactoring
2023-05-04 21:55:58 +03:00
DYefremov
30aa967f82 playback bar redesign 2023-04-29 17:55:13 +03:00
DYefremov
b90040f473 fix kos web source 2023-04-28 22:07:27 +03:00
DYefremov
43bf5ac44b bump version 2023-04-25 20:53:42 +03:00
DYefremov
e714b10431 changed columns for filtering 2023-04-25 20:52:44 +03:00
DYefremov
f0d0813e75 refactoring of getting picon pixbuf 2023-04-25 01:04:38 +03:00
DYefremov
9dc4df73c4 added piconSNPblack logo 2023-04-25 00:32:44 +03:00
DYefremov
f2da1e4cd4 minor refactoring of player 2023-04-25 00:12:29 +03:00
DYefremov
3d4588833b vlc module update 2023-04-24 22:49:26 +03:00
DYefremov
4cf19e5413 minor code adjustment
* Preventing mixins [str -> Enum] bug in Python 3.11 [100458]
2023-04-22 18:33:18 +03:00
audi06_19
cd4a814838 Turkish translation update (#176) 2023-04-22 17:31:42 +03:00
DYefremov
d7c49f50f2 bump version 2023-04-19 14:07:53 +03:00
DYefremov
b61b8e16fa small fix for Web import options init 2023-04-19 09:56:17 +03:00
DYefremov
56d2a3e991 fix dir copy for FTP tab 2023-04-17 23:16:00 +03:00
DYefremov
b65ea9c0d3 vlc module update 2023-04-17 13:53:46 +03:00
DYefremov
a62ee8f378 extension API improvement 2023-04-15 16:49:20 +03:00
DYefremov
0db8ee6d47 prevent focus lack for main views 2023-04-14 10:48:37 +03:00
DYefremov
ed41b01f63 it *.mo file update 2023-04-13 10:27:24 +03:00
mapi68
c2eaecb8b8 Italian translation update (#174) 2023-04-13 10:20:26 +03:00
DYefremov
f5313f2c40 minor translations update 2023-04-12 23:40:50 +03:00
DYefremov
c6a0b80fdd bouquets gen improvement 2023-04-12 23:30:07 +03:00
DYefremov
7813aeb059 reverted lowercase for some prefixes (#170) 2023-04-12 09:34:50 +03:00
DYefremov
3a0e5c09a1 it *.mo file update 2023-04-09 10:42:48 +03:00
mapi68
ab6a44dc3f Italian translation update (#173) 2023-04-09 10:38:27 +03:00
audi06_19
caefb4587d Update: Turkish translation update (#172) 2023-04-09 10:37:42 +03:00
DYefremov
93ff78d7ce version update 2023-04-08 14:32:00 +03:00
DYefremov
1d583ecd99 fix root bouquet rename 2023-04-08 14:24:12 +03:00
DYefremov
d4914ac451 Russian, Belarusian and German translations update 2023-04-08 00:21:16 +03:00
DYefremov
6207b6a10d Spanish and Dutch translations update 2023-04-07 20:51:13 +03:00
DYefremov
1eb8fe621d save satellites selection for web import 2023-04-07 19:01:16 +03:00
DYefremov
79b41b1661 minor refactoring of settings save 2023-04-07 18:37:40 +03:00
DYefremov
7a36ba8148 minor fix 2023-04-07 13:22:39 +03:00
DYefremov
08e970fc96 save source for web import 2023-04-07 08:42:04 +03:00
DYefremov
9681fcbc79 saving settings for web import 2023-04-07 00:27:55 +03:00
DYefremov
7b002b208f added additional yt link checking (#170) 2023-04-06 14:55:30 +03:00
DYefremov
95a1732f01 satellites merge support for web import (#165) 2023-04-06 00:24:45 +03:00
DYefremov
57e4fdff7f changed case for yt links prefixes (#170) 2023-04-04 22:37:06 +03:00
DYefremov
89c456993f allowed to rename root bouquet 2023-04-03 09:12:18 +03:00
DYefremov
2f3fc31023 minor fix 2023-04-02 12:49:00 +03:00
DYefremov
5c18e49cf7 added 'No' prefix for yt links (#170) 2023-04-02 12:42:59 +03:00
DYefremov
64530bcb85 prefix support for yt playlist import (#170) 2023-03-30 18:57:01 +03:00
DYefremov
4d472609b4 url prefix elems for yt dialog 2023-03-30 00:13:25 +03:00
DYefremov
640b995ab8 prefix support for yt links (#170) 2023-03-27 00:01:08 +03:00
DYefremov
42980a988f minor ui changes for IPTV dialogs 2023-03-26 18:18:32 +03:00
DYefremov
64c5f28957 enabled quality change for yt links 2023-03-26 16:22:12 +03:00
DYefremov
a32bf230cf fix yt link double check on edit (#170) 2023-03-25 16:04:11 +03:00
audi06_19
b8cac728a8 Turkish translation update (#166) 2023-03-12 16:28:55 +03:00
DYefremov
079f07cfd2 mpv module update 2023-03-09 08:47:57 +03:00
DYefremov
9a5884cc9a it *.mo file update 2023-03-06 16:49:55 +03:00
mapi68
115237a10f Italian translation update (#163) 2023-03-06 16:46:29 +03:00
DYefremov
994bd0ee1c Russian, Belarusian and German translations update 2023-03-05 14:01:02 +03:00
DYefremov
27e5b373a3 version update 2023-03-04 20:43:15 +03:00
DYefremov
43c05b1739 fix namespace for web import 2023-03-04 20:00:13 +03:00
DYefremov
bd96c286e9 option skip c-band for web import dialog 2023-03-04 18:15:16 +03:00
DYefremov
1bded41eab close options button for import dialog 2023-03-04 16:00:41 +03:00
DYefremov
5f0f51679c sat list init on update dialog start 2023-03-04 12:28:10 +03:00
DYefremov
380bb3150b updated it *.mo file 2023-03-04 11:15:23 +03:00
mapi68
d1a7a486a2 Italian translation update (#162) 2023-03-04 11:07:52 +03:00
DYefremov
1be167bec3 sorting by pos on bouquets generation 2023-03-04 00:27:37 +03:00
DYefremov
dd3e88589c Russian, Belarusian and German translations update 2023-03-03 17:38:29 +03:00
DYefremov
c5a2df6d7d prevent duplicate for web import 2023-03-03 12:04:04 +03:00
DYefremov
c9fc3803c7 fix FEC value for web import 2023-03-03 10:49:06 +03:00
DYefremov
6afd518cfc fix adding duplicates to the main list 2023-03-03 10:36:46 +03:00
DYefremov
02a51c9b56 bouquets generation for kos web source 2023-03-02 17:50:31 +03:00
DYefremov
c96cfa0e1b category and lang for kingofsat 2023-03-01 23:02:11 +03:00
DYefremov
08bc4ff4c4 bouquets only option for import dialog 2023-03-01 13:36:20 +03:00
DYefremov
ae2b78e990 README update 2023-02-28 00:23:00 +03:00
DYefremov
88be9fe49c updated it *.mo file 2023-02-27 23:48:01 +03:00
mapi68
9dae9b7219 Italian translation update (#161) 2023-02-27 23:42:58 +03:00
DYefremov
f296a6c90b paths normalization in settings 2023-02-27 23:40:41 +03:00
DYefremov
0486776d83 minor fix 2023-02-27 10:31:20 +03:00
DYefremov
3e6146d825 .gitignore update 2023-02-23 16:31:03 +03:00
audi06_19
9b97341e70 add .gitignore and Turkish translation update (#160)
* add .gitignore

* Update: Turkish translation update
2023-02-23 14:36:34 +03:00
DYefremov
41714136e6 README update 2023-02-23 14:31:56 +03:00
DYefremov
f781cbb9f6 bump version 2023-02-23 11:05:50 +03:00
DYefremov
177be7679b added version for extensions 2023-02-22 13:43:24 +03:00
DYefremov
a65914a48c duplicate removal support for the fav list (#159) 2023-02-22 11:55:34 +03:00
DYefremov
e3ffc2e24b playback fix for empty list 2023-02-22 11:46:11 +03:00
DYefremov
79415c69c5 minor refactoring 2023-02-21 15:39:22 +03:00
DYefremov
54c4e02cee bump version 2023-02-18 21:15:50 +03:00
DYefremov
f580c5d83c added header bar option on macOS 2023-02-18 18:35:09 +03:00
DYefremov
e0cbdb2f8d header bar option refactoring 2023-02-18 11:30:06 +03:00
DYefremov
839855c076 reworking of the program options tab 2023-02-18 09:37:05 +03:00
audi06_19
87db39590b Update: Turkish translation update (#158) 2023-02-18 08:28:41 +03:00
DYefremov
ebe58903e5 corrected service type setting for IPTV 2023-02-17 21:51:35 +03:00
DYefremov
ff8d4e5321 fix ref assignment from XML source (#154) 2023-02-17 20:17:17 +03:00
DYefremov
907415a2c9 added path to extensions 2023-02-17 13:15:12 +03:00
DYefremov
f9afffbdb3 *.spec file correction 2023-02-17 11:25:32 +03:00
DYefremov
326856b1e3 added config for extensions 2023-02-17 00:37:43 +03:00
DYefremov
865a326fe9 minor translations update 2023-02-16 20:05:27 +03:00
DYefremov
dd2661c6c9 picons location corrections (#157) 2023-02-16 19:36:15 +03:00
DYefremov
623e5a17f5 small fix to set bouquet lock 2023-02-16 16:29:08 +03:00
DYefremov
18c1fa736b display channel id as tooltip for EPG dialog (#154) 2023-02-16 14:42:20 +03:00
DYefremov
0e6142c751 parental lock and hiding for bouquets 2023-02-15 22:41:37 +03:00
DYefremov
c274c265c6 fix set lock for bouquet 2023-02-15 21:59:30 +03:00
DYefremov
777c09c9b8 storing parental lock for bouquets 2023-02-15 14:17:25 +03:00
DYefremov
12a68a4dbb displaying parental lock for bouquets 2023-02-15 13:04:54 +03:00
DYefremov
eab869d4d5 hidden marker for bouquets 2023-02-15 12:34:06 +03:00
DYefremov
aec76eec45 filtering by sat position for XML source (#154) 2023-02-14 17:36:43 +03:00
DYefremov
bdab316ba7 small resizing fix 2023-02-14 10:55:11 +03:00
DYefremov
771ecb696f updated it *.mo file 2023-02-12 14:43:15 +03:00
mapi68
8993fbed5d Italian translation update (#156) 2023-02-12 14:36:32 +03:00
DYefremov
f3cad81a7d build-deb file update 2023-02-11 13:06:13 +03:00
DYefremov
c1cf343f69 logging support for extensions 2023-02-11 12:32:33 +03:00
DYefremov
f998f66a35 version update 2023-02-11 10:09:46 +03:00
DYefremov
9eb4cdc574 option to enable extensions 2023-02-11 10:04:04 +03:00
DYefremov
20120e0db4 README for extensions 2023-02-10 17:42:54 +03:00
DYefremov
3446bb225c moved extensions module 2023-02-10 14:04:42 +03:00
DYefremov
191975bd14 extension as singleton 2023-02-10 11:59:14 +03:00
DYefremov
f4be52a202 basic extensions API 2023-02-09 23:51:14 +03:00
DYefremov
ba2272cf13 updated tr *.mo file 2023-02-06 21:49:56 +03:00
audi06_19
7bd3fcd9a6 Turkish translation update (#155) 2023-02-06 21:46:10 +03:00
DYefremov
e54719ca2c bump version 2023-02-04 12:37:16 +03:00
DYefremov
0c1c44c866 columns resizing for EPG tab 2023-02-04 11:03:12 +03:00
DYefremov
06b82251ef duration format correction for win 2023-02-02 00:09:51 +03:00
DYefremov
fb929ec723 playback pause on mouse click 2023-02-01 13:24:51 +03:00
DYefremov
fd1c1bfd6e layout init correction 2023-01-31 12:45:26 +03:00
DYefremov
c48a08b239 header bar for yt dialog 2023-01-30 17:36:22 +03:00
DYefremov
b647b0a338 delayed cache init 2023-01-30 14:41:16 +03:00
DYefremov
9b608eeb74 added custom header bar widget 2023-01-29 12:59:57 +03:00
DYefremov
07e55b3f1e header bar activation for macOS 2023-01-29 00:33:59 +03:00
DYefremov
7e639f5637 header bar for update dialog 2023-01-29 00:26:28 +03:00
DYefremov
5570d47cae header bar activation for macOS 2023-01-28 21:45:01 +03:00
DYefremov
5c49c0d123 refs assignment from filtered list only (#154) 2023-01-28 17:55:19 +03:00
DYefremov
ee6dd511b5 clearing EPG on profile change 2023-01-28 14:43:13 +03:00
DYefremov
1f847233b3 copyright update 2023-01-28 14:03:04 +03:00
DYefremov
835e1af8e4 README update 2023-01-28 14:00:22 +03:00
DYefremov
50e0d8b66a time display correction for XMLTV 2023-01-28 13:53:15 +03:00
DYefremov
fb0789664a version update 2023-01-27 22:41:49 +03:00
DYefremov
5dd39492f2 updated it *.mo file 2023-01-27 22:30:55 +03:00
mapi68
33be9f21a2 Italian translation update (#153) 2023-01-27 22:16:45 +03:00
DYefremov
7acc9ae74f Russian, Belarusian and German translations update 2023-01-27 17:04:18 +03:00
DYefremov
d492022232 some ui corrections 2023-01-27 13:44:41 +03:00
DYefremov
b034995130 added unlimited buffer option 2023-01-26 22:06:29 +03:00
DYefremov
f037b3554d disabled fixed height for picon views 2023-01-26 20:30:20 +03:00
DYefremov
7f1f27da57 minor adjustment 2023-01-26 00:53:28 +03:00
DYefremov
d7f3afecb0 increased EPG tab columns width (#150) 2023-01-26 00:17:38 +03:00
DYefremov
f309005c52 additional time columns for EPG tab (#150) 2023-01-25 17:54:09 +03:00
DYefremov
25661816e7 'replace existing' for bouquets tab 2023-01-25 01:00:16 +03:00
DYefremov
a2652cef4b fix update EPG from alt service 2023-01-25 00:29:23 +03:00
DYefremov
adbc9ad322 option 'replace existing' for import dialog (#127) 2023-01-24 23:02:28 +03:00
DYefremov
2dc8611294 bump version 2023-01-21 16:27:08 +03:00
DYefremov
72bfd21056 fix input for settings dialog 2023-01-21 15:49:18 +03:00
DYefremov
65ef018f81 modifiers fix for yt dialog 2023-01-21 15:47:47 +03:00
DYefremov
1236c5ebc9 filtering by satellite position for the EPG dialog (#148) 2023-01-21 15:06:27 +03:00
DYefremov
f0011ebcf2 fixed copy/paste refs from some satellites (#146) 2023-01-20 12:17:59 +03:00
DYefremov
392e94e7ba bump version 2023-01-19 22:05:40 +03:00
DYefremov
c6de18271d setting EPG path active by default 2023-01-19 21:57:52 +03:00
DYefremov
71a65242c1 ui correction for EPG tab 2023-01-19 21:04:53 +03:00
DYefremov
4efc956870 minor ui correction for remote control 2023-01-19 16:02:11 +03:00
DYefremov
a605fdd545 minor ui corrections 2023-01-18 18:52:54 +03:00
DYefremov
285c1cae69 minor style changes 2023-01-14 23:02:24 +03:00
DYefremov
2e937a42a3 updated tr *.mo file 2023-01-14 00:30:46 +03:00
audi06_19
e208cf4656 Turkish translation update (#147) 2023-01-14 00:26:01 +03:00
DYefremov
3db82e3e18 don't hide filter popups after items selection (#145) 2023-01-13 16:11:57 +03:00
DYefremov
38e9a85694 remote control improvement 2023-01-12 21:46:04 +03:00
DYefremov
438e9c10d4 corrected reference assignment for IPTV services (#146) 2023-01-08 01:59:46 +03:00
DYefremov
920fa01159 bump version 2023-01-06 12:00:46 +03:00
DYefremov
d2787364cd fixed *.xml files deletion skip when saving data 2023-01-06 11:24:10 +03:00
DYefremov
839c0fae23 compressed picons path correction 2023-01-06 11:01:39 +03:00
DYefremov
f87548e12e minor style correction for filter items (#145) 2023-01-01 21:33:05 +03:00
DYefremov
463702c371 IPTV description tag correction (#144) 2022-12-27 00:26:47 +03:00
DYefremov
0b84a81439 version update 2022-12-25 14:56:22 +03:00
DYefremov
6b68740961 skipping existing channels when grouping by satellites (#127) 2022-12-25 14:49:02 +03:00
DYefremov
92aa2400f6 mpv module correction
* fixed work with API ver. 2
2022-12-20 14:36:14 +03:00
DYefremov
2cf4e5b756 updated it *.mo file 2022-12-14 13:23:37 +03:00
mapi68
6cfa68e219 Italian translation update (#141) 2022-12-14 13:10:36 +03:00
DYefremov
138aa54b44 Russian, Belarusian and German translations update 2022-12-13 14:33:51 +03:00
DYefremov
3e1a3d1595 fixed display of tooltip icon 2022-12-13 14:04:28 +03:00
DYefremov
b833458c45 version update 2022-12-11 11:29:37 +03:00
DYefremov
d1e88be1cc grouping by satellites in the import dialog (#127) 2022-12-10 21:34:19 +03:00
DYefremov
72e128aeb9 Dutch and Spanish translations update 2022-12-08 22:14:16 +03:00
DYefremov
457b4e4645 support for SNP picons filtering (#128) 2022-12-08 21:56:14 +03:00
DYefremov
076243b0ac changed picon reference creation
* Reverted stream type (important for some images).
2022-12-04 16:45:46 +03:00
DYefremov
e5ff185791 snr display in dB (#140) 2022-11-28 15:15:40 +03:00
DYefremov
2b6b0dd827 version update 2022-11-24 21:38:33 +03:00
DYefremov
a11fdd683e updated it *.mo file 2022-11-24 11:53:47 +03:00
mapi68
636bc5c52f Italian translation update (#137) 2022-11-24 11:47:52 +03:00
DYefremov
90d64d46c7 Russian, Belarusian and German translations update 2022-11-24 00:46:49 +03:00
DYefremov
115f77108c Russian, Belarusian and German translations update 2022-11-22 09:48:50 +03:00
DYefremov
2082f8e973 IPTV tab update after EPG configuration (#132) 2022-11-21 22:12:56 +03:00
DYefremov
7e586bf0a6 redesigned refs assignment for EPG dialog (#132) 2022-11-19 00:20:59 +03:00
DYefremov
c5c4823534 support for cleaning flag "new" (#136) 2022-11-17 00:27:39 +03:00
DYefremov
6235519cf9 increased chars width for service column (#126) 2022-11-15 22:19:00 +03:00
DYefremov
7d3a9f768c version update 2022-11-12 00:03:48 +03:00
DYefremov
37bea3a93c setting service type in EPG configuration (#132) 2022-11-11 23:38:06 +03:00
DYefremov
33a22fdca7 reference assigning with a service type (#132) 2022-11-11 21:52:01 +03:00
DYefremov
d54e97b1b8 picon reference corrections for IPTV dialogs 2022-11-11 21:38:24 +03:00
DYefremov
12c2a449ea picon reference formation change (#131) 2022-11-10 18:21:29 +03:00
DYefremov
7164f54773 picons display by service name (#128) 2022-11-10 00:12:07 +03:00
DYefremov
84adeb994e added power mode option 2022-11-09 21:23:14 +03:00
DYefremov
69c3b4a6c1 uploading data to multiple hosts 2022-11-05 00:58:39 +03:00
DYefremov
cbec74c2a4 added multiple hosts option 2022-11-04 01:06:18 +03:00
DYefremov
8d856bc989 updated it *.mo file 2022-10-28 10:32:40 +03:00
mapi68
a1ec7600da Italian translation corrections (#135) 2022-10-28 10:28:04 +03:00
DYefremov
2fd71c3645 assign refs refactoring 2022-10-26 10:42:34 +03:00
DYefremov
a0f6b1f651 finalization of selective import 2022-10-21 18:53:32 +03:00
DYefremov
671c2204bf paths setting refactoring (#123) 2022-10-20 09:03:58 +03:00
DYefremov
d2a0419c06 highlighting existing services for the import dialog 2022-10-15 22:22:05 +03:00
DYefremov
fb48395d1e added service details for import dialog 2022-10-14 23:08:10 +03:00
DYefremov
44fb760241 prototype of selective services import (#127) 2022-10-05 19:32:15 +03:00
DYefremov
428a240416 restored open data button 2022-10-03 23:15:21 +03:00
DYefremov
8c58b9395b version update 2022-10-03 16:53:23 +03:00
DYefremov
021b2b08cf added compression option for *.deb (#125) 2022-10-01 18:36:06 +03:00
DYefremov
0d68e43212 version change 2022-09-25 20:04:19 +03:00
DYefremov
751e633a51 send/receive elements dynamic activity 2022-09-25 19:59:17 +03:00
DYefremov
9ff96f2e1d minor logging improvement 2022-09-22 20:09:03 +03:00
DYefremov
3e85fa0149 backup fix 2022-09-20 23:58:32 +03:00
DYefremov
ab5620f9d1 updated tr *.mo file 2022-08-28 07:33:13 +03:00
audi06_19
bceddc199b Turkish translation update (#124) 2022-08-28 07:29:59 +03:00
DYefremov
4f26855ec3 xmltv reading correction 2022-08-25 21:31:16 +03:00
DYefremov
15b8483107 minor improvement to the FTP tab
* Involved send-receive toolbar buttons.
2022-08-24 23:47:31 +03:00
DYefremov
f4dac57d06 improved recordings download 2022-08-24 23:07:18 +03:00
DYefremov
71f7b3a570 improved list font change (#119) 2022-08-24 11:16:04 +03:00
DYefremov
265fb59f0b minor refactoring 2022-08-24 10:48:34 +03:00
DYefremov
5b090672d9 updated it *.mo file 2022-08-21 22:17:14 +03:00
mapi68
aa8f2a8df0 Italian translation improvement (#114)
Fixed errors, and many improvements. Now Italian version of DemonEditor is less verbose!
2022-08-21 22:13:09 +03:00
DYefremov
6782da5f83 Russian, Belarusian and German translations update 2022-08-19 16:34:37 +03:00
DYefremov
26d7b22c3a minor fixes 2022-08-18 16:00:48 +03:00
DYefremov
69ec1f8359 updated *.spec files 2022-08-16 14:09:37 +03:00
DYefremov
c5aac859b0 loading xml EPG as background task 2022-08-16 11:36:57 +03:00
DYefremov
8d0b241ca3 updated tr *.mo file 2022-08-14 14:38:13 +03:00
audi06_19
8943eab99d Turkish translation updateo (#111) 2022-08-14 14:33:21 +03:00
DYefremov
a5a4f267cc some improvements for recordings tab
* logos support
 * download support
 * meta data removing
2022-08-12 09:26:26 +03:00
DYefremov
a69127f0cc background tasks prototype 2022-08-12 09:14:13 +03:00
DYefremov
858b2ae2d6 minor ui corrections 2022-08-05 19:51:47 +03:00
DYefremov
13a08c98de decoupling of processing DVB data 2022-08-04 00:02:33 +03:00
DYefremov
272e3786dc getting data from transponder dialogs 2022-08-03 00:23:24 +03:00
DYefremov
3b510e6935 terrestrial and cable dialogs skeleton 2022-08-01 22:55:38 +03:00
DYefremov
f3a6d2bd9c transponder dialog refactoring 2022-08-01 18:19:15 +03:00
DYefremov
ca20852bfe minor fix for EPG tab 2022-08-01 17:57:44 +03:00
DYefremov
5e1bd8e1c9 DVB dialogs refactoring 2022-07-31 00:10:19 +03:00
DYefremov
0b87f4f143 remote control improvement 2022-07-28 00:17:44 +03:00
DYefremov
00cbe43aa7 minor improvements for timers tab 2022-07-27 00:44:40 +03:00
DYefremov
deb161a153 improved reference assignment for IPTV 2022-07-27 00:03:28 +03:00
DYefremov
2a2611abde minor improvements for EPG tab 2022-07-20 23:50:31 +03:00
DYefremov
dbcfb71224 minor correction 2022-07-17 14:15:15 +03:00
DYefremov
2441d3726b basic Multi EPG support 2022-07-16 23:56:02 +03:00
DYefremov
681b43b164 changed format for time columns (#110) 2022-07-14 10:32:58 +03:00
DYefremov
31025777a3 changed network grid position 2022-07-13 14:19:26 +03:00
DYefremov
33e39d2f25 improved sorting by recording time 2022-07-11 08:52:15 +03:00
DYefremov
14e200f262 improved sorting for EPG time column (#110) 2022-07-10 18:05:52 +03:00
DYefremov
3cef75e765 minor timer time format correction (#110) 2022-07-08 22:21:22 +03:00
DYefremov
a973f8e636 added popup menu for recordings tab 2022-07-08 21:59:35 +03:00
DYefremov
c6bea94ff5 improved timer start time setting (#110) 2022-07-08 01:05:14 +03:00
DYefremov
736655542c minor improvements for the timers tab (#110) 2022-07-07 08:39:53 +03:00
DYefremov
073521de75 deletion support for DVB-T/C 2022-07-04 21:47:05 +03:00
DYefremov
46748d3fc4 corrected satellites web update 2022-07-03 12:44:03 +03:00
DYefremov
f2e571185d xml write support for DVB-T/C 2022-07-02 23:14:32 +03:00
DYefremov
c2fd116252 xml data rendering refactoring 2022-07-02 18:40:26 +03:00
DYefremov
c1c5e866ad some correction of recordings deletion 2022-06-25 00:13:57 +03:00
DYefremov
d4a2e78a09 minor ui corrections 2022-06-22 22:38:36 +03:00
DYefremov
0f68d5b292 transponders display for DVB-T/C 2022-06-21 22:58:47 +03:00
DYefremov
c3d9159822 list display for DVB-T/C 2022-06-21 01:19:35 +03:00
DYefremov
b5a508ef54 ui prototype for *.xml [dvb-t\c] editing 2022-06-20 23:30:26 +03:00
DYefremov
14459b8e7e satellite tools decoupling 2022-06-18 21:29:10 +03:00
DYefremov
a9998b9d17 fixed xmltv loading on Windows 2022-06-17 22:46:00 +03:00
DYefremov
6d05a6ec20 lazy xmltv loading 2022-06-17 17:53:39 +03:00
DYefremov
b821fd54be some epg load changes 2022-06-16 23:30:28 +03:00
DYefremov
c3bc3a1160 basic XMLTV support 2022-06-14 20:14:08 +03:00
DYefremov
748b41e31c epg display in bouquet list 2022-06-06 20:33:37 +03:00
DYefremov
a4cbe00e96 updated it *.mo file 2022-06-02 20:23:38 +03:00
mapi68
9f0ad72d42 Italian translation сorrection (#109)
Typo fix
2022-06-02 20:17:47 +03:00
DYefremov
673a8547ff minor start script correction 2022-05-31 22:37:52 +03:00
mapi68
f6058dafb9 *.desktop file update (#108) 2022-05-31 22:29:09 +03:00
DYefremov
0140fb4eb4 added epg path option 2022-05-31 18:27:11 +03:00
DYefremov
97f04999c4 base tools decoupling 2022-05-22 23:55:13 +03:00
DYefremov
e879c8db18 style correction 2022-05-19 21:50:27 +03:00
DYefremov
d41ceb6bbc updated it *.mo file 2022-05-18 22:10:11 +03:00
mapi68
8e82d8562a Italian translation сorrection (#107) 2022-05-18 21:58:35 +03:00
DYefremov
4cba9a6754 minor fix 2022-05-17 18:48:16 +03:00
DYefremov
d568a9429d minor improvements for data upload 2022-05-16 16:09:05 +03:00
DYefremov
8b39fedaed minor refactoring of ftp dialogs 2022-05-11 10:38:06 +03:00
DYefremov
c3351b43cc attributes change support for FTP (#105) 2022-05-10 23:10:38 +03:00
DYefremov
58156dd4c1 backup dialog improvement 2022-05-09 23:57:50 +03:00
DYefremov
d09c14518e added simple network explorer (#60) 2022-05-07 23:13:17 +03:00
DYefremov
a8739be31d data upload correction 2022-05-06 23:06:35 +03:00
DYefremov
67a75e5ffa corrected tooltip for picons option 2022-05-06 23:06:03 +03:00
mapi68
7d31a050fe changed tooltip for picons option (#103) 2022-05-06 18:22:01 +03:00
DYefremov
5f4cee759f bouquets reading correction (#104) 2022-05-05 23:21:26 +03:00
DYefremov
f7ed1736c5 bump version 2022-05-05 22:06:33 +03:00
DYefremov
4bdbb511ee added FTP submenu 2022-05-05 21:57:34 +03:00
DYefremov
e49d32f931 improvement of picons unzipping (#102) 2022-05-05 18:07:07 +03:00
DYefremov
42feb8d5f6 small fix 2022-05-05 09:33:41 +03:00
DYefremov
e408f88afd added FTP transfer settings 2022-05-04 23:03:18 +03:00
DYefremov
eaf0434e19 added compress picons option 2022-05-04 20:49:33 +03:00
DYefremov
079cf6a482 picons compression support on upload (#101) 2022-05-04 17:28:57 +03:00
DYefremov
d3ae2187c8 restore backup creation 2022-05-03 23:08:49 +03:00
DYefremov
eac0cc47a9 added URL column to IPTV view (#92) 2022-05-03 18:21:44 +03:00
DYefremov
1972374505 refactoring of send and receive data (#101) 2022-05-03 01:12:49 +03:00
DYefremov
d8626e63cc minor correction for IPTV list dialog 2022-04-29 20:13:42 +03:00
DYefremov
528f59d990 updated it *.mo file 2022-04-26 17:46:53 +03:00
mapi68
28802957fc Italian translation update (#99) 2022-04-26 17:42:33 +03:00
DYefremov
b763d9785d minor translation improvement (#97) 2022-04-26 16:52:48 +03:00
DYefremov
601a81beb9 bump version 2022-04-25 21:00:08 +03:00
DYefremov
ace38433a1 fixed layout switching for the control tab 2022-04-25 20:48:22 +03:00
DYefremov
536b23a845 minor fix 2022-04-25 20:19:36 +03:00
DYefremov
d71a1d5dac removed unused option 2022-04-25 20:18:34 +03:00
DYefremov
4a92084c75 updated pl *.mo file 2022-04-18 13:09:32 +03:00
lareq
e7b8412c11 Polish translation update (#96)
added missing polish translations
2022-04-18 12:51:37 +03:00
audi06_19
ef53de1796 Turkish translation update (#95) 2022-04-17 22:56:10 +03:00
DYefremov
8e7a116db7 multiple selection for picons explorer 2022-04-12 23:33:17 +03:00
DYefremov
285014480f win style correction 2022-04-12 13:42:44 +03:00
DYefremov
279c255ad0 minor fixes in the control panel 2022-04-11 15:04:47 +03:00
DYefremov
90a3053192 bump version 2022-04-11 11:23:11 +03:00
DYefremov
19c6a5bef9 disabling FlySat source (#55) 2022-04-11 10:52:20 +03:00
DYefremov
945ee13058 hooks config for *.spec file 2022-04-10 10:39:59 +03:00
DYefremov
c65b6c540c transponder menu fix 2022-04-09 12:57:45 +03:00
DYefremov
62091dfa96 mac style correction 2022-04-07 22:29:00 +03:00
DYefremov
6ca06fd2cd minor start script correction 2022-04-07 16:29:46 +03:00
DYefremov
4cab05fc09 minor mac style correction 2022-04-06 14:16:50 +03:00
DYefremov
81e714ebab updated it *.mo file 2022-04-04 15:26:15 +03:00
DYefremov
147430d4f3 README update 2022-04-04 15:14:32 +03:00
DYefremov
f8f209d288 build instruction for Windows (#87) 2022-04-04 14:58:29 +03:00
DYefremov
bd0e08e90b changed ssl context for Windows package (#89) 2022-04-03 19:15:27 +03:00
DYefremov
57020423d7 minor fix 2022-04-03 19:08:08 +03:00
mapi68
1fd3e45dd3 Italian translation сorrection (#88) 2022-04-02 09:17:36 +03:00
DYefremov
ac1725b3ef changed path for 7zip on Windows 2022-04-01 12:12:02 +03:00
DYefremov
39a592fd4d bump version 2022-03-30 17:42:09 +03:00
DYefremov
e40e0f2458 added force external themes option 2022-03-30 14:46:57 +03:00
DYefremov
d5889cd96c minor mac style changes 2022-03-30 02:17:10 +03:00
DYefremov
77a8bfe2c6 fixed name parsing for KingOfSat source 2022-03-27 11:33:09 +03:00
DYefremov
470d2d843b fixed translation missing (#85)
* Fixed translation missing for dropdown lists on Windows.
2022-03-26 12:12:22 +03:00
DYefremov
a908845b4e filtering support for recordings tab 2022-03-25 21:25:30 +03:00
DYefremov
8fee5033a4 updated it *.mo file 2022-03-24 21:20:56 +03:00
mapi68
a9b1f8b26c Italian translation сorrection (#84) 2022-03-24 21:13:23 +03:00
DYefremov
8fa306a9d1 updated it *.mo file 2022-03-23 11:02:15 +03:00
mapi68
383ea2b9b3 Italian translation update (#82) 2022-03-23 10:55:20 +03:00
DYefremov
a40ba2ff68 minor correction for settings dialog 2022-03-21 12:19:00 +03:00
DYefremov
421d9b1c96 control file correction 2022-03-20 11:57:26 +03:00
DYefremov
7357939241 bump version 2022-03-20 11:36:58 +03:00
DYefremov
08ef7bc451 ftp bookmark activation via single click 2022-03-20 11:31:21 +03:00
DYefremov
8b255ec824 Russian, Belarusian and German translations update 2022-03-20 11:26:12 +03:00
DYefremov
c81084015d correction of iptv streams check (#69) 2022-03-20 01:02:19 +03:00
mapi68
66c8e9e916 Update control (#80)
Added homepage and extended info.
2022-03-20 00:57:51 +03:00
DYefremov
4b93ae6950 updated it *.mo file 2022-03-19 21:48:42 +03:00
mapi68
e2cafef113 Italian translation correction (#79)
Fixed column labels
2022-03-19 21:44:16 +03:00
DYefremov
c35be2aa24 added newline parameter 2022-03-19 21:07:49 +03:00
mapi68
852404bae6 Update control (#78)
Added p7zip-full to Depends (to extract Picons from picon,cz)
2022-03-19 13:44:22 +03:00
mapi68
f6d2765137 Italian translation correction (#77) 2022-03-19 13:43:55 +03:00
DYefremov
182c7a9cc7 updated it *.mo file 2022-03-14 20:52:48 +03:00
mapi68
271ea97040 Italian translation update (#73)
Fixed and updated italian language.
2022-03-14 20:34:36 +03:00
DYefremov
364fb68743 minor correction 2022-03-14 14:18:05 +03:00
DYefremov
85a9d5e67e added comment in italian to *.desktop file 2022-03-14 10:10:47 +03:00
mapi68
50c0a0cf37 Update DemonEditor.desktop (#72) 2022-03-14 10:06:29 +03:00
DYefremov
25fd6df967 playback support from the main list 2022-03-13 21:08:33 +03:00
DYefremov
6106e86d18 minor settings dialog improvements 2022-03-13 17:57:06 +03:00
DYefremov
1c5f7fab11 tooltips support for main iptv list 2022-03-13 00:07:59 +03:00
DYefremov
024f90d23f t2mi plp id support for satellite editing tool (#70) 2022-03-11 11:40:23 +03:00
DYefremov
ee3041174c streams playback from the main list 2022-03-10 23:06:06 +03:00
DYefremov
6f28aae40c style correction 2022-03-06 12:22:39 +03:00
DYefremov
1b2de795a2 minor rework of settings dialog 2022-03-06 12:19:54 +03:00
DYefremov
8d485a9993 minor style corrections 2022-02-27 09:03:39 +03:00
Víctor Pont
1721567731 Spanish translation fixes and new strings (#68) 2022-02-24 13:46:21 +03:00
DYefremov
fd4325961c minor style changes 2022-02-23 14:22:29 +03:00
DYefremov
294d32c705 Russian, Belarusian and German translations update 2022-02-22 19:55:39 +03:00
DYefremov
aa961030ce changing path by link for FTP client 2022-02-22 14:44:55 +03:00
DYefremov
bc4c6746c9 minor corrections 2022-02-22 14:18:40 +03:00
DYefremov
61282b0cc8 copied tr *.mo file 2022-02-22 10:21:10 +03:00
audi06_19
07e855f99d Turkish translation update (#66) 2022-02-22 10:15:12 +03:00
DYefremov
0d0f19122b updated comments in *.desktop file 2022-02-21 20:33:24 +03:00
DYefremov
4789688efd updated es *.mo file 2022-02-21 20:09:40 +03:00
Víctor Pont
b13c2c3be0 Spanish translation update (#65) 2022-02-21 20:03:21 +03:00
DYefremov
d72189abc4 bump version 2022-02-21 15:03:17 +03:00
DYefremov
35d194100b added keyboard shortcut for renaming 2022-02-21 14:28:47 +03:00
DYefremov
aa0b97b9ae added extra tab for IPTV 2022-02-21 12:22:44 +03:00
DYefremov
5f54452ee2 fixed getting satellites list from FlySat 2022-02-21 00:23:07 +03:00
DYefremov
580e8ca82c basic bookmarks support for the FTP client 2022-02-13 00:29:44 +03:00
DYefremov
4ba2fb1a04 skip importing groups from m3u for Neutrino 2022-02-12 14:22:48 +03:00
DYefremov
b4612c26cb changed file names reading for FTP client 2022-02-09 21:56:45 +03:00
DYefremov
9a8b1e871d bump version 2022-02-05 15:20:28 +03:00
DYefremov
3a53a95f86 fixed current channel recording on Windows 2022-02-05 15:11:29 +03:00
DYefremov
24a94cfe9a improved MPV support 2022-02-05 13:50:59 +03:00
DYefremov
0ca08e3a1d fixed bouquets DND for macOS (#64) 2022-02-04 02:12:48 +03:00
DYefremov
db4e9d2696 fixed single bouquets import on macOS (#63) 2022-02-04 01:16:37 +03:00
DYefremov
3be9b374c8 bump version 2022-01-29 18:43:36 +03:00
DYefremov
0b9fd37ee9 minor revision and improvement of the FTP client 2022-01-28 16:31:12 +03:00
DYefremov
be90d9694a fixed removal of unused picons (#62) 2022-01-28 00:37:02 +03:00
DYefremov
a5144e8e34 fixed multiple selection with the Shift key (#61) 2022-01-27 22:46:34 +03:00
DYefremov
04e0a25956 minor fixes for picons 2022-01-26 23:07:41 +03:00
DYefremov
dc24c899af version update 2022-01-25 18:16:00 +03:00
DYefremov
a55495fd7c reworked built-in function for getting yt links 2022-01-25 18:07:34 +03:00
DYefremov
ed3aea42f5 minor corrections for EPG dialog 2022-01-24 19:17:11 +03:00
DYefremov
a6904360f9 improvement of EPG refs mapping (#59) 2022-01-24 16:39:59 +03:00
DYefremov
1e42d693cc added info panel auto closing for playback (#58) 2022-01-23 14:52:34 +03:00
DYefremov
5e64605be6 minor changes in saving the EPG link mapping list (#59) 2022-01-23 14:24:50 +03:00
DYefremov
63c55ea2ed Russian, Belarusian and German translations update 2022-01-19 22:32:47 +03:00
DYefremov
c7f85b027d bump version 2022-01-05 15:10:10 +03:00
DYefremov
b24910a9a5 fixed command to open file for FTP 2022-01-05 14:57:39 +03:00
DYefremov
1bdb4f123f minor data cleaning optimization 2022-01-05 12:16:16 +03:00
DYefremov
8f8d7633b8 added display picons option 2022-01-05 00:41:06 +03:00
DYefremov
c997724300 refactoring of picon assignment 2022-01-04 20:39:59 +03:00
DYefremov
727a3fa8a2 data rendering optimization 2022-01-03 00:08:31 +03:00
DYefremov
4009f5c2a2 multi-stream support for services web import 2022-01-01 23:09:31 +03:00
DYefremov
ca5d648032 multi-stream transponders support for LyngSat 2021-12-30 15:54:09 +03:00
DYefremov
938fe297c5 corrected satellite update from KingOfSat 2021-12-30 00:02:32 +03:00
DYefremov
db29b78fd7 basic editing support for FTP client 2021-12-29 00:21:11 +03:00
DYefremov
e599ea04c7 improved bouquet export to *.m3u (#56) 2021-12-28 15:17:39 +03:00
DYefremov
50a2f66fc3 bump version 2021-12-24 11:42:33 +03:00
DYefremov
34056b1006 minor rework of recordings tab 2021-12-24 00:15:17 +03:00
DYefremov
6188fecda9 reduced waiting time for web import 2021-12-23 22:07:06 +03:00
DYefremov
eb41b9629e minor revision of satellite update dialog 2021-12-22 14:19:10 +03:00
DYefremov
7694754919 fixed satellites web update from FlySat (#55) 2021-12-21 22:08:54 +03:00
DYefremov
11f240a81f minor rework of ftp client 2021-12-19 20:37:23 +03:00
DYefremov
1b75034317 basic alternate layout support 2021-12-19 13:15:58 +03:00
DYefremov
5b6900cae7 tooltips support for the bouquets list 2021-12-11 16:48:13 +03:00
DYefremov
eeeca881e8 added default id value for iptv service 2021-12-10 12:09:27 +03:00
DYefremov
ed16fb0195 minor correction for settings dialog 2021-12-07 15:05:58 +03:00
DYefremov
833b386356 win style correction 2021-12-07 14:54:12 +03:00
DYefremov
83424124d3 fix saving sub-bouquets 2021-12-07 14:39:31 +03:00
DYefremov
d77aa68a39 Russian, Belarusian and German translations update 2021-12-04 14:53:47 +03:00
DYefremov
94266e13b8 bump version 2021-12-03 21:11:00 +03:00
DYefremov
8db6fb1b0b improved picons filtering and assignment (#49) 2021-12-03 20:05:46 +03:00
DYefremov
e07c5d4bf7 minor code cleanup 2021-12-02 17:28:25 +03:00
DYefremov
6f3090a7e1 sub-bouquets creation support 2021-12-02 15:39:33 +03:00
DYefremov
b73c1d1118 sub bouquets saving support 2021-12-01 11:37:23 +03:00
DYefremov
df127c05f3 services marking not presented in bouquets 2021-11-28 23:41:16 +03:00
DYefremov
9b579af528 added filter option "not in bouquets" 2021-11-28 18:55:37 +03:00
DYefremov
8290e723c9 state saving for the main paned widgets 2021-11-27 10:20:10 +03:00
DYefremov
b12c29be84 minor correction for tid and nid values 2021-11-24 23:54:16 +03:00
DYefremov
1780fbadbd fixed signal update for control tab 2021-11-22 19:41:30 +03:00
DYefremov
4fe4e92442 fixed some folders display for the recording tab 2021-11-22 16:33:24 +03:00
DYefremov
bfad5cf9ac bump version 2021-11-22 14:19:52 +03:00
DYefremov
5d285f61c0 fixed a typo in the Italian translation 2021-11-18 19:52:40 +03:00
DYefremov
f61d9a1f61 fix tid and nid order for KingOfSat web source 2021-11-18 11:31:01 +03:00
DYefremov
8d115677d1 minor fix for picon downloader 2021-11-17 22:42:09 +03:00
DYefremov
f7c6cd6908 preventing gen bouquets for an empty config 2021-11-17 13:05:15 +03:00
DYefremov
2d2a90542c main icons initialization refactoring 2021-11-16 13:21:52 +03:00
DYefremov
535c9c9102 fix themes unpacking on Windows 2021-11-16 12:09:29 +03:00
DYefremov
866e18762d enabled http api setting for Neutrino 2021-11-14 23:37:56 +03:00
DYefremov
aef5027d23 bump version 2021-11-14 17:28:38 +03:00
DYefremov
3a142eca4a README update 2021-11-14 17:16:40 +03:00
DYefremov
606bad7716 fix getting youtube-dl for Windows 2021-11-13 13:13:52 +03:00
DYefremov
fa07f8bf85 fix picon path on profile change 2021-11-12 19:11:19 +03:00
DYefremov
92280162c6 added logging for transponder validation 2021-11-12 16:23:20 +03:00
DYefremov
5d285e88d8 copied tr *.mo file 2021-11-12 11:10:56 +03:00
audi06_19
0355714e92 Turkish translation update (#53) 2021-11-12 11:05:37 +03:00
DYefremov
b06e877a0c update of pl *.mo file 2021-11-11 22:37:58 +03:00
Wieslaw Weglowski
9b479b051d Polish translation update (#52) 2021-11-11 22:28:23 +03:00
DYefremov
b953ee8762 minor fix of playback window title 2021-11-08 11:06:31 +03:00
DYefremov
0e7d6bec69 fixed some configs loading with dvb-t 2021-11-07 23:34:47 +03:00
DYefremov
cb9824d404 changed audio menu icon 2021-11-07 21:47:11 +03:00
DYefremov
c87adb256f fix sid config for IPTV lists 2021-11-07 21:20:06 +03:00
DYefremov
899d05a186 added "Return" key support for FTP client 2021-11-07 00:10:15 +03:00
DYefremov
bd4f86e91e Russian, Belarusian and German translations update 2021-11-06 15:45:58 +03:00
DYefremov
42fb365b45 fix picon assignment by drag for mac 2021-11-06 14:42:13 +03:00
DYefremov
67d6ea861e minor playback fixes 2021-11-06 12:11:05 +03:00
DYefremov
d887a61636 fix settings saving for Ubuntu [18.04] 2021-11-05 23:01:07 +03:00
DYefremov
9d9efb7577 Russian, Belarusian and German translations update 2021-11-03 18:14:41 +03:00
DYefremov
b6d331a311 redesigned info output for download dialog 2021-11-03 18:09:46 +03:00
DYefremov
1060e169a1 minor refactoring 2021-11-03 12:30:23 +03:00
DYefremov
562c1a5955 modifiers correction 2021-11-01 11:09:45 +03:00
DYefremov
3c4dec323f minor mac style correction 2021-11-01 00:44:12 +03:00
DYefremov
3bafe08030 adapting dialogs for Gnome 2021-10-31 20:26:19 +03:00
DYefremov
722f8df813 header bar for Gnome in epg dialog 2021-10-31 16:09:07 +03:00
DYefremov
8f6984dbaf added src and pos display for epg (#51) 2021-10-31 13:34:48 +03:00
DYefremov
bf6e9617ec header bar changes in the backup dialog 2021-10-30 18:25:20 +03:00
DYefremov
ec27c32d35 bump version 2021-10-29 17:26:34 +03:00
DYefremov
bfa3b1aa66 added dialog call before reset of settings 2021-10-29 17:21:30 +03:00
DYefremov
a1e32abd07 minor settings dialog changes for Gnome 2021-10-29 15:57:28 +03:00
DYefremov
f93370293b Russian, Belarusian and German translations update 2021-10-29 11:31:59 +03:00
DYefremov
79a2a034eb fix app menu translation for Windows 2021-10-29 10:45:13 +03:00
DYefremov
791c073d1a redesigned info output for sat update dialog 2021-10-26 12:24:46 +03:00
DYefremov
de5ec53a18 Russian, Belarusian and German translations update 2021-10-26 11:47:26 +03:00
DYefremov
156ac7d364 bump version 2021-10-25 20:29:14 +03:00
DYefremov
5680423f14 added picons extraction from archives 2021-10-25 20:06:33 +03:00
DYefremov
fa256c5a0b added picon copying menu from ext path 2021-10-25 17:06:23 +03:00
DYefremov
d9a5d9a972 added tool menu for Gnome session 2021-10-23 19:25:19 +03:00
DYefremov
4e59bdf38e redesigned info displaying for the picons tab 2021-10-23 11:48:20 +03:00
DYefremov
7edb03836a added support for setting id for iptv 2021-10-23 00:18:51 +03:00
DYefremov
0ea0c889d4 fixed language selection for *.deb 2021-10-22 10:51:15 +03:00
DYefremov
e494a34bc4 fix for display dvb-t2 system (#50) 2021-10-21 22:50:35 +03:00
DYefremov
4a57234293 added logs display in the gui 2021-10-21 18:53:57 +03:00
DYefremov
aca4875ee6 fixed data loading with hex flag values (#50) 2021-10-20 11:46:22 +03:00
128 changed files with 47195 additions and 26191 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.pyc
*.pyo
*__pycache__
.idea

View File

@@ -1,13 +0,0 @@
[Desktop Entry]
Version=1.0
Name=DemonEditor
Comment=Channel and satellite list editor for Enigma2
Comment[ru]=Редактор списка каналов и спутников для Enigma2
Comment[be]=Рэдактар спіса каналаў і спадарожнікаў для Enigma2
Comment[de]=Programm- und Satellitenlisten-Editor für Enigma2
Icon=demon-editor
Exec=bash -c 'cd $(dirname %k) && ./start.py'
Terminal=false
Type=Application
Categories=Utility;Application;
StartupNotify=false

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2018-2021 Dmitriy Yefremov
Copyright (c) 2018-2026 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,49 +1,47 @@
# <img src="app/ui/icons/hicolor/96x96/apps/demon-editor.png" width="32" /> DemonEditor
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![platform](https://img.shields.io/badge/platform-linux%20|%20macos-lightgrey)
### Enigma2 channel and satellite list editor for GNU/Linux.
[<img src="https://user-images.githubusercontent.com/7511379/118884719-8277e980-b8ff-11eb-8621-c8c4afd6181b.png" width="560"/>](https://user-images.githubusercontent.com/7511379/118884719-8277e980-b8ff-11eb-8621-c8c4afd6181b.png)
Experimental support of Neutrino-MP or others on the same basis (BPanther, etc).
Focused on the convenience of working in lists from the keyboard. The mouse is also fully supported (Drag and Drop etc).
### Enigma2 channel and satellite list editor for GNU/Linux.
Experimental support of Neutrino-MP or others on the same basis (BPanther, etc).
## Main features of the program
* Editing bouquets, channels, satellites.
[<img src="https://user-images.githubusercontent.com/7511379/118884747-8ad02480-b8ff-11eb-9104-8cf8fb6e785d.png" width="480"/>](https://user-images.githubusercontent.com/7511379/118884747-8ad02480-b8ff-11eb-9104-8cf8fb6e785d.png)
[<img src="https://user-images.githubusercontent.com/7511379/141680963-9b8eb6cc-c712-46b2-aefe-19769e21a7d5.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141680963-9b8eb6cc-c712-46b2-aefe-19769e21a7d5.png)
* Import function.
[<img src="https://user-images.githubusercontent.com/7511379/118526825-4dc23180-b749-11eb-8197-e9bbccbc3bdf.png" width="480"/>](https://user-images.githubusercontent.com/7511379/118526825-4dc23180-b749-11eb-8197-e9bbccbc3bdf.png)
[<img src="https://user-images.githubusercontent.com/7511379/141681059-68bc1b55-6fab-436c-aa73-ef24e2e5113b.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141681059-68bc1b55-6fab-436c-aa73-ef24e2e5113b.png)
* Backup function.
[<img src="https://user-images.githubusercontent.com/7511379/118528402-f58c2f00-b74a-11eb-9b84-edf220526e6e.png" width="480"/>](https://user-images.githubusercontent.com/7511379/118528402-f58c2f00-b74a-11eb-9b84-edf220526e6e.png)
[<img src="https://user-images.githubusercontent.com/7511379/141681104-ed9b5d35-25de-426f-b9bb-2a6e4db022bb.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141681104-ed9b5d35-25de-426f-b9bb-2a6e4db022bb.png)
* Support of picons.
[<img src="https://user-images.githubusercontent.com/7511379/118526864-5c104d80-b749-11eb-8497-6e8c78542ab1.png" width="480"/>](https://user-images.githubusercontent.com/7511379/118526864-5c104d80-b749-11eb-8497-6e8c78542ab1.png)
[<img src="https://user-images.githubusercontent.com/7511379/141681115-957c63a3-4113-422d-bb27-2d96b1463cd1.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141681115-957c63a3-4113-422d-bb27-2d96b1463cd1.png)
* Importing services, downloading picons and updating satellites from the Web.
[<img src="https://user-images.githubusercontent.com/7511379/118530243-1a81a180-b74d-11eb-8e01-aea904d954af.png" width="250"/>](https://user-images.githubusercontent.com/7511379/118530243-1a81a180-b74d-11eb-8e01-aea904d954af.png)
[<img src="https://user-images.githubusercontent.com/7511379/118526706-31be9000-b749-11eb-9956-c4bf2e13f968.png" width="292"/>](https://user-images.githubusercontent.com/7511379/118526706-31be9000-b749-11eb-9956-c4bf2e13f968.png)
[<img src="https://user-images.githubusercontent.com/7511379/141681075-28f18ea5-e456-4e84-bf64-1b7d9a95324d.png" width="262"/>](https://user-images.githubusercontent.com/7511379/141681075-28f18ea5-e456-4e84-bf64-1b7d9a95324d.png)
[<img src="https://user-images.githubusercontent.com/7511379/141681040-b1ad190a-6bc2-4741-bb42-1fb219a0fcab.png" width="250"/>](https://user-images.githubusercontent.com/7511379/141681040-b1ad190a-6bc2-4741-bb42-1fb219a0fcab.png)
* Extended support of IPTV.
* Import to bouquet(Neutrino WEBTV) from m3u.
* Export of bouquets with IPTV services in m3u.
* Assignment of EPG from DVB or XML for IPTV services (only Enigma2, experimental).
* Preview (playback) of IPTV or other streams directly from the bouquet list.
[<img src="https://user-images.githubusercontent.com/7511379/118884891-b3f0b500-b8ff-11eb-8717-3588d6e089de.png" width="480"/>](https://user-images.githubusercontent.com/7511379/118884891-b3f0b500-b8ff-11eb-8717-3588d6e089de.png)
* Control panel with the ability to view EPG and manage timers (via HTTP API, experimental).
[<img src="https://user-images.githubusercontent.com/7511379/118886284-66754780-b901-11eb-9068-29b5a607ccaf.png" width="480"/>](https://user-images.githubusercontent.com/7511379/118886284-66754780-b901-11eb-9068-29b5a607ccaf.png)
* Assignment of EPG from DVB or XML for IPTV services (Enigma2 only).
[<img src="https://user-images.githubusercontent.com/7511379/141681187-fae4e784-c9e0-43df-b499-4d38e83d6560.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141681187-fae4e784-c9e0-43df-b499-4d38e83d6560.png)
* Playback of IPTV or other streams directly from the bouquet list.
[<img src="https://user-images.githubusercontent.com/7511379/141681129-98f78cdc-9a98-46ef-b738-618a327634d4.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141681129-98f78cdc-9a98-46ef-b738-618a327634d4.png)
* Control panel (via HTTP API).
[<img src="https://user-images.githubusercontent.com/7511379/141684475-4511ea4f-b152-42d5-b9c8-f3e1e9a160d0.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141684475-4511ea4f-b152-42d5-b9c8-f3e1e9a160d0.png)
* Ability to view EPG and manage timers (via HTTP API).
* Simple FTP client (experimental).
[<img src="https://user-images.githubusercontent.com/7511379/118527372-e8bb0b80-b749-11eb-9653-4ad64c99a05a.png" width="480"/>](https://user-images.githubusercontent.com/7511379/118527372-e8bb0b80-b749-11eb-9653-4ad64c99a05a.png)
[<img src="https://user-images.githubusercontent.com/7511379/141681165-5679c331-72e7-4044-b365-dcdb30b1433c.png" width="480"/>](https://user-images.githubusercontent.com/7511379/141681165-5679c331-72e7-4044-b365-dcdb30b1433c.png)
**To increase program functionality you can use [extensions](https://github.com/DYefremov/demoneditor-extensions).**
#### Keyboard shortcuts
* **Ctrl + X** - only in bouquet list.
* **Ctrl + C** - only in services list.
Clipboard is **"rubber"**. There is an accumulation before the insertion!
* **Ctrl + C** - only in services list.
* **Ctrl + Insert** - copies the selected channels from the main list to the bouquet
beginning or inserts (creates) a new bouquet.
* **Ctrl + BackSpace** - copies the selected channels from the main list to the bouquet end.
* **Ctrl + E** - edit.
* **Ctrl + R, F2** - rename.
* **Ctrl + R, F2** - rename.
* **Ctrl + Alt + R** - rename for bouquet.
* **Ctrl + S, T** in Satellites edit tool for create satellite or transponder.
* **Ctrl + L** - parental lock.
* **Ctrl + H** - hide/skip.
* **Ctrl + P** - start play IPTV or other stream in the bouquet list.
* **Ctrl + Z** - switch(**zap**) the channel(works when the HTTP API is enabled, Enigma2 only).
* **Ctrl + W** - switch to the channel and watch in the program.
* **Ctrl + H** - hide/skip.
* **Space** - select/deselect.
* **Left/Right** - remove selection.
* **Ctrl + Up, Down, PageUp, PageDown, Home, End**- move selected items in the list.
@@ -51,15 +49,20 @@ Clipboard is **"rubber"**. There is an accumulation before the insertion!
* **Ctrl + D** - load data from receiver.
* **Ctrl + U/B** - upload data/bouquets to receiver.
* **Ctrl + I** - extra info, details.
* **Ctrl + F** - show/hide search bar.
* **Ctrl + F** - show search bar.
* **Ctrl + Shift + F** - show/hide filter bar.
* **Ctrl + T** - show/hide built-in Telnet client.
* **Ctrl + Shift + L** - show/hide logging panel.
* **Shift + P** - start play IPTV or other stream in the bouquet list.
* **Shift + Z** - switch(**zap**) the channel(works when the HTTP API is enabled, Enigma2 only).
* **Shift + W** - switch to the channel and watch in the program.
For **multiple** selection with the mouse, press and hold the **Ctrl** key!
## Minimum requirements
*Python >= 3.6, GTK+ >= 3.22, python3-gi, python3-gi-cairo, python3-requests.*
***Optional:** python3-pil, python3-chardet.*
***Optional:** python3-pil, python3-chardet, ffmpeg.*
## Installation and Launch
* ### Linux
To start the program, in most cases it is enough to download the [archive](https://github.com/DYefremov/DemonEditor/archive/master.zip), unpack
@@ -71,21 +74,43 @@ To create a simple **debian package**, you can use the *build-deb.sh.* You can a
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.
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.
* ### macOS
**This program can be run on macOS.** To work in this OS, you must use a [separate branch](https://github.com/DYefremov/DemonEditor/tree/experimental-mac). A ready-made package can be downloaded from the [releases](https://github.com/DYefremov/DemonEditor/releases) page.
**The functionality and performance of this version may be different from the Linux version!**
**This program can be run on macOS.**
To run the program on macOS, you need to install [Homebrew](https://brew.sh/).
Then install the required components via terminal:
```brew install python3 gtk+3 pygobject3 adwaita-icon-theme gtksourceview3```
```pip3 install requests telnetlib-313-and-up --break-system-packages```
*Optional:* ```brew install pillow python-chardet ffmpeg```
Launch is similar to Linux.
You can also download the ready-made package as a ***.dmg** file from the [releases](https://github.com/DYefremov/DemonEditor/releases) page.
Recommended copy the package to the **Application** directory.
Perhaps in the security settings it will be necessary to allow the launch of this application!
* ### MS Windows
**Windows users can also run this program.**
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.
In addition, you can download a ready-made build (**64-bit**) from the [releases](https://github.com/DYefremov/DemonEditor/releases) page.
**All builds may contain components distributed under the GPL [v3](http://www.gnu.org/licenses/gpl-3.0.html) or lower license.
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!**
THIS SOFTWARE COMES WITH ABSOLUTELY NO WARRANTY.
AUTHOR IS NOT LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY CONNECTION WITH THIS SOFTWARE.
## Important
The program is tested only with [openATV](https://www.opena.tv/) image and **Formuler F1** receiver in [Linux Mint](https://linuxmint.com/) (MATE 64-bit) distribution!
Support for DVB-T/T2 and DVB-C channels for Neutrino is not fully implemented and has an experimental status.
Terrestrial(DVB-T/T2) and cable(DVB-C) channels are only supported for Enigma2.
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.
When using the multiple import feature, from *lamedb* will be taken data **only for channels that are in the selected bouquets!**
If you need full set of the data, including *[satellites, terrestrial, cables].xml* (current files will be overwritten),
just load your data via *"File/Open"* and press *"Save"*. When importing separate bouquet files, only those services
(excluding IPTV) that are in the **current open lamedb** (main list of services) will be imported.
When importing separate bouquet files, only those services (excluding IPTV) that are in the **current open lamedb** (main list of services) will be imported.
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.
**The built-in Telnet client does not support ANSI escape sequences!**
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.
#### Command line arguments:
* **-l** - write logs to file.
* **-d on/off** - turn on/off debug mode. Allows to display more information in the logs.

View File

@@ -1,29 +1,57 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov <https://github.com/DYefremov>
#
import logging
from collections import defaultdict
from functools import wraps
from threading import Thread, Timer
from threading import Timer
from gi.repository import GLib
from gi.repository.Gio import Task
_LOG_FILE = "demon-editor.log"
_DATE_FORMAT = "%d-%m-%y %H:%M:%S"
_LOGGER_NAME = None
LOG_DATE_FORMAT = "%d-%m-%y %H:%M:%S"
LOGGER_NAME = "main_logger"
LOG_FORMAT = "%(asctime)s %(message)s"
def init_logger():
global _LOGGER_NAME
_LOGGER_NAME = "main_logger"
logging.Logger(_LOGGER_NAME)
logging.Logger(LOGGER_NAME)
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(message)s",
datefmt=_DATE_FORMAT,
format=LOG_FORMAT,
datefmt=LOG_DATE_FORMAT,
handlers=[logging.FileHandler(_LOG_FILE), logging.StreamHandler()])
log("Logging is enabled.", level=logging.INFO)
def log(message, level=logging.ERROR, debug=False, fmt_message="{}"):
""" The main logging function. """
logger = logging.getLogger(_LOGGER_NAME)
logger = logging.getLogger(LOGGER_NAME)
if debug:
from traceback import format_exc
logger.log(level, fmt_message.format(format_exc()))
@@ -42,12 +70,12 @@ def run_idle(func):
def run_task(func):
""" Runs function in separate thread """
""" Runs a function in a separate thread. """
@wraps(func)
def wrapper(*args, **kwargs):
task = Thread(target=func, args=args, kwargs=kwargs, daemon=True)
task.start()
task = Task()
task.run_in_thread(lambda t, s, d, c: func(*args, **kwargs))
return wrapper
@@ -78,6 +106,25 @@ def run_with_delay(timeout=5):
return run_with
def get_size_from_bytes(size):
""" Simple convert function from bytes to other units like K, M or G. """
try:
b = float(size)
except ValueError:
return size
else:
kb, mb, gb = 1024.0, 1048576.0, 1073741824.0
if b < kb:
return str(b)
elif kb <= b < mb:
return f"{b / kb:.1f} K"
elif mb <= b < gb:
return f"{b / mb:.1f} M"
elif gb <= b:
return f"{b / gb:.1f} G"
class DefaultDict(defaultdict):
""" Extended to support functions with params as default factory. """

View File

@@ -1,13 +1,42 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import os
import re
import selectors
import socket
import time
import urllib
import xml.etree.ElementTree as ETree
from enum import Enum
from ftplib import FTP, CRLF, Error, error_perm
from ftplib import FTP, FTP_PORT, CRLF, Error, all_errors
from http.client import RemoteDisconnected
from telnetlib import Telnet
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode, quote
from urllib.request import (urlopen, HTTPPasswordMgrWithDefaultRealm, HTTPBasicAuthHandler, build_opener,
@@ -16,23 +45,29 @@ from urllib.request import (urlopen, HTTPPasswordMgrWithDefaultRealm, HTTPBasicA
from app.commons import log, run_task
from app.settings import SettingsType
BQ_FILES_LIST = ("tv", "radio", # enigma 2
"services.xml", "myservices.xml", "bouquets.xml", "ubouquets.xml") # neutrino
BQ_FILES_LIST = ("tv", "radio", # Enigma2.
"services.xml", "myservices.xml", "bouquets.xml", "ubouquets.xml") # Neutrino.
DATA_FILES_LIST = ("lamedb", "lamedb5", "blacklist", "whitelist",)
DATA_FILES_LIST = ("lamedb", "lamedb5", "blacklist", "whitelist", "whitelist_streamrelay")
STC_XML_FILE = ("satellites.xml", "terrestrial.xml", "cables.xml")
WEB_TV_XML_FILE = ("webtv.xml",)
WEB_TV_XML_FILE = ("webtv.xml", "webtv_usr.xml")
PICONS_SUF = (".jpg", ".png")
PICONS_MAX_NUM = 1000 # Maximum picon number for sending without compression.
class DownloadType(Enum):
ALL = 0
BOUQUETS = 1
SATELLITES = 2
PICONS = 3
WEBTV = 4
EPG = 5
SERVICES = 2
SATELLITES = 3
PICONS = 4
WEBTV = 5
EPG = 6
@classmethod
def _missing_(cls, value):
return cls.ALL
class TestException(Exception):
@@ -43,9 +78,66 @@ class HttpApiException(Exception):
pass
class StubTelnet:
""" Stub class for Telnet.
Used to run a program on an OS with Python >= 3.13
without the need to install telnetlib .
-> https://github.com/DYefremov/DemonEditor/issues/218.
"""
def __init__(self, **kwargs):
self._msg = "Please (re)install [telnetlib] module. -> [https://github.com/DYefremov/DemonEditor/issues/218]"
log(self._msg)
def read_until(self, match, timeout=None):
raise TestException(self._msg)
TN = StubTelnet
try:
from telnetlib import Telnet
except ModuleNotFoundError as e:
log(e)
else:
TN = Telnet
class ExtTelnet(TN):
def __init__(self, output_callback=None, **kwargs):
super().__init__(**kwargs)
self._output_callback = output_callback
def interact(self):
""" Interaction function, emulates a very dumb telnet client. """
with selectors.DefaultSelector() as selector:
selector.register(self, selectors.EVENT_READ)
while True:
for key, events in selector.select():
if key.fileobj is self:
try:
text = self.read_very_eager()
except EOFError as e:
msg = "\n*** Connection closed by remote host ***\n"
if self._output_callback:
self._output_callback(msg)
log(msg)
raise e
else:
if text and self._output_callback:
self._output_callback(text)
class UtfFTP(FTP):
""" FTP class wrapper. """
def __init__(self, *, host="", port=FTP_PORT, user="", passwd="", **kwargs):
self.port = port
super().__init__(host, user, passwd, **kwargs)
def retrlines(self, cmd, callback=None):
""" Small modification of the original method.
@@ -60,11 +152,11 @@ class UtfFTP(FTP):
while 1:
line = fp.readline(self.maxline + 1)
if len(line) > self.maxline:
msg = "UtfFTP [retrlines] error: got more than {} bytes".format(self.maxline)
msg = f"UtfFTP [retrlines] error: got more than {self.maxline} bytes"
log(msg)
raise Error(msg)
if self.debugging > 2:
log('UtfFTP [retrlines] *retr* {}'.format(repr(line)))
log(f"UtfFTP [retrlines] *retr* {repr(line)}")
if not line:
break
if line[-2:] == CRLF:
@@ -83,50 +175,55 @@ class UtfFTP(FTP):
def download_file(self, name, save_path, callback=None):
with open(save_path + name, "wb") as f:
msg = "Downloading file: {}. Status: {}\n"
try:
resp = str(self.retrbinary("RETR " + name, f.write))
except error_perm as e:
resp = str(e)
msg = msg.format(name, e)
log(msg.rstrip())
else:
msg = msg.format(name, resp)
resp = self.download_binary(name, f)
msg = f"Downloading file: {name}. Status: {resp}"
callback(msg) if callback else log(msg.rstrip())
return resp
def download_binary(self, src, fo):
try:
resp = str(self.retrbinary(f"RETR {src}", fo.write))
except all_errors as e:
resp = str(e)
log(f"Error. {e}")
return resp
def download_dir(self, path, save_path, callback=None):
""" Downloads directory from FTP with all contents.
Creates a leaf directory and all intermediate ones. This is recursive.
Creates a leaf directory and all intermediate ones. This is recursive.
"""
os.makedirs(os.path.join(save_path, path), exist_ok=True)
dir_path = os.path.join(save_path, path, "")
os.makedirs(dir_path, exist_ok=True)
current_path = self.pwd()
files = []
self.dir(path, files.append)
try:
self.cwd(path)
except all_errors as e:
msg = f"Download dir error: {e}".rstrip()
log(msg)
return f"500 {msg}"
for f in files:
f_data = f.split()
f_path = os.path.join(path, " ".join(f_data[8:]))
f_data = self.get_file_data(f)
f_path = f_data[8]
if f_data[0][0] == "d":
try:
os.makedirs(os.path.join(save_path, f_path), exist_ok=True)
except OSError as e:
msg = "Download dir error: {}".format(e).rstrip()
log(msg)
return "500 " + msg
else:
self.download_dir(f_path, save_path, callback)
self.download_dir(f_path, dir_path, callback)
else:
try:
self.download_file(f_path, save_path, callback)
self.download_file(f_path, dir_path, callback)
except OSError as e:
log("Download dir error: {}".format(e).rstrip())
log(f"Download dir error: {e}".rstrip())
self.cwd(current_path)
resp = "226 Transfer complete."
msg = "Copy directory {}. Status: {}".format(path, resp)
msg = f"Copying directory: {path}. Status: {resp}"
log(msg)
if callback:
@@ -142,7 +239,7 @@ class UtfFTP(FTP):
def download_picons(self, src, dest, callback, files_filter=None):
try:
self.cwd(src)
except error_perm as e:
except all_errors as e:
callback(str(e))
return
@@ -172,7 +269,7 @@ class UtfFTP(FTP):
def upload_picons(self, src, dest, callback, files_filter=None):
try:
self.cwd(dest)
except error_perm as e:
except all_errors as e:
if str(e).startswith("550"):
self.mkd(dest) # if not exist
self.cwd(dest)
@@ -181,7 +278,7 @@ class UtfFTP(FTP):
self.send_file(file_name, src, callback)
def remove_unused_bouquets(self, callback):
bq_files = ("userbouquet.", "bouquets.xml", "ubouquets.xml")
bq_files = ("userbouquet.", "subbouquet.", "bouquets.xml", "ubouquets.xml")
for file in filter(lambda f: f.startswith(bq_files), self.nlst()):
self.delete_file(file, callback)
@@ -195,18 +292,17 @@ class UtfFTP(FTP):
return resp + " File not found."
with open(file_src, "rb") as f:
msg = "Uploading file: {}. Status: {}\n"
msg = "Uploading file: {}. Status: {}"
try:
resp = str(self.storbinary("STOR " + file_name, f))
except Error as e:
except all_errors as e:
resp = str(e)
msg = msg.format(file_name, resp)
log(msg)
else:
msg = msg.format(file_name, resp)
if callback:
callback(msg)
if callback:
callback(msg)
return resp
@@ -230,12 +326,12 @@ class UtfFTP(FTP):
elif os.path.isdir(file):
try:
self.mkd(f)
except Error:
except all_errors:
pass # NOP
try:
self.cwd(f)
except Error as e:
except all_errors as e:
resp = str(e)
log(msg.format(f, resp))
else:
@@ -255,7 +351,7 @@ class UtfFTP(FTP):
if dest:
try:
self.cwd(dest)
except Error as e:
except all_errors as e:
callback(str(e))
return
@@ -263,10 +359,10 @@ class UtfFTP(FTP):
self.delete_file(file, callback)
def delete_file(self, file, callback=log):
msg = "Deleting file: {}. Status: {}\n"
msg = "Deleting file: {}. Status: {}"
try:
resp = self.delete(file)
except Error as e:
except all_errors as e:
resp = str(e)
msg = msg.format(file, resp)
log(msg)
@@ -282,19 +378,18 @@ class UtfFTP(FTP):
files = []
self.dir(path, files.append)
for f in files:
f_data = f.split()
name = " ".join(f_data[8:])
f_path = path + "/" + name
f_data = self.get_file_data(f)
f_path = f"{path}/{f_data[8]}"
if f_data[0][0] == "d":
self.delete_dir(f_path, callback)
else:
self.delete_file(f_path, callback)
msg = "Remove directory {}. Status: {}\n"
msg = "Remove directory {}. Status: {}"
try:
resp = self.rmd(path)
except Error as e:
except all_errors as e:
msg = msg.format(path, e)
log(msg)
return "500"
@@ -308,10 +403,10 @@ class UtfFTP(FTP):
return resp
def rename_file(self, from_name, to_name, callback=None):
msg = "File rename: {}. Status: {}\n"
msg = "File rename: {}. Status: {}"
try:
resp = self.rename(from_name, to_name)
except Error as e:
except all_errors as e:
resp = str(e)
msg = msg.format(from_name, resp)
log(msg)
@@ -323,21 +418,32 @@ class UtfFTP(FTP):
return resp
@staticmethod
def get_file_data(file):
""" Returns a prepared list of file data from a file string. """
f_data = file.split()
# Ignoring space in file name.
f_data = f_data[0:9]
f_data[8] = file[file.index(f_data[8]):]
return f_data
def download_data(*, settings, download_type=DownloadType.ALL, callback=log, files_filter=None):
with UtfFTP(host=settings.host, user=settings.user, passwd=settings.password) as ftp:
with UtfFTP(host=settings.host, port=settings.port, user=settings.user, passwd=settings.password) as ftp:
ftp.encoding = "utf-8"
callback("FTP OK.\n")
callback("FTP OK.")
save_path = settings.profile_data_path
os.makedirs(os.path.dirname(save_path), exist_ok=True)
# bouquets
if download_type is DownloadType.ALL or download_type is DownloadType.BOUQUETS:
if download_type in (DownloadType.ALL, DownloadType.BOUQUETS, DownloadType.SERVICES):
ftp.cwd(settings.services_path)
file_list = BQ_FILES_LIST + DATA_FILES_LIST if download_type is DownloadType.ALL else BQ_FILES_LIST
file_list = BQ_FILES_LIST
if download_type is DownloadType.ALL or DownloadType.SERVICES:
file_list += DATA_FILES_LIST
ftp.download_files(save_path, file_list, callback)
# *.xml and webtv
if download_type in (DownloadType.ALL, DownloadType.SATELLITES):
ftp.download_xml(save_path, settings.satellites_xml_path, STC_XML_FILE, callback)
ftp.download_xml(save_path, settings.satellites_xml_path, files_filter or STC_XML_FILE, callback)
if download_type in (DownloadType.ALL, DownloadType.WEBTV):
ftp.download_xml(save_path, settings.satellites_xml_path, WEB_TV_XML_FILE, callback)
@@ -347,41 +453,36 @@ def download_data(*, settings, download_type=DownloadType.ALL, callback=log, fil
ftp.download_picons(settings.picons_path, picons_path, callback, files_filter)
# epg.dat
if download_type is DownloadType.EPG:
stb_path = settings.services_path
epg_options = settings.epg_options
if epg_options:
stb_path = epg_options.get("epg_dat_stb_path", stb_path)
save_path = epg_options.get("epg_dat_path", save_path)
ftp.cwd(settings.epg_dat_path)
ftp.download_files(f"{settings.profile_data_path}epg{os.sep}", "epg.dat", callback)
ftp.cwd(stb_path)
ftp.download_files(save_path, "epg.dat", callback)
callback("\nDone.\n")
callback("*** Done. ***")
def upload_data(*, settings, download_type=DownloadType.ALL, remove_unused=False,
callback=log, done_callback=None, use_http=False, files_filter=None):
def upload_data(*, settings, download_type=DownloadType.ALL, callback=log, done_callback=None,
files_filter=None, ext_host=None, ext_path=None):
s_type = settings.setting_type
data_path = settings.profile_data_path
host, port, use_ssl = settings.host, settings.http_port, settings.http_use_ssl
use_http = s_type is SettingsType.ENIGMA_2 and settings.use_http
host, port, use_ssl = ext_host or settings.host, settings.http_port, settings.http_use_ssl
user, password = settings.user, settings.password
base_url = f"http{'s' if use_ssl else ''}://{host}:{port}"
base = "web" if s_type is SettingsType.ENIGMA_2 else "control"
url = f"{base_url}/{base}/"
tn, ht = None, None # telnet, http
tn, ht = None, None # Telnet, HTTP.
ftp_port, telnet_port = settings.port, settings.telnet_port
data_path = ext_path or settings.profile_data_path
try:
use_http = use_http and test_http(host, port, user, password, use_ssl=use_ssl, skip_message=True, s_type=s_type)
except TestException:
log("HTTP test failed.")
use_http = False
try:
if use_http:
ht = http(settings.user, settings.password, base_url, callback, use_ssl, s_type)
ht = http(user, password, base_url, callback, use_ssl, s_type)
next(ht)
message = ""
if download_type is DownloadType.BOUQUETS:
message = "User bouquets will be updated!"
elif download_type is DownloadType.ALL:
message = "All user data will be reloaded!"
elif download_type is DownloadType.SATELLITES:
message = "Satellites.xml file will be updated!"
elif download_type is DownloadType.PICONS:
message = "Picons will be updated!"
message = get_upload_info_message(download_type)
if s_type is SettingsType.ENIGMA_2:
params = urlencode({"text": message, "type": 2, "timeout": 5})
@@ -390,62 +491,109 @@ def upload_data(*, settings, download_type=DownloadType.ALL, remove_unused=False
ht.send((f"{url}message?{params}", "Sending info message... "))
if s_type is SettingsType.ENIGMA_2 and download_type is DownloadType.ALL:
if s_type is SettingsType.ENIGMA_2 and download_type in (DownloadType.ALL, DownloadType.SERVICES):
time.sleep(5)
ht.send((f"{url}powerstate?newstate=0", "Toggle Standby "))
if not settings.keep_power_mode:
ht.send((f"{url}powerstate?newstate=0", "Toggle Standby "))
time.sleep(2)
else:
if download_type is not DownloadType.PICONS:
# Telnet
tn = telnet(host=host,
user=settings.user,
password=settings.password,
timeout=settings.telnet_timeout)
tn = telnet(host=host, port=telnet_port, user=user, password=password, timeout=settings.telnet_timeout)
next(tn)
# Terminate Enigma2 or Neutrino.
callback("Telnet initialization ...\n")
callback("Telnet initialization ...")
tn.send("init 4")
callback("Stopping GUI...\n")
callback("Stopping GUI...")
with UtfFTP(host=host, user=settings.user, passwd=settings.password) as ftp:
with UtfFTP(host=host, port=ftp_port, user=user, passwd=password) as ftp:
ftp.encoding = "utf-8"
callback("FTP OK.\n")
callback("FTP OK.")
sat_xml_path = settings.satellites_xml_path
services_path = settings.services_path
if download_type is DownloadType.SATELLITES:
ftp.upload_xml(data_path, sat_xml_path, STC_XML_FILE, callback)
ftp.upload_xml(data_path, sat_xml_path, files_filter or STC_XML_FILE, callback)
if s_type is SettingsType.NEUTRINO_MP and download_type is DownloadType.WEBTV:
ftp.upload_xml(data_path, sat_xml_path, WEB_TV_XML_FILE, callback)
if download_type is DownloadType.BOUQUETS:
ftp.cwd(services_path)
ftp.upload_bouquets(data_path, remove_unused, callback)
ftp.upload_bouquets(data_path, settings.remove_unused_bouquets, callback)
if download_type is DownloadType.ALL or download_type is DownloadType.SERVICES:
if download_type is DownloadType.ALL:
ftp.upload_xml(data_path, sat_xml_path, files_filter or STC_XML_FILE, callback)
if download_type is DownloadType.ALL:
ftp.upload_xml(data_path, sat_xml_path, STC_XML_FILE, callback)
if s_type is SettingsType.NEUTRINO_MP:
ftp.upload_xml(data_path, sat_xml_path, WEB_TV_XML_FILE, callback)
ftp.cwd(services_path)
ftp.upload_bouquets(data_path, remove_unused, callback)
ftp.upload_bouquets(data_path, settings.remove_unused_bouquets, callback)
ftp.upload_files(data_path, DATA_FILES_LIST, callback)
if download_type is DownloadType.PICONS:
ftp.upload_picons(settings.profile_picons_path, settings.picons_path, callback, files_filter)
p_src, p_dst = settings.profile_picons_path, settings.picons_path
compress = all((settings.compress_picons, files_filter, len(files_filter) > PICONS_MAX_NUM))
if compress:
from zipfile import ZipFile
if tn and not use_http:
z_name = "picons.zip"
zip_file = f"{p_src}{z_name}"
p_dst = Path(p_dst).parent.as_posix()
if files_filter and z_name in files_filter:
files_filter.remove(z_name)
if os.path.isfile(zip_file):
try:
os.unlink(zip_file)
except OSError:
pass # NOP
log("Compressing picons...")
with ZipFile(zip_file, "w") as zf:
list(map(lambda p: zf.write(os.path.join(p_src, p), arcname=p), files_filter))
files_filter = {z_name}
log("Uploading...")
ftp.upload_picons(p_src, p_dst, callback, files_filter)
if compress:
if not tn:
callback("Telnet initialization...")
tn = telnet(host=host, port=telnet_port, user=user, password=password,
timeout=settings.telnet_timeout)
next(tn)
callback("Extracting...")
cmd = f"mkdir -p {settings.picons_path} && unzip -o -q {p_dst}/{z_name} -d {settings.picons_path}"
tn.send(cmd)
ftp.delete_file(z_name)
try:
os.unlink(zip_file)
except OSError:
pass # NOP
if all((tn, download_type is not DownloadType.PICONS, not use_http)):
# Resume Enigma2 or restart Neutrino.
tn.send("init 3" if s_type is SettingsType.ENIGMA_2 else "init 6")
callback("Starting...\n" if s_type is SettingsType.ENIGMA_2 else "Rebooting...\n")
callback("Starting..." if s_type is SettingsType.ENIGMA_2 else "Rebooting...")
elif ht and use_http:
if s_type is SettingsType.ENIGMA_2:
if download_type is DownloadType.BOUQUETS:
ht.send((f"{url}servicelistreload?mode=2", "Reloading Userbouquets."))
elif download_type is DownloadType.ALL:
elif download_type is DownloadType.ALL or download_type is DownloadType.SERVICES:
ht.send((f"{url}servicelistreload?mode=0", "Reloading lamedb and Userbouquets."))
ht.send((f"{url}powerstate?newstate=4", "Wakeup from Standby."))
time.sleep(2)
ht.send((f"{url}servicelistreload?mode=4", "Updating parental control."))
if not settings.keep_power_mode:
ht.send((f"{url}powerstate?newstate=4", "Wakeup from Standby."))
elif download_type is DownloadType.SATELLITES:
ht.send((f"{url}servicelistreload?mode=3", "Reloading transponders."))
else:
ht.send((f"{url}reloadchannels", "Reloading channels..."))
@@ -458,12 +606,26 @@ def upload_data(*, settings, download_type=DownloadType.ALL, remove_unused=False
ht.close()
def get_upload_info_message(download_type):
if download_type is DownloadType.BOUQUETS:
return "User bouquets will be updated!"
if download_type is DownloadType.SERVICES:
return "User bouquets and services list will be updated!"
elif download_type is DownloadType.ALL:
return "All user data will be reloaded!"
elif download_type is DownloadType.SATELLITES:
return "*.xml file will be updated!"
elif download_type is DownloadType.PICONS:
return "Picons will be updated!"
return ""
# ***************** Picons *******************#
def remove_picons(*, settings, callback, done_callback=None, files_filter=None):
def remove_picons(*, settings, callback=log, done_callback=None, files_filter=None):
with UtfFTP(host=settings.host, user=settings.user, passwd=settings.password) as ftp:
ftp.encoding = "utf-8"
callback("FTP OK.\n")
callback("FTP OK.")
ftp.delete_picons(callback, settings.picons_path, files_filter)
if done_callback:
done_callback()
@@ -483,12 +645,12 @@ def http(user, password, url, callback, use_ssl=False, s_type=SettingsType.ENIGM
if s_type is SettingsType.ENIGMA_2:
resp = resp.get("e2statetext", None)
callback(f"HTTP: {message} {'Successful.' if resp and message else ''}\n")
callback(f"HTTP: {message} {'Successful.' if resp and message else ''}")
def telnet(host, port=23, user="", password="", timeout=5):
try:
tn = Telnet(host=host, port=port, timeout=timeout)
tn = ExtTelnet(host=host, port=port, timeout=timeout)
except socket.timeout:
log("telnet error: socket timeout")
else:
@@ -502,19 +664,27 @@ def telnet(host, port=23, user="", password="", timeout=5):
tn.read_until(b"Password: ", timeout)
tn.write(password.encode("utf-8") + b"\n")
time.sleep(timeout)
tn.write("{}\r\n".format(command).encode("utf-8"))
time.sleep(timeout)
command = f"{command}\r\n".encode("utf-8")
tn.write(command)
msg = tn.read_until(command, timeout)
while msg.endswith(command) or not msg:
time.sleep(timeout)
msg = tn.read_until(command, timeout)
command = yield
time.sleep(timeout)
tn.write("{}\r\n".format(command).encode("utf-8"))
tn.write(f"{command}\r\n".encode("utf-8"))
time.sleep(timeout)
yield
# ***************** HTTP API *******************#
# ***************** HTTP API ******************* #
class HttpAPI:
__MAX_WORKERS = 4
_MAX_WORKERS = 4
_TIMEOUT = 10
class Request(str, Enum):
ZAP = "zap?sRef="
@@ -540,6 +710,8 @@ class HttpAPI:
VOL = "vol?set=set"
# EPG
EPG = "epgservice?sRef="
EPG_NOW = "epgnow?bRef="
EPG_MULTI = "epgmulti?bRef="
# Timer
TIMER = ""
TIMER_LIST = "timerlist"
@@ -554,8 +726,21 @@ class HttpAPI:
N_ZAP = "zapto"
N_STREAM = "build_playlist?id="
def __str__(self):
return self.value
class Remote(str, Enum):
""" Args for HttpRequestType [REMOTE] class. """
ONE = "2"
TWO = "3"
THREE = "4"
FOUR = "5"
FIVE = "6"
SIX = "7"
SEVEN = "8"
EIGHT = "9"
NINE = "10"
ZERO = "11"
UP = "103"
LEFT = "105"
RIGHT = "106"
@@ -564,12 +749,23 @@ class HttpAPI:
EXIT = "174"
OK = "352"
INFO = "358"
EPG = "365"
TV = "377"
RADIO = "385"
AUDIO = "392"
FAV = "393"
RED = "398"
GREEN = "399"
YELLOW = "400"
BLUE = "401"
CH_UP = "402"
CH_DOWN = "403"
NEXT = "407"
BACK = "412"
def __str__(self):
return self.value
class Power(str, Enum):
""" Args for HttpRequestType [POWER] class. """
TOGGLE_STANDBY = "0"
@@ -579,10 +775,15 @@ class HttpAPI:
WAKEUP = "4"
STANDBY = "5"
def __str__(self):
return self.value
PARAM_REQUESTS = {Request.REMOTE,
Request.POWER,
Request.VOL,
Request.EPG,
Request.EPG_NOW,
Request.EPG_MULTI,
Request.TIMER,
Request.RECORDINGS,
Request.N_ZAP}
@@ -594,7 +795,7 @@ class HttpAPI:
def __init__(self, settings):
from concurrent.futures import ThreadPoolExecutor as PoolExecutor
self._executor = PoolExecutor(max_workers=self.__MAX_WORKERS)
self._executor = PoolExecutor(max_workers=self._MAX_WORKERS)
self._settings = settings
self._shutdown = False
@@ -606,7 +807,7 @@ class HttpAPI:
self._s_type = SettingsType.ENIGMA_2
self.init()
def send(self, req_type, ref, callback=print, ref_prefix=""):
def send(self, req_type, ref, callback=print, ref_prefix="", timeout=_TIMEOUT):
if self._shutdown:
return
@@ -626,7 +827,7 @@ class HttpAPI:
def done_callback(f):
callback(f.result())
future = self._executor.submit(self.get_response, req_type, url, data, self._s_type)
future = self._executor.submit(self.get_response, req_type, url, data, self._s_type, timeout)
future.add_done_callback(done_callback)
@run_task
@@ -663,9 +864,9 @@ class HttpAPI:
self._executor.shutdown()
@staticmethod
def get_response(req_type, url, data=None, s_type=SettingsType.ENIGMA_2):
def get_response(req_type, url, data=None, s_type=SettingsType.ENIGMA_2, timeout=_TIMEOUT):
try:
with urlopen(Request(url, data=data), timeout=10) as f:
with urlopen(Request(url, data=data), timeout=timeout) as f:
if s_type is SettingsType.ENIGMA_2:
return HttpAPI.get_e2_response_data(req_type, f)
elif s_type is SettingsType.NEUTRINO_MP:
@@ -676,7 +877,7 @@ class HttpAPI:
if req_type is HttpAPI.Request.TEST:
raise e
return {"error_code": e.code}
except (URLError, RemoteDisconnected, ConnectionResetError) as e:
except OSError as e:
if req_type is HttpAPI.Request.TEST:
raise e
except ETree.ParseError as e:
@@ -696,7 +897,7 @@ class HttpAPI:
elif req_type is HttpAPI.Request.PLAYER_LIST:
return [{el.tag: el.text for el in el.iter()} for el in
ETree.fromstring(f.read().decode("utf-8")).iter("e2file")]
elif req_type is HttpAPI.Request.EPG:
elif req_type in (HttpAPI.Request.EPG, HttpAPI.Request.EPG_NOW, HttpAPI.Request.EPG_MULTI):
return {"event_list": [{el.tag: el.text for el in el.iter()} for el in
ETree.fromstring(f.read().decode("utf-8")).iter("e2event")]}
elif req_type is HttpAPI.Request.TIMER_LIST:
@@ -753,9 +954,9 @@ class HttpAPI:
def test_ftp(host, port, user, password, timeout=5):
try:
with FTP(host=host, user=user, passwd=password, timeout=timeout) as ftp:
with UtfFTP(host=host, port=port, user=user, passwd=password, timeout=timeout) as ftp:
return ftp.getwelcome()
except (error_perm, ConnectionRefusedError, OSError) as e:
except all_errors as e:
raise TestException(e)
@@ -763,7 +964,7 @@ def test_http(host, port, user, password, timeout=5, use_ssl=False, skip_message
t_msg = "Connection test!"
if s_type is SettingsType.ENIGMA_2:
params = urlencode({"text": t_msg, "type": 2, "timeout": timeout})
params = "statusinfo" if skip_message else f"message?{params}"
params = "deviceinfo" if skip_message else f"message?{params}"
elif s_type is SettingsType.NEUTRINO_MP:
params = urlencode({"nmsg": t_msg, "timeout": 5}, quote_via=quote)
params = "info" if skip_message else f"message?{params}"
@@ -778,10 +979,9 @@ def test_http(host, port, user, password, timeout=5, use_ssl=False, skip_message
data = HttpAPI.get_post_data(base_url, password, user) if s_type is SettingsType.ENIGMA_2 else None
try:
log("Testing HTTP connection...")
resp = HttpAPI.get_response(HttpAPI.Request.TEST, url, data, s_type)
if s_type is SettingsType.ENIGMA_2:
return resp.get("e2statetext", "")
return resp
return resp.get("e2enigmaversion" if s_type is SettingsType.ENIGMA_2 else "data", "")
except (RemoteDisconnected, URLError, HTTPError) as e:
raise TestException(e)
@@ -801,7 +1001,7 @@ def test_telnet(host, port, user, password, timeout=5):
def telnet_test(host, port, user, password, timeout):
tn = Telnet(host=host, port=port, timeout=timeout)
tn = ExtTelnet(host=host, port=port, timeout=timeout)
time.sleep(1)
tn.read_until(b"login: ", timeout=2)
tn.write(user.encode("utf-8") + b"\r")

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -29,7 +29,7 @@ from app.commons import run_task
from app.settings import SettingsType
from .ecommons import Service, Satellite, Transponder, Bouquet, Bouquets, is_transponder_valid
from .enigma.blacklist import get_blacklist, write_blacklist
from .enigma.bouquets import to_bouquet_id, BouquetsWriter, BouquetsReader
from .enigma.bouquets import BouquetsWriter, BouquetsReader
from .enigma.lamedb import get_services as get_enigma_services, write_services as write_enigma_services
from .iptv import parse_m3u
from .neutrino.bouquets import get_bouquets as get_neutrino_bouquets, write_bouquets as write_neutrino_bouquets
@@ -38,10 +38,9 @@ from .satxml import get_satellites, write_satellites
def get_services(data_path, s_type, format_version):
if s_type is SettingsType.ENIGMA_2:
return get_enigma_services(data_path, format_version)
elif s_type is SettingsType.NEUTRINO_MP:
if s_type is SettingsType.NEUTRINO_MP:
return get_neutrino_services(data_path)
return get_enigma_services(data_path, format_version)
@run_task
@@ -53,25 +52,25 @@ def write_services(path, channels, s_type, format_version):
def get_bouquets(path, s_type):
if s_type is SettingsType.ENIGMA_2:
return BouquetsReader(path).get()
elif s_type is SettingsType.NEUTRINO_MP:
return get_neutrino_bouquets(path)
if s_type is SettingsType.NEUTRINO_MP:
return get_neutrino_bouquets(path), 0
reader = BouquetsReader(path)
return reader.get(), reader.errors
def write_bouquet(path, bq, s_type):
if s_type is SettingsType.ENIGMA_2:
writer = BouquetsWriter(path, None)
writer.write_bouquet(path + "userbouquet.{}.{}".format(bq.name, bq.type), bq.name, bq.services)
writer.write_bouquet(f"{path}userbouquet.{bq.name}.{bq.type}", bq.name, bq.services)
elif s_type is SettingsType.NEUTRINO_MP:
from .neutrino.bouquets import write_bouquet
write_bouquet(path, bq)
@run_task
def write_bouquets(path, bouquets, s_type, force_bq_names=False):
def write_bouquets(path, bouquets, s_type, force_bq_names=False, blacklist=None):
if s_type is SettingsType.ENIGMA_2:
BouquetsWriter(path, bouquets, force_bq_names).write()
BouquetsWriter(path, bouquets, force_bq_names, blacklist).write()
elif s_type is SettingsType.NEUTRINO_MP:
write_neutrino_bouquets(path, bouquets)

View File

@@ -1,7 +1,37 @@
""" Common elements module """
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
""" Common elements module. """
from collections import namedtuple
from enum import Enum
from app.commons import log
Service = namedtuple("Service", ["flags_cas", "transponder_type", "coded", "service", "locked", "hide", "package",
"service_type", "picon", "picon_id", "ssid", "freq", "rate", "pol", "fec",
"system", "pos", "data_id", "fav_id", "transponder"])
@@ -17,27 +47,41 @@ class BqServiceType(Enum):
ALT = "ALT" # Service with alternatives
BOUQUET = "BOUQUET" # Sub bouquet.
@classmethod
def _missing_(cls, value):
return cls.DEFAULT
Bouquet = namedtuple("Bouquet", ["name", "type", "services", "locked", "hidden", "file"])
Bouquet.__new__.__defaults__ = (None, BqServiceType.DEFAULT, [], None, None, None) # For Python3 < 3.7
Bouquets = namedtuple("Bouquets", ["name", "type", "bouquets"])
BouquetService = namedtuple("BouquetService", ["name", "type", "data", "num"])
# ***************** Satellites *******************#
# *************** *.xml [Satellites, Terrestrial, Cable] ***************** #
Satellite = namedtuple("Satellite", ["name", "flags", "position", "transponders"])
Terrestrial = namedtuple("Terrestrial", ["name", "flags", "countrycode", "transponders"])
Cable = namedtuple("Cable", ["name", "flags", "satfeed", "countrycode", "transponders"])
Transponder = namedtuple("Transponder", ["frequency", "symbol_rate", "polarization", "fec_inner",
"system", "modulation", "pls_mode", "pls_code", "is_id"])
Transponder = namedtuple("Transponder", ["frequency", "symbol_rate", "polarization", "fec_inner", "system",
"modulation", "pls_mode", "pls_code", "is_id", "t2mi_plp_id", "t2mi_pid"])
TerTransponder = namedtuple("TerTransponder", ["centre_frequency", "system", "bandwidth", "constellation",
"code_rate_hp", "code_rate_lp", "guard_interval", "transmission_mode",
"hierarchy_information", "inversion", "plp_id"])
CableTransponder = namedtuple("CableTransponder", ["frequency", "symbol_rate", "fec_inner", "modulation"])
class TrType(Enum):
""" Transponders type """
""" Transponders type. """
Satellite = "s"
Terrestrial = "t"
Cable = "c"
ATSC = "a"
@classmethod
def _missing_(cls, value):
return cls.Satellite
class BqType(Enum):
""" Bouquet type. """
@@ -82,6 +126,21 @@ class Flag(Enum):
def is_new(value: int):
return value & 1 << 5
@staticmethod
def parse(value: str) -> int:
""" Returns an int representation of the flag value.
The flag value is usually represented by the number [int],
but can also be appear in hex format.
"""
if len(value) < 3:
return 0
value = value[2:]
if value.isdigit():
return int(value)
return int(value, 16)
class Pids(Enum):
VIDEO = "c:00"
@@ -146,6 +205,8 @@ SERVICE_TYPE = {"-2": "Data", "1": "TV", "2": "Radio", "3": "Data", "10": "Radio
# Terrestrial
BANDWIDTH = {"0": "8MHz", "1": "7MHz", "2": "6MHz", "3": "Auto", "4": "5MHz", "5": "1/712MHz", "6": "10MHz"}
CONSTELLATION = {"0": "QPSK", "1": "16-QAM", "2": "64-QAM", "3": "Auto"}
T_MODULATION = {"0": "QPSK", "1": "QAM16", "2": "QAM64", "3": "Auto", "4": "QAM256"}
TRANSMISSION_MODE = {"0": "2k", "1": "8k", "2": "Auto", "3": "4k", "4": "1k", "5": "16k", "6": "32k"}
@@ -160,7 +221,7 @@ T_FEC = {"0": "1/2", "1": "2/3", "2": "3/4", "3": "5/6", "4": "7/8", "5": "Auto"
T_SYSTEM = {"0": "DVB-T", "1": "DVB-T2", "-1": "DVB-T/T2"}
# Cable
C_MODULATION = {"0": "Auto", "1": "QAM16", "2": "QAM32", "3": "QAM64", "4": "QAM128", "5": "QAM256"}
C_MODULATION = {"0": "QPSK", "1": "QAM16", "2": "QAM32", "3": "QAM64", "4": "QAM128", "5": "QAM256", "6": "Auto"}
# ATSC
A_MODULATION = {"0": "Auto", "1": "QAM16", "2": "QAM32", "3": "QAM64", "4": "QAM128", "5": "QAM256", "6": "8VSB",
@@ -168,7 +229,8 @@ A_MODULATION = {"0": "Auto", "1": "QAM16", "2": "QAM32", "3": "QAM64", "4": "QAM
# CAS
CAS = {"C:26": "BISS", "C:0B": "Conax", "C:06": "Irdeto", "C:18": "Nagravision", "C:05": "Viaccess", "C:01": "SECA",
"C:0E": "PowerVu", "C:4A": "DRE-Crypt", "C:7B": "DRE-Crypt", "C:56": "Verimatrix", "C:09": "VideoGuard"}
"C:0E": "PowerVu", "C:4A": "DRE-Crypt", "C:7B": "DRE-Crypt", "C:56": "Verimatrix", "C:09": "VideoGuard",
"C:4AFC": "Panaccess"}
# 'on' attribute 0070(hex) = 112(int) = ONID(ONID-TID on www.lyngsat.com)
PROVIDER = {112: "HTB+", 253: "Tricolor TV"}
@@ -191,23 +253,25 @@ def get_value_by_name(en, name):
def is_transponder_valid(tr: Transponder):
""" Checks transponder validity """
""" Checks transponder validity. """
try:
int(tr.frequency)
int(tr.symbol_rate)
tr.pls_mode is None or int(tr.pls_mode)
tr.pls_code is None or int(tr.pls_code)
tr.is_id is None or int(tr.is_id)
except TypeError:
tr.t2mi_plp_id is None or int(tr.t2mi_plp_id)
except (TypeError, ValueError) as e:
log(f"Transponder validation error: {e}\n{tr}")
return False
if tr.polarization not in POLARIZATION.values():
if tr.polarization not in POLARIZATION:
return False
if tr.fec_inner not in FEC.values():
if tr.fec_inner not in FEC:
return False
if tr.system not in SYSTEM.values():
if tr.system not in SYSTEM:
return False
if tr.modulation not in MODULATION.values():
if tr.modulation not in MODULATION:
return False
return True

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -27,6 +27,7 @@
""" Module for working with Enigma2 bouquets. """
import os.path
import re
from collections import Counter
from enum import Enum
@@ -41,59 +42,90 @@ _DEFAULT_BOUQUET_NAME = "favourites"
_MARKER_PREFIX = "[MARKER!] "
class ServiceType(Enum):
SERVICE = "0"
BOUQUET = "7" # Sub bouquet.
MARKER = "64"
SPACE = "832"
ALT = "134" # Alternatives.
UDP = "256"
HIDDEN = "519" # Skip, hide.
@classmethod
def _missing_(cls, value):
log("Error. No matching service type [{} {}] was found.".format(cls.__name__, value))
return cls.SERVICE
def __str__(self):
return self.value
class BouquetsWriter:
""" Class for creating and writing bouquet files.
If "force_bq_names" then naming the files using the name of the bouquet.
Some images may have problems displaying the favorites list!
"""
_SERVICE = '#SERVICE 1:7:{}:0:0:0:0:0:0:0:FROM BOUQUET "userbouquet.{}.{}" ORDER BY bouquet\n'
_SERVICE = '#SERVICE 1:{}:{}:0:0:0:0:0:0:0:FROM BOUQUET "{}" ORDER BY bouquet\n'
_MARKER = "#SERVICE 1:64:{:X}:0:0:0:0:0:0:0::{}\n"
_SPACE = "#SERVICE 1:832:D:{}:0:0:0:0:0:0:\n"
_LOCKED = '1:{}:{}:0:0:0:0:0:0:0:FROM BOUQUET "{}" ORDER BY bouquet'
_ALT = '#SERVICE 1:134:1:0:0:0:0:0:0:0:FROM BOUQUET "{}" ORDER BY bouquet\n'
_ALT_PAT = r"[<>:\"/\\|?*\-\s]"
def __init__(self, path, bouquets, force_bq_names=False):
def __init__(self, path, bouquets, force_bq_names=False, blacklist=None):
self._path = path
self._bouquets = bouquets
self._force_bq_names = force_bq_names
self._black_list = set() if blacklist is None else blacklist
self._marker_index = 1
self._space_index = 0
self._alt_names = set()
self._NAME_PATTERN = re.compile("[^\\w_()]+")
def write(self):
line = []
pattern = re.compile("[^\\w_()]+")
for bqs in self._bouquets:
line.clear()
line.append("#NAME {}\n".format(bqs.name))
line.append(f"#NAME {bqs.name}\n")
bq_file_names = {b.file for b in bqs.bouquets}
count = 1
m_count = 0
for bq in bqs.bouquets:
bq_name = bq.file
if not bq_name:
if self._force_bq_names:
bq_name = re.sub(pattern, "_", bq.name)
f_name = bq.file
bq_type = BqType(bq.type)
if not f_name:
if self._force_bq_names or bq_type is BqType.BOUQUET:
f_name = f"userbouquet.{re.sub(self._NAME_PATTERN, '_', bq.name)}.{bqs.type}"
else:
bq_name = "de{0:02d}".format(count)
while bq_name in bq_file_names:
f_name = f"userbouquet.de{count:02d}.{bqs.type}"
while f_name in bq_file_names:
count += 1
bq_name = "de{0:02d}".format(count)
bq_file_names.add(bq_name)
f_name = f"userbouquet.de{count:02d}.{bqs.type}"
bq_file_names.add(f_name)
if BqType(bq.type) is BqType.MARKER:
if bq_type is BqType.MARKER:
m_data = bq.file.split(":") if bq.file else None
b_name = m_data[-1].strip() if m_data else bq.name.lstrip(_MARKER_PREFIX)
line.append(self._MARKER.format(m_count, b_name))
m_count += 1
else:
line.append(self._SERVICE.format(2 if bq.type == BqType.RADIO.value else 1, bq_name, bq.type))
self.write_bouquet(f"{self._path}userbouquet.{bq_name}.{bq.type}", bq.name, bq.services)
if bq_type is BqType.BOUQUET:
self.write_sub_bouquet(self._path, f_name, bq, bqs.type)
else:
self.write_bouquet(f"{self._path}{f_name}", bq.name, bq.services)
bq_type = 2 if bqs.type == BqType.RADIO.value else 1
# Parental lock.
locked = self._LOCKED.format(ServiceType.SERVICE, bq_type, f_name)
self._black_list.add(locked) if bq.locked else self._black_list.discard(locked)
# Hiding.
s_type = ServiceType.HIDDEN if bq.hidden else ServiceType.BOUQUET
line.append(self._SERVICE.format(s_type, bq_type, f_name))
with open(self._path + "bouquets.{}".format(bqs.type), "w", encoding="utf-8") as file:
with open(f"{self._path}bouquets.{bqs.type}", "w", encoding="utf-8", newline="\n") as file:
file.writelines(line)
def write_bouquet(self, path, name, services):
@@ -125,40 +157,43 @@ class BouquetsWriter:
bouquet.append(self._ALT.format(f_name))
self.write_bouquet(f"{p.parent}/{f_name}", srv.service, services)
else:
data = to_bouquet_id(srv)
if srv.service:
bouquet.append(f"#SERVICE {data}:{srv.service}\n#DESCRIPTION {srv.service}\n")
bouquet.append(f"#SERVICE {srv.fav_id}:{srv.service}\n#DESCRIPTION {srv.service}\n")
else:
bouquet.append(f"#SERVICE {data}\n")
bouquet.append(f"#SERVICE {srv.fav_id}\n")
with open(path, "w", encoding="utf-8") as file:
with open(path, "w", encoding="utf-8", newline="\n") as file:
file.writelines(bouquet)
def write_sub_bouquet(self, path, file_name, bq, bq_type):
bouquet = [f"#NAME {bq.name}\n"]
sb_type = 2 if bq_type == BqType.RADIO.value else 1
class ServiceType(Enum):
SERVICE = "0"
BOUQUET = "7" # Sub bouquet.
MARKER = "64"
SPACE = "832" # Hidden marker.
ALT = "134" # Alternatives.
for sb in bq.services:
sb_file = sb.file or f"subbouquet.{re.sub(self._NAME_PATTERN, '_', sb.name)}.{sb.type}"
self.write_bouquet(f"{path}{sb_file}", sb.name, sb.services)
bouquet.append(f"#SERVICE 1:7:{sb_type}:0:0:0:0:0:0:0:FROM BOUQUET \"{sb_file}\" ORDER BY bouquet\n")
@classmethod
def _missing_(cls, value):
log("Error. No matching service type [{} {}] was found.".format(cls.__name__, value))
return cls.SERVICE
with open(f"{self._path}{file_name}", "w", encoding="utf-8", newline="\n") as file:
file.writelines(bouquet)
class BouquetsReader:
""" Class for reading and parsing bouquets. """
_ALT_PAT = re.compile(".*alternatives\\.+(.*)\\.([tv|radio]+).*")
_BQ_PAT = re.compile(".*userbouquet\\.+(.*)\\.+[tv|radio].*")
_SUB_BQ_PAT = re.compile(".*subbouquet\\.+(.*)\\.([tv|radio]+).*")
_BQ_PAT = re.compile(r".*FROM BOUQUET\s+\"((.*bouquet|alternatives)?\.?([\w-]+)\.?(\w+)?)\"\s+.*$", re.IGNORECASE)
_BQ_PAT2 = re.compile(r"#SERVICE:+\s+(?:[0-9a-f]+:+)+([^:]+[.](?:tv|radio))$", re.IGNORECASE)
_BQ_POST_PAT = re.compile(r".*FROM BOUQUET\s+\"((.*bouquet|alternatives)?\.?(.*)\.?(\w+)?)\"\s+.*$", re.IGNORECASE)
_STREAM_TYPES = {"4097", "5001", "5002", "8193", "8739"}
__slots__ = ["_path"]
__slots__ = ["_path", "_errors"]
def __init__(self, path):
def __init__(self, path=""):
self._path = path
self._errors = 0
@property
def errors(self):
return self._errors
def get(self):
""" Returns a tuple of TV and Radio bouquets. """
@@ -170,6 +205,7 @@ class BouquetsReader:
_, _, bqs_name = line.partition("#NAME")
if not bqs_name:
log(f"No bouquets name found in '{bq_name}'")
self._errors += 1
bqs_name = "Bouquets (TV)" if bq_type == BqType.TV.value else "Bouquets (Radio)"
bouquets = Bouquets(bqs_name.strip(), bq_type, [])
@@ -178,46 +214,72 @@ class BouquetsReader:
for line in file.readlines():
if "#SERVICE" in line:
name = re.match(self._BQ_PAT, line)
if name:
b_name = name.group(1)
s_data = line.split(":")
s_type = ServiceType.BOUQUET
mt = re.match(self._BQ_PAT, line) or re.match(self._BQ_PAT2, line)
if not mt:
# Additional file name checking.
mt = re.match(self._BQ_POST_PAT, line)
if mt:
log(f"Warning: The bouquet file name may be formed incorrectly. -> {mt.group(1)}")
if mt:
if len(mt.groups()) > 1:
file_name, prefix, b_name = mt.group(1), mt.group(2), mt.group(3)
s_type = ServiceType(s_data[1])
s_data[:2] = "10"
else:
file_name, prefix, b_name = mt.group(1), "", ""
s_type = ServiceType(s_data[2])
if b_name in b_names:
log(f"The list of bouquets contains duplicate [{b_name}] names!")
else:
b_names.add(b_name)
rb_name, services = self.get_bouquet(self._path, b_name, bq_type)
rb_name, services = self.get_bouquet(self._path, file_name, b_name)
if rb_name in real_b_names:
log(f"Bouquet file 'userbouquet.{b_name}.{bq_type}' has duplicate name: {rb_name}")
log(f"Bouquet file '{file_name}' has duplicate name: {rb_name}")
real_b_names[rb_name] += 1
rb_name = f"{rb_name} {real_b_names[rb_name]}"
else:
real_b_names[rb_name] = 0
bouquets[2].append(Bouquet(rb_name, bq_type, services, None, None, b_name))
# Locked, hidden.
locked = ":".join(s_data).rstrip()
hidden = s_type is ServiceType.HIDDEN
bouquets[2].append(Bouquet(rb_name, bq_type, services, locked, hidden, file_name))
else:
s_data = line.split(":")
if len(s_data) == 12 and s_data[1] == ServiceType.MARKER.value:
b_name = "{}{}".format(_MARKER_PREFIX, s_data[-1].strip())
if len(s_data) == 12 and s_type is ServiceType.MARKER:
b_name = f"{_MARKER_PREFIX}{s_data[-1].strip()}"
bouquets[2].append(Bouquet(b_name, BqType.MARKER.value, [], None, None, line.strip()))
else:
log(f"Unsupported or invalid data format: [{line}].")
self._errors += 1
else:
log(f"Unsupported or invalid line format: [{line}].")
self._errors += 1
return bouquets
@staticmethod
def get_bouquet(path, bq_name, bq_type, prefix="userbouquet"):
def get_bouquet(self, path, f_name, bq_name):
""" Parsing services ids from bouquet file. """
with open(path + "{}.{}.{}".format(prefix, bq_name, bq_type), encoding="utf-8", errors="replace") as file:
bq_file = f"{path}{f_name}"
services = []
if not os.path.isfile(bq_file):
log(f"Bouquet reading error: No such bouquet [{bq_name}] file -> '{f_name}'.")
self._errors += 1
return f"! -> {bq_name}", services
with open(bq_file, encoding="utf-8", errors="replace") as file:
chs_list = file.read()
services = []
srvs = list(filter(None, chs_list.split("\n#SERVICE"))) # filtering ['']
# May come across empty[wrong] files!
if not srvs:
log("Bouquet file 'userbouquet.{}.{}' is empty or wrong!".format(bq_name, bq_type))
return "{} [empty]".format(bq_name), services
log(f"Bouquet file '{f_name}' is empty or wrong!")
self._errors += 1
return f"{bq_name} [empty]", services
bq_name = srvs.pop(0)
@@ -225,51 +287,44 @@ class BouquetsReader:
srv_data = srv.strip().split(":")
data_len = len(srv_data)
if data_len < 10:
log("The bouquet [{}] service [{}] has the wrong data format: [{}]".format(bq_name, num, srv))
log(f"The bouquet [{bq_name}] service [{num}] has the wrong data format: [{srv}]")
self._errors += 1
continue
s_type = ServiceType(srv_data[1])
if s_type is ServiceType.MARKER:
m_data, sep, desc = srv.partition("#DESCRIPTION")
services.append(BouquetService(desc.strip() if desc else "", BqServiceType.MARKER, srv, num))
m_data, sep, desc = srv_data[-1].partition("#DESCRIPTION")
services.append(BouquetService(desc.strip() if desc else m_data, BqServiceType.MARKER, srv, num))
elif s_type is ServiceType.SPACE:
m_data, sep, desc = srv.partition("#DESCRIPTION")
services.append(BouquetService(desc.strip() if desc else "", BqServiceType.SPACE, srv, num))
elif s_type is ServiceType.ALT:
alt = re.match(BouquetsReader._ALT_PAT, srv)
alt = re.match(self._BQ_PAT, srv)
if alt:
alt_name, alt_type = alt.group(1), alt.group(2)
alt_bq_name, alt_srvs = BouquetsReader.get_bouquet(path, alt_name, alt_type, "alternatives")
af_name, alt_name = alt.group(1), alt.group(3)
alt_bq_name, alt_srvs = self.get_bouquet(path, af_name, alt_name)
services.append(BouquetService(alt_bq_name, BqServiceType.ALT, alt_name, tuple(alt_srvs)))
elif s_type is ServiceType.BOUQUET:
sub = re.match(BouquetsReader._SUB_BQ_PAT, srv)
sub = re.match(self._BQ_PAT, srv)
if sub:
sub_name, sub_type = sub.group(1), sub.group(2)
sub_bq_name, sub_srvs = BouquetsReader.get_bouquet(path, sub_name, sub_type, "subbouquet")
bq = Bouquet(sub_bq_name, sub_type, tuple(sub_srvs), None, None, sub_name)
sf_name, sub_name, sub_type = sub.group(1), sub.group(3), sub.group(4)
sub_bq_name, sub_srvs = self.get_bouquet(path, sf_name, sub_name)
bq = Bouquet(sub_bq_name, sub_type, tuple(sub_srvs), None, None, sf_name)
services.append(BouquetService(sub_bq_name, BqServiceType.BOUQUET, bq, num))
elif srv_data[0].strip() in BouquetsReader._STREAM_TYPES or srv_data[10].startswith(("http", "rtsp")):
elif srv_data[0].strip() in self._STREAM_TYPES or srv_data[10].startswith(("http", "rtsp")):
stream_data, sep, desc = srv.partition("#DESCRIPTION")
desc = desc.lstrip(":").strip() if desc else srv_data[-1].strip()
services.append(BouquetService(desc, BqServiceType.IPTV, srv, num))
else:
fav_id = "{}:{}:{}:{}".format(srv_data[3], srv_data[4], srv_data[5], srv_data[6])
fav_id = srv.strip().upper()
name = None
if data_len == 12:
fav_id = f":".join(srv_data[:11])
name, sep, desc = str(srv_data[-1]).partition("\n#DESCRIPTION")
services.append(BouquetService(name, BqServiceType.DEFAULT, fav_id.upper(), num))
services.append(BouquetService(name, BqServiceType.DEFAULT, fav_id, num))
return bq_name.lstrip("#NAME").strip(), services
def to_bouquet_id(srv):
""" Creates bouquet channel id. """
data_type = srv.data_id
if data_type and len(data_type) > 4:
data_type = int(srv.data_id.split(":")[4])
return "{}:0:{:X}:{}:0:0:0:".format(1, data_type, srv.fav_id)
if __name__ == "__main__":
pass

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2024 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -30,6 +30,7 @@
import re
from app.commons import log
from app.eparser.satxml import get_pos_str
from app.ui.uicommons import CODED_ICON, LOCKED_ICON, HIDE_ICON
from .blacklist import get_blacklist
from ..ecommons import Service, POLARIZATION, FEC, SERVICE_TYPE, Flag, T_FEC, TrType, FEC_DEFAULT, T_SYSTEM
@@ -52,7 +53,7 @@ class LameDbReader:
""" Lamedb parser class.
Reads and parses the Enigma2 lamedb[5] file.
Supports versions 3, 4 and 5..
Supports versions 3, 4 and 5.
"""
__slots__ = ["_path", "_fmt"]
@@ -68,7 +69,7 @@ class LameDbReader:
return self.parse_v5()
raise SyntaxError("Unsupported version of the format.")
def parse_v3(self, services, transponders):
def parse_v3(self, services_data, transponders):
""" Parsing version 3. """
for t in transponders:
tr = transponders[t].lower()
@@ -91,7 +92,7 @@ class LameDbReader:
transponders[t] = tr
return self.parse_services(services, transponders)
return self.parse_services(services_data, transponders)
def parse_v4(self):
""" Parsing version 4. """
@@ -99,7 +100,7 @@ class LameDbReader:
try:
data = str(file.read())
except UnicodeDecodeError as e:
log("lamedb parse error: " + str(e))
log(f"lamedb parse error: {e}")
else:
return self.get_services_list(data)
@@ -111,14 +112,17 @@ class LameDbReader:
if lns and not lns[0].endswith("/5/\n"):
raise SyntaxError("lamedb ver.5 parsing error: unsupported format.")
trs, srvs = {}, [""]
trs, srvs = {}, []
for line in lns:
if line.startswith("s:"):
srv_data = line.strip("s:").split(",", 2)
srv_data[1] = srv_data[1].strip("\"")
srv_data[1] = srv_data[1].strip("\"\n")
data_len = len(srv_data)
if data_len == 3:
srv_data[2] = srv_data[2].strip()
s_data = srv_data[2].strip()
if not s_data.startswith("p:"):
s_data = f"p:,{s_data}"
srv_data[2] = s_data
elif data_len == 2:
srv_data.append("p:")
srvs.extend(srv_data)
@@ -129,19 +133,16 @@ class LameDbReader:
tr, srv = data[0].strip("t:"), data[1].strip().replace(":", " ", 1)
trs[tr] = srv
else:
log("Error while parsing transponder data [ver. 5] for line: {}".format(line))
log(f"Error while parsing transponder data [ver. 5] for line: {line}")
return self.parse_services(srvs, trs)
def parse_services(self, services, transponders):
def parse_services(self, services_data, transponders):
""" Parsing services. """
services_list = []
blacklist = get_blacklist(self._path) if self._path else {}
srvs = self.split(services, 3)
if srvs[0][0] == "": # Remove first empty element.
srvs.remove(srvs[0])
for srv in srvs:
for srv in self.get_services(services_data):
data_id = str(srv[0]).lower() # Lower is for lamedb ver.3.
data = data_id.split(_SEP)
sp = "0"
@@ -151,34 +152,35 @@ class LameDbReader:
is_v3 = False
if len(tid) < 4:
is_v3 = True
tid = "{:0>4}".format(tid)
tid = f"{tid:0>4}"
data[2] = tid
if len(nid) < 4:
is_v3 = True
nid = "{:0>4}".format(nid)
nid = f"{nid:0>4}"
data[3] = nid
if is_v3:
data[0] = "{:0>4}".format(data[0])
data[0] = f"{data[0]:0>4}"
data_id = _SEP.join(data)
srv_type = int(data[4])
transponder_id = "{}:{}:{}".format(data[1], tid, nid)
transponder_id = f"{data[1]}:{tid}:{nid}"
transponder = transponders.get(transponder_id, None)
tid = tid.lstrip(sp).upper()
nid = nid.lstrip(sp).upper()
# The tid and nid values can be 0.
tid = tid.lstrip(sp).upper() or "0"
nid = nid.lstrip(sp).upper() or "0"
ssid = str(data[0]).lstrip(sp).upper()
onid = str(data[1]).lstrip(sp).upper()
# For comparison in bouquets. Needed in upper case!!!
fav_id = "{}:{}:{}:{}".format(ssid, tid, nid, onid)
picon_id = "1_0_{:X}_{}_{}_{}_{}_0_0_0.png".format(srv_type, ssid, tid, nid, onid)
s_id = "1:0:{:X}:{}:{}:{}:{}:0:0:0:".format(srv_type, ssid, tid, nid, onid)
fav_id = f"1:0:{srv_type:X}:{ssid}:{tid}:{nid}:{onid}:0:0:0:"
if len(data) > 9:
fav_id = f"{fav_id}:0:0:0:0"
picon_id = f"1_0_{srv_type:X}_{ssid}_{tid}_{nid}_{onid}_0_0_0.png"
all_flags = srv[2].split(",")
coded = CODED_ICON if list(filter(lambda x: x.startswith("C:"), all_flags)) else None
flags = list(filter(lambda x: x.startswith("f:"), all_flags))
hide = HIDE_ICON if flags and Flag.is_hide(int(flags[0][2:])) else None
locked = LOCKED_ICON if s_id in blacklist else None
hide = HIDE_ICON if flags and Flag.is_hide(Flag.parse(flags[0])) else None
locked = LOCKED_ICON if fav_id in blacklist else None
package = list(filter(lambda x: x.startswith("p:"), all_flags))
package = package[0][2:] if package else ""
@@ -188,7 +190,7 @@ class LameDbReader:
tr_type = TrType(tr_type)
tr = tr.split(_SEP)
service_type = SERVICE_TYPE.get(data[4], SERVICE_TYPE["-2"])
# Removing all non printable symbols!
# Removing all non-printable symbols!
srv_name = "".join(c for c in srv[1] if c.isprintable())
freq = tr[0]
rate = tr[1]
@@ -203,7 +205,7 @@ class LameDbReader:
system = "DVB-S2" if len(tr) > 7 else "DVB-S"
pos = tr[4]
if tr_type is TrType.Terrestrial:
system = T_SYSTEM.get(tr[9], None)
system = T_SYSTEM.get(tr[10] if len(tr) > 10 else "0", None)
pos = "T"
fec = T_FEC.get(tr[3], None)
elif tr_type is TrType.Cable:
@@ -217,13 +219,12 @@ class LameDbReader:
# Formatting displayed values.
try:
freq = "{}".format(int(freq) // 1000)
rate = "{}".format(int(rate) // 1000)
freq = f"{int(freq) // 1000}"
rate = f"{int(rate) // 1000}"
if tr_type is TrType.Satellite:
pos = int(pos)
pos = "{:0.1f}{}".format(abs(pos / 10), "W" if pos < 0 else "E")
pos = get_pos_str(int(pos))
except ValueError as e:
log("Parse error [parse_services]: {}".format(e))
log(f"Parse error [parse_services]: {e}")
s = Service(srv[2], tr_type.value, coded, srv_name, locked, hide, package, service_type, None,
picon_id, data[0], freq, rate, pol, fec, system, pos, data_id, fav_id, transponder)
@@ -243,11 +244,12 @@ class LameDbReader:
transponders, sep, services = services.partition("services") # 2 step
services, sep, _ = services.partition("\nend") # 3 step
services = services.strip()
if match.group() == "/3/":
return self.parse_v3(services.split("\n"), self.parse_transponders(transponders.split("/")))
return self.parse_v3(services.splitlines(), self.parse_transponders(transponders.split("/")))
return self.parse_services(services.split("\n"), self.parse_transponders(transponders.split("/")))
return self.parse_services(services.splitlines(), self.parse_transponders(transponders.split("/")))
@staticmethod
def get_services_lines(services):
@@ -258,18 +260,17 @@ class LameDbReader:
tr_set = set()
for srv in services:
data_id = str(srv.data_id).split(_SEP)
tr_id = "{}:{}:{}".format(data_id[1], data_id[2], data_id[3])
tr_id = f"{data_id[1]}:{data_id[2]}:{data_id[3]}"
if tr_id not in tr_set:
transponder = "{}\n\t{}\n/\n".format(tr_id, srv.transponder)
tr_lines.append(transponder)
tr_lines.append(f"{tr_id}\n\t{srv.transponder}\n/\n")
tr_set.add(tr_id)
# Services
services_lines.append("{}\n{}\n{}\n".format(srv.data_id, srv.service, srv.flags_cas))
services_lines.append(f"{srv.data_id}\n{srv.service}\n{srv.flags_cas}\n")
tr_lines.sort()
lines.extend(tr_lines)
lines.extend(services_lines)
lines.append("end\n" + _END_LINE)
lines.append(f"end\n{_END_LINE}")
return lines
@@ -283,17 +284,26 @@ class LameDbReader:
return transponders
def split(self, itr, size):
""" Divide the iterable. """
srv = []
def get_services(self, itr, size=3):
""" Separates and extract services data. """
services = []
tmp = []
for i, line in enumerate(itr):
i = 0
for line in itr:
i += 1
tmp.append(line)
if i % size == 0:
srv.append(tuple(tmp))
tmp.clear()
return srv
if i == size:
# check if provider (p:) is present in line
if "p:" not in line:
# To prevent cases of incorrect service data formation
# (e.g. the name contains a line break)
tmp.pop()
i -= 1
else:
services.append(tuple(tmp))
tmp.clear()
i = 0
return services
class LameDbWriter:
@@ -311,7 +321,7 @@ class LameDbWriter:
def write(self):
if self._fmt == 4:
# Writing lamedb file ver.4
with open(self._path + _FILE_NAME, "w", encoding="utf-8") as file:
with open(self._path + _FILE_NAME, "w", encoding="utf-8", newline="\n") as file:
file.writelines(LameDbReader.get_services_lines(self._services))
elif self._fmt == 5:
self.write_to_lamedb5()
@@ -324,19 +334,19 @@ class LameDbWriter:
for srv in self._services:
data_id = str(srv.data_id).split(_SEP)
tr_id = "{}:{}:{}".format(data_id[1], data_id[2], data_id[3])
tr_set.add("t:{},{}\n".format(tr_id, srv.transponder.replace(" ", ":", 1)))
tr_id = f"{data_id[1]}:{data_id[2]}:{data_id[3]}"
tr_set.add(f"t:{tr_id},{srv.transponder.replace(' ', ':', 1)}\n")
# Removing empty packages
flags = list(filter(lambda x: x != "p:", srv.flags_cas.split(",")))
flags = ",".join(flags)
flags = "," + flags if flags else ""
services_lines.append("s:{},\"{}\"{}\n".format(srv.data_id, srv.service, flags))
services_lines.append(f"s:{srv.data_id},\"{srv.service}\"{flags}\n")
lines.extend(sorted(tr_set))
lines.extend(services_lines)
lines.append(_END_LINE)
with open(self._path + "lamedb5", "w", encoding="utf-8") as file:
with open(self._path + "lamedb5", "w", encoding="utf-8", newline="\n") as file:
file.writelines(lines)

View File

@@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2024 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
""" Additional module to use stream relay functionality.
Reads/Writes 'whitelist_streamrelay' file.
"""
import os.path
from contextlib import suppress
from app.commons import log
_FILE_NAME = "whitelist_streamrelay"
class StreamRelay(dict):
""" Class to hold/process service references used by a stream relay. """
def refresh(self, path):
self.clear()
f_path = f"{path}{_FILE_NAME}"
if os.path.isfile(f_path):
log("Updating stream relay cache...")
with suppress(FileNotFoundError):
with open(f"{path}{_FILE_NAME}", "r", encoding="utf-8") as file:
refs = filter(None, (x.rstrip("\n") for x in file.readlines()))
self.update(self.get_ref_data(ref) for ref in refs)
def get_ref_data(self, ref):
""" Returns tuple from FAV ID and ref or ref and None for comments. """
data = ref.split(":")
if len(data) == 11:
if "http" in data[-1]:
return ref.replace("%3a", "%3A"), ref
return f"{data[3]}:{data[4]}:{data[5]}:{data[6]}", ref
return ref, None
def save(self, path):
""" Saves current refs to a file.
If no refs is present, delites current relay file.
"""
f_name = f"{path}{_FILE_NAME}"
if len(self):
with open(f_name, "w", encoding="utf-8") as file:
file.writelines([f"{v if v else k}\n\n" for k, v in self.items()])
else:
if os.path.exists(f_name):
os.remove(f_name)
if __name__ == "__main__":
pass

View File

@@ -1,3 +1,31 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
""" Module for IPTV and streams support """
import re
from enum import Enum
@@ -10,8 +38,11 @@ from app.ui.uicommons import IPTV_ICON
# url, description, urlkey, account, usrname, psw, s_type, iconsrc, iconsrc_b, group
NEUTRINO_FAV_ID_FORMAT = "{}::{}::{}::{}::{}::{}::{}::{}::{}::{}"
ENIGMA2_FAV_ID_FORMAT = " {}:0:{}:{:X}:{:X}:{:X}:{:X}:0:0:0:{}:{}\n#DESCRIPTION: {}\n"
ENIGMA2_FAV_ID_FORMAT = " {}:{}:{:X}:{:X}:{:X}:{:X}:{:X}:0:0:0:{}:{}\n#DESCRIPTION {}\n"
MARKER_FORMAT = " 1:64:{}:0:0:0:0:0:0:0::{}\n#DESCRIPTION {}\n"
PICON_FORMAT = "{}_{}_{:X}_{:X}_{:X}_{:X}_{:X}_0_0_0.png"
ENCODING_BLACKLIST = {"MacRoman"}
class StreamType(Enum):
@@ -21,9 +52,17 @@ class StreamType(Enum):
NONE_REC_2 = "5002"
E_SERVICE_URI = "8193"
E_SERVICE_HLS = "8739"
UNKNOWN = "0"
@classmethod
def _missing_(cls, value):
return cls.UNKNOWN
def parse_m3u(path, s_type, detect_encoding=True, params=None):
""" Parses *m3u* file and returns tuple with EPG src URLs and services list. """
pattern = re.compile(r'(\S+)="(.*?)"')
with open(path, "rb") as file:
data = file.read()
encoding = "utf-8"
@@ -36,11 +75,14 @@ def parse_m3u(path, s_type, detect_encoding=True, params=None):
else:
enc = chardet.detect(data)
encoding = enc.get("encoding", "utf-8")
encoding = "utf-8" if encoding in ENCODING_BLACKLIST else encoding
aggr = [None] * 10
s_aggr = aggr[: -3]
services = []
epg_src = None
group = None
groups = set()
services = []
marker_counter = 1
sid_counter = 1
name = None
@@ -48,82 +90,95 @@ def parse_m3u(path, s_type, detect_encoding=True, params=None):
p_id = "1_0_1_0_0_0_0_0_0_0.png"
st = BqServiceType.IPTV.name
params = params or [0, 0, 0, 0]
m_name = BqServiceType.MARKER.name
for line in str(data, encoding=encoding, errors="ignore").splitlines():
if line.startswith("#EXTM3U"):
data = dict(pattern.findall(line))
epg_src = data.get("x-tvg-url", data.get("url-tvg", None))
epg_src = epg_src.split(",") if epg_src else None
if line.startswith("#EXTINF"):
inf, sep, line = line.partition(" ")
if not line:
line = inf
line, sep, name = line.rpartition(",")
data = dict(pattern.findall(line))
name = data.get("tvg-name", name)
picon = data.get("tvg-logo", None)
epg_id = data.get("tvg-id", None)
data = re.split('"', line)
size = len(data)
if size < 3:
continue
d = {data[i].lower().strip(" ="): data[i + 1] for i in range(0, len(data) - 1, 2)}
picon = d.get("tvg-logo", None)
grp_name = d.get("group-title", None)
if grp_name not in groups:
groups.add(grp_name)
fav_id = MARKER_FORMAT.format(marker_counter, grp_name, grp_name)
marker_counter += 1
mr = Service(None, None, None, grp_name, *aggr[0:3], BqServiceType.MARKER.name, *aggr, fav_id, None)
services.append(mr)
if s_type is SettingsType.ENIGMA_2:
group = data.get("group-title", None)
elif line.startswith("#EXTGRP") and s_type is SettingsType.ENIGMA_2:
grp_name = line.strip("#EXTGRP:").strip()
if grp_name not in groups:
groups.add(grp_name)
fav_id = MARKER_FORMAT.format(marker_counter, grp_name, grp_name)
marker_counter += 1
mr = Service(None, None, None, grp_name, *aggr[0:3], BqServiceType.MARKER.name, *aggr, fav_id, None)
services.append(mr)
elif not line.startswith("#"):
group = line.strip("#EXTGRP:").strip()
elif not line.startswith("#") and "://" in line:
url = line.strip()
params[0] = sid_counter
sid_counter += 1
fav_id = get_fav_id(url, name, s_type, params)
if s_type is SettingsType.ENIGMA_2:
p_id = get_picon_id(params)
if group not in groups:
# Some playlists have "random" of group names.
# We will take only the first one we found on the list!
groups.add(group)
m_id = MARKER_FORMAT.format(marker_counter, group, group)
marker_counter += 1
services.append(Service(None, None, None, group, *aggr[0:3], m_name, *aggr, m_id, None))
if all((name, url, fav_id)):
srv = Service(None, None, IPTV_ICON, name, *aggr[0:3], st, picon, p_id, *s_aggr, url, fav_id, None)
services.append(srv)
services.append(Service(epg_id, None, IPTV_ICON, name, *aggr[0:2], group,
st, picon, p_id, *s_aggr, url, fav_id, None))
else:
log("*.m3u* parse error ['{}']: name[{}], url[{}], fav id[{}]".format(path, name, url, fav_id))
log(f"*.m3u* parse error ['{path}']: name[{name}], url[{url}], fav id[{fav_id}]")
return services
return epg_src, services
def export_to_m3u(path, bouquet, s_type):
pattern = re.compile(".*:(http.*):.*") if s_type is SettingsType.ENIGMA_2 else re.compile("(http.*?)::::.*")
def export_to_m3u(path, bouquet, s_type, url=None):
pattern = re.compile(".*:(http.*).*") if s_type is SettingsType.ENIGMA_2 else re.compile("(http.*?)::::.*")
lines = ["#EXTM3U\n"]
current_grp = None
for s in bouquet.services:
s_type = s.type
if s_type is BqServiceType.IPTV:
srv_type = s.type
if srv_type is BqServiceType.IPTV:
res = re.match(pattern, s.data)
if not res:
continue
data = res.group(1)
lines.append("#EXTINF:-1,{}\n".format(s.name))
if current_grp:
lines.append(current_grp)
lines.append("{}\n".format(unquote(data.strip())))
elif s_type is BqServiceType.MARKER:
current_grp = "#EXTGRP:{}\n".format(s.name)
lines.append(f"#EXTINF:-1,{s.name}\n")
lines.append(current_grp) if current_grp else None
u = res.group(1)
if s_type is SettingsType.ENIGMA_2:
index = u.rfind(":")
lines.append(f"{unquote(u[:index] if index > 0 else u)}\n")
else:
lines.append(f"{u}\n")
elif srv_type is BqServiceType.MARKER:
current_grp = f"#EXTGRP:{s.name}\n"
elif srv_type is BqServiceType.DEFAULT and url:
lines.append(f"#EXTINF:-1,{s.name}\n")
lines.append(current_grp) if current_grp else None
lines.append(f"{url}{s.data}\n")
with open(path + "{}.m3u".format(bouquet.name), "w", encoding="utf-8") as file:
with open(f"{path}{bouquet.name}.m3u", "w", encoding="utf-8") as file:
file.writelines(lines)
def get_fav_id(url, service_name, settings_type, params=None, stream_type=None, s_type=1):
""" Returns fav id depending on the profile. """
def get_fav_id(url, name, settings_type, params=None, st_type=None, s_id=0, srv_type=1, force_quote=True):
""" Returns fav id depending on the settings type. """
if settings_type is SettingsType.ENIGMA_2:
stream_type = stream_type or StreamType.NONE_TS.value
st_type = st_type or StreamType.NONE_TS.value
params = params or (0, 0, 0, 0)
return ENIGMA2_FAV_ID_FORMAT.format(stream_type, s_type, *params, quote(url), service_name, service_name, None)
url = quote(url) if force_quote else url
return ENIGMA2_FAV_ID_FORMAT.format(st_type, s_id, srv_type, *params, url, name, name, None)
elif settings_type is SettingsType.NEUTRINO_MP:
return NEUTRINO_FAV_ID_FORMAT.format(url, "", 0, None, None, None, None, "", "", 1)
def get_picon_id(params=None, st_type=None, s_id=0, srv_type=1):
st_type = st_type or StreamType.NONE_TS.value
params = params or (0, 0, 0, 0)
return PICON_FORMAT.format(st_type, s_id, srv_type, *params)
if __name__ == "__main__":
pass

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2024 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -28,15 +28,16 @@
import os
from app.commons import log
from app.eparser.iptv import NEUTRINO_FAV_ID_FORMAT
from app.eparser.neutrino import KSP, SP, get_xml_attributes, get_attributes, API_VER
from app.eparser.neutrino.nxml import XmlHandler, NeutrinoDocument
from app.ui.uicommons import LOCKED_ICON, HIDE_ICON
from ..ecommons import Bouquets, Bouquet, BouquetService, BqServiceType, PROVIDER, BqType
_FILE = "bouquets.xml"
_U_FILE = "ubouquets.xml"
_W_FILE = "webtv.xml"
_W_FILE = "webtv_usr.xml"
_WEB_TV_NAME = "[Web TV]"
_COMMENT = " File was created in DemonEditor. Enjoy watching! "
@@ -61,20 +62,27 @@ def parse_bouquets(file, name, bq_type):
hidden = bq_attrs.get("hidden", "0")
locked = bq_attrs.get("locked", "0")
services = []
for srv_elem in elem.getElementsByTagName("S"):
if srv_elem.hasAttributes():
s_attrs = get_xml_attributes(srv_elem)
ssid = s_attrs.get("i", "0")
on = s_attrs.get("on", "0")
tr_id = s_attrs.get("t", "0")
fav_id = "{}:{}:{}".format(tr_id, on, ssid)
services.append(BouquetService(None, BqServiceType.DEFAULT, fav_id, 0))
if "i" in s_attrs:
ssid = s_attrs.get("i", "0")
on = s_attrs.get("on", "0")
tr_id = s_attrs.get("t", "0")
fav_id = f"{tr_id}:{on}:{ssid}"
services.append(BouquetService(None, BqServiceType.DEFAULT, fav_id, 0))
elif "u" in s_attrs:
services.append(get_webtv_service(s_attrs))
else:
log(f"Parse bouquets [Neutrino] error: Unknown service type. -> {s_attrs}")
bouquets[2].append(Bouquet(name=bq_name,
type=bq_type,
services=services,
locked=LOCKED_ICON if locked == "1" else None,
hidden=HIDE_ICON if hidden == "1" else None,
file=SP.join("{}{}{}".format(k, KSP, v) for k, v in bq_attrs.items())))
locked=locked == "1",
hidden=hidden == "1",
file=SP.join(f"{k}{KSP}{v}" for k, v in bq_attrs.items())))
if BqType(bq_type) is BqType.BOUQUET:
for bq in bouquets.bouquets:
@@ -93,31 +101,40 @@ def parse_webtv(path, name, bq_type):
return bouquets
dom = XmlHandler.parse(path)
# Display name.
name = None
for e in dom.childNodes:
if e.nodeType == e.ELEMENT_NODE:
name = e.getAttribute("name")
break
services = []
for elem in dom.getElementsByTagName("webtv"):
if elem.hasAttributes():
web_attrs = get_xml_attributes(elem)
title = web_attrs.get("title", "")
url = web_attrs.get("url", "")
description = web_attrs.get("description", "")
urlkey = web_attrs.get("urlkey", None)
account = web_attrs.get("account", None)
usrname = web_attrs.get("usrname", None)
psw = web_attrs.get("psw", None)
s_type = web_attrs.get("type", None)
iconsrc = web_attrs.get("iconsrc", None)
iconsrc_b = web_attrs.get("iconsrc_b", None)
group = web_attrs.get("group", None)
fav_id = NEUTRINO_FAV_ID_FORMAT.format(url, description, urlkey, account, usrname, psw, s_type, iconsrc,
iconsrc_b, group)
services.append(BouquetService(name=title, type=BqServiceType.IPTV, data=fav_id, num=0))
services.append(get_webtv_service(web_attrs))
bouquet = Bouquet(name="default", type=bq_type, services=services, locked=None, hidden=None, file=None)
bouquet = Bouquet(name=name or _WEB_TV_NAME, type=bq_type, services=services, locked=None, hidden=None, file=None)
bouquets[2].append(bouquet)
return bouquets
def get_webtv_service(web_attrs):
title = web_attrs.get("title", web_attrs.get("n", ""))
fav_id = NEUTRINO_FAV_ID_FORMAT.format(web_attrs.get("url", web_attrs.get("u", )),
web_attrs.get("description", ""),
web_attrs.get("urlkey", None),
web_attrs.get("account", None),
web_attrs.get("usrname", None),
web_attrs.get("psw", None),
web_attrs.get("type", None),
web_attrs.get("iconsrc", None),
web_attrs.get("iconsrc_b", None),
web_attrs.get("group", None))
return BouquetService(name=title, type=BqServiceType.IPTV, data=fav_id, num=0)
def write_bouquets(path, bouquets):
for bq in bouquets:
bq_type = BqType(bq.type)
@@ -154,14 +171,25 @@ def write_bouquet(file, bouquet):
root.appendChild(bq_elem)
for srv in bq.services:
tr_id, on, ssid = srv.fav_id.split(":")
srv_elem = doc.createElement("S")
srv_elem.setAttribute("i", ssid)
srv_elem.setAttribute("n", srv.service)
srv_elem.setAttribute("t", tr_id)
srv_elem.setAttribute("on", on)
srv_elem.setAttribute("frq", srv.freq)
srv_elem.setAttribute("s", get_attributes(srv.flags_cas).get("position", "0"))
s_type = BqServiceType(srv.service_type)
if s_type is BqServiceType.DEFAULT:
tr_id, on, ssid = srv.fav_id.split(":")
srv_elem.setAttribute("i", ssid)
srv_elem.setAttribute("t", tr_id)
srv_elem.setAttribute("on", on)
srv_elem.setAttribute("frq", srv.freq)
srv_elem.setAttribute("s", get_attributes(srv.flags_cas).get("position", "0"))
elif s_type is BqServiceType.IPTV:
s_data = srv.fav_id.split("::")
if s_data:
srv_elem.setAttribute("n", srv.service)
srv_elem.setAttribute("u", s_data[0])
else:
log(f"Write bouquet [Neutrino] error: Unsupported service type. -> {s_type.value}")
bq_elem.appendChild(srv_elem)
doc.write_xml(file)
@@ -175,6 +203,7 @@ def write_webtv(file, bouquet):
doc.appendChild(comment)
for bq in bouquet.bouquets:
root.setAttribute("name", bq.name or _WEB_TV_NAME)
for srv in bq.services:
url, description, urlkey, account, usrname, psw, s_type, iconsrc, iconsrc_b, group = srv.fav_id.split("::")
srv_elem = doc.createElement("webtv")

View File

@@ -1,120 +1,201 @@
""" Module foe parsing Satellites.xml
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
For more info see __COMMENT
""" Module for working with *.xml files.
For more info see comments.
"""
from xml.dom.minidom import parse, Document
import xml.etree.ElementTree as ETree
from app.commons import log
from .ecommons import POLARIZATION, FEC, SYSTEM, MODULATION, Transponder, Satellite, get_key_by_value
from .ecommons import Satellite, Terrestrial, Cable, Transponder, TerTransponder, CableTransponder
__COMMENT = (" File was created in DemonEditor\n\n"
"usable flags are\n"
" 1: Network Scan\n"
" 2: use BAT\n"
" 4: use ONIT\n"
" 8: skip NITs of known networks\n"
" and combinations of this.\n\n"
_SAT_COMMENT = ("\tFile was created in DemonEditor.\n\n"
"Usable flags are:\n"
" 1: Network Scan\n"
" 2: use BAT\n"
" 4: use ONIT\n"
" 8: skip NITs of known networks\n"
" This is a bitmap and combinations can be used.\n\n"
"Transponder parameters:\n"
"\tpolarization: 0 - Horizontal, 1 - Vertical, 2 - Left Circular, 3 - Right Circular\n"
"\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"
"\t8 - 4/5, 9 - 9/10, 15 - None\n"
"\tmodulation: 0 - Auto, 1 - QPSK, 2 - 8PSK, 4 - 16APSK, 5 - 32APSK\n"
"\trolloff: 0 - 0.35, 1 - 0.25, 2 - 0.20, 3 - Auto\n"
"\tpilot: 0 - Off, 1 - On, 2 - Auto\n"
"\tinversion: 0 = Off, 1 = On, 2 = Auto (default)\n"
"\tsystem: 0 = DVB-S, 1 = DVB-S2\n"
"\tis_id: 0 - 255\n"
"\tpls_mode: 0 - Root, 1 - Gold, 2 - Combo\n"
"\tpls_code: 0 - 262142\n\n")
"transponder parameters:\n"
"polarization: 0 - Horizontal, 1 - Vertical, 2 - Left Circular, 3 - Right Circular\n"
"fec_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"
"8 - 4/5, 9 - 9/10, 15 - None\n"
"modulation: 0 - Auto, 1 - QPSK, 2 - 8PSK, 4 - 16APSK, 5 - 32APSK\n"
"rolloff: 0 - 0.35, 1 - 0.25, 2 - 0.20, 3 - Auto\n"
"pilot: 0 - Off, 1 - On, 2 - Auto\n"
"inversion: 0 = Off, 1 = On, 2 = Auto (default)\n"
"system: 0 = DVB-S, 1 = DVB-S2\n"
"is_id: 0 - 255\n"
"pls_mode: 0 - Root, 1 - Gold, 2 - Combo\n"
"pls_code: 0 - 262142\n\n")
_TERRESTRIAL_COMMENT = ("\tFile was created in DemonEditor.\n\n"
"Usable flags are:\n"
" 1: Network Scan\n"
" 2: use BAT\n"
" 4: use ONIT\n"
" 8: skip NITs of known networks\n"
" This is a bitmap and combinations can be used.\n\n")
_CABLE_COMMENT = ("\tFile was created in DemonEditor.\n\n"
"Transponder parameters:\n"
"\tmodulation:\n"
"\t3: QAM64\n"
"\t5: QAM256\n")
def get_satellites(path):
return parse_satellites(path)
""" Returns data [Satellite] list from *.xml. """
return [Satellite(e.get("name", None),
e.get("flags", None),
e.get("position", None) or "0",
get_sat_transponders(e)) for e in ETree.parse(path).iter("sat")]
def write_satellites(satellites, data_path):
""" Creation satellites.xml file """
doc = Document()
comment = doc.createComment(__COMMENT)
doc.appendChild(comment)
root = doc.createElement("satellites")
doc.appendChild(root)
for sat in satellites:
# Create Element
sat_child = doc.createElement("sat")
sat_child.setAttribute("name", sat.name)
sat_child.setAttribute("flags", sat.flags)
sat_child.setAttribute("position", sat.position)
for tr in sat.transponders:
transponder_child = doc.createElement("transponder")
transponder_child.setAttribute("frequency", tr.frequency)
transponder_child.setAttribute("symbol_rate", tr.symbol_rate)
transponder_child.setAttribute("polarization", get_key_by_value(POLARIZATION, tr.polarization))
transponder_child.setAttribute("fec_inner", get_key_by_value(FEC, tr.fec_inner) or "0")
transponder_child.setAttribute("system", get_key_by_value(SYSTEM, tr.system) or "0")
transponder_child.setAttribute("modulation", get_key_by_value(MODULATION, tr.modulation) or "0")
if tr.pls_mode:
transponder_child.setAttribute("pls_mode", tr.pls_mode)
if tr.pls_code:
transponder_child.setAttribute("pls_code", tr.pls_code)
if tr.is_id:
transponder_child.setAttribute("is_id", tr.is_id)
sat_child.appendChild(transponder_child)
root.appendChild(sat_child)
doc.writexml(open(data_path, "w"),
# indent="",
addindent=" ",
newl='\n',
encoding="iso-8859-1")
doc.unlink()
def get_sat_transponders(elem):
""" Returns satellite transponders list. """
return [Transponder(e.get("frequency", "0"),
e.get("symbol_rate", "0"),
e.get("polarization", None),
e.get("fec_inner", None),
e.get("system", None),
e.get("modulation", None),
e.get("pls_mode", None),
e.get("pls_code", None),
e.get("is_id", None),
e.get("t2mi_plp_id", None),
e.get("t2mi_pid", None)) for e in elem.iter("transponder")]
def parse_transponders(elem, sat_name):
""" Parsing satellite transponders """
transponders = []
for el in elem.getElementsByTagName("transponder"):
if el.hasAttributes():
atr = el.attributes
try:
tr = Transponder(atr["frequency"].value,
atr["symbol_rate"].value,
POLARIZATION[atr["polarization"].value],
FEC[atr["fec_inner"].value],
SYSTEM[atr["system"].value],
MODULATION[atr["modulation"].value],
atr["pls_mode"].value if "pls_mode" in atr else None,
atr["pls_code"].value if "pls_code" in atr else None,
atr["is_id"].value if "is_id" in atr else None)
except Exception as e:
message = "Error: can't parse transponder for '{}' satellite! {}".format(sat_name, repr(e))
log(message)
else:
transponders.append(tr)
return transponders
def get_terrestrial(path):
""" Returns data [Terrestrial] list from *.xml. """
return [Terrestrial(e.get("name", None),
e.get("flags", None),
e.get("countrycode", None),
[get_ter_transponder(e) for e in e.iter("transponder")]
) for e in ETree.parse(path).iter("terrestrial")]
def parse_sat(elem):
""" Parsing satellite """
sat_name = elem.attributes["name"].value
return Satellite(sat_name,
elem.attributes["flags"].value,
elem.attributes["position"].value,
parse_transponders(elem, sat_name))
def get_ter_transponder(elem):
""" Returns terrestrial transponder. """
return TerTransponder(elem.get("centre_frequency", "0"),
elem.get("system", None),
elem.get("bandwidth", None),
elem.get("constellation", None),
elem.get("code_rate_hp", None),
elem.get("code_rate_lp", None),
elem.get("guard_interval", None),
elem.get("transmission_mode", None),
elem.get("hierarchy_information", None),
elem.get("inversion", None),
elem.get("plp_id", None))
def parse_satellites(path):
""" Parsing satellites from xml"""
dom = parse(path)
satellites = []
def get_cable(path):
""" Returns data [Cable] list from *.xml. """
return [Cable(e.get("name", None),
e.get("flags", None),
e.get("satfeed", None),
e.get("countrycode", None),
get_cable_transponders(e)) for e in ETree.parse(path).iter("cable")]
for elem in dom.getElementsByTagName("sat"):
if elem.hasAttributes():
satellites.append(parse_sat(elem))
return satellites
def get_cable_transponders(elem):
""" Returns cable transponders list. """
return [CableTransponder(e.get("frequency", "0"),
e.get("symbol_rate", "0"),
e.get("fec_inner", None),
e.get("modulation", None)) for e in elem.iter("transponder")]
def write_satellites(satellites, data_path, encoding="UTF-8"):
""" Creates satellites.xml file. """
write_xml("satellites", "sat", satellites, data_path, _SAT_COMMENT, encoding)
def write_terrestrial(terrestrial, data_path, encoding="UTF-8"):
""" Creates terrestrial.xml file. """
write_xml("locations", "terrestrial", terrestrial, data_path, _TERRESTRIAL_COMMENT, encoding)
def write_cable(cables, data_path, encoding="UTF-8"):
""" Creates cables.xml file. """
write_xml("cables", "cable", cables, data_path, _CABLE_COMMENT, encoding)
def write_xml(root_name, sub_name, data, data_path, comment="", encoding="UTF-8"):
""" Creates *.xml files. """
xml = ETree.Element(root_name)
[write_element(sub_name, "transponder", t, xml) for t in data]
tree = ETree.ElementTree(xml)
indent(tree.getroot())
with open(data_path, "wb") as f:
# To put comment on top.
f.write(f'<?xml version="1.0" encoding="{encoding}"?>\n<!--\n{comment}-->\n\n'.encode("utf-8"))
tree.write(f, encoding=encoding)
def write_element(e_name, ch_name, e_data, root):
""" Writes element with sub elements.
@param e_name: Element name.
@param ch_name: Child element name.
@param e_data: Element data -> defaultdict
@param root: Parent of the element.
"""
t = e_data._asdict()
subs = t.pop("transponders")
root_sub = ETree.SubElement(root, e_name, {k: v for k, v in t.items() if v})
[ETree.SubElement(root_sub, ch_name, {k: v for k, v in tr._asdict().items() if v}) for tr in subs]
def indent(elem, parent=None, index=-1, level=0, space=" "):
""" Appends whitespace to the subtree to indent the tree visually.
Since the minimum supported version < 3.9, we will use our own implementation.
"""
for i, sub in enumerate(elem):
indent(sub, elem, i, level + 1)
if parent:
if index == 0:
parent.text = f"\n{space * level}"
else:
parent[index - 1].tail = f"\n{space * level}"
if index == len(parent) - 1:
elem.tail = f"\n{space * (level - 1)}"
def get_pos_str(pos: int) -> str:
""" Converts satellite position int value to readable string. """
return f"{abs(pos / 10):0.1f}{'W' if pos < 0 else 'E'}"
if __name__ == "__main__":

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -31,7 +31,7 @@ import json
import locale
import os
import sys
from enum import Enum, IntEnum
from enum import IntEnum
from functools import lru_cache
from pathlib import Path
from pprint import pformat
@@ -39,47 +39,54 @@ from textwrap import dedent
SEP = os.sep
HOME_PATH = str(Path.home())
CONFIG_PATH = HOME_PATH + "{}.config{}demon-editor{}".format(SEP, SEP, SEP)
CONFIG_PATH = HOME_PATH + f"{SEP}.config{SEP}demon-editor{SEP}"
CONFIG_FILE = CONFIG_PATH + "config.json"
DATA_PATH = HOME_PATH + "{}DemonEditor{}".format(SEP, SEP)
DATA_PATH = HOME_PATH + f"{SEP}DemonEditor{SEP}"
GTK_PATH = os.environ.get("GTK_PATH", None)
IS_DARWIN = sys.platform == "darwin"
IS_WIN = sys.platform == "win32"
IS_LINUX = sys.platform == "linux"
USE_HEADER_BAR = int(bool(os.environ.get("GNOME_DESKTOP_SESSION_ID")))
class Defaults(Enum):
""" Default program settings """
class Defaults:
""" Default program settings. """
USER = "root"
PASSWORD = ""
HOST = "127.0.0.1"
FTP_PORT = "21"
HTTP_PORT = "80"
TELNET_PORT = "23"
FTP_PORT = 21
HTTP_PORT = 80
TELNET_PORT = 23
HTTP_USE_SSL = False
# Enigma2.
BOX_SERVICES_PATH = "/etc/enigma2/"
BOX_SATELLITE_PATH = "/etc/tuxbox/"
BOX_EPG_PATH = "/etc/enigma2/"
BOX_PICON_PATH = "/usr/share/enigma2/picon/"
BOX_PICON_PATHS = ("/usr/share/enigma2/picon/",
"/media/hdd/picon/",
"/media/usb/picon/",
"/media/mmc/picon/",
"/media/cf/picon/")
"/media/cf/picon/",
"/hdd/picon/",
"/usb/picon/")
# Neutrino.
NEUTRINO_BOX_SERVICES_PATH = "/var/tuxbox/config/zapit/"
NEUTRINO_BOX_SATELLITE_PATH = "/var/tuxbox/config/"
NEUTRINO_BOX_PICON_PATH = "/usr/share/tuxbox/neutrino/icons/logo/"
NEUTRINO_BOX_PICON_PATHS = ("/usr/share/tuxbox/neutrino/icons/logo/",)
# Paths.
BACKUP_PATH = "{}backup{}".format(DATA_PATH, SEP)
PICON_PATH = "{}picons{}".format(DATA_PATH, SEP)
BACKUP_PATH = f"{DATA_PATH}backup{SEP}"
PICON_PATH = f"{DATA_PATH}picons{SEP}"
DEFAULT_PROFILE = "default"
BACKUP_BEFORE_DOWNLOADING = True
BACKUP_BEFORE_SAVE = True
V5_SUPPORT = False
UNLIMITED_COPY_BUFFER = False
EXTENSIONS_SUPPORT = False
FORCE_BQ_NAMES = False
HTTP_API_SUPPORT = True
ENABLE_YT_DL = False
@@ -92,10 +99,11 @@ class Defaults(Enum):
FAV_CLICK_MODE = 0
PLAY_STREAMS_MODE = 1 if IS_DARWIN else 0
STREAM_LIB = "mpv" if IS_WIN else "vlc"
MAIN_LIST_PLAYBACK = False
PROFILE_FOLDER_DEFAULT = False
RECORDS_PATH = DATA_PATH + "records{}".format(SEP)
RECORDINGS_PATH = f"{DATA_PATH}recordings{SEP}"
ACTIVATE_TRANSCODING = False
ACTIVE_TRANSCODING_PRESET = "720p TV{}device".format(SEP)
ACTIVE_TRANSCODING_PRESET = f"720p TV{SEP}device"
class SettingsType(IntEnum):
@@ -106,32 +114,35 @@ class SettingsType(IntEnum):
def get_default_settings(self):
""" Returns default settings for current type. """
if self is self.ENIGMA_2:
srv_path = Defaults.BOX_SERVICES_PATH.value
sat_path = Defaults.BOX_SATELLITE_PATH.value
picons_path = Defaults.BOX_PICON_PATH.value
srv_path = Defaults.BOX_SERVICES_PATH
sat_path = Defaults.BOX_SATELLITE_PATH
picons_path = Defaults.BOX_PICON_PATH
epg_path = Defaults.BOX_EPG_PATH
http_timeout = 5
telnet_timeout = 5
else:
srv_path = Defaults.NEUTRINO_BOX_SERVICES_PATH.value
sat_path = Defaults.NEUTRINO_BOX_SATELLITE_PATH.value
picons_path = Defaults.NEUTRINO_BOX_PICON_PATH.value
srv_path = Defaults.NEUTRINO_BOX_SERVICES_PATH
sat_path = Defaults.NEUTRINO_BOX_SATELLITE_PATH
picons_path = Defaults.NEUTRINO_BOX_PICON_PATH
epg_path = ""
http_timeout = 2
telnet_timeout = 1
return {"setting_type": self.value,
"host": Defaults.HOST.value,
"port": Defaults.FTP_PORT.value,
"host": Defaults.HOST,
"port": Defaults.FTP_PORT,
"timeout": 5,
"user": Defaults.USER.value,
"password": Defaults.PASSWORD.value,
"http_port": Defaults.HTTP_PORT.value,
"user": Defaults.USER,
"password": Defaults.PASSWORD,
"http_port": Defaults.HTTP_PORT,
"http_timeout": http_timeout,
"http_use_ssl": Defaults.HTTP_USE_SSL.value,
"telnet_port": Defaults.TELNET_PORT.value,
"http_use_ssl": Defaults.HTTP_USE_SSL,
"telnet_port": Defaults.TELNET_PORT,
"telnet_timeout": telnet_timeout,
"services_path": srv_path,
"user_bouquet_path": srv_path,
"satellites_xml_path": sat_path,
"epg_dat_path": epg_path,
"picons_path": picons_path}
@@ -150,6 +161,21 @@ class PlayStreamsMode(IntEnum):
M3U = 2
class PlaybackMode(IntEnum):
""" Playback mode by double click of mouse in the bouquet (FAV) list. """
DISABLED = 0
STREAM = 1
PLAY = 2
ZAP = 3
ZAP_PLAY = 4
class EpgSource(IntEnum):
HTTP = 0 # HTTP API -> WebIf
DAT = 1 # epg.dat file
XML = 2 # XML TV
class Settings:
__INSTANCE = None
__VERSION = 2
@@ -271,11 +297,19 @@ class Settings:
self._cp_settings["host"] = value
@property
def port(self):
return self._cp_settings.get("port", self.get_default("port"))
def hosts(self):
return self._cp_settings.get("hosts", [self.host, ])
@hosts.setter
def hosts(self, value):
self._cp_settings["hosts"] = value
@property
def port(self) -> int:
return int(self._cp_settings.get("port", self.get_default("port")))
@port.setter
def port(self, value):
def port(self, value: int):
self._cp_settings["port"] = value
@property
@@ -295,19 +329,19 @@ class Settings:
self._cp_settings["password"] = value
@property
def http_port(self):
return self._cp_settings.get("http_port", self.get_default("http_port"))
def http_port(self) -> int:
return int(self._cp_settings.get("http_port", self.get_default("http_port")))
@http_port.setter
def http_port(self, value):
def http_port(self, value: int):
self._cp_settings["http_port"] = value
@property
def http_timeout(self):
def http_timeout(self) -> int:
return self._cp_settings.get("http_timeout", self.get_default("http_timeout"))
@http_timeout.setter
def http_timeout(self, value):
def http_timeout(self, value: int):
self._cp_settings["http_timeout"] = value
@property
@@ -319,11 +353,11 @@ class Settings:
self._cp_settings["http_use_ssl"] = value
@property
def telnet_port(self):
return self._cp_settings.get("telnet_port", self.get_default("telnet_port"))
def telnet_port(self) -> int:
return int(self._cp_settings.get("telnet_port", self.get_default("telnet_port")))
@telnet_port.setter
def telnet_port(self, value):
def telnet_port(self, value: int):
self._cp_settings["telnet_port"] = value
@property
@@ -358,6 +392,14 @@ class Settings:
def satellites_xml_path(self, value):
self._cp_settings["satellites_xml_path"] = value
@property
def epg_dat_path(self):
return self._cp_settings.get("epg_dat_path", self.get_default("epg_dat_path"))
@epg_dat_path.setter
def epg_dat_path(self, value):
self._cp_settings["epg_dat_path"] = value
@property
def picons_path(self):
return self._cp_settings.get("picons_path", self.get_default("picons_path"))
@@ -369,9 +411,9 @@ class Settings:
@property
def picons_paths(self):
if self.setting_type is SettingsType.NEUTRINO_MP:
return self._settings.get("neutrino_picon_paths", Defaults.NEUTRINO_BOX_PICON_PATHS.value)
return self._settings.get("neutrino_picon_paths", Defaults.NEUTRINO_BOX_PICON_PATHS)
else:
return self._settings.get("picon_paths", Defaults.BOX_PICON_PATHS.value)
return self._settings.get("picon_paths", Defaults.BOX_PICON_PATHS)
@picons_paths.setter
def picons_paths(self, value):
@@ -384,39 +426,47 @@ class Settings:
@property
def profile_folder_is_default(self):
return self._settings.get("profile_folder_is_default", Defaults.PROFILE_FOLDER_DEFAULT.value)
return self._settings.get("profile_folder_is_default", Defaults.PROFILE_FOLDER_DEFAULT)
@profile_folder_is_default.setter
def profile_folder_is_default(self, value):
self._settings["profile_folder_is_default"] = value
@property
def use_common_picon_path(self):
return self._settings.get("use_common_picon_path", False)
@use_common_picon_path.setter
def use_common_picon_path(self, value):
self._settings["use_common_picon_path"] = value
@property
def default_data_path(self):
return self._settings.get("default_data_path", DATA_PATH)
@default_data_path.setter
def default_data_path(self, value):
self._settings["default_data_path"] = value
self._settings["default_data_path"] = Settings.normalize_path(value)
@property
def default_backup_path(self):
return self._settings.get("default_backup_path", Defaults.BACKUP_PATH.value)
return self._settings.get("default_backup_path", Defaults.BACKUP_PATH)
@default_backup_path.setter
def default_backup_path(self, value):
self._settings["default_backup_path"] = value
self._settings["default_backup_path"] = Settings.normalize_path(value)
@property
def default_picon_path(self):
return self._settings.get("default_picon_path", Defaults.PICON_PATH.value)
return self._settings.get("default_picon_path", Defaults.PICON_PATH)
@default_picon_path.setter
def default_picon_path(self, value):
self._settings["default_picon_path"] = value
self._settings["default_picon_path"] = Settings.normalize_path(value)
@property
def profile_data_path(self):
return "{}data{}{}{}".format(self.default_data_path, SEP, self._current_profile, SEP)
return f"{self.default_data_path}data{SEP}{self._current_profile}{SEP}"
@profile_data_path.setter
def profile_data_path(self, value):
@@ -424,9 +474,12 @@ class Settings:
@property
def profile_picons_path(self):
if self.use_common_picon_path:
return self.default_picon_path
if self.profile_folder_is_default:
return "{}picons{}".format(self.profile_data_path, SEP)
return "{}{}{}".format(self.default_picon_path, self._current_profile, SEP)
return f"{self.profile_data_path}picons{SEP}"
return f"{self.default_picon_path}{self._current_profile}{SEP}"
@profile_picons_path.setter
def profile_picons_path(self, value):
@@ -435,26 +488,26 @@ class Settings:
@property
def profile_backup_path(self):
if self.profile_folder_is_default:
return "{}backup{}".format(self.profile_data_path, SEP)
return "{}{}{}".format(self.default_backup_path, self._current_profile, SEP)
return f"{self.profile_data_path}backup{SEP}"
return f"{self.default_backup_path}{self._current_profile}{SEP}"
@profile_backup_path.setter
def profile_backup_path(self, value):
self._cp_settings["profile_backup_path"] = value
@property
def records_path(self):
return self._settings.get("records_path", Defaults.RECORDS_PATH.value)
def recordings_path(self):
return self._settings.get("recordings_path", Defaults.RECORDINGS_PATH)
@records_path.setter
def records_path(self, value):
self._settings["records_path"] = value
@recordings_path.setter
def recordings_path(self, value):
self._settings["recordings_path"] = Settings.normalize_path(value)
# ******** Streaming ********* #
@property
def activate_transcoding(self):
return self._settings.get("activate_transcoding", Defaults.ACTIVATE_TRANSCODING.value)
return self._settings.get("activate_transcoding", Defaults.ACTIVATE_TRANSCODING)
@activate_transcoding.setter
def activate_transcoding(self, value):
@@ -462,7 +515,7 @@ class Settings:
@property
def active_preset(self):
return self._settings.get("active_preset", Defaults.ACTIVE_TRANSCODING_PRESET.value)
return self._settings.get("active_preset", Defaults.ACTIVE_TRANSCODING_PRESET)
@active_preset.setter
def active_preset(self, value):
@@ -478,7 +531,7 @@ class Settings:
@property
def play_streams_mode(self):
return PlayStreamsMode(self._settings.get("play_streams_mode", Defaults.PLAY_STREAMS_MODE.value))
return PlayStreamsMode(self._settings.get("play_streams_mode", Defaults.PLAY_STREAMS_MODE))
@play_streams_mode.setter
def play_streams_mode(self, value):
@@ -486,12 +539,28 @@ class Settings:
@property
def stream_lib(self):
return self._settings.get("stream_lib", Defaults.STREAM_LIB.value)
return self._settings.get("stream_lib", Defaults.STREAM_LIB)
@stream_lib.setter
def stream_lib(self, value):
self._settings["stream_lib"] = value
@property
def fav_click_mode(self):
return self._settings.get("fav_click_mode", Defaults.FAV_CLICK_MODE)
@fav_click_mode.setter
def fav_click_mode(self, value):
self._settings["fav_click_mode"] = value
@property
def main_list_playback(self):
return self._settings.get("main_list_playback", Defaults.MAIN_LIST_PLAYBACK)
@main_list_playback.setter
def main_list_playback(self, value):
self._settings["main_list_playback"] = value
# *********** EPG ************ #
@property
@@ -503,11 +572,62 @@ class Settings:
def epg_options(self, value):
self._cp_settings["epg_options"] = value
@property
def epg_source(self):
return EpgSource(self._cp_settings.get("epg_source", EpgSource.HTTP))
@epg_source.setter
def epg_source(self, value):
self._cp_settings["epg_source"] = value
@property
def epg_update_interval(self):
return self._cp_settings.get("epg_update_interval", 5)
@epg_update_interval.setter
def epg_update_interval(self, value):
self._cp_settings["epg_update_interval"] = value
@property
def epg_xml_source(self):
return self._cp_settings.get("epg_xml_source", "")
@epg_xml_source.setter
def epg_xml_source(self, value):
self._cp_settings["epg_xml_source"] = value
@property
def epg_xml_sources(self):
return self._cp_settings.get("epg_xml_sources", [self.epg_xml_source])
@epg_xml_sources.setter
def epg_xml_sources(self, value):
self._cp_settings["epg_xml_sources"] = value
@property
def enable_epg_name_cache(self):
""" Enables additional name cache for EPG. """
return self._settings.get("enable_epg_name_cache", False)
@enable_epg_name_cache.setter
def enable_epg_name_cache(self, value):
self._settings["enable_epg_name_cache"] = value
# *********** FTP ************ #
@property
def ftp_bookmarks(self):
return self._cp_settings.get("ftp_bookmarks", [])
@ftp_bookmarks.setter
def ftp_bookmarks(self, value):
self._cp_settings["ftp_bookmarks"] = value
# ***** Program settings ***** #
@property
def backup_before_save(self):
return self._settings.get("backup_before_save", Defaults.BACKUP_BEFORE_SAVE.value)
return self._settings.get("backup_before_save", Defaults.BACKUP_BEFORE_SAVE)
@backup_before_save.setter
def backup_before_save(self, value):
@@ -515,7 +635,7 @@ class Settings:
@property
def backup_before_downloading(self):
return self._settings.get("backup_before_downloading", Defaults.BACKUP_BEFORE_DOWNLOADING.value)
return self._settings.get("backup_before_downloading", Defaults.BACKUP_BEFORE_DOWNLOADING)
@backup_before_downloading.setter
def backup_before_downloading(self, value):
@@ -523,15 +643,31 @@ class Settings:
@property
def v5_support(self):
return self._settings.get("v5_support", Defaults.V5_SUPPORT.value)
return self._settings.get("v5_support", Defaults.V5_SUPPORT)
@v5_support.setter
def v5_support(self, value):
self._settings["v5_support"] = value
@property
def unlimited_copy_buffer(self):
return self._settings.get("unlimited_copy_buffer", Defaults.UNLIMITED_COPY_BUFFER)
@unlimited_copy_buffer.setter
def unlimited_copy_buffer(self, value):
self._settings["unlimited_copy_buffer"] = value
@property
def extensions_support(self):
return self._settings.get("extensions_support", Defaults.EXTENSIONS_SUPPORT)
@extensions_support.setter
def extensions_support(self, value):
self._settings["extensions_support"] = value
@property
def force_bq_names(self):
return self._settings.get("force_bq_names", Defaults.FORCE_BQ_NAMES.value)
return self._settings.get("force_bq_names", Defaults.FORCE_BQ_NAMES)
@force_bq_names.setter
def force_bq_names(self, value):
@@ -539,7 +675,7 @@ class Settings:
@property
def http_api_support(self):
return self._settings.get("http_api_support", Defaults.HTTP_API_SUPPORT.value)
return self._settings.get("http_api_support", Defaults.HTTP_API_SUPPORT)
@http_api_support.setter
def http_api_support(self, value):
@@ -547,7 +683,7 @@ class Settings:
@property
def enable_yt_dl(self):
return self._settings.get("enable_yt_dl", Defaults.ENABLE_YT_DL.value)
return self._settings.get("enable_yt_dl", Defaults.ENABLE_YT_DL)
@enable_yt_dl.setter
def enable_yt_dl(self, value):
@@ -555,7 +691,7 @@ class Settings:
@property
def enable_yt_dl_update(self):
return self._settings.get("enable_yt_dl_update", Defaults.ENABLE_YT_DL.value)
return self._settings.get("enable_yt_dl_update", Defaults.ENABLE_YT_DL)
@enable_yt_dl_update.setter
def enable_yt_dl_update(self, value):
@@ -563,20 +699,12 @@ class Settings:
@property
def enable_send_to(self):
return self._settings.get("enable_send_to", Defaults.ENABLE_SEND_TO.value)
return self._settings.get("enable_send_to", Defaults.ENABLE_SEND_TO)
@enable_send_to.setter
def enable_send_to(self, value):
self._settings["enable_send_to"] = value
@property
def fav_click_mode(self):
return self._settings.get("fav_click_mode", Defaults.FAV_CLICK_MODE.value)
@fav_click_mode.setter
def fav_click_mode(self, value):
self._settings["fav_click_mode"] = value
@property
def language(self):
return self._settings.get("language", locale.getlocale()[0] or "en_US")
@@ -613,6 +741,14 @@ class Settings:
# *********** Appearance *********** #
@property
def use_header_bar(self):
return self._settings.get("use_header_bar", USE_HEADER_BAR)
@use_header_bar.setter
def use_header_bar(self, value):
self._settings["use_header_bar"] = value
@property
def list_font(self):
return self._settings.get("list_font", "")
@@ -623,7 +759,7 @@ class Settings:
@property
def list_picon_size(self):
return self._settings.get("list_picon_size", Defaults.LIST_PICON_SIZE.value)
return self._settings.get("list_picon_size", Defaults.LIST_PICON_SIZE)
@list_picon_size.setter
def list_picon_size(self, value):
@@ -631,7 +767,7 @@ class Settings:
@property
def tooltip_logo_size(self):
return self._settings.get("tooltip_logo_size", Defaults.TOOLTIP_LOGO_SIZE.value)
return self._settings.get("tooltip_logo_size", Defaults.TOOLTIP_LOGO_SIZE)
@tooltip_logo_size.setter
def tooltip_logo_size(self, value):
@@ -639,7 +775,7 @@ class Settings:
@property
def use_colors(self):
return self._settings.get("use_colors", Defaults.USE_COLORS.value)
return self._settings.get("use_colors", Defaults.USE_COLORS)
@use_colors.setter
def use_colors(self, value):
@@ -647,7 +783,7 @@ class Settings:
@property
def new_color(self):
return self._settings.get("new_color", Defaults.NEW_COLOR.value)
return self._settings.get("new_color", Defaults.NEW_COLOR)
@new_color.setter
def new_color(self, value):
@@ -655,7 +791,7 @@ class Settings:
@property
def extra_color(self):
return self._settings.get("extra_color", Defaults.EXTRA_COLOR.value)
return self._settings.get("extra_color", Defaults.EXTRA_COLOR)
@extra_color.setter
def extra_color(self, value):
@@ -676,6 +812,22 @@ class Settings:
def dark_mode(self, value):
self._settings["dark_mode"] = value
@property
def display_picons(self):
return self._settings.get("display_picons", True)
@display_picons.setter
def display_picons(self, value):
self._settings["display_picons"] = value
@property
def display_epg(self):
return self._settings.get("display_epg", False)
@display_epg.setter
def display_epg(self, value):
self._settings["display_epg"] = value
@property
def alternate_layout(self):
return self._settings.get("alternate_layout", IS_DARWIN)
@@ -711,7 +863,7 @@ class Settings:
@property
@lru_cache(1)
def themes_path(self):
return "{}{}.themes{}".format(HOME_PATH, SEP, SEP)
return f"{HOME_PATH}{SEP}.themes{SEP}"
@property
def icon_theme(self):
@@ -724,13 +876,13 @@ class Settings:
@property
@lru_cache(1)
def icon_themes_path(self):
return "{}{}.icons{}".format(HOME_PATH, SEP, SEP)
return f"{HOME_PATH}{SEP}.icons{SEP}"
@property
def is_darwin(self):
return IS_DARWIN
# *********** Download dialog *********** #
# ************* Download ************** #
@property
def use_http(self):
@@ -748,6 +900,22 @@ class Settings:
def remove_unused_bouquets(self, value):
self._settings["remove_unused_bouquets"] = value
@property
def keep_power_mode(self):
return self._settings.get("keep_power_mode", False)
@keep_power_mode.setter
def keep_power_mode(self, value):
self._settings["keep_power_mode"] = value
@property
def compress_picons(self):
return self._settings.get("compress_picons", False)
@compress_picons.setter
def compress_picons(self, value):
self._settings["compress_picons"] = value
# **************** Debug **************** #
@property
@@ -772,12 +940,16 @@ class Settings:
# **************** Get-Set settings **************** #
@staticmethod
def get_settings():
if not os.path.isfile(CONFIG_FILE) or os.stat(CONFIG_FILE).st_size == 0:
Settings.write_settings(Settings.get_default_settings())
def get_settings(config_file=CONFIG_FILE, default_settings=None):
if not os.path.isfile(config_file) or os.stat(config_file).st_size == 0:
df = Settings.get_default_settings() if default_settings is None else default_settings
Settings.write_settings(df, config_file=config_file)
with open(CONFIG_FILE, "r", encoding="utf-8") as config_file:
return json.load(config_file)
with open(config_file, "r", encoding="utf-8") as cf:
try:
return json.load(cf)
except ValueError as e:
raise SettingsReadException(e)
@staticmethod
def get_default_settings(profile_name="default"):
@@ -785,18 +957,18 @@ class Settings:
return {
"version": Settings.__VERSION,
"default_profile": Defaults.DEFAULT_PROFILE.value,
"default_profile": Defaults.DEFAULT_PROFILE,
"profiles": {profile_name: def_settings},
"v5_support": Defaults.V5_SUPPORT.value,
"http_api_support": Defaults.HTTP_API_SUPPORT.value,
"enable_yt_dl": Defaults.ENABLE_YT_DL.value,
"enable_send_to": Defaults.ENABLE_SEND_TO.value,
"use_colors": Defaults.USE_COLORS.value,
"new_color": Defaults.NEW_COLOR.value,
"extra_color": Defaults.EXTRA_COLOR.value,
"fav_click_mode": Defaults.FAV_CLICK_MODE.value,
"profile_folder_is_default": Defaults.PROFILE_FOLDER_DEFAULT.value,
"records_path": Defaults.RECORDS_PATH.value
"v5_support": Defaults.V5_SUPPORT,
"http_api_support": Defaults.HTTP_API_SUPPORT,
"enable_yt_dl": Defaults.ENABLE_YT_DL,
"enable_send_to": Defaults.ENABLE_SEND_TO,
"use_colors": Defaults.USE_COLORS,
"new_color": Defaults.NEW_COLOR,
"extra_color": Defaults.EXTRA_COLOR,
"fav_click_mode": Defaults.FAV_CLICK_MODE,
"profile_folder_is_default": Defaults.PROFILE_FOLDER_DEFAULT,
"records_path": Defaults.RECORDINGS_PATH
}
@staticmethod
@@ -807,10 +979,14 @@ class Settings:
"ab": "192", "channels": "2", "samplerate": "44100", "scodec": "none"}}
@staticmethod
def write_settings(config):
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
with open(CONFIG_FILE, "w", encoding="utf-8") as config_file:
json.dump(config, config_file, indent=" ")
def write_settings(config, config_path=CONFIG_PATH, config_file=CONFIG_FILE):
os.makedirs(os.path.dirname(config_path), exist_ok=True)
with open(config_file, "w", encoding="utf-8") as cf:
json.dump(config, cf, indent=" ")
@staticmethod
def normalize_path(path):
return f"{os.path.normpath(path)}{SEP}"
if __name__ == "__main__":

View File

@@ -1,45 +1,413 @@
""" Module for working with epg.dat file """
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
""" Module for working with epg.dat file. """
import abc
import os
import re
import shutil
import struct
from datetime import datetime
import xml.etree.ElementTree as ET
from collections import namedtuple, defaultdict
from datetime import datetime, timezone
from tempfile import NamedTemporaryFile
from urllib.parse import urlparse
from xml.dom.minidom import parse, Node, Document
import requests
from app.commons import log
from app.eparser.ecommons import BqServiceType, BouquetService
from app.settings import IS_WIN
ENCODING = "utf-8"
DETECT_ENCODING = False
try:
import chardet
except ModuleNotFoundError:
pass
else:
DETECT_ENCODING = True
EpgEvent = namedtuple("EpgEvent", ["service_name", "title", "start", "end", "length", "desc", "event_data"])
EpgEvent.__new__.__defaults__ = ("N/A", "N/A", 0, 0, 0, "N/A", None) # For Python3 < 3.7
class Reader(metaclass=abc.ABCMeta):
@property
@abc.abstractmethod
def cache(self) -> dict: pass
@abc.abstractmethod
def download(self, clb=None): pass
@abc.abstractmethod
def get_current_events(self, ids: set) -> dict: pass
class EPG:
""" Base EPG class. """
# DVB/EPG count days with a 'modified Julian calendar' where day 1 is 17 November 1858.
# datetime.datetime.toordinal(1858,11,17) => 678576
ZERO_DAY = 678576
@staticmethod
def get_epg_refs(path):
""" The read algorithm was taken from the eEPGCache::load() function from this source:
Event = namedtuple("EpgEvent", ["id", "data", "start", "duration", "title", "desc", "ext_desc"])
class EventData:
""" Event data representation class. """
__slots__ = ["raw_data", "crc", "size", "type"]
def __init__(self, size=0, e_type=0):
self.raw_data = None
self.crc = None
self.size = size
self.type = e_type
def get_event_id(self):
return self.raw_data[0] << 8 | self.raw_data[1]
def get_start_time(self):
""" Returns start time [sec.]. """
# Date
start_date = datetime.fromordinal((self.raw_data[2] << 8 | self.raw_data[3]) + EPG.ZERO_DAY).timestamp()
# Time
tm_hour = EPG.get_from_bcd(self.raw_data[4])
tm_min = EPG.get_from_bcd(self.raw_data[5])
tm_sec = EPG.get_from_bcd(self.raw_data[6])
# UTC.
s_time = start_date + tm_hour * 3600 + tm_min * 60 + tm_sec
# Time zone correction.
s_time += datetime.now(timezone.utc).astimezone().utcoffset().seconds
return s_time
def get_duration(self):
""" Returns duration [sec.]."""
return EPG.get_from_bcd(self.raw_data[7]) * 3600 + EPG.get_from_bcd(
self.raw_data[8]) * 60 + EPG.get_from_bcd(self.raw_data[9])
class DatReader(Reader):
""" The epd.dat file reading class.
The read algorithm was taken from the eEPGCache::load() function from this source:
https://github.com/OpenPLi/enigma2/blob/44d9b92f5260c7de1b3b3a1b9a9cbe0f70ca4bf0/lib/dvb/epgcache.cpp#L1300
"""
refs = set()
with open(path, mode="rb") as f:
crc = struct.unpack("<I", f.read(4))[0]
if crc != int(0x98765432):
raise ValueError("Epg file has incorrect byte order!")
def __init__(self, path):
self._path = path
self._refs = {}
self._desc = {}
header = f.read(13).decode()
if header != "ENIGMA_EPG_V7":
raise ValueError("Unsupported format of epd.dat file!")
@property
def cache(self) -> dict:
return self._refs
channels_count = struct.unpack("<I", f.read(4))[0]
def download(self, clb=None):
pass
for i in range(channels_count):
sid, nid, tsid, events_size = struct.unpack("<IIII", f.read(16))
service_id = "{:X}:{:X}:{:X}".format(sid, tsid, nid)
def get_current_events(self, ids: set) -> dict:
pass
for j in range(events_size):
_type, _len = struct.unpack("<BB", f.read(2))
f.read(10)
n_crc = (_len - 10) // 4
if n_crc > 0:
[f.read(4) for n in range(n_crc)]
def get_refs(self):
return self._refs.keys()
refs.add(service_id)
def get_services(self):
return self._refs
return refs
def get_event(self, evd):
title, desc, ext_desc = None, None, None
e_id, start, duration = evd.get_event_id(), evd.get_start_time(), evd.get_duration()
for c in evd.crc:
data = self._desc.get(c, None)
if not data:
continue
encoding = ENCODING
if DETECT_ENCODING:
# May be slow.
encoding = chardet.detect(data).get("encoding", "utf-8") or encoding
desc_type = data[0]
if desc_type == 77: # Short event descriptor -> 0x4d -> 77
size = data[6]
txt = data[7:-1].decode(encoding, errors="ignore")
t_len = len(txt)
st = 0
if size and size < t_len:
st = abs(size - t_len)
if size < 32:
title = txt
else:
desc = txt[st:]
elif desc_type == 78: # Extended event descriptor -> 0x4e -> 78
ext_desc = data[9:].decode(encoding, errors="ignore") if data[7] and data[8] < 32 else None
return EPG.Event(e_id, evd, start, duration, title, desc, ext_desc)
def get_events(self, ref):
return self._refs.get(ref, {})
def read(self):
with open(self._path, mode="rb") as f:
crc = struct.unpack("I", f.read(4))[0]
if crc != int(0x98765432):
raise ValueError("Epg file has incorrect byte order!")
header = f.read(13).decode()
if header == "ENIGMA_EPG_V7":
epg_ver = 7
elif header == "ENIGMA_EPG_V8":
epg_ver = 8
else:
raise ValueError("Unsupported format of epd.dat file!")
channels_count = struct.unpack("I", f.read(4))[0]
_len_read_size = 3 if epg_ver == 8 else 2
_type_read_str = f"{'H' if epg_ver == 8 else 'B'}B"
for i in range(channels_count):
sid, nid, tsid, events_size = struct.unpack("IIII", f.read(16))
service_id = f"{sid:X}:{tsid:X}:{nid:X}"
events = {}
for j in range(events_size):
_type, _len = struct.unpack(_type_read_str, f.read(_len_read_size))
event = EPG.EventData(size=_len, e_type=_type)
event.raw_data = f.read(10)
n_crc = (_len - 10) // 4
if n_crc > 0:
event.crc = [struct.unpack("I", f.read(4))[0] for n in range(n_crc)]
events[event.get_event_id()] = event
self._refs[service_id] = events
for i in range(struct.unpack("I", f.read(4))[0]):
_id, ref_count = struct.unpack("II", f.read(8))
header = struct.unpack("BB", f.read(2))
_bytes = header[1] + 2
f.seek(-2, os.SEEK_CUR)
self._desc[_id] = f.read(_bytes)
@staticmethod
def get_from_bcd(value: int):
""" Converts a BCD to an integer. """
if ((value & 0xF0) >= 0xA0) or ((value & 0xF) >= 0xA):
return -1
return ((value & 0xF0) >> 4) * 10 + (value & 0xF)
class XmlTvReader(Reader):
PR_TAG = "programme"
CH_TAG = "channel"
DSP_NAME_TAG = "display-name"
ICON_TAG = "icon"
TITLE_TAG = "title"
DESC_TAG = "desc"
TIME_FORMAT_STR = "%Y%m%d%H%M%S %z"
SUFFIXES = {".gz", ".xz", ".lzma", ".xml"}
Service = namedtuple("Service", ["id", "names", "logo", "events"])
Event = namedtuple("EpgEvent", ["start", "duration", "title", "desc"])
def __init__(self, path, url=None):
self._path = path
self._url = url
self._cache = {}
@property
def cache(self) -> dict:
return self._cache
def download(self, clb=None):
""" Downloads an XMLTV file. """
res = urlparse(self._url)
if not all((res.scheme, res.netloc)):
log(f"{self.__class__.__name__} [download] error: Invalid URL {self._url}")
return
try:
with requests.get(url=self._url, stream=True, timeout=(5, 5)) as resp:
if resp.reason == "OK":
suf = self._url[self._url.rfind("."):]
if suf not in self.SUFFIXES:
log(f"{self.__class__.__name__} [download] error: Unsupported file extension.")
return
data_size = resp.headers.get("content-length")
if not data_size:
log(f"{self.__class__.__name__} [download *.{suf}] error: Error getting data size.")
return
with NamedTemporaryFile(suffix=suf, delete=not IS_WIN) as tf:
downloaded = 0
data_size = int(data_size)
completed = set()
for data in resp.iter_content(chunk_size=128):
downloaded += len(data)
tf.write(data)
done = int(100 * downloaded / data_size)
if done % 25 == 0 and done not in completed:
completed.add(done)
log(f"Downloading XMLTV file...{done}%" if done < 100 else "XMLTV file download complete.")
tf.seek(0)
os.makedirs(os.path.dirname(self._path), exist_ok=True)
if suf.endswith(".gz"):
try:
shutil.copyfile(tf.name, self._path)
except OSError as e:
log(f"{self.__class__.__name__} [download *.gz] error: {e}")
elif self._url.endswith((".xz", ".lzma")):
import lzma
try:
with lzma.open(tf, "rb") as lzf:
shutil.copyfileobj(lzf, self._path)
except (lzma.LZMAError, OSError) as e:
log(f"{self.__class__.__name__} [download *.xz] error: {e}")
else:
try:
import gzip
with gzip.open(self._path, "wb") as f_out:
shutil.copyfileobj(tf, f_out)
except OSError as e:
log(f"{self.__class__.__name__} [download *.xml] error: {e}")
if IS_WIN and os.path.isfile(tf.name):
tf.close()
os.remove(tf.name)
else:
log(f"{self.__class__.__name__} [download] error: {resp.reason}")
except requests.exceptions.RequestException as e:
log(f"{self.__class__.__name__} [download] error: {e}")
return
if clb:
clb()
def get_current_events(self, names: set) -> dict:
events = defaultdict(list)
dt = datetime.utcnow()
utc = dt.timestamp()
offset = datetime.now() - dt
for srv in filter(lambda s: s.id in names or any(name in names for name in s.names), self._cache.values()):
[self.process_event(ev, events, offset, srv) for ev in filter(lambda s: s.duration > utc, srv.events)]
return events
@staticmethod
def process_event(ev, events, offset, srv):
start = datetime.fromtimestamp(ev.start) + offset
end_time = datetime.fromtimestamp(ev.duration) + offset
start = start.timestamp()
end_time = end_time.timestamp()
duration = end_time - start
for n in srv.names:
data = {"e2eventservicename": n,
"e2eventtitle": ev.title,
"e2eventdescription": ev.desc,
"e2eventstart": start,
"e2eventduration": duration}
events[n].append(EpgEvent(n, ev.title, start, end_time, duration, ev.desc, data))
def parse(self):
""" Parses XML. """
try:
log("Processing XMLTV data...")
suf = os.path.splitext(self._path)[1]
if suf == ".gz":
import gzip
with gzip.open(self._path, "rb") as gzf:
list(map(self.process_node, ET.iterparse(gzf)))
elif suf == ".xml":
with open(self._path, "rb") as xml:
list(map(self.process_node, ET.iterparse(xml)))
else:
log(f"{self.__class__.__name__} [parse] error: Unsupported file type [{suf}].")
except OSError as e:
log(f"{self.__class__.__name__} [parse] error: {e}")
else:
log("XMLTV data parsing is complete.")
def process_node(self, node):
event, element = node
if element.tag == self.CH_TAG:
ch_id = element.get("id", None)
logo = None # Currently not in use.
# Since a service can have several names, we will store a set of names in the "names" field!
self._cache[ch_id] = self.Service(ch_id, {c.text for c in element if c.tag == self.DSP_NAME_TAG}, logo, [])
elif element.tag == self.PR_TAG:
channel = self._cache.get(element.get(self.CH_TAG, None), None)
if channel:
events = channel[-1]
start = element.get("start", None)
if start:
start = self.get_utc_time(start)
stop = element.get("stop", None)
if stop:
stop = self.get_utc_time(stop)
title, desc = None, None
for c in element:
if c.tag == self.TITLE_TAG:
title = c.text
elif c.tag == self.DESC_TAG:
desc = c.text
if all((start, stop, title)):
events.append(self.Event(start, stop, title, desc))
def to_epg_dat(self):
""" Converts and saves imported data to 'epg.dat' file. """
raise ValueError("Not implemented yet!")
@staticmethod
def get_utc_time(time_str):
""" Returns the UTC time in seconds. """
t, sep, delta = time_str.partition(" ")
t = datetime(*map(int, (t[:4], t[4:6], t[6:8], t[8:10], t[10:12], t[12:]))).timestamp()
if delta:
t -= (3600 * int(delta) // 100)
return t
class ChannelsParser:
@@ -51,32 +419,39 @@ class ChannelsParser:
refs = []
dom = parse(path)
description = "".join(n.data + "\n" for n in dom.childNodes if n.nodeType == Node.COMMENT_NODE)
pos_pat = re.compile(r"^\d+\.\d+[EW]$")
for elem in dom.getElementsByTagName("channels"):
c_count = 0
comment_count = 0
current_data = ""
data = ""
ch_id = None
pos = None
ch_type = BqServiceType.DEFAULT
if elem.hasChildNodes():
for n in elem.childNodes:
if n.nodeType == Node.ELEMENT_NODE:
ch_id = n.getAttribute("id")
if n.nodeType == Node.COMMENT_NODE:
c_count += 1
comment_count += 1
txt = n.data.strip()
if re.match(pos_pat, txt):
pos = txt
if comment_count:
comment_count -= 1
else:
ref_data = current_data.split(":")
refs.append(BouquetService(name=txt,
type=BqServiceType.DEFAULT,
data="{}:{}:{}:{}".format(*ref_data[3:7]).upper(),
num="{}:{}:{}".format(*ref_data[3:6]).upper()))
refs.append(BouquetService(name=txt, type=ch_type, data=data.upper(), num=(pos, ch_id)))
if n.hasChildNodes():
for s_node in n.childNodes:
if s_node.nodeType == Node.TEXT_NODE:
comment_count -= 1
current_data = s_node.data
data = s_node.data
return refs, description
@staticmethod
@@ -90,14 +465,14 @@ class ChannelsParser:
srv_type = srv.type
if srv_type is BqServiceType.IPTV:
channel_child = doc.createElement("channel")
channel_child.setAttribute("id", str(srv.num))
channel_child.setAttribute("id", srv.name)
data = srv.data.strip().split(":")
channel_child.appendChild(doc.createTextNode(":".join(data[:10])))
comment = doc.createComment(srv.name)
lines.append("{} {}\n".format(str(channel_child.toxml()), str(comment.toxml())))
lines.append(f"{channel_child.toxml()} {comment.toxml()}\n")
elif srv_type is BqServiceType.MARKER:
comment = doc.createComment(srv.name)
lines.append("{}\n".format(str(comment.toxml())))
lines.append(f"{comment.toxml()}\n")
lines.append("</channels>")
doc.unlink()

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2023 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -30,16 +30,20 @@ import os
import sys
from datetime import datetime
from gi.repository import Gdk, Gtk, GObject
from gi.repository import GObject
from app.commons import run_task, log, _DATE_FORMAT, run_with_delay
from app.commons import run_task, log, LOG_DATE_FORMAT
from app.settings import IS_DARWIN, IS_LINUX, IS_WIN
class Player(Gtk.DrawingArea):
class Player(GObject.GObject):
""" Base player class. Also used as a factory. """
def __init__(self, mode, widget, **kwargs):
super().__init__(**kwargs)
self._mode = mode
self._is_playing = False
self._handle = self.get_window_handle(widget.playback_widget)
GObject.signal_new("error", self, GObject.SIGNAL_RUN_LAST,
GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,))
@@ -54,15 +58,9 @@ class Player(Gtk.DrawingArea):
GObject.signal_new("subtitle-track", self, GObject.SIGNAL_RUN_LAST,
GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,))
self.connect("draw", self.on_draw)
self.connect("motion-notify-event", self.on_mouse_motion)
self.set_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
widget.add(self)
parent = widget.get_parent()
parent.connect("play", self.on_play)
parent.connect("stop", self.on_stop)
self.show()
widget.connect("play", self.on_play)
widget.connect("stop", self.on_stop)
widget.connect("pause", self.on_pause)
def get_play_mode(self):
pass
@@ -106,54 +104,38 @@ class Player(Gtk.DrawingArea):
def on_stop(self, widget, state):
self.stop()
def on_pause(self, widget, state):
self.pause()
def on_release(self, widget, state):
self.release()
def get_window_handle(self):
def get_window_handle(self, widget):
""" Returns the identifier [pointer] for the window.
Based on gtkvlc.py[get_window_pointer] example from here:
https://github.com/oaubert/python-vlc/tree/master/examples
"""
if sys.platform == "linux":
return self.get_window().get_xid()
if IS_LINUX:
return widget.get_window().get_xid()
else:
is_darwin = sys.platform == "darwin"
try:
import ctypes
libgdk = ctypes.CDLL("libgdk-3.0.dylib" if is_darwin else "libgdk-3-0.dll")
libgdk = ctypes.CDLL("libgdk-3.0.dylib" if IS_DARWIN else "libgdk-3-0.dll")
except OSError as e:
log("{}: Load library error: {}".format(__class__.__name__, e))
log(f"{__class__.__name__}: Load library error: {e}")
else:
# https://gitlab.gnome.org/GNOME/pygobject/-/issues/112
ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
ctypes.pythonapi.PyCapsule_GetPointer.argtypes = [ctypes.py_object]
gpointer = ctypes.pythonapi.PyCapsule_GetPointer(self.get_window().__gpointer__, None)
get_pointer = libgdk.gdk_quartz_window_get_nsview if is_darwin else libgdk.gdk_win32_window_get_handle
gpointer = ctypes.pythonapi.PyCapsule_GetPointer(widget.get_window().__gpointer__, None)
get_pointer = libgdk.gdk_quartz_window_get_nsview if IS_DARWIN else libgdk.gdk_win32_window_get_handle
get_pointer.restype = ctypes.c_void_p
get_pointer.argtypes = [ctypes.c_void_p]
return get_pointer(gpointer)
def on_draw(self, widget, cr):
""" Used for black background drawing in the player drawing area. """
cr.set_source_rgb(0, 0, 0)
cr.paint()
def on_mouse_motion(self, widget, event):
display = widget.get_display()
window = widget.get_window()
cursor = Gdk.Cursor.new_from_name(display, "default")
window.set_cursor(cursor)
self.hide_mouse_cursor(window, display)
@run_with_delay(3)
def hide_mouse_cursor(self, window, display):
cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.BLANK_CURSOR)
window.set_cursor(cursor)
@staticmethod
def make(name, mode, widget):
""" Factory method. We will not use a separate factory to return a specific implementation.
@@ -171,7 +153,7 @@ class Player(Gtk.DrawingArea):
elif name == "vlc":
return VlcPlayer.get_instance(mode, widget)
else:
raise NameError("There is no such [{}] implementation.".format(name))
raise NameError(f"There is no such [{name}] implementation.")
class MpvPlayer(Player):
@@ -186,27 +168,35 @@ class MpvPlayer(Player):
try:
from app.tools import mpv
self._player = mpv.MPV(wid=str(self.get_window_handle()),
self._player = mpv.MPV(wid=str(self._handle),
input_default_bindings=False,
input_cursor=False,
cursor_autohide="no")
except OSError as e:
log("{}: Load library error: {}".format(__class__.__name__, e))
log(f"{__class__.__name__}: Load library error: {e}")
raise ImportError("No libmpv is found. Check that it is installed!")
else:
self._mode = mode
self._is_playing = False
@self._player.event_callback(mpv.MpvEventID.FILE_LOADED)
def on_open(event):
log("Starting playback...")
self.emit("played", 0)
t_list = self._player._get_property("track-list")
if t_list:
# Audio tracks.
a_tracks = filter(lambda t: t.get("type", "") == "audio", t_list)
self.emit("audio-track", ((t.get("id", 1), t.get("lang", "Unknown")) for t in a_tracks))
# Subtitle.
sub_tracks = [(0, "no")]
tracks = filter(lambda t: t.get("type", "") == "sub", t_list)
[sub_tracks.append((t.get("id", 1), t.get("lang", "Unknown"))) for t in tracks]
self.emit("subtitle-track", sub_tracks)
@self._player.event_callback(mpv.MpvEventID.END_FILE)
def on_end(event):
event = event.get("event", {})
if event.get("reason", mpv.MpvEventEndFile.ERROR) == mpv.MpvEventEndFile.ERROR:
log("Stream playback error: {}".format(event.get("error", mpv.ErrorCode.GENERIC)))
log(f"Stream playback error: {event.get('error', mpv.ErrorCode.GENERIC)}")
self.emit("error", "Can't Playback!")
@classmethod
@@ -226,23 +216,34 @@ class MpvPlayer(Player):
self._is_playing = True
def stop(self):
self._player.stop()
self._is_playing = True
if self._is_playing:
self._player.stop()
self._is_playing = False
def pause(self):
pass
self._player.pause = not self._player.pause
def set_time(self, time):
pass
@run_task
def release(self):
self._player.terminate()
self.__INSTANCE = None
if self._player:
self._player.terminate()
self.__INSTANCE = None
def is_playing(self):
return self._is_playing
def set_audio_track(self, track):
self._player._set_property("aid", track)
def set_subtitle_track(self, track):
self._player._set_property("sub", track)
def set_aspect_ratio(self, ratio):
self._player._set_property("aspect", ratio or "-1.0")
class GstPlayer(Player):
""" Simple wrapper for GStreamer playbin. """
@@ -260,16 +261,14 @@ class GstPlayer(Player):
# Initialization of GStreamer.
Gst.init(sys.argv)
except (OSError, ValueError) as e:
log("{}: Load library error: {}".format(__class__.__name__, e))
log(f"{__class__.__name__}: Load library error: {e}")
raise ImportError("No GStreamer is found. Check that it is installed!")
else:
self.STATE = Gst.State
self.STAT_RETURN = Gst.StateChangeReturn
self._mode = mode
self._is_playing = False
self._player = Gst.ElementFactory.make("playbin", "player")
self._player.set_window_handle(self.get_window_handle())
self._player.set_window_handle(self._handle)
bus = self._player.get_bus()
bus.add_signal_watch()
@@ -293,11 +292,11 @@ class GstPlayer(Player):
self._player.set_property("uri", mrl)
log("Setting the URL for playback: {}".format(mrl))
log(f"Setting the URL for playback: {mrl}")
ret = self._player.set_state(self.STATE.PLAYING)
if ret == self.STAT_RETURN.FAILURE:
msg = "ERROR: Unable to set the 'PLAYING' state for '{}'.".format(mrl)
msg = f"ERROR: Unable to set the 'PLAYING' state for '{mrl}'."
log(msg)
self.emit("error", msg)
else:
@@ -305,21 +304,27 @@ class GstPlayer(Player):
self._is_playing = True
def stop(self):
log("Stop playback...")
self._player.set_state(self.STATE.READY)
if self._is_playing:
log("Stop playback...")
self._player.set_state(self.STATE.READY)
self._is_playing = False
def pause(self):
self._player.set_state(self.STATE.PAUSED)
state = self._player.get_state(self.STATE.NULL).state
if state == self.STATE.PLAYING:
self._player.set_state(self.STATE.PAUSED)
elif state == self.STATE.PAUSED:
self._player.set_state(self.STATE.PLAYING)
def set_time(self, time):
pass
@run_task
def release(self):
self._is_playing = False
self._player.set_state(self.STATE.NULL)
self.__INSTANCE = None
if self._player:
self._is_playing = False
self._player.set_state(self.STATE.NULL)
self.__INSTANCE = None
def set_mrl(self, mrl):
self._player.set_property("uri", mrl)
@@ -356,7 +361,7 @@ class GstPlayer(Player):
tags = self._player.emit("get-video-tags", i)
if tags:
_, cod = tags.get_string("video-codec")
log("Video codec: {}".format(cod or "unknown"))
log(f"Video codec: {cod or 'unknown'}")
nr_audio = self._player.get_property("n-audio")
for i in range(nr_audio):
@@ -364,7 +369,7 @@ class GstPlayer(Player):
tags = self._player.emit("get-audio-tags", i)
if tags:
_, cod = tags.get_string("audio-codec")
log("Audio codec: {}".format(cod or "unknown"))
log(f"Audio codec: {cod or 'unknown'}")
class VlcPlayer(Player):
@@ -378,30 +383,27 @@ class VlcPlayer(Player):
def __init__(self, mode, widget):
super().__init__(mode, widget)
try:
if sys.platform == "win32":
if IS_WIN:
os.add_dll_directory(r"C:\Program Files\VideoLAN\VLC")
from app.tools import vlc
from app.tools.vlc import EventType
args = "--quiet {}".format("" if sys.platform == "darwin" else "--no-xlib")
args = f"--quiet {'--no-xlib' if IS_LINUX else ''}"
self._player = vlc.Instance(args).media_player_new()
vlc.libvlc_video_set_key_input(self._player, False)
vlc.libvlc_video_set_mouse_input(self._player, False)
except (OSError, AttributeError, NameError) as e:
log("{}: Load library error: {}".format(__class__.__name__, e))
log(f"{__class__.__name__}: Load library error: {e}")
raise ImportError("No VLC is found. Check that it is installed!")
else:
self._mode = mode
self._is_playing = False
ev_mgr = self._player.event_manager()
ev_mgr.event_attach(EventType.MediaPlayerVout, self.on_playback_start)
ev_mgr.event_attach(EventType.MediaPlayerTimeChanged,
lambda et: self.emit("position", self._player.get_time()))
ev_mgr.event_attach(EventType.MediaPlayerEncounteredError, lambda et: self.emit("error", "Can't Playback!"))
self.init_video_widget(widget)
self.init_video_widget()
@classmethod
def get_instance(cls, mode, widget):
@@ -421,7 +423,7 @@ class VlcPlayer(Player):
def stop(self):
if self._is_playing:
self._player.stop()
self._is_playing = False
self._is_playing = False
def pause(self):
self._player.pause()
@@ -457,20 +459,20 @@ class VlcPlayer(Player):
def on_playback_start(self, event):
self.emit("played", self._player.get_media().get_duration())
# Audio tracks
# Audio tracks.
a_desc = self._player.audio_get_track_description()
self.emit("audio-track", [(t[0], t[1].decode(encoding="utf-8", errors="ignore")) for t in a_desc])
# Subtitle
# Subtitle.
s_desc = self._player.video_get_spu_description()
self.emit("subtitle-track", [(s[0], s[1].decode(encoding="utf-8", errors="ignore")) for s in s_desc])
def init_video_widget(self, widget):
if sys.platform == "linux":
self._player.set_xwindow(self.get_window_handle())
elif sys.platform == "darwin":
self._player.set_nsobject(self.get_window_handle())
def init_video_widget(self):
if IS_LINUX:
self._player.set_xwindow(self._handle)
elif IS_DARWIN:
self._player.set_nsobject(self._handle)
else:
self._player.set_hwnd(self.get_window_handle())
self._player.set_hwnd(self._handle)
class Recorder:
@@ -481,15 +483,18 @@ class Recorder:
def __init__(self, settings):
try:
if IS_WIN:
os.add_dll_directory(r"C:\Program Files\VideoLAN\VLC")
from app.tools import vlc
from app.tools.vlc import EventType
except OSError as e:
log("{}: Load library error: {}".format(__class__.__name__, e))
log(f"{__class__.__name__}: Load library error: {e}")
raise ImportError
else:
self._settings = settings
self._is_record = False
args = "--quiet {}".format("" if sys.platform == "darwin" else "--no-xlib")
args = f"--quiet {'' if IS_DARWIN else '--no-xlib'}"
self._recorder = vlc.Instance(args).media_player_new()
@classmethod
@@ -503,10 +508,11 @@ class Recorder:
if self._recorder:
self._recorder.stop()
path = self._settings.records_path
path = self._settings.recordings_path
os.makedirs(os.path.dirname(path), exist_ok=True)
d_now = datetime.now().strftime(_DATE_FORMAT)
path = "{}{}_{}".format(path, name.replace(" ", "_"), d_now.replace(" ", "_"))
d_now = datetime.now().strftime(LOG_DATE_FORMAT)
d_now = d_now.replace(" ", "_").replace(":", "-") if IS_WIN else d_now.replace(" ", "_")
path = f"{path}{name.replace(' ', '_')}_{d_now}"
cmd = self.get_transcoding_cmd(path) if self._settings.activate_transcoding else self._CMD.format(path)
media = self._recorder.get_instance().media_new(url, cmd)
media.get_mrl()
@@ -514,7 +520,7 @@ class Recorder:
self._recorder.set_media(media)
self._is_record = True
self._recorder.play()
log("Record started {}".format(d_now))
log(f"Record started {d_now}")
@run_task
def stop(self):
@@ -536,7 +542,7 @@ class Recorder:
def get_transcoding_cmd(self, path):
presets = self._settings.transcoding_presets
prs = presets.get(self._settings.active_preset)
return self._TR_CMD.format(",".join("{}={}".format(k, v) for k, v in prs.items()), path)
return self._TR_CMD.format(",".join(f"{k}={v}" for k, v in prs.items()), path)
if __name__ == "__main__":

View File

@@ -16,25 +16,26 @@
# <http://www.gnu.org/licenses/>.
#
from ctypes import *
import ctypes.util
import threading
import os
import sys
from warnings import warn
from functools import partial, wraps
from contextlib import contextmanager
import collections
import ctypes.util
import os
import re
import sys
import threading
import traceback
from contextlib import contextmanager
from ctypes import *
from functools import partial, wraps
from warnings import warn
if os.name == 'nt':
dll = ctypes.util.find_library('mpv-1.dll')
dll = ctypes.util.find_library('libmpv-2.dll') or ctypes.util.find_library('mpv-1.dll')
if dll is None:
raise OSError('Cannot find mpv-1.dll in your system %PATH%. One way to deal with this is to ship mpv-1.dll '
'with your script and put the directory your script is in into %PATH% before "import mpv": '
'os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] '
'If mpv-1.dll is located elsewhere, you can add that path to os.environ["PATH"].')
raise OSError(
'Cannot find [lib]mpv-*.dll in your system %PATH%. One way to deal with this is to ship [lib]mpv-*.dll '
'with your script and put the directory your script is in into %PATH% before "import mpv": '
'os.environ["PATH"] = os.path.dirname(__file__) + os.pathsep + os.environ["PATH"] '
'If mpv-1.dll is located elsewhere, you can add that path to os.environ["PATH"].')
backend = CDLL(dll)
fs_enc = 'utf-8'
else:
@@ -569,10 +570,13 @@ _mpv_free_node_contents = backend.mpv_free_node_contents
backend.mpv_create.restype = MpvHandle
_mpv_create = backend.mpv_create
_API_VER = _mpv_client_api_version()[0]
_handle_func('mpv_destroy' if _API_VER > 1 else 'mpv_detach_destroy', [], None, errcheck=None)
_handle_func('mpv_create_client', [c_char_p], MpvHandle, notnull_errcheck)
_handle_func('mpv_client_name', [], c_char_p, errcheck=None)
_handle_func('mpv_initialize', [], c_int, ec_errcheck)
_handle_func('mpv_detach_destroy', [], None, errcheck=None)
_handle_func('mpv_terminate_destroy', [], None, errcheck=None)
_handle_func('mpv_load_config_file', [c_char_p], c_int, ec_errcheck)
_handle_func('mpv_get_time_us', [], c_ulonglong, errcheck=None)
@@ -608,28 +612,6 @@ _handle_func('mpv_get_wakeup_pipe', [], c_int, errcheck=None)
_handle_func('mpv_stream_cb_add_ro', [c_char_p, c_void_p, StreamOpenFn], c_int, ec_errcheck)
# Disabled for compatibility with the old version of mpv!!!
# _handle_func('mpv_render_context_create', [MpvRenderCtxHandle, MpvHandle, POINTER(MpvRenderParam)], c_int, ec_errcheck, ctx=None)
# _handle_func('mpv_render_context_set_parameter', [MpvRenderParam], c_int, ec_errcheck, ctx=MpvRenderCtxHandle)
# _handle_func('mpv_render_context_get_info', [MpvRenderParam], c_int, ec_errcheck, ctx=MpvRenderCtxHandle)
# _handle_func('mpv_render_context_set_update_callback', [RenderUpdateFn, c_void_p], None, errcheck=None, ctx=MpvRenderCtxHandle)
# _handle_func('mpv_render_context_update', [], c_int64, errcheck=None, ctx=MpvRenderCtxHandle)
# _handle_func('mpv_render_context_render', [POINTER(MpvRenderParam)], c_int, ec_errcheck, ctx=MpvRenderCtxHandle)
# _handle_func('mpv_render_context_report_swap', [], None, errcheck=None, ctx=MpvRenderCtxHandle)
# _handle_func('mpv_render_context_free', [], None, errcheck=None, ctx=MpvRenderCtxHandle)
# Deprecated in v0.29.0 and may disappear eventually
if hasattr(backend, 'mpv_get_sub_api'):
_handle_func('mpv_get_sub_api', [MpvSubApi], c_void_p, notnull_errcheck, deprecated=True)
_handle_gl_func('mpv_opengl_cb_set_update_callback', [OpenGlCbUpdateFn, c_void_p], deprecated=True)
_handle_gl_func('mpv_opengl_cb_init_gl', [c_char_p, OpenGlCbGetProcAddrFn, c_void_p], c_int, deprecated=True)
_handle_gl_func('mpv_opengl_cb_draw', [c_int, c_int, c_int], c_int, deprecated=True)
_handle_gl_func('mpv_opengl_cb_render', [c_int, c_int], c_int, deprecated=True)
_handle_gl_func('mpv_opengl_cb_report_flip', [c_ulonglong], c_int, deprecated=True)
_handle_gl_func('mpv_opengl_cb_uninit_gl', [], c_int, deprecated=True)
def _mpv_coax_proptype(value, proptype=str):
"""Intelligently coax the given python value into something that can be understood as a proptype property."""
@@ -934,7 +916,7 @@ class MPV(object):
self._message_handlers[target](*args)
if eid == MpvEventID.SHUTDOWN:
_mpv_detach_destroy(self._event_handle)
_mpv_destroy(self._event_handle) if _API_VER > 1 else _mpv_detach_destroy(self._event_handle)
return
except Exception as e:

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -31,14 +31,19 @@ import os
import re
import shutil
import subprocess
import tempfile
from collections import namedtuple
from datetime import datetime
from enum import IntEnum
from html.parser import HTMLParser
from io import BytesIO
from pathlib import Path
import requests
from app.commons import run_task, log
from app.commons import log, run_task
from app.settings import SettingsType, IS_LINUX, IS_WIN, IS_DARWIN, GTK_PATH
from .satellites import _HEADERS
from app.tools.satellites import _HEADERS
_ENIGMA2_PICON_KEY = "{:X}:{:X}:{}"
_NEUTRINO_PICON_KEY = "{:x}{:04x}{:04x}.png"
@@ -51,15 +56,21 @@ class PiconsError(Exception):
pass
class PiconFormat(IntEnum):
ENIGMA2 = 0
NEUTRINO = 1
OSCAM = 3
class PiconsCzDownloader:
""" The main class for loading picons from the https://picon.cz/ source (by Chocholoušek). """
_PERM_URL = "https://picon.cz/download/7337"
_BASE_URL = "https://picon.cz/download/"
_BASE_LOGO_URL = "https://picon.cz/picon/0/"
_HEADER = {"User-Agent": "DemonEditor/2.0.0", "Referer": ""}
_HEADER = {"User-Agent": "DemonEditor/3.0.0", "Referer": ""}
_LINK_PATTERN = re.compile(r"((.*)-\d+x\d+)-(.*)_by_chocholousek.7z$")
_FILE_PATTERN = re.compile(b"\\s+(1_.*\\.png).*")
_FILE_PATTERN = re.compile(b"\\s+(\\w+\\.png).*")
def __init__(self, picon_ids=set(), appender=log):
self._perm_links = {}
@@ -67,46 +78,76 @@ class PiconsCzDownloader:
self._provider_logos = {}
self._picon_ids = picon_ids
self._appender = appender
self._logo_map = self.get_logos_map()
self._name_map = self.get_name_map()
self._perm_cache_file = Path(tempfile.gettempdir()).joinpath("picon_cz_links")
# subprocess creation flags
self._sbp_flags = subprocess.CREATE_NO_WINDOW if IS_WIN else 0
@property
def providers(self):
return self._providers
def init(self):
""" Initializes dict with values: download_id -> perm link and provider data. """
if self._perm_links:
return
self._HEADER["Referer"] = self._PERM_URL
if self._perm_cache_file.exists():
st = self._perm_cache_file.stat()
dif = datetime.now() - datetime.fromtimestamp(st.st_mtime)
# We will update daily.
if dif.days > 0:
self.download_permalinks()
else:
self.download_permalinks()
self.read_permalinks()
def download_permalinks(self):
self._HEADER["Referer"] = self._PERM_URL
with requests.get(url=self._PERM_URL, headers=self._HEADER, stream=True) as request:
if request.reason == "OK":
logo_map = self.get_logos_map()
name_map = self.get_name_map()
log(f"{self.__class__.__name__}: downloading permalinks file...")
buf = BytesIO()
[buf.write(chunk) for chunk in request.iter_content(chunk_size=128)]
buf.seek(0)
for line in request.iter_lines():
data = line.decode(encoding="utf-8", errors="ignore").split(maxsplit=1)
if len(data) != 2:
continue
l_id, perm_link = data
self._perm_links[str(l_id)] = str(perm_link)
data = re.match(self._LINK_PATTERN, perm_link)
if data:
sat_pos = data.group(3)
# Logo url.
logo = logo_map.get(data.group(2), None)
l_name = name_map.get(sat_pos, None) or sat_pos.replace(".", "")
logo_url = f"{self._BASE_LOGO_URL}{logo}/{l_name}.png" if logo else None
prv = Provider(None, data.group(1), sat_pos, self._BASE_URL + l_id, l_id, logo_url, None, False)
if sat_pos in self._providers:
self._providers[sat_pos].append(prv)
else:
self._providers[sat_pos] = [prv]
self._perm_cache_file.touch()
self._perm_cache_file.write_bytes(buf.read())
else:
log(f"{self.__class__.__name__} [get permalinks] error: {request.reason}")
raise PiconsError(request.reason)
@property
def providers(self):
return self._providers
def read_permalinks(self):
with self._perm_cache_file.open(encoding="utf-8", errors="ignore") as f:
for l in f.readlines():
data = l.split(maxsplit=1)
if len(data) != 2:
continue
data = l.split(maxsplit=1)
if len(data) != 2:
continue
l_id, perm_link = data
self._perm_links[str(l_id)] = str(perm_link)
self.update_provider_data(l_id, perm_link)
def update_provider_data(self, l_id, perm_link):
data = re.match(self._LINK_PATTERN, perm_link)
if data:
sat_pos = data.group(3)
# Logo url.
logo = self._logo_map.get(data.group(2), None)
l_name = self._name_map.get(sat_pos, None) or sat_pos.replace(".", "")
logo_url = f"{self._BASE_LOGO_URL}{logo}/{l_name}.png" if logo else None
prv = Provider(None, data.group(1), sat_pos, self._BASE_URL + l_id, l_id, logo_url, None, False)
if sat_pos in self._providers:
self._providers[sat_pos].append(prv)
else:
self._providers[sat_pos] = [prv]
def get_sat_providers(self, url):
return self._providers.get(url, [])
@@ -116,11 +157,11 @@ class PiconsCzDownloader:
with requests.get(url=provider.url, headers=self._HEADER, stream=True) as request:
if request.reason == "OK":
dest = f"{picons_path}{provider.on_id}.7z"
self._appender(f"Downloading: {provider.url}\n")
self._appender(f"Downloading: {provider.url}")
with open(dest, mode="bw") as f:
for data in request.iter_content(chunk_size=1024):
f.write(data)
self._appender(f"Extracting: {provider.on_id}\n")
self._appender(f"Extracting: {provider.on_id}")
self.extract(dest, picons_path, picon_ids)
else:
log(f"{self.__class__.__name__} [download] error: {request.reason}")
@@ -130,19 +171,22 @@ class PiconsCzDownloader:
# TODO: think about https://github.com/miurahr/py7zr
exe = "7z"
if IS_DARWIN and GTK_PATH:
exe = "./7z"
exe = "./7zr"
if IS_LINUX and not os.path.isfile(f"/usr/bin/{exe}"):
raise PiconsError("7-zip [7z] archiver not found!")
if IS_WIN:
exe = f"C:{os.sep}Program Files{os.sep}7-Zip{os.sep}{exe}.exe"
exe = f"{exe}.exe" if GTK_PATH else f"C:{os.sep}Program Files{os.sep}7-Zip{os.sep}{exe}.exe"
if not os.path.isfile(exe):
raise PiconsError("7-Zip executable not found!")
cmd = [exe, "l", src]
try:
out, err = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
out, err = subprocess.Popen(cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=self._sbp_flags).communicate()
if err:
log(f"{self.__class__.__name__} [extract] error: {err}")
raise PiconsError(err)
@@ -167,7 +211,10 @@ class PiconsCzDownloader:
cmd = [exe, "e", src, "-o{}".format(dest), "-y", "-r"]
cmd.extend(to_extract)
try:
out, err = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
out, err = subprocess.Popen(cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
creationflags=self._sbp_flags).communicate()
if err:
log(f"{self.__class__.__name__} [extract] error: {err}")
raise PiconsError(err)
@@ -200,7 +247,8 @@ class PiconsCzDownloader:
except requests.exceptions.ConnectionError as e:
log(f"{self.__class__.__name__} error [get provider logo]: {e}")
def get_logos_map(self):
@staticmethod
def get_logos_map():
return {"piconblack": "b50",
"picontransparent": "t50",
"piconwhite": "w50",
@@ -219,10 +267,14 @@ class PiconsCzDownloader:
"picontransparentdark": "td220",
"piconoled": "o96",
"piconblack80": "b50",
"piconblack3d": "b50"
"piconblack3d": "b50",
"piconwin11": "win11220",
"piconSNPtransparent": "t50",
"piconSNPblack": "b50",
}
def get_name_map(self):
@staticmethod
def get_name_map():
return {"antiksat": "ANTIK",
"digiczsk": "DIGI",
"DTTitaly": "picon_trs-it",
@@ -301,7 +353,7 @@ class PiconsParser(HTMLParser):
if req.status_code == 200:
logo_data = req.text
else:
log("Provider picons downloading error: {} {}".format(provider.url, req.reason))
log(f"Provider picons downloading error: {provider.url} {req.reason}")
return
on_id, pos, ssid, single = provider.on_id, provider.pos, provider.ssid, provider.single
@@ -332,7 +384,7 @@ class PiconsParser(HTMLParser):
p_name = picons_path + (name if name else os.path.basename(p.ref))
picons_data.append(("{}{}".format(PiconsParser._BASE_URL, p.ref), p_name))
except (TypeError, ValueError) as e:
msg = "Picons format parse error: {}".format(p) + "\n" + str(e)
msg = f"Picons format parse error: {p}\n{e}"
log(msg)
return picons_data
@@ -345,15 +397,15 @@ class PiconsParser(HTMLParser):
tr_id = int(ssid[:-2] if len(ssid) < 4 else ssid[:2])
return _NEUTRINO_PICON_KEY.format(tr_id, int(on_id), int(ssid))
else:
return "{}.png".format(ssid)
return f"{ssid}.png"
class ProviderParser(HTMLParser):
""" Parser for satellite html page. (https://www.lyngsat.com/*sat-name*.html) """
_POSITION_PATTERN = re.compile("at\s\d+\..*(?:E|W)']")
_ONID_TID_PATTERN = re.compile("^\d+-\d+.*")
_TRANSPONDER_FREQUENCY_PATTERN = re.compile("^\d+ [HVLR]+")
_POSITION_PATTERN = re.compile(r"at\s\d+\..*(?:E|W)']")
_ONID_TID_PATTERN = re.compile(r"^\d+-\d+.*")
_TRANSPONDER_FREQUENCY_PATTERN = re.compile(r"^\d+ [HVLR]+")
_DOMAINS = {"/tvchannels/", "/radiochannels/", "/packages/", "/logo/"}
_BASE_URL = "https://www.lyngsat.com"
@@ -439,7 +491,7 @@ class ProviderParser(HTMLParser):
if req.status_code == 200:
logo_data = req.content
else:
log("Downloading provider logo error: {}".format(req.reason))
log(f"Downloading provider logo error: {req.reason}")
self.rows.append(Provider(logo=logo_data, name=name, pos=self._positon, url=row[6], on_id=on_id,
ssid=None, single=False, selected=True))
elif 6 < len_row < 12:
@@ -472,7 +524,7 @@ def parse_providers(url):
if request.status_code == 200:
parser.feed(request.text)
else:
log("Parse providers error [{}]: {}".format(url, request.reason))
log(f"Parse providers error [{url}]: {request.reason}")
def srt(p):
if p.logo is None:
@@ -485,48 +537,97 @@ def parse_providers(url):
return providers
def download_picon(src_url, dest_path, callback):
def download_picon(src_url, dest_path):
""" Downloads and saves the picon to file. """
err_msg = "Picon download error: {} [{}]"
timeout = (3, 5) # connect and read timeouts
if callback:
callback("Downloading: {}.\n".format(os.path.basename(dest_path)))
log("Downloading: {}.".format(os.path.basename(dest_path)))
req = requests.get(src_url, timeout=timeout, stream=True)
if req.status_code != 200:
err_msg = err_msg.format(src_url, req.reason)
log(err_msg)
if callback:
callback(err_msg + "\n")
else:
try:
with open(dest_path, "wb") as f:
for chunk in req:
f.write(chunk)
except OSError as e:
err_msg = "Saving picon [{}] error: {}".format(dest_path, e)
err_msg = f"Saving picon [{dest_path}] error: {e}"
log(err_msg)
if callback:
callback(err_msg + "\n")
@run_task
def convert_to(src_path, dest_path, s_type, callback, done_callback):
""" Converts names format of picons.
def convert_to(src_path, dest_path, p_format, ids=None, services=None, done_callback=None):
""" Converts format [names] of picons.
Copies resulting files from src to dest and writes state to callback.
"""
pattern = "/*_0_0_0.png" if s_type is SettingsType.ENIGMA_2 else "/*.png"
pattern = "/*_0_0_0.png" if p_format is PiconFormat.NEUTRINO else "/*.png"
to_convert = []
for file in glob.glob(src_path + pattern):
base_name = os.path.basename(file)
if ids is not None and base_name not in ids:
continue
to_convert.append((base_name, file))
if p_format is PiconFormat.NEUTRINO:
convert_to_neutrino(to_convert, dest_path)
elif p_format is PiconFormat.OSCAM:
convert_to_oscam(to_convert, dest_path, services)
if done_callback:
done_callback()
def convert_to_neutrino(files, dest_path):
for base_name, file in files:
pic_data = base_name.rstrip(".png").split("_")
dest_file = _NEUTRINO_PICON_KEY.format(int(pic_data[4], 16), int(pic_data[5], 16), int(pic_data[3], 16))
dest = "{}/{}".format(dest_path, dest_file)
callback('Converting "{}" to "{}"\n'.format(base_name, dest_file))
dest = f"{dest_path}{os.sep}{dest_file}"
log(f'Converting "{base_name}" to "{dest_file}"')
shutil.copyfile(file, dest)
done_callback()
def convert_to_oscam(files, dest_path, services):
if not files:
return
os.makedirs(dest_path, exist_ok=True)
import base64
from io import BytesIO
from PIL import Image
for base_name, file in files:
to_convert = []
srv = services.get(base_name, None)
if srv:
sid, flags = srv.ssid, srv.flags_cas
if flags:
cas = list(map(lambda c: c.lstrip("C:"), filter(lambda x: x.startswith("C:"), flags.split(","))))
if cas:
[to_convert.append(f"{dest_path}{os.sep}IC_{c.upper()}_{sid.upper()}.tpl") for c in cas]
else:
to_convert.append(f"{dest_path}{os.sep}{base_name}.tpl")
else:
to_convert.append(f"{dest_path}{os.sep}{base_name}.tpl")
else:
to_convert.append(f"{dest_path}{os.sep}{base_name}.tpl")
image = Image.open(file)
image.thumbnail((100, 60))
buff = BytesIO()
image.save(buff, format="PNG")
data_bytes = b"data:image/png;base64," + base64.b64encode(buff.getvalue())
for dest_file in to_convert:
log(f'Converting "{base_name}" to "{dest_file}"')
with open(dest_file, "wb") as f:
f.write(data_bytes)
if __name__ == "__main__":

View File

@@ -1,6 +1,34 @@
""" Module for downloading satellites, transponders ans services from the web.
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
Sources: www.flysat.com, www.lyngsat.com.
""" Module for downloading satellites, transponders and services from the Web.
Sources: www.flysat.com, www.lyngsat.com, www.kingofsat.net.
Replaces or updates the current satellites.xml file.
"""
import re
@@ -15,13 +43,14 @@ from app.eparser.ecommons import (PLS_MODE, get_key_by_value, FEC, SYSTEM, POLAR
Service, CAS)
_HEADERS = {"User-Agent": "Mozilla/5.0 (Linux x86_64; rv:92.0) Gecko/20100101 Firefox/92.0"}
_TIMEOUT = 10
class SatelliteSource(Enum):
FLYSAT = ("https://www.flysat.com/satlist.php",)
FLYSAT = ("https://www.flysat.com/en/satellitelist",)
LYNGSAT = ("https://www.lyngsat.com/asia.html", "https://www.lyngsat.com/europe.html",
"https://www.lyngsat.com/atlantic.html", "https://www.lyngsat.com/america.html")
KINGOFSAT = ("https://en.kingofsat.net/satellites.php",)
KINGOFSAT = ("https://en.kingofsat.tv/satellites.php",)
@staticmethod
def get_sources(src):
@@ -38,10 +67,10 @@ class Cell:
self._img = img
def __repr__(self):
return "Cell({}, {}, {})".format(self._text, self._url, self._img)
return f"Cell({self._text}, {self._url}, {self._img})"
def __str__(self):
return "<Cell(text={}, link={}, img={})>".format(self._text, self._url, self._img)
return f"<Cell(text={self._text}, link={self._url}, img={self._img})>"
def __iter__(self):
return (x for x in (self._text, self._url, self._img))
@@ -93,6 +122,12 @@ class SatellitesParser(HTMLParser):
self._rows = []
self._source = source
self.PLS_MODES = {v: k for k, v in PLS_MODE.items()}
self.POLARIZATION = {v: k for k, v in POLARIZATION.items()}
self.FEC = {v: k for k, v in FEC.items()}
self.SYSTEM = {v: k for k, v in SYSTEM.items()}
self.MODULATION = {v: k for k, v in MODULATION.items()}
def handle_starttag(self, tag, attrs):
if tag == "td":
self._is_td = True
@@ -134,76 +169,114 @@ class SatellitesParser(HTMLParser):
for src in SatelliteSource.get_sources(self._source):
try:
request = requests.get(url=src, headers=_HEADERS)
except requests.exceptions.ConnectionError as e:
log(repr(e))
return []
resp = requests.get(url=src, headers=_HEADERS, timeout=_TIMEOUT)
except requests.exceptions.RequestException as e:
log(f"Getting satellite list error: {repr(e)}")
else:
reason = request.reason
reason = resp.reason
if reason == "OK":
self.feed(request.text)
self.feed(resp.text)
else:
log(reason)
log(f"Getting satellite list error: {reason} -> {resp.url}")
if self._rows:
if self._source is SatelliteSource.FLYSAT:
def get_sat(r):
return r[1], self.parse_position(r[2]), r[3], r[0], False
return list(map(get_sat, filter(lambda x: all(x) and len(x) == 5, self._rows)))
return self.get_satellites_for_fly_sat()
elif self._source is SatelliteSource.LYNGSAT:
base_url = "https://www.lyngsat.com/"
sats = []
cur_pos = "0"
for row in filter(lambda x: 3 < len(x) < 8, self._rows):
if not row[0]:
row = row[1:]
pos = self.parse_position(row[1])
if not self.POS_PAT.match(pos):
if len(row) == 4 and row[0].endswith(".html"):
sats.append((row[1], cur_pos, row[-2], base_url + row[0], False))
continue
sats.append((row[-3], pos, row[-2], base_url + row[0], False))
cur_pos = pos
return sats
return self.get_satellites_for_lyng_sat()
elif source is SatelliteSource.KINGOFSAT:
def get_sat(r):
return r[3], self.parse_position(r[1]), None, r[2], False
return self.get_satellites_for_king_of_sat()
return list(map(get_sat, filter(lambda x: len(x) == 17, self._rows)))
return []
def get_satellite(self, sat):
pos = sat[1]
return Satellite(name="{} {}".format(pos, sat[0]),
flags="0",
return Satellite(name=f"{pos} {sat[0]}", flags="0",
position=self.get_position(pos.replace(".", "")),
transponders=self.get_transponders(sat[3]))
def get_satellites_for_fly_sat(self):
sat_pat = re.compile(r"https://.*/satellite/.+")
pos_pat = re.compile(r"https://.*/satellite/position/.+")
names = []
pos = ""
pos_url = ""
satellites = []
def normalize_pos(p):
return f"{float(p[:-1])}{p[-1]}" if "." not in p else p
for row in filter(lambda x: len(x) > 6, self._rows):
if re.match(sat_pat, row[1]):
row.pop(0)
if re.match(sat_pat, row[0]) and row[-2]: # r[-2] -> skip EMPTY satellites!
if re.match(pos_pat, row[0]):
names.clear()
pos_url = row[0]
name = row[3]
pos = normalize_pos(self.parse_position(row[-4]))
names.append(name)
satellites.append((name, pos, row[-2], row[2], False))
if len(row) == 7:
single_pos = normalize_pos(self.parse_position(row[-4]))
name = row[1]
if pos == single_pos:
names.append(name)
else:
# Uniting satellites in position.
if len(names) > 1:
satellites.append(("/".join(names), pos, None, pos_url, False))
names.clear()
satellites.append((name, single_pos, row[-2], row[0], False))
return satellites
def get_satellites_for_lyng_sat(self):
base_url = "https://www.lyngsat.com/"
sats = []
cur_pos = "0"
for row in filter(lambda x: 3 < len(x) < 8, self._rows):
if not row[0]:
row = row[1:]
pos = self.parse_position(row[1])
if not self.POS_PAT.match(pos):
if len(row) == 4 and row[0].endswith(".html"):
sats.append((row[1], cur_pos, row[-2], base_url + row[0], False))
continue
sats.append((row[-3], pos, row[-2], base_url + row[0], False))
cur_pos = pos
return sats
def get_satellites_for_king_of_sat(self):
def get_sat(r):
return r[3], self.parse_position(r[1]), None, r[2], False
return list(map(get_sat, filter(lambda x: len(x) == 17, self._rows)))
@staticmethod
def parse_position(pos_str):
return "".join(c for c in pos_str if c.isdigit() or c.isalpha() or c == ".")
@staticmethod
def get_position(pos):
return "{}{}".format("-" if pos[-1] == "W" else "", pos[:-1])
return f"{'-' if pos[-1] == 'W' else ''}{pos[:-1]}"
def get_transponders(self, sat_url):
""" Getting transponders(sorted by frequency). """
self._rows.clear()
trs = []
url = sat_url
if self._source is SatelliteSource.FLYSAT:
url = "https://www.flysat.com/" + sat_url
elif self._source is SatelliteSource.KINGOFSAT:
url = "https://en.kingofsat.net/" + sat_url
if self._source is SatelliteSource.KINGOFSAT:
sat_url = f"https://en.kingofsat.tv/{sat_url}"
try:
request = requests.get(url=url, headers=_HEADERS)
except requests.exceptions.ConnectionError as e:
log("Getting transponders error: {}".format(e))
request = requests.get(url=sat_url, headers=_HEADERS, timeout=_TIMEOUT)
except requests.exceptions.RequestException as e:
log(f"Getting transponders error: {e}")
else:
if request.status_code == 200:
self.feed(request.text)
@@ -214,87 +287,99 @@ class SatellitesParser(HTMLParser):
elif self._source is SatelliteSource.KINGOFSAT:
self.get_transponders_for_king_of_sat(trs)
else:
log("SatellitesParser [get transponders] error: {} {}".format(url, request.reason))
log(f"SatellitesParser [get transponders] error: {sat_url} {request.reason}")
return sorted(trs, key=lambda x: int(x.frequency))
def get_transponders_for_fly_sat(self, trs):
""" Parsing transponders for FlySat """
pls_pattern = re.compile("(PLS:)+ (Root|Gold|Combo)+ (\\d+)?")
is_id_pattern = re.compile("(Stream) (\\d+)")
pls_modes = {v: k for k, v in PLS_MODE.items()}
""" Parsing transponders for FlySat. """
frq_pol_pattern = re.compile(r"(\d{4,5})+\s+([RLHV]).*(DVB-S[2]?)/(.+PSK)?.*")
pls_pattern = re.compile(r".*PLS\s+(Root|Gold|Combo)+\s(\d+)?")
is_id_pattern = re.compile(r"Stream\s(\d+)")
sr_fec_pattern = re.compile(r"(\d{4,5})+\s+(\d+/\d+).*")
n_trs = []
if self._rows:
zeros = "000"
is_ids = []
for r in self._rows:
if len(r) == 1:
row_len = len(r)
if row_len == 1:
is_ids.extend(re.findall(is_id_pattern, r[0]))
continue
if len(r) < 3:
if row_len < 12:
continue
data = r[2].split(" ")
if len(data) != 2:
continue
sr, fec = data
data = r[1].split(" ")
if len(data) < 3:
continue
freq, pol, sys = data[0], data[1], data[2]
sys = sys.split("/")
if len(sys) != 2:
continue
sys, mod = sys
mod = "QPSK" if sys == "DVB-S" else mod
pls = re.findall(pls_pattern, r[1])
freq = re.findall(frq_pol_pattern, r[2])
if not freq:
continue
freq, pol, sys, mod = freq[0]
sr_fec = re.match(sr_fec_pattern, r[3])
if not sr_fec:
continue
sr, fec = sr_fec.group(1), sr_fec.group(2)
pls = re.match(pls_pattern, r[2])
pls_code = None
pls_mode = None
if pls:
pls_code = pls[0][2]
pls_mode = pls_modes.get(pls[0][1], None)
pls_mode = self.PLS_MODES.get(pls.group(1), None)
pls_code = pls.group(2)
if is_ids:
tr = trs.pop()
for index, is_id in enumerate(is_ids):
tr = tr._replace(is_id=is_id[1])
tr = tr._replace(is_id=is_id)
if is_transponder_valid(tr):
n_trs.append(tr)
else:
tr = Transponder(freq + zeros, sr + zeros, pol, fec, sys, mod, pls_mode, pls_code, None)
if is_transponder_valid(tr):
trs.append(tr)
tr = Transponder(f"{freq}000", f"{sr}000",
self.POLARIZATION.get(pol, None),
self.FEC.get(fec, None),
self.SYSTEM.get(sys, None),
self.MODULATION.get(mod, None),
pls_mode, pls_code, None, None, None)
if is_transponder_valid(tr):
trs.append(tr)
is_ids.clear()
trs.extend(n_trs)
def get_transponders_for_lyng_sat(self, trs):
""" Parsing transponders for LyngSat. """
frq_pol_pattern = re.compile("(\\d{4,5})\\s+([RLHV]).*")
sr_fec_pattern = re.compile(r"(DVB-S[2]?)\s+(.+PSK)?.*?(\d+)\s+(\d/\d)\s*(?:T2-MI\s+PLP\s+(\d+))?.*")
zeros = "000"
pls_mode, pls_code, pls_id = None, None, None
frq_pol_pattern = re.compile(r"(\d{4,5})\s+([RLHV]).*")
sr_fec_pattern = re.compile((r"(DVB-S[2]?)\s+(.+PSK)?.*?(\d+)\s+(\d/\d)\s?"
r"(?:T2-MI\s+PLP\s+(\d+))?.*"
r"?(?:PLS\s+(Root|Gold|Combo)\s+(\d+))?"
r"(?:.*Stream\s+(\d+))?.*"))
for row in filter(lambda x: len(x) > 8, self._rows):
for frq in row[1], row[2], row[3]:
freq = re.match(frq_pol_pattern, frq)
if freq:
for freq in row[1], row[2], row[3]:
res = re.match(frq_pol_pattern, freq)
if res:
break
if not freq:
if not res:
continue
frq, pol = freq.group(1), freq.group(2)
srf = " ".join(row[3:5])
sr_fec = re.search(sr_fec_pattern, srf)
if not sr_fec:
freq, pol = res.group(1), res.group(2)
res = re.search(sr_fec_pattern, row[3])
if not res:
continue
sys, mod, sr, fec = sr_fec.group(1), sr_fec.group(2), sr_fec.group(3), sr_fec.group(4)
sys, mod, sr, fec = res.group(1), res.group(2), res.group(3), res.group(4)
mod = mod.strip() if mod else "Auto"
pls_id = sr_fec.group(5)
plp, pls_mode, pls_code, is_id = res.group(5), res.group(6), res.group(7), res.group(8)
pls_mode = self.PLS_MODES.get(pls_mode, None)
tr = Transponder(frq + zeros, sr + zeros, pol, fec, sys, mod, pls_mode, pls_code, pls_id)
if plp is not None:
log(f"Detected T2-MI transponder! [{freq} {sr} {pol}] ")
tr = Transponder(f"{freq}000", f"{sr}000",
self.POLARIZATION.get(pol, None),
self.FEC.get(fec, None),
self.SYSTEM.get(sys, None),
self.MODULATION.get(mod, None),
pls_mode, pls_code, is_id, None, None)
if is_transponder_valid(tr):
trs.append(tr)
@@ -303,25 +388,48 @@ class SatellitesParser(HTMLParser):
Since the *.ini file contains incomplete information, it is not used.
"""
zeros = "000"
pat = re.compile(
r"(\d+).00\s+([RLHV])\s+(DVB-S[2]?)\s+(?:T2-MI, PLP (\d+)\s+)?(.*PSK).*?(?:Stream\s+(\d+))?\s+(\d+)\s+(\d+/\d+)$")
sys_pat = re.compile(r"(DVB-S[2]?)\s?(?:T2-MI,\s+PLP\s+(\d+))?.*?(?:PLS:\s+(Root|Gold|Combo)\+(\d+))?")
mod_pat = re.compile(r"(.*PSK).*?(?:.*Stream\s+(\d+))?.*")
sr_fec_pattern = re.compile(r"(\d{4,5})+\s+(\d+/\d+).*")
for row in filter(lambda r: len(r) == 16 and self.POS_PAT.match(r[0]), self._rows):
res = pat.search(" ".join((row[0], row[2], row[3], row[8], row[9], row[10])))
if res:
freq, sr, pol, fec, sys = res.group(1), res.group(7), res.group(2), res.group(8), res.group(3)
mod, pls_id, pls_code = res.group(5), res.group(4), res.group(6)
for row in filter(lambda r: len(r) == 14 and self.POS_PAT.match(r[0]), self._rows):
freq, pol = row[2].replace(".", "0"), row[3]
if not freq.isdigit() or pol not in "VHLR":
continue
tr = Transponder(freq + zeros, sr + zeros, pol, fec, sys, mod, None, pls_code, pls_id)
if is_transponder_valid(tr):
trs.append(tr)
res = re.match(sys_pat, row[8])
if not res:
continue
sys, t2_mi, pls_id, pls_code = res.group(1), res.group(2), res.group(3), res.group(4)
pls_id = self.PLS_MODES.get(pls_id, None)
res = re.match(mod_pat, row[9])
if not res:
continue
mod, is_id = res.group(1), res.group(2)
res = re.match(sr_fec_pattern, row[10])
if not res:
continue
sr, fec = res.group(1), res.group(2)
if t2_mi:
log(f"Detected T2-MI transponder! [{freq} {sr} {pol}] ")
tr = Transponder(freq, f"{sr}000",
self.POLARIZATION.get(pol, None),
self.FEC.get(fec, None),
self.SYSTEM.get(sys, None),
self.MODULATION.get(mod, None),
pls_id, pls_code, is_id, None, None)
if is_transponder_valid(tr):
trs.append(tr)
class ServicesParser(HTMLParser):
""" Services parser for LYNGSAT source. """
def __init__(self, source=SatelliteSource.LYNGSAT, entities=False, separator=' '):
def __init__(self, source=SatelliteSource.LYNGSAT, entities=False, separator=' ', lang=None):
HTMLParser.__init__(self)
@@ -329,16 +437,31 @@ class ServicesParser(HTMLParser):
"MPEG-4": "22", "HEVC SD": "22", "MPEG-4/HD": "25", "MPEG-4 HD": "25", "MPEG-4 HD 1080": "25",
"MPEG-4 HD 720": "25", "HEVC HD": "25", "HEVC/HD": "25", "HEVC": "31", "HEVC/UHD": "31",
"HEVC UHD": "31", "HEVC UHD 4K": "31", "3": "Data"}
self._TR_PAT = re.compile(
r".*?(\d+)\s+([RLHV]).*(DVB-S[2]?)/?(.*PSK)?\s(T2-MI)?\s?SR-FEC:\s(\d+)-(\d/\d)\s+.*ONID-TID:\s+(\d+)-(\d+).*")
self._POS_PAT = re.compile(r".*?(\d+\.\d°[EW]).*")
self._TR = "s {}000:{}000:{}:{}:{}:{}:{}:{}"
self._S2_TR = "{}:{}:{}:{}"
self._POS_PAT = re.compile(r".*?(\d+\.\d°[EW]).*")
# LyngSat.
self._TR_PAT = re.compile(r".*?(\d{4,5})\.?\d?\s+([RLHV]).*(DVB-S2?X?)/?(.*PSK)?.*SR-FEC:\s(\d+)-(\d+/\d+).*")
self._ID_PAT = re.compile(r"C/N lock:.*?(?:.*ONID-TID:\s+(\d+)-(\d+))?.*")
self._MULTI_PAT = re.compile(r"PLS\s+(Root|Gold|Combo)+\s(\d+)?\s+(?:Stream\s(\d+))")
# KingOfSat.
self._KING_TR_PAT = re.compile((r"(DVB-S[2]?)\s?(?:T2-MI,\s+PLP\s+(\d+))?.*"
r"?(?:PLS:\s+(Root|Gold|Combo)\+(\d+))?"
r"\s+(.*PSK).*?(?:.*Stream\s+(\d+))?.*"))
self._lang = "en"
if lang:
langs = {"en", "fr", "nl", "de", "se", "no", "pt", "es", "it", "pl",
"cz", "gr", "fi", "ar", "tr", "ru", "sc", "ro", "hu", "sq"}
lang, _, _ = lang.partition("_")
self._lang = lang if lang in langs else self._lang
self._parse_html_entities = entities
self._separator = separator
self._is_td = False
self._is_th = False
self._is_mux_div = False
self._current_row = []
self._current_cell_text = []
self._current_cell = Cell()
@@ -346,6 +469,7 @@ class ServicesParser(HTMLParser):
self._source = source
self._t_url = ""
self._use_short_names = True
self._pls_modes = {v: k for k, v in PLS_MODE.items()}
@property
def source(self):
@@ -379,9 +503,11 @@ class ServicesParser(HTMLParser):
if a[0] != "title":
continue
txt = a[1]
if txt and txt.startswith("Id: "):
sep = "Id: "
if txt and txt.startswith(sep):
# Saving the 'short' name.
self._current_cell.text = txt.lstrip("Id: ")
_, sep, name = txt.partition(sep)
self._current_cell.text = name
elif tag == "img":
img_link = attrs[0][1]
if self._source is SatelliteSource.LYNGSAT:
@@ -389,12 +515,18 @@ class ServicesParser(HTMLParser):
self._current_cell.img = img_link
elif self._source is SatelliteSource.KINGOFSAT:
self._current_cell.img = img_link
elif tag == "div" and self._source is SatelliteSource.LYNGSAT:
self._is_mux_div = bool(list(filter(lambda at: at[-1] == "mux-header", attrs)))
def handle_data(self, data):
""" Save content to a cell """
if self._is_td or self._is_th:
self._current_cell_text.append(data.strip())
if self._is_mux_div:
self._current_cell.url = data.strip()
self._is_mux_div = False
def handle_endtag(self, tag):
if tag == "td":
self._is_td = False
@@ -414,27 +546,30 @@ class ServicesParser(HTMLParser):
self._current_row = []
def error(self, message):
log("ServicesParser error: {}".format(message))
log(f"ServicesParser error: {message}")
def init_data(self, url):
""" Initializes data for the given URL. """
if self._source not in (SatelliteSource.LYNGSAT, SatelliteSource.KINGOFSAT):
raise ValueError("Unsupported source: {}!".format(self._source.name))
raise ValueError(f"Unsupported source: {self._source.name}!")
self._rows.clear()
request = requests.get(url=url, headers=_HEADERS)
reason = request.reason
if reason == "OK":
self.feed(request.text)
try:
request = requests.get(url=url, headers=_HEADERS, timeout=_TIMEOUT)
except requests.exceptions.RequestException as e:
raise ValueError(e)
else:
raise ValueError(reason)
reason = request.reason
if reason == "OK":
self.feed(request.text)
else:
raise ValueError(reason)
def get_transponders_links(self, sat_url):
""" Returns transponder links. """
try:
if self._source is SatelliteSource.KINGOFSAT:
sat_url = "https://en.kingofsat.net/" + sat_url
sat_url = f"https://en.kingofsat.tv/{sat_url}"
self.init_data(sat_url)
except ValueError as e:
log(e)
@@ -446,10 +581,10 @@ class ServicesParser(HTMLParser):
elif self._source is SatelliteSource.KINGOFSAT:
trs = []
for r in self._rows:
if len(r) == 13 and SatellitesParser.POS_PAT.match(r[0].text):
if len(r) == 12 and SatellitesParser.POS_PAT.match(r[0].text):
t_cell = r[4]
if t_cell.url and t_cell.url.startswith("tp.php?tp="):
t_cell.url = f"https://en.kingofsat.net/{t_cell.url}"
if t_cell.url and t_cell.url.startswith("tp"):
t_cell.url = f"https://{self._lang}.kingofsat.tv/{t_cell.url}"
t_cell.text = f"{r[2].text} {r[3].text} {r[6].text} {r[8].text}"
trs.append(t_cell)
return trs
@@ -467,22 +602,24 @@ class ServicesParser(HTMLParser):
self.init_data(tr_url)
except ValueError as e:
log(e)
return []
else:
if self._source is SatelliteSource.LYNGSAT:
return self.get_lyngsat_services(sat_position, use_pids)
elif self._source is SatelliteSource.KINGOFSAT:
return self.get_kingofsat_services(sat_position, use_pids)
else:
return []
return []
def get_lyngsat_services(self, sat_position=None, use_pids=False):
services = []
pos, freq, sr, fec, pol, nsp, tid, nid = sat_position or 0, 0, 0, 0, 0, 0, 0, 0
sys = "DVB-S"
pos_found = False
tr = None
# Transponder
for r in filter(lambda x: x and 6 < len(x) < 9, self._rows):
pos_found, tr, td, t_id = False, None, None, None
# Multi-stream.
multi_tr = None
multi = False
# Transponder.
for r in self._rows:
if not pos_found:
pos_tr = re.match(self._POS_PAT, r[0].text)
if not pos_tr:
@@ -490,87 +627,124 @@ class ServicesParser(HTMLParser):
if not sat_position:
pos = self.get_position(pos_tr.group(1))
pos_found = True
if pos_found:
text = " ".join(c.text for c in r[1:])
td = re.match(self._TR_PAT, text)
if td:
freq, pol = int(td.group(1)), get_key_by_value(POLARIZATION, td.group(2))
if td.group(5):
log("Detected T2-MI transponder!")
continue
if pos_found and not td:
td = re.match(self._TR_PAT, " ".join(c.text for c in r))
sys, mod, sr, _fec, = td.group(3), td.group(4), td.group(6), td.group(7)
nid, tid = td.group(8), td.group(9)
if td and not t_id:
t_id = re.match(self._ID_PAT, " ".join(c.text for c in r))
if t_id:
# The ONID-TID values may not present!
_nid, _tid = t_id.group(1), t_id.group(2)
if _nid and _tid:
nid, tid = int(_nid), int(_tid)
else:
log((f"Values 'ONID-TID' for transponder [{self._t_url}] are not present."
" Default values are used."))
freq, pol = int(td.group(1)), get_key_by_value(POLARIZATION, td.group(2))
sys, mod, sr, _fec = td.group(3), td.group(4), td.group(5), td.group(6)
sys, mod, fec, nsp, s2_flags, roll_off, pilot, inv = self.get_transponder_data(pos, _fec, sys, mod)
nid, tid = int(nid), int(tid)
tr = self._TR.format(freq, sr, pol, fec, pos, inv, sys, s2_flags)
if not tr:
er = f"Transponder [{freq}] not found or its type [T2-MI, etc] not supported yet."
er = f"Transponder [{self._t_url}] not found or its type [T2-MI, etc] not supported yet."
log(f"ServicesParser error [get transponder services]: {er}")
return services
# Services
for r in filter(lambda x: x and len(x) == 12 and (x[0].text.isdigit()), self._rows):
sid, name, s_type, v_pid, a_pid, cas, pkg = r[0].text, r[2].text, r[4].text, r[
5].text.strip(), r[6].text.split(), r[9].text, r[10].text.strip()
try:
s_type = self._S_TYPES.get(s_type, "3") # 3 = Data
_s_type = SERVICE_TYPE.get(s_type, SERVICE_TYPE.get("3")) # str repr
flags, sid, fav_id, picon_id, data_id = self.get_service_data(s_type, pkg, sid, tid, nid, nsp,
v_pid, a_pid, cas, use_pids)
services.append(Service(flags, "s", None, name, None, None, pkg, _s_type, r[1].img, picon_id,
sid, freq, sr, pol, fec, sys, pos, data_id, fav_id, tr))
except ValueError as e:
log(f"ServicesParser error [get transponder services]: {e}")
# Services.
for r in filter(None, self._rows):
if multi and r[0].url:
res = re.match(self._MULTI_PAT, r[0].url)
if res:
pls_mode, is_code, is_id = self._pls_modes.get(res.group(1), None), res.group(2), res.group(3)
multi_tr = f"{tr}:{is_id}:{is_code}:{pls_mode}" if all((pls_mode, is_code, is_id)) else None
tid = int(is_id) if multi_tr else tid
if len(r) == 12 and r[0].text.isdigit():
sid, name, s_type, v_pid, a_pid, cas, pkg = r[0].text, r[2].text, r[4].text, r[
5].text.strip(), r[6].text.split(), r[9].text, r[10].text.strip()
try:
s_type = self._S_TYPES.get(s_type, "3") # 3 = Data
_s_type = SERVICE_TYPE.get(s_type, SERVICE_TYPE.get("3")) # str repr
flags, sid, fav_id, picon_id, data_id = self.get_service_data(s_type, pkg, sid, tid, nid, nsp,
v_pid, a_pid, cas, use_pids)
services.append(Service(flags, "s", None, name, None, None, pkg, _s_type, r[1].img, picon_id,
sid, freq, sr, pol, fec, sys, pos, data_id, fav_id, multi_tr or tr))
except ValueError as e:
log(f"ServicesParser error [get transponder services]: {e}")
return services
def get_kingofsat_services(self, sat_position=None, use_pids=False):
services = []
# Transponder
tr = list(filter(lambda r: len(r) == 13 and r[4].url and r[4].url.startswith("tp.php?tp="), self._rows))
tr = list(filter(lambda r: len(r) == 12 and r[4].url and r[4].url.startswith("tp"), self._rows))
if not tr:
log(f"ServicesParser error [get transponder services]: Transponder [{self._t_url}] not found!")
return services
tr = tr[0]
s_pos, freq, pol, sys, mod, sr_fec = tr[0].text, tr[2].text, tr[3].text, tr[6].text, tr[7].text, tr[8].text
tid, nid = tr[10].text, tr[11].text
tr, multi_tr, tid, nid, nsp = None, None, None, None, None
freq, sr, pol, fec, sys, pos = None, None, None, None, None, None
pos = sat_position
if not sat_position:
pos_tr = re.match(self._POS_PAT, s_pos)
if pos_tr:
pos = self.get_position(pos_tr.group(1))
for r in filter(lambda x: len(x) > 11, self._rows):
r_size = len(r)
if r_size == 12 and r[4].url and r[4].url.startswith("tp"):
res = re.match(self._KING_TR_PAT, f"{r[6].text} {r[7].text}")
if not res:
continue
sr, fec = sr_fec.split()
pol = get_key_by_value(POLARIZATION, pol)
sys, mod, fec, nsp, s2_flags, roll_off, pilot, inv = self.get_transponder_data(pos, fec, sys, mod)
freq, nid, tid = int(float(freq)), int(nid), int(tid)
tr = self._TR.format(freq, sr, pol, fec, pos, inv, sys, s2_flags)
sys, mod = res.group(1), res.group(5)
s_pos, freq, pol, sr_fec = r[0].text, r[2].text, r[3].text, r[8].text
nid, tid = r[10].text, r[11].text
for r in filter(lambda x: len(x) == 14 and not x[1].text and x[7].text and x[7].text.isdigit(), self._rows):
if r[1].img == "/radio.gif":
s_type = ""
elif r[8].img == "/hd.gif":
s_type = "HEVC HD"
elif r[1].img == "/data.gif":
s_type = "Data"
else:
s_type = "SD"
pos = sat_position
if not sat_position:
pos_tr = re.match(self._POS_PAT, s_pos)
if pos_tr:
pos = self.get_position(pos_tr.group(1))
s_type = self._S_TYPES.get(s_type, "3")
_s_type = SERVICE_TYPE.get(s_type, SERVICE_TYPE.get("3"))
sr, fec = sr_fec.split()
pol = get_key_by_value(POLARIZATION, pol)
sys, mod, fec, nsp, s2_flags, roll_off, pilot, inv = self.get_transponder_data(pos, fec, sys, mod)
if not all((freq, nid, tid)):
log(f"Error. Not enough parameters [Frequency={freq}, NID={nid}, TID={tid}].")
continue
name, pkg, cas, sid, v_pid, a_pid = r[2].text, r[5].text, r[6].text, r[7].text, None, None
flags, sid, fav_id, picon_id, data_id = self.get_service_data(s_type, pkg, sid, tid, nid, nsp,
v_pid, a_pid, cas, use_pids)
services.append(Service(flags, "s", None, name, None, None, pkg, _s_type, None, picon_id,
sid, str(freq), sr, pol, fec, sys, pos, data_id, fav_id, tr))
freq, nid, tid = int(float(freq)), int(nid), int(tid)
tr = self._TR.format(freq, sr, pol, fec, pos, inv, sys, s2_flags)
pls_mode, is_code, is_id = self._pls_modes.get(res.group(3), None), res.group(4), res.group(6)
multi_tr = f"{tr}:{is_id}:{is_code}:{pls_mode}" if all((pls_mode, is_code, is_id)) else None
tid = int(is_id) if multi_tr else tid
if res.group(2):
log(f"Detected T2-MI transponder! [{freq} {sr}]")
if multi_tr:
log(f"Detected multi-stream transponder! [{freq} {sr}]")
if tr and r_size == 14 and not r[1].text and r[7].text and r[7].text.isdigit():
if r[1].img == "/radio.gif":
s_type = ""
elif r[8].img == "/hd.gif":
s_type = "HEVC HD"
elif r[1].img == "/data.gif":
s_type = "Data"
else:
s_type = "SD"
s_type = self._S_TYPES.get(s_type, "3")
_s_type = SERVICE_TYPE.get(s_type, SERVICE_TYPE.get("3"))
reg, grp = r[3].text, r[4].text
name, pkg, cas, sid, v_pid, a_pid = r[2].text, r[5].text, r[6].text, r[7].text, None, None
flags, sid, fav_id, picon_id, data_id = self.get_service_data(s_type, pkg, sid, tid, nid, nsp,
v_pid, a_pid, cas, use_pids)
services.append(Service(flags, "s", None, name, reg, grp, pkg, _s_type, None, picon_id,
sid, str(freq), sr, pol, fec, sys, pos, data_id, fav_id, multi_tr or tr))
return services
@@ -578,9 +752,9 @@ class ServicesParser(HTMLParser):
""" Returns converted transponder data. """
sys = get_key_by_value(SYSTEM, sys)
mod = get_key_by_value(MODULATION, mod)
fec = get_key_by_value(FEC, fec)
fec = get_key_by_value(FEC, fec) or "0"
# For negative (West) positions: 3600 - numeric position value!!!
namespace = "{:04x}0000".format(3600 - pos if pos < 0 else pos)
namespace = f"{3600 - abs(pos) if pos < 0 else pos:04x}0000"
tr_flag = 1
roll_off = 0 # 35% DVB-S2/DVB-S (default)
pilot = 2 # Auto
@@ -592,15 +766,16 @@ class ServicesParser(HTMLParser):
@staticmethod
def get_service_data(s_type, pkg, sid, tid, nid, namespace, v_pid, a_pid, cas, use_pids=False):
sid = int(sid)
data_id = "{:04x}:{}:{:04x}:{:04x}:{}:0:0".format(sid, namespace, tid, nid, s_type)
fav_id = "{}:{}:{}:{}".format(sid, tid, nid, namespace)
picon_id = "1_0_{:X}_{}_{}_{}_{}_0_0_0.png".format(int(s_type), sid, tid, nid, namespace)
data_id = f"{sid:04x}:{namespace}:{tid:04x}:{nid:04x}:{s_type}:0:0"
fav_id = f"1:0:{int(s_type):X}:{sid}:{tid}:{nid}:{namespace}:0:0:0:"
picon_id = f"1_0_{int(s_type):X}_{sid}_{tid}_{nid}_{namespace}_0_0_0.png"
# Flags.
flags = "p:{}".format(pkg)
cas = ",".join(get_key_by_value(CAS, c) or "C:0000" for c in cas.split()) if cas else None
flags = f"p:{pkg}"
cas = ",".join(get_key_by_value(CAS, c) or "" for c in cas.split()) if cas else None
if use_pids:
v_pid = "c:00{:04x}".format(int(v_pid)) if v_pid else None
a_pid = ",".join(["c:01{:04x}".format(int(p)) for p in a_pid]) if a_pid else None
v_pid = f"c:00{int(v_pid):04x}" if v_pid else None
a_pid = ",".join([f"c:01{int(p):04x}" for p in a_pid]) if a_pid else None
flags = ",".join(filter(None, (flags, v_pid, a_pid, cas)))
else:
flags = ",".join(filter(None, (flags, cas)))

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -26,7 +26,7 @@
#
""" Module for working with YouTube service """
""" Module for working with YouTube service. """
import gzip
import json
import os
@@ -35,20 +35,21 @@ import shutil
import sys
from html.parser import HTMLParser
from json import JSONDecodeError
from urllib import parse
from urllib.error import URLError
from urllib.parse import unquote
from urllib.request import Request, urlopen, urlretrieve
from app.commons import log
from app.commons import log, run_task
from app.settings import SEP
from app.ui.uicommons import show_notification
_TIMEOUT = 5
_HEADERS = {"User-Agent": "Mozilla/5.0 (Linux x86_64; rv:92.0) Gecko/20100101 Firefox/92.0",
"DNT": "1",
"Accept-Encoding": "gzip, deflate"}
_YT_PATTERN = re.compile(r"https://www.youtube.com/.+(?:v=)([\w-]{11}).*")
_YT_LIST_PATTERN = re.compile(r"https://www.youtube.com/.+?(?:list=)([\w-]{18,})?.*")
_YT_VIDEO_PATTERN = re.compile(r"https://r\d+---sn-[\w]{10}-[\w]{3,5}.googlevideo.com/videoplayback?.*")
_HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux i586; rv:31.0) Gecko/20100101 Firefox/69.0",
"DNT": "1",
"Accept-Encoding": "gzip, deflate"}
Quality = {137: "1080p", 136: "720p", 135: "480p", 134: "360p",
133: "240p", 160: "144p", 0: "0p", 18: "360p", 22: "720p"}
@@ -109,6 +110,8 @@ class YouTube:
if self._settings.enable_yt_dl and url:
if not self._yt_dl:
self._yt_dl = YouTubeDL.get_instance(self._settings, self._callback)
if not self._yt_dl:
raise YouTubeException("yt-dlp initialization error.")
return self._yt_dl.get_yt_link(url, skip_errors)
return self.get_yt_link_by_id(video_id)
@@ -119,61 +122,120 @@ class YouTube:
Returns tuple from the video links dict and title.
"""
req = Request(YouTube._VIDEO_INFO_LINK.format(video_id), headers=_HEADERS)
info = InnerTube().player(video_id)
det = info.get("videoDetails", None)
title = det.get("title", None) if det else None
streaming_data = info.get("streamingData", None)
fmts = streaming_data.get("formats", None) if streaming_data else None
with urlopen(req, timeout=2) as resp:
data = unquote(gzip.decompress(resp.read()).decode("utf-8")).split("&")
out = {k: v for k, sep, v in (str(d).partition("=") for d in map(unquote, data))}
player_resp = out.get("player_response", None)
if fmts:
links = {Quality[i["itag"]]: i["url"] for i in fmts if i.get("itag", -1) in Quality and "url" in i}
if player_resp:
try:
resp = json.loads(player_resp)
except JSONDecodeError as e:
log("{}: Parsing player response error: {}".format(__class__.__name__, e))
else:
det = resp.get("videoDetails", None)
title = det.get("title", None) if det else None
streaming_data = resp.get("streamingData", None)
fmts = streaming_data.get("formats", None) if streaming_data else None
if links and title:
return links, title.replace("+", " ")
if fmts:
urls = {Quality[i["itag"]]: i["url"] for i in
filter(lambda i: i.get("itag", -1) in Quality, fmts) if "url" in i}
cause = None
status = info.get("playabilityStatus", None)
if status:
cause = f"[{status.get('status', '')}] {status.get('reason', '')}"
if urls and title:
return urls, title.replace("+", " ")
log(f"{__class__.__name__}: Getting link to video with id '{video_id}' filed! Cause: {cause}")
stream_map = out.get("url_encoded_fmt_stream_map", None)
if stream_map:
s_map = {k: v for k, sep, v in (str(d).partition("=") for d in stream_map.split("&"))}
url, title = s_map.get("url", None), out.get("title", None)
url, title = unquote(url) if url else "", title.replace("+", " ") if title else ""
if url and title:
return {Quality[0]: url}, title.replace("+", " ")
rsn = out.get("reason", None)
rsn = rsn.replace("+", " ") if rsn else ""
log("{}: Getting link to video with id {} filed! Cause: {}".format(__class__.__name__, video_id, rsn))
return None, rsn
return None, cause
def get_yt_playlist(self, list_id, url=None):
""" Returns tuple from the playlist header and list of tuples (title, video id). """
if self._settings.enable_yt_dl and url:
try:
if not self._yt_dl:
raise YouTubeException("yt-dlp is not initialized!")
self._yt_dl.update_options({"noplaylist": False, "extract_flat": True})
info = self._yt_dl.get_info(url, skip_errors=False)
if "url" in info:
info = self._yt_dl.get_info(info.get("url"), skip_errors=False)
return info.get("title", ""), [(e.get("title", ""), e.get("id", "")) for e in info.get("entries", [])]
finally:
# Restoring default options
self._yt_dl.update_options({"noplaylist": True, "extract_flat": False})
if self._yt_dl:
self._yt_dl.update_options({"noplaylist": True, "extract_flat": False})
return PlayListParser.get_yt_playlist(list_id)
class InnerTube:
""" Object for interacting with the innertube API.
Based on InnerTube class from pytube [https://github.com/pytube/pytube] project!
"""
_BASE_URI = "https://www.youtube.com/youtubei/v1"
_DEFAULT_CLIENTS = {
# The client is taken from -> https://github.com/JuanBindez/pytubefix
"ANDROID": {"context": {"client": {"clientName": "ANDROID",
"clientVersion": "19.44.38",
"platform": "MOBILE",
"osName": "Android",
"osVersion": "14",
"androidSdkVersion": "34"}},
"header": {"User-Agent": "com.google.android.youtube/",
"X-Youtube-Client-Name": "3"},
"api_key": "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8",
"require_js_player": False,
"require_po_token": True}
}
def __init__(self, client="ANDROID"):
""" Initialize an InnerTube object.
@param client: Client to use for the object. Default to web because it returns the most playback types.
"""
self.context = self._DEFAULT_CLIENTS[client]["context"]
self.api_key = self._DEFAULT_CLIENTS[client]["api_key"]
@property
def base_data(self):
"""Return the base json data to transmit to the innertube API."""
return {"context": self.context}
@property
def base_params(self):
"""Return the base query parameters to transmit to the innertube API."""
return {"key": self.api_key, "contentCheckOk": True, "racyCheckOk": True}
def player(self, video_id):
""" Make a request to the player endpoint. Returns raw player info results. """
endpoint = f"{self._BASE_URI}/player"
query = {"videoId": video_id}
query.update(self.base_params)
return self._call_api(endpoint, query, self.base_data) or {}
@staticmethod
def _call_api(endpoint, query, data):
""" Make a request to a given endpoint with the provided query parameters and data."""
headers = {"Content-Type": "application/json", }
response = InnerTube._execute(f"{endpoint}?{parse.urlencode(query)}", "POST", headers=headers, data=data)
try:
resp = json.loads(response.read())
except JSONDecodeError as e:
log(f"{__class__.__name__}: Parsing response error: {e}")
else:
return resp
@staticmethod
def _execute(url, method=None, headers=None, data=None, timeout=_TIMEOUT):
base_headers = {"User-Agent": "Mozilla/5.0", "accept-language": "en-US,en"}
if headers:
base_headers.update(headers)
if data:
# Encoding data for request.
if not isinstance(data, bytes):
data = bytes(json.dumps(data), encoding="utf-8")
return urlopen(Request(url, headers=base_headers, method=method, data=data), timeout=timeout)
class PlayListParser(HTMLParser):
""" Very simple parser to handle YouTube playlist pages. """
@@ -200,7 +262,7 @@ class PlayListParser(HTMLParser):
try:
resp = json.loads(data)
except JSONDecodeError as e:
log("{}: Parsing data error: {}".format(__class__.__name__, e))
log(f"{__class__.__name__}: Parsing data error: {e}")
else:
sb = resp.get("sidebar", None)
if sb:
@@ -218,7 +280,7 @@ class PlayListParser(HTMLParser):
self._is_script = False
def error(self, message):
log("{} Parsing error: {}".format(__class__.__name__, message))
log(f"{__class__.__name__} Parsing error: {message}")
@property
def header(self):
@@ -234,9 +296,9 @@ class PlayListParser(HTMLParser):
returns tuple from the playlist header and list of tuples (title, video id)
"""
request = Request("https://www.youtube.com/playlist?list={}&hl=en".format(play_list_id), headers=_HEADERS)
request = Request(f"https://www.youtube.com/playlist?list={play_list_id}&hl=en", headers=_HEADERS)
with urlopen(request, timeout=2) as resp:
with urlopen(request, timeout=_TIMEOUT) as resp:
data = gzip.decompress(resp.read()).decode("utf-8")
parser = PlayListParser()
parser.feed(data)
@@ -244,14 +306,14 @@ class PlayListParser(HTMLParser):
class YouTubeDL:
""" Utility class [experimental] for working with youtube-dl.
""" Utility class [experimental] for working with yt-dlp.
[https://github.com/ytdl-org/youtube-dl]
[https://github.com/yt-dlp/yt-dlp]
"""
_DL_INSTANCE = None
_DownloadError = None
_LATEST_RELEASE_URL = "https://api.github.com/repos/ytdl-org/youtube-dl/releases/latest"
_LATEST_RELEASE_URL = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest"
_OPTIONS = {"noplaylist": True, # Single video instead of a playlist [ignoring playlist in URL].
"extract_flat": False, # Do not resolve URLs, return the immediate result.
"quiet": True, # Do not print messages to stdout.
@@ -259,7 +321,7 @@ class YouTubeDL:
"cookiefile": "cookies.txt"} # File name where cookies should be read from and dumped to.
def __init__(self, settings, callback):
self._path = "{}tools{}".format(settings.default_data_path, SEP)
self._path = f"{settings.default_data_path}tools{SEP}"
self._update = settings.enable_yt_dl_update
self._supported = {"22", "18"}
self._dl = None
@@ -276,88 +338,99 @@ class YouTubeDL:
return cls._DL_INSTANCE
def init(self):
if not os.path.isfile("{}youtube_dl{}version.py".format(self._path, SEP)):
if os.path.isfile(f"{self._path}yt_dlp{SEP}version.py"):
if self._path not in sys.path:
sys.path.append(self._path)
self.init_dl()
else:
self.get_latest_release()
if self._path not in sys.path:
sys.path.append(self._path)
self.init_dl()
def init_dl(self):
try:
import youtube_dl
import yt_dlp
except ModuleNotFoundError as e:
log("YouTubeDLHelper error: {}".format(str(e)))
log(f"YouTubeDLHelper error: {e}")
raise YouTubeException(e)
except ImportError as e:
log("YouTubeDLHelper error: {}".format(str(e)))
log(f"YouTubeDLHelper error: {e}")
else:
if self._update:
if hasattr(youtube_dl.version, "__version__"):
l_ver = self.get_last_release_id()
cur_ver = youtube_dl.version.__version__
if l_ver and youtube_dl.version.__version__ < l_ver:
msg = "youtube-dl has new release!\nCurrent: {}. Last: {}.".format(cur_ver, l_ver)
show_notification(msg)
log(msg)
self._callback(msg, False)
self.get_latest_release()
if self._path not in yt_dlp.__file__:
msg = "Another version of yt-dlp was found on your system!"
log(msg)
raise YouTubeException(msg)
self._DownloadError = youtube_dl.utils.DownloadError
self._dl = youtube_dl.YoutubeDL(self._OPTIONS)
msg = "youtube-dl initialized..."
self._DownloadError = yt_dlp.utils.DownloadError
self._dl = yt_dlp.YoutubeDL(self._OPTIONS)
msg = "yt-dlp initialized..."
show_notification(msg)
log(msg)
if self._update:
if hasattr(yt_dlp.version, "__version__"):
self.update(yt_dlp.version.__version__)
@staticmethod
def get_last_release_id():
""" Getting last release id. """
url = "https://api.github.com/repos/ytdl-org/youtube-dl/releases/latest"
url = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest"
try:
with urlopen(url, timeout=10) as resp:
return json.loads(resp.read().decode("utf-8")).get("tag_name", "0")
except URLError as e:
log("YouTubeDLHelper error [get last release id]: {}".format(e))
log(f"YouTubeDLHelper error [get last release id]: {e}")
def get_latest_release(self):
@run_task
def update(self, current_version):
l_ver = self.get_last_release_id()
if l_ver and current_version < l_ver:
msg = f"yt-dlp has new release!\nCurrent: {current_version}. Last: {l_ver}."
show_notification(msg)
log(msg)
self._callback(msg, False)
self.get_latest_release(update=True)
@run_task
def get_latest_release(self, update=False):
try:
self._is_update_process = True
log("Getting the last youtube-dl release...")
log("Getting the last yt-dlp release...")
with urlopen(YouTubeDL._LATEST_RELEASE_URL, timeout=10) as resp:
r = json.loads(resp.read().decode("utf-8"))
zip_url = r.get("zipball_url", None)
if zip_url:
zip_file = self._path + "yt.zip"
if os.path.isdir(self._path):
shutil.rmtree(self._path)
zip_file = f"{self._path}yt.zip"
os.makedirs(os.path.dirname(self._path), exist_ok=True)
f_name, headers = urlretrieve(zip_url, filename=zip_file)
import zipfile
with zipfile.ZipFile(f_name) as arch:
if os.path.isdir(self._path):
shutil.rmtree(self._path)
else:
os.makedirs(os.path.dirname(self._path), exist_ok=True)
for info in arch.infolist():
pref, sep, f = info.filename.partition("/youtube_dl/")
pref, sep, f = info.filename.partition("/yt_dlp/")
if sep:
arch.extract(info.filename)
shutil.move(info.filename, "{}{}{}".format(self._path, sep, f))
shutil.move(info.filename, f"{self._path}{sep}{f}")
shutil.rmtree(pref)
msg = "Getting the last youtube-dl release is done!"
msg = "Getting the last yt-dlp release is done!"
show_notification(msg)
log(msg)
self._callback(msg, False)
return True
if os.path.isfile(zip_file):
os.remove(zip_file)
return True
except URLError as e:
log("YouTubeDLHelper error: {}".format(e))
log(f"YouTubeDLHelper error: {e}")
raise YouTubeException(e)
finally:
self._is_update_process = False
if not update:
self.init()
def get_yt_link(self, url, skip_errors=False):
""" Returns tuple from the video links [dict] and title. """
@@ -377,10 +450,10 @@ class YouTubeDL:
try:
return self._dl.extract_info(url, download=False)
except URLError as e:
log(str(e))
log(f"YouTubeDLHelper error [get info]: {e}")
raise YouTubeException(e)
except self._DownloadError as e:
log(str(e))
log(f"YouTubeDLHelper error [get info]: {e}")
if not skip_errors:
raise YouTubeException(e)

View File

@@ -58,21 +58,30 @@
</item>
</section>
<section>
<item>
<submenu>
<attribute name="label" translatable="yes">FTP-transfer</attribute>
<attribute name="action">app.on_download</attribute>
</item>
<section>
<item>
<attribute name="label" translatable="yes">Download from the receiver</attribute>
<attribute name="action">app.on_receive</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Transfer to receiver</attribute>
<attribute name="action">app.on_send</attribute>
</item>
</section>
</submenu>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Settings</attribute>
<attribute name="action">app.on_settings</attribute>
<attribute name="action">app.preferences</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Exit</attribute>
<attribute name="action">app.on_close_app</attribute>
<attribute name="action">app.quit</attribute>
</item>
</section>
</submenu>
@@ -133,8 +142,28 @@
<attribute name="hidden-when">action-disabled</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Display picons</attribute>
<attribute name="action">app.display_picons</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Display EPG in bouquet list</attribute>
<attribute name="action">app.display_epg</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Alternate layout</attribute>
<attribute name="action">app.set_alternate_layout</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Alternate window title</attribute>
<attribute name="action">app.set_alternate_title</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
</section>
</submenu>
<submenu>
<submenu id="tools_menu">
<attribute name="label" translatable="yes">Tools</attribute>
<attribute name="action">app.hide_menu_bar</attribute>
<attribute name="hidden-when">action-disabled</attribute>
@@ -143,10 +172,21 @@
<attribute name="label" translatable="yes">Backups</attribute>
<attribute name="action">app.on_backup_tool_show</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Boot Logo</attribute>
<attribute name="action">app.on_boot_logo_tool_show</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Telnet</attribute>
<attribute name="action">app.on_telnet_show</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Logs</attribute>
<attribute name="action">app.on_logs_show</attribute>
</item>
</section>
<section id="extension_section">
</section>
</submenu>
<submenu>
@@ -163,7 +203,7 @@
<section>
<item>
<attribute name="label" translatable="yes">About</attribute>
<attribute name="action">app.on_about_app</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
</submenu>
@@ -172,19 +212,19 @@
<section>
<item>
<attribute name="label" translatable="yes">About</attribute>
<attribute name="action">app.on_about_app</attribute>
<attribute name="action">app.about</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Settings</attribute>
<attribute name="action">app.on_settings</attribute>
<attribute name="action">app.preferences</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Exit</attribute>
<attribute name="action">app.on_close_app</attribute>
<attribute name="action">app.quit</attribute>
</item>
</section>
</menu>
@@ -256,10 +296,19 @@
</item>
</section>
<section>
<item>
<submenu>
<attribute name="label" translatable="yes">FTP-transfer</attribute>
<attribute name="action">app.on_download</attribute>
</item>
<section>
<item>
<attribute name="label" translatable="yes">Download from the receiver</attribute>
<attribute name="action">app.on_receive</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Transfer to receiver</attribute>
<attribute name="action">app.on_send</attribute>
</item>
</section>
</submenu>
</section>
</submenu>
<submenu>
@@ -319,6 +368,26 @@
<attribute name="hidden-when">action-disabled</attribute>
</item>
</section>
<section>
<item>
<attribute name="label" translatable="yes">Display picons</attribute>
<attribute name="action">app.display_picons</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Display EPG in bouquet list</attribute>
<attribute name="action">app.display_epg</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Alternate layout</attribute>
<attribute name="action">app.set_alternate_layout</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Alternate window title</attribute>
<attribute name="action">app.set_alternate_title</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
</section>
</submenu>
<submenu>
<attribute name="label" translatable="yes">Tools</attribute>
@@ -329,10 +398,21 @@
<attribute name="label" translatable="yes">Backups</attribute>
<attribute name="action">app.on_backup_tool_show</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Boot Logo</attribute>
<attribute name="action">app.on_boot_logo_tool_show</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Telnet</attribute>
<attribute name="action">app.on_telnet_show</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Logs</attribute>
<attribute name="action">app.on_logs_show</attribute>
</item>
</section>
<section id="mac_extension_section">
</section>
</submenu>
<submenu>
@@ -354,14 +434,6 @@
<attribute name="label" translatable="yes">Import YouTube playlist</attribute>
<attribute name="action">app.on_import_yt_list</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Import m3u</attribute>
<attribute name="action">app.on_import_m3u</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Export to m3u</attribute>
<attribute name="action">app.on_export_to_m3u</attribute>
</item>
<section>
<item>
<attribute name="label" translatable="yes">EPG configuration</attribute>

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -33,12 +33,19 @@ import time
import zipfile
from datetime import datetime
from enum import Enum
from pathlib import Path
from app.commons import run_idle
from app.commons import run_idle, get_size_from_bytes
from app.settings import SettingsType, SEP
from app.ui.dialogs import show_dialog, DialogType, get_builder
from app.ui.main_helper import append_text_to_tview
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, MOD_MASK
from app.ui.main_helper import append_text_to_tview, show_info_bar_message
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, MOD_MASK, HeaderBar
KEEP_DATA = {"satellites.xml",
"terrestrial.xml",
"cables.xml",
"whitelist",
"whitelist_streamrelay"}
class RestoreType(Enum):
@@ -63,17 +70,35 @@ class BackupDialog:
self._settings = settings
self._s_type = settings.setting_type
self._data_path = self._settings.profile_data_path
self._backup_path = self._settings.profile_backup_path or "{}backup{}".format(self._data_path, os.sep)
self._backup_path = self._settings.profile_backup_path or f"{self._data_path}backup{os.sep}"
self._open_data_callback = callback
self._dialog_window = builder.get_object("dialog_window")
self._dialog_window.set_transient_for(transient)
self._model = builder.get_object("main_list_store")
self._main_view = builder.get_object("main_view")
self._text_view = builder.get_object("text_view")
self._text_view_scrolled_window = builder.get_object("text_view_scrolled_window")
self._info_check_button = builder.get_object("info_check_button")
self._info_bar = builder.get_object("info_bar")
self._message_label = builder.get_object("message_label")
self._file_count_label = builder.get_object("file_count_label")
if self._settings.use_header_bar:
header_bar = HeaderBar()
self._dialog_window.set_titlebar(header_bar)
button_box = builder.get_object("main_button_box")
button_box.set_margin_top(0)
button_box.set_margin_bottom(0)
button_box.set_margin_left(0)
button_box.reparent(header_bar)
ch_button = builder.get_object("info_check_button")
ch_button.set_margin_right(0)
h_bar = builder.get_object("header_bar")
h_bar.remove(ch_button)
h_bar.set_visible(False)
header_bar.pack_end(ch_button)
# Setting the last size of the dialog window if it was saved
window_size = self._settings.get("backup_tool_window_size")
if window_size:
@@ -88,10 +113,14 @@ class BackupDialog:
def init_data(self):
if os.path.isdir(self._backup_path):
for file in filter(lambda x: x.endswith(".zip"), os.listdir(self._backup_path)):
self._model.append((file.rstrip(".zip"), False))
p = Path(os.path.join(self._backup_path, file))
if p.is_file():
self._model.append((p.stem, get_size_from_bytes(p.stat().st_size)))
else:
os.makedirs(os.path.dirname(self._backup_path), exist_ok=True)
self._file_count_label.set_text(str(len(self._model)))
def on_restore_bouquets(self, item):
self.restore(RestoreType.BOUQUETS)
@@ -111,28 +140,26 @@ class BackupDialog:
try:
for itr in map(model.get_iter, paths):
file_name = model.get_value(itr, 0)
os.remove("{}{}{}".format(self._backup_path, file_name, ".zip"))
os.remove(f"{self._backup_path}{file_name}.zip")
itrs_to_delete.append(itr)
except FileNotFoundError as e:
self.show_info_message(str(e), Gtk.MessageType.ERROR)
else:
list(map(model.remove, itrs_to_delete))
self._file_count_label.set_text(str(len(self._model)))
def on_view_popup_menu(self, menu, event):
if event.get_event_type() == Gdk.EventType.BUTTON_PRESS and event.button == Gdk.BUTTON_SECONDARY:
menu.popup(None, None, None, None, event.button, event.time)
def on_info_button_toggled(self, button):
active = button.get_active()
self._text_view_scrolled_window.set_visible(active)
if active:
if button.get_active():
self.on_cursor_changed(self._main_view)
@run_idle
def show_info_message(self, text, message_type):
self._info_bar.set_visible(True)
self._info_bar.set_message_type(message_type)
self._message_label.set_text(text)
show_info_bar_message(self._info_bar, self._message_label, text, message_type)
def on_info_bar_close(self, bar=None, resp=None):
self._info_bar.set_visible(False)
@@ -147,7 +174,7 @@ class BackupDialog:
file_name = self._backup_path + model.get_value(model.get_iter(paths[0]), 0) + ".zip"
created = time.ctime(os.path.getctime(file_name))
self._text_view.get_buffer().set_text(
"Created: {}\n********** Files: **********\n".format(created))
f"Created: {created}\n********** Files: **********\n")
with zipfile.ZipFile(file_name) as zip_file:
for name in zip_file.namelist():
append_text_to_tview(name + "\n", self._text_view)
@@ -170,7 +197,7 @@ class BackupDialog:
return
file_name = model.get_value(model.get_iter(paths[0]), 0)
full_file_name = self._backup_path + file_name + ".zip"
full_file_name = f"{self._backup_path}{file_name}.zip"
try:
if restore_type is RestoreType.ALL:
@@ -196,11 +223,11 @@ class BackupDialog:
self._settings.add("backup_tool_window_size", window.get_size())
def on_key_release(self, view, event):
""" Handling keystrokes """
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
""" Handling keystrokes. """
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
key = KeyboardKey(key_code)
ctrl = event.state & MOD_MASK
if key is KeyboardKey.DELETE:
@@ -211,20 +238,21 @@ class BackupDialog:
self.restore(RestoreType.BOUQUETS)
def backup_data(path, backup_path, move=True):
def backup_data(path, backup_path, move=True, keep=None):
""" Creating data backup from a folder at the specified path
Returns full path to the compressed file.
"""
backup_path = "{}{}{}".format(backup_path, datetime.now().strftime("%Y-%m-%d_%H-%M-%S"), SEP)
keep = keep or KEEP_DATA
backup_path = f"{backup_path}{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}{SEP}"
os.makedirs(os.path.dirname(backup_path), exist_ok=True)
os.makedirs(os.path.dirname(path), exist_ok=True)
# backup files in data dir(skipping dirs and satellites.xml)
for file in filter(lambda f: f != "satellites.xml" and os.path.isfile(os.path.join(path, f)), os.listdir(path)):
# Backup files in data dir.
for file in filter(lambda f: os.path.isfile(os.path.join(path, f)), os.listdir(path)):
src, dst = os.path.join(path, file), backup_path + file
shutil.move(src, dst) if move else shutil.copy(src, dst)
# compressing to zip and delete remaining files
zip_file = shutil.make_archive(backup_path, "zip", backup_path)
shutil.move(src, dst) if move and file not in keep else shutil.copy(src, dst)
# Compressing to zip and delete remaining files.
zip_file = shutil.make_archive(backup_path.rstrip(SEP), "zip", backup_path)
shutil.rmtree(backup_path)
return zip_file
@@ -237,8 +265,8 @@ def restore_data(src, dst):
def clear_data_path(path):
""" Clearing data at the specified path excluding satellites.xml file """
for file in filter(lambda f: f != "satellites.xml" and os.path.isfile(os.path.join(path, f)), os.listdir(path)):
""" Clearing data at the specified path excluding *.xml file. """
for file in filter(lambda f: f not in KEEP_DATA and os.path.isfile(os.path.join(path, f)), os.listdir(path)):
os.remove(os.path.join(path, file))

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.1
<!-- Generated with glade 3.38.2
The MIT License (MIT)
Copyright (c) 2018-2020 Dmitriy Yefremov
Copyright (c) 2018-2024 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -27,127 +27,66 @@ Author: Dmitriy Yefremov
-->
<interface>
<requires lib="gtk+" version="3.16"/>
<requires lib="gtk+" version="3.22"/>
<!-- interface-css-provider-path style.css -->
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellites list editor for GNU/Linux. -->
<!-- interface-copyright 2018-2020 Dmitriy Yefremov -->
<!-- interface-copyright 2018-2024 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkImage" id="details_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">emblem-important-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkListStore" id="main_list_store">
<columns>
<!-- column-name date -->
<!-- column-name name -->
<column type="gchararray"/>
<!-- column-name size -->
<column type="gchararray"/>
<!-- column-name selected -->
<column type="gboolean"/>
</columns>
</object>
<object class="GtkMenu" id="popup_menu">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkImageMenuItem" id="restore_bouquets_popup_menu_item">
<property name="label" translatable="yes">Restore bouquets</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="use_stock">False</property>
<signal name="activate" handler="on_restore_bouquets" swapped="no"/>
<accelerator key="r" signal="activate" modifiers="Primary"/>
</object>
</child>
<child>
<object class="GtkImageMenuItem" id="restore_all_popup_menu_item">
<property name="label" translatable="yes">Restore all</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="use_stock">False</property>
<signal name="activate" handler="on_restore_all" swapped="no"/>
<accelerator key="e" signal="activate" modifiers="Primary"/>
</object>
</child>
<child>
<object class="GtkSeparatorMenuItem" id="popup_menu_separator">
<property name="visible">True</property>
<property name="can_focus">False</property>
</object>
</child>
<child>
<object class="GtkImageMenuItem" id="remove_popup_menu_item">
<property name="label">gtk-remove</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="use_underline">True</property>
<property name="use_stock">True</property>
<signal name="activate" handler="on_remove" swapped="no"/>
<accelerator key="Delete" signal="activate"/>
</object>
</child>
</object>
<object class="GtkImage" id="remove_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">user-trash-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkImage" id="restore_all_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">edit-select-all-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkImage" id="restore_bouquets_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">document-revert-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkWindow" id="dialog_window">
<property name="width_request">560</property>
<property name="height_request">320</property>
<property name="can_focus">False</property>
<property name="width-request">560</property>
<property name="height-request">320</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Backups</property>
<property name="modal">True</property>
<property name="window_position">center-on-parent</property>
<property name="destroy_with_parent">True</property>
<property name="icon_name">document-revert</property>
<property name="window-position">center-on-parent</property>
<property name="destroy-with-parent">True</property>
<property name="icon-name">document-revert-symbolic</property>
<signal name="check-resize" handler="on_resize" swapped="no"/>
<child>
<placeholder/>
</child>
<child>
<object class="GtkBox" id="main_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<child>
<object class="GtkBox" id="header_bar">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<child>
<object class="GtkButtonBox" id="main_button_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<property name="valign">center</property>
<property name="margin_left">15</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="layout_style">expand</property>
<property name="margin-start">15</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkButton" id="restore_bouquets_header_button">
<property name="label" translatable="yes">Restore bouquets</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="receives_default">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Restore bouquets</property>
<property name="valign">center</property>
<property name="image">restore_bouquets_image</property>
<property name="always_show_image">True</property>
<property name="always-show-image">True</property>
<signal name="clicked" handler="on_restore_bouquets" swapped="no"/>
<child>
<object class="GtkImage" id="restore_bouquets_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">document-revert-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
@@ -157,14 +96,21 @@ Author: Dmitriy Yefremov
</child>
<child>
<object class="GtkButton" id="restore_all_header_button">
<property name="label" translatable="yes">Restore all</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="receives_default">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Restore all</property>
<property name="valign">center</property>
<property name="image">restore_all_image</property>
<property name="always_show_image">True</property>
<property name="always-show-image">True</property>
<signal name="clicked" handler="on_restore_all" swapped="no"/>
<child>
<object class="GtkImage" id="restore_all_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-select-all-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
@@ -174,14 +120,21 @@ Author: Dmitriy Yefremov
</child>
<child>
<object class="GtkButton" id="remove_header_button">
<property name="label" translatable="yes">Remove</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="receives_default">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Remove</property>
<property name="valign">center</property>
<property name="image">remove_image</property>
<property name="always_show_image">True</property>
<property name="always-show-image">True</property>
<signal name="clicked" handler="on_remove" swapped="no"/>
<child>
<object class="GtkImage" id="remove_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">user-trash-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
<accelerator key="Delete" signal="clicked"/>
</object>
<packing>
@@ -199,22 +152,29 @@ Author: Dmitriy Yefremov
</child>
<child>
<object class="GtkCheckButton" id="info_check_button">
<property name="label" translatable="yes">Details</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="receives_default">False</property>
<property name="can-focus">False</property>
<property name="receives-default">False</property>
<property name="tooltip-text" translatable="yes">Details</property>
<property name="valign">center</property>
<property name="margin_right">15</property>
<property name="image">details_image</property>
<property name="always_show_image">True</property>
<property name="draw_indicator">False</property>
<property name="margin-end">15</property>
<property name="always-show-image">True</property>
<property name="draw-indicator">False</property>
<signal name="toggled" handler="on_info_button_toggled" swapped="no"/>
<child>
<object class="GtkImage" id="details_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">emblem-important-symbolic</property>
<property name="icon_size">1</property>
</object>
</child>
<accelerator key="i" signal="clicked" modifiers="Primary"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack_type">end</property>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
@@ -229,83 +189,191 @@ Author: Dmitriy Yefremov
</packing>
</child>
<child>
<object class="GtkPaned" id="main_paned">
<object class="GtkFrame" id="main_frame">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="margin_bottom">5</property>
<property name="wide_handle">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="label-xalign">0</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkScrolledWindow" id="main_view_scrolled_window">
<object class="GtkPaned" id="main_paned">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<property name="can-focus">True</property>
<property name="wide-handle">True</property>
<child>
<object class="GtkTreeView" id="main_view">
<object class="GtkViewport" id="backups_viewport">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="hexpand">True</property>
<property name="model">main_list_store</property>
<property name="headers_visible">False</property>
<property name="search_column">0</property>
<property name="rubber_banding">True</property>
<property name="activate_on_single_click">True</property>
<signal name="button-press-event" handler="on_view_popup_menu" object="popup_menu" swapped="no"/>
<signal name="cursor-changed" handler="on_cursor_changed" swapped="no"/>
<signal name="key-release-event" handler="on_key_release" swapped="no"/>
<child internal-child="selection">
<object class="GtkTreeSelection" id="main_view_selection">
<property name="mode">multiple</property>
</object>
</child>
<property name="can-focus">False</property>
<child>
<object class="GtkTreeViewColumn" id="backup_date_column">
<property name="title" translatable="yes">Backup</property>
<property name="clickable">True</property>
<property name="alignment">0.5</property>
<property name="reorderable">True</property>
<property name="sort_column_id">0</property>
<object class="GtkBox" id="backups_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkCellRendererText" id="date_render">
<property name="xpad">10</property>
<object class="GtkScrolledWindow" id="main_view_scrolled_window">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="shadow-type">in</property>
<child>
<object class="GtkTreeView" id="main_view">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="hexpand">True</property>
<property name="model">main_list_store</property>
<property name="search-column">0</property>
<property name="rubber-banding">True</property>
<property name="activate-on-single-click">True</property>
<signal name="button-press-event" handler="on_view_popup_menu" object="popup_menu" swapped="no"/>
<signal name="cursor-changed" handler="on_cursor_changed" swapped="no"/>
<signal name="key-release-event" handler="on_key_release" swapped="no"/>
<child internal-child="selection">
<object class="GtkTreeSelection" id="backup_selection">
<property name="mode">multiple</property>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="backup_name_column">
<property name="min-width">75</property>
<property name="title" translatable="yes">Name</property>
<property name="expand">True</property>
<property name="clickable">True</property>
<property name="alignment">0.5</property>
<property name="reorderable">True</property>
<property name="sort-column-id">0</property>
<child>
<object class="GtkCellRendererText" id="name_renderer">
<property name="xpad">10</property>
</object>
<attributes>
<attribute name="text">0</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="backup_size_column">
<property name="sizing">fixed</property>
<property name="fixed-width">120</property>
<property name="title" translatable="yes">Size</property>
<property name="alignment">0.5</property>
<child>
<object class="GtkCellRendererText" id="size_renderer">
<property name="xalign">0.49000000953674316</property>
</object>
<attributes>
<attribute name="text">1</attribute>
</attributes>
</child>
</object>
</child>
</object>
</child>
</object>
<attributes>
<attribute name="text">0</attribute>
</attributes>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="status_box">
<property name="height-request">26</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkImage" id="count_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">document-properties</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="file_count_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">0</property>
<property name="width-chars">4</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
<packing>
<property name="resize">True</property>
<property name="shrink">True</property>
</packing>
</child>
</object>
<packing>
<property name="resize">True</property>
<property name="shrink">True</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="text_view_scrolled_window">
<property name="can_focus">False</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkTextView" id="text_view">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixels_above_lines">5</property>
<property name="editable">False</property>
<property name="left_margin">10</property>
<property name="right_margin">10</property>
<property name="indent">10</property>
<property name="cursor_visible">False</property>
<property name="accepts_tab">False</property>
<object class="GtkViewport" id="text_viewport">
<property name="visible" bind-source="info_check_button" bind-property="active">False</property>
<property name="can-focus">False</property>
<child>
<object class="GtkScrolledWindow" id="text_view_scrolled_window">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="shadow-type">in</property>
<child>
<object class="GtkTextView" id="text_view">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="pixels-above-lines">5</property>
<property name="editable">False</property>
<property name="left-margin">10</property>
<property name="right-margin">10</property>
<property name="indent">10</property>
<property name="cursor-visible">False</property>
<property name="accepts-tab">False</property>
</object>
</child>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
<packing>
<property name="resize">True</property>
<property name="shrink">True</property>
</packing>
</child>
</object>
<packing>
<property name="resize">True</property>
<property name="shrink">True</property>
</packing>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
@@ -316,14 +384,14 @@ Author: Dmitriy Yefremov
</child>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="can_focus">False</property>
<property name="show_close_button">True</property>
<property name="can-focus">False</property>
<property name="show-close-button">True</property>
<signal name="response" handler="on_info_bar_close" swapped="no"/>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<property name="spacing">6</property>
<property name="layout_style">end</property>
<property name="layout-style">end</property>
</object>
<packing>
<property name="expand">False</property>
@@ -333,13 +401,13 @@ Author: Dmitriy Yefremov
</child>
<child internal-child="content_area">
<object class="GtkBox">
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<property name="spacing">16</property>
<child>
<object class="GtkLabel" id="message_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="label" translatable="yes">message</property>
</object>
<packing>
@@ -365,4 +433,41 @@ Author: Dmitriy Yefremov
</object>
</child>
</object>
<object class="GtkMenu" id="popup_menu">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkMenuItem" id="restore_bouquets_popup_menu_item">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Restore bouquets</property>
<signal name="activate" handler="on_restore_bouquets" swapped="no"/>
<accelerator key="r" signal="activate" modifiers="Primary"/>
</object>
</child>
<child>
<object class="GtkMenuItem" id="restore_all_popup_menu_item">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Restore all</property>
<signal name="activate" handler="on_restore_all" swapped="no"/>
<accelerator key="e" signal="activate" modifiers="Primary"/>
</object>
</child>
<child>
<object class="GtkSeparatorMenuItem" id="popup_menu_separator">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
</child>
<child>
<object class="GtkMenuItem" id="remove_popup_menu_item">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Remove</property>
<signal name="activate" handler="on_remove" swapped="no"/>
<accelerator key="Delete" signal="activate"/>
</object>
</child>
</object>
</interface>

407
app/ui/bootlogo.py Normal file
View File

@@ -0,0 +1,407 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import os
import subprocess
import sys
from datetime import datetime
from ftplib import all_errors
from pathlib import Path
from gi.repository.GObject import BindingFlags
from app.commons import log, run_task
from app.connections import UtfFTP
from app.settings import IS_DARWIN, IS_WIN
from app.ui.dialogs import translate, get_chooser_dialog, show_dialog, DialogType
from app.ui.main_helper import get_picon_pixbuf, redraw_image
from app.ui.uicommons import HeaderBar
from .uicommons import Gtk, GLib
_OUTPUT_FILES = ("bootlogo",
"bootlogo_wait",
"backdrop",
"reboot",
"shutdown",
"radio")
_E2_STB_PATHS = ("/usr/share", "/usr/share/enigma2")
class BootLogoManager(Gtk.Window):
def __init__(self, app, **kwargs):
super().__init__(title=translate("Boot Logo"), icon_name="demon-editor", application=app,
transient_for=app.app_window, destroy_with_parent=True,
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
default_width=560, default_height=320, modal=False, **kwargs)
self._app = app
self._exe = f"{'./' if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS') else ''}ffmpeg"
self._pix = None
self._img_path = None
# subprocess creation flags
self._sbp_flags = subprocess.CREATE_NO_WINDOW if IS_WIN else 0
margin = {"margin_start": 5, "margin_end": 5, "margin_top": 5, "margin_bottom": 5}
base_margin = {"margin_start": 10, "margin_end": 10, "margin_top": 10, "margin_bottom": 10}
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
frame = Gtk.Frame(shadow_type=Gtk.ShadowType.IN, **base_margin)
frame.get_style_context().add_class("view")
data_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.VERTICAL, **base_margin)
data_box.set_margin_bottom(margin.get("margin_bottom", 5))
data_box.set_margin_start(10)
frame.add(data_box)
self._image_area = Gtk.DrawingArea()
self._image_area.connect("draw", self.on_image_draw)
data_box.pack_end(self._image_area, True, True, 5)
self.add(main_box)
# Buttons
add_path_button = Gtk.Button.new_from_icon_name("insert-image-symbolic", Gtk.IconSize.BUTTON)
add_path_button.set_tooltip_text(translate("Add image"))
add_path_button.set_always_show_image(True)
add_path_button.connect("clicked", self.on_add_image)
receive_button = Gtk.Button.new_from_icon_name("network-receive-symbolic", Gtk.IconSize.BUTTON)
receive_button.set_tooltip_text(translate("Download from the receiver"))
receive_button.set_always_show_image(True)
receive_button.connect("clicked", self.on_receive)
transmit_button = Gtk.Button.new_from_icon_name("network-transmit-symbolic", Gtk.IconSize.BUTTON)
transmit_button.set_tooltip_text(translate("Transfer to receiver"))
transmit_button.set_sensitive(False)
transmit_button.set_always_show_image(True)
transmit_button.connect("clicked", self.on_transmit)
self._convert_button = Gtk.Button.new_from_icon_name("object-rotate-right-symbolic", Gtk.IconSize.BUTTON)
self._convert_button.set_tooltip_text(translate("Convert"))
self._convert_button.set_always_show_image(True)
self._convert_button.set_sensitive(False)
self._convert_button.connect("clicked", self.on_convert)
self._convert_button.bind_property("sensitive", transmit_button, "sensitive", 4)
settings_close_button = Gtk.ModelButton(label=translate("Close"), centered=True, margin_top=5)
# Formats.
self._format_button = Gtk.ComboBoxText()
self._format_button.set_tooltip_text(translate("TV Format"))
self._format_button.append("hd720", "HD-Ready (720)")
self._format_button.append("hd1080", "Full HD (1080)")
self._format_button.set_active_id("hd720")
action_box = Gtk.ButtonBox()
action_box.set_layout(Gtk.ButtonBoxStyle.EXPAND)
action_box.add(add_path_button)
action_box.add(self._convert_button)
action_box.add(self._format_button)
data_box.pack_start(action_box, False, False, 0)
# Settings.
self._stb_path_property = "boot_logo_manager_stb_paths"
popover = Gtk.Popover()
settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5, **base_margin)
file_name_box = Gtk.Box(spacing=5)
file_name_box.add(Gtk.Label(f"{translate('File')}:"))
self._file_combo_box = Gtk.ComboBoxText()
[self._file_combo_box.append(f"{f}.mvi", f) for f in _OUTPUT_FILES]
self._file_combo_box.set_active(0)
file_name_box.pack_start(self._file_combo_box, True, True, 0)
settings_box.add(file_name_box)
paths_box = Gtk.Box(spacing=5)
paths_box.add(Gtk.Label(translate("STB path:")))
self._path_combo_box = Gtk.ComboBoxText(has_entry=True)
self._path_entry = self._path_combo_box.get_child()
self._path_entry.set_can_focus(False)
self._path_entry.connect("focus-out-event", self.on_path_entry_focus_out)
# Init paths.
self._stb_paths = self._app.app_settings.get(self._stb_path_property, _E2_STB_PATHS)
[self._path_combo_box.append(p, p) for p in self._stb_paths]
self._path_combo_box.set_active_id(self._stb_paths[0])
paths_box.pack_start(self._path_combo_box, True, True, 0)
# Paths action box.
paths_action_box = Gtk.ButtonBox(homogeneous=True, layout_style=Gtk.ButtonBoxStyle.EXPAND)
self._remove_path_button = Gtk.Button.new_from_icon_name("list-remove-symbolic", Gtk.IconSize.BUTTON)
self._remove_path_button.set_tooltip_text(translate("Remove"))
self._remove_path_button.connect("clicked", self.on_remove_path)
add_e2_path_button = Gtk.Button.new_from_icon_name("list-add-symbolic", Gtk.IconSize.BUTTON)
add_e2_path_button.set_tooltip_text(translate("Add"))
add_e2_path_button.connect("clicked", self.on_add_path)
cancel_path_button = Gtk.Button.new_from_icon_name("edit-undo-symbolic", Gtk.IconSize.BUTTON)
cancel_path_button.set_tooltip_text(translate("Cancel"))
apply_path_button = Gtk.Button.new_from_icon_name("insert-link-symbolic", Gtk.IconSize.BUTTON)
apply_path_button.set_tooltip_text(translate("Apply"))
apply_path_button.set_can_focus(False)
apply_path_button.connect("clicked", self.on_apply_path)
paths_action_box.add(self._remove_path_button)
paths_action_box.add(add_e2_path_button)
paths_action_box.add(cancel_path_button)
paths_action_box.add(apply_path_button)
paths_box.pack_end(paths_action_box, True, True, 0)
settings_box.add(paths_box)
settings_box.pack_end(settings_close_button, False, False, 0)
settings_box.show_all()
cancel_path_button.set_visible(False)
apply_path_button.set_visible(False)
self._path_entry.bind_property("has-focus", apply_path_button, "visible")
apply_path_button.bind_property("visible", cancel_path_button, "visible")
apply_path_button.bind_property("visible", add_e2_path_button, "visible", BindingFlags.INVERT_BOOLEAN)
apply_path_button.bind_property("visible", self._remove_path_button, "visible", BindingFlags.INVERT_BOOLEAN)
popover.add(settings_box)
popover.connect("closed", self.on_settings_closed)
settings_button = Gtk.MenuButton(popover=popover, valign=Gtk.Align.CENTER, tooltip_text=translate("Options"))
settings_button.add(Gtk.Image.new_from_icon_name("applications-system-symbolic", Gtk.IconSize.BUTTON))
# Header and toolbar.
if app.app_settings.use_header_bar:
header = HeaderBar(title=translate("Boot Logo"))
header.pack_start(receive_button)
header.pack_start(transmit_button)
header.pack_end(settings_button)
self.set_titlebar(header)
header.show_all()
else:
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
toolbar.get_style_context().add_class("primary-toolbar")
margin["margin_start"] = 15
margin["margin_top"] = 5
button_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, **margin)
button_box.pack_start(receive_button, False, False, 0)
button_box.pack_start(transmit_button, False, False, 0)
toolbar.pack_start(button_box, True, True, 0)
toolbar.pack_end(settings_button, False, False, 0)
main_box.pack_start(toolbar, False, False, 0)
settings_button.set_margin_end(15)
main_box.pack_start(frame, True, True, 0)
main_box.show_all()
ws_property = "boot_logo_manager_window_size"
window_size = self._app.app_settings.get(ws_property, None)
if window_size:
self.resize(*window_size)
self.connect("delete-event", lambda w, e: self._app.app_settings.add(ws_property, w.get_size()))
self.connect("realize", self.init)
def init(self, *args):
log(f"{self.__class__.__name__} [init] Checking FFmpeg...")
try:
out = subprocess.check_output([self._exe, "-version"],
stderr=subprocess.STDOUT,
creationflags=self._sbp_flags)
except FileNotFoundError as e:
msg = translate("Check if FFmpeg is installed!")
self._app.show_error_message(f"Error. {e} {msg}")
log(e)
else:
lines = out.decode(errors="ignore").splitlines()
log(lines[0] if lines else lines)
def on_add_path(self, button):
self._path_entry.set_can_focus(True)
self._path_entry.grab_focus()
def on_remove_path(self, button):
self._path_combo_box.remove(self._path_combo_box.get_active())
self._path_combo_box.set_active(0)
self._remove_path_button.set_sensitive(len(self._path_combo_box.get_model()) > 1)
def on_apply_path(self, button):
path = self._path_entry.get_text()
paths = {r[0] for r in self._path_combo_box.get_model()}
if path in paths:
self._app.show_error_message("This path already exists!")
return True
self._path_combo_box.append(path, path)
self._path_combo_box.set_active_id(path)
self._remove_path_button.grab_focus()
self._remove_path_button.set_sensitive(len(paths))
return False
def on_path_entry_focus_out(self, entry, event):
entry.set_can_focus(False)
active = self._path_combo_box.get_active_id()
txt = entry.get_text()
if active != txt:
entry.set_text(active or "")
def on_settings_closed(self, popover):
paths = tuple(r[0] for r in self._path_combo_box.get_model())
if paths != self._stb_paths:
self._stb_paths = paths
self._app.app_settings.add(self._stb_path_property, self._stb_paths)
def on_add_image(self, button):
file_filter = None
if IS_DARWIN:
file_filter = Gtk.FileFilter()
file_filter.set_name("*.jpg, *.jpeg, *.png")
file_filter.add_mime_type("image/jpeg")
file_filter.add_mime_type("image/png")
response = get_chooser_dialog(self._app.app_window, self._app.app_settings, "*.jpg, *.jpeg, *.png files",
("*.jpg", "*.jpeg", "*.png"), "Select image", file_filter)
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
self._img_path = response
self._pix = get_picon_pixbuf(response, -1)
self._convert_button.set_sensitive(True)
self._image_area.queue_draw()
def on_receive(self, button):
self.download_data(self._file_combo_box.get_active_id())
def on_transmit(self, button):
if show_dialog(DialogType.QUESTION, self) != Gtk.ResponseType.OK:
return True
mvi_file = Path(self._img_path).parent.joinpath(self._file_combo_box.get_active_id())
if not mvi_file.is_file():
log(self._app.show_error_message(translate("No *.mvi file found for the selected image!")))
return
self.transfer_data(mvi_file)
def on_convert(self, button):
self.convert_to_mvi()
def convert_to_mvi(self, frame_rate=25, bit_rate=2000):
path = Path(self._img_path)
if not path.is_file():
self._app.show_error_message(translate("No image selected!"))
return
output = path.parent.joinpath(self._file_combo_box.get_active_id())
if Path(output).exists():
msg = f"\n{translate('The file already exists!')}\n\n\t{translate('Are you sure?')}"
if show_dialog(DialogType.QUESTION, self, msg) != Gtk.ResponseType.OK:
return True
ffmpeg_output = path.parent.joinpath(f"{self._file_combo_box.get_active_text()}.m2v")
cmd = [self._exe,
"-i", self._img_path,
"-r", str(frame_rate),
"-b", str(bit_rate),
"-s", self._format_button.get_active_id(),
ffmpeg_output]
try:
from PIL import Image
except ImportError as e:
self._app.show_error_message(f"{translate('Conversion error.')} {e}")
else:
with Image.open(self._img_path) as img:
width, height = img.size
if width != 1280 and height != 720:
log(f"{self.__class__.__name__} [convert] Resizing image...")
img.resize((1280, 720), Image.Resampling.LANCZOS)
tmp = path.parent.joinpath(f"{path.name}.tmp{path.suffix}").absolute()
cmd[2] = tmp
img.save(tmp)
# Processing image.
log(f"{self.__class__.__name__} [convert] Converting...")
subprocess.run(cmd, creationflags=self._sbp_flags)
if Path(ffmpeg_output).exists():
os.replace(ffmpeg_output, output)
log(f"{self.__class__.__name__} [convert] -> '{output}'. Done!")
if cmd[2] != self._img_path:
tmp_path = Path(cmd[2])
if tmp_path.exists():
tmp_path.unlink()
self._convert_button.set_sensitive(False)
def convert_to_image(self, video_path, img_path):
cmd = [self._exe, "-y", "-i", video_path, img_path]
subprocess.run(cmd, creationflags=self._sbp_flags)
@run_task
def download_data(self, f_name):
try:
settings = self._app.app_settings
with UtfFTP(host=settings.host, port=settings.port, user=settings.user, passwd=settings.password) as ftp:
ftp.encoding = "utf-8"
ftp.cwd(self._path_combo_box.get_active_id())
dest = Path(settings.profile_data_path).joinpath("bootlogo")
dest.mkdir(parents=True, exist_ok=True)
path = f"{dest}{os.sep}"
ftp.download_file(f_name, path)
vp = Path(f"{path}{f_name}")
img_path = f"{path}{f_name}.jpg"
if vp.exists():
rn_path = f"{path}{self._file_combo_box.get_active_text()}.m2v"
vp.rename(rn_path)
self.convert_to_image(rn_path, img_path)
self._pix = get_picon_pixbuf(img_path, -1)
GLib.idle_add(self._image_area.queue_draw)
except all_errors as e:
log(f"{self.__class__.__name__} [download error] {e}")
GLib.idle_add(self._app.show_error_message, f"{translate('Failed to download data:')} {e}")
@run_task
def transfer_data(self, f_path):
try:
settings = self._app.app_settings
with UtfFTP(host=settings.host, port=settings.port, user=settings.user, passwd=settings.password) as ftp:
ftp.encoding = "utf-8"
ftp.cwd(self._path_combo_box.get_active_id())
log(f"{self.__class__.__name__} [transfer data] Creating backup...")
backup_path = Path(settings.profile_backup_path).joinpath("bootlogo")
backup_path.mkdir(parents=True, exist_ok=True)
ftp.download_file(f_path.name, f"{backup_path}{os.sep}")
backup_file = backup_path.joinpath(f_path.name)
if backup_file.exists():
target = backup_path.joinpath(f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}_{f_path.name}")
backup_file.rename(target)
ftp.send_file(f_path.name, f"{f_path.parent}{os.sep}")
except all_errors as e:
log(f"{self.__class__.__name__} [upload error] {e}")
GLib.idle_add(self._app.show_error_message, f"{translate('Data transfer error:')} {e}")
else:
self._app.show_info_message("Done!")
def on_image_draw(self, area, cr):
if self._pix:
redraw_image(area, cr, self._pix)
if __name__ == "__main__":
pass

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2024 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -28,785 +28,37 @@
""" Receiver control module via HTTP API. """
import os
from datetime import datetime
from enum import Enum
from ftplib import all_errors
from urllib.parse import quote
import re
from gi.repository import GLib
from .dialogs import get_builder, show_dialog, DialogType, get_message
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, Page, Column, KeyboardKey
from .main_helper import redraw_image
from .dialogs import get_builder, translate
from .uicommons import Gtk, UI_RESOURCES_PATH
from ..commons import run_task, run_with_delay, log, run_idle
from ..connections import HttpAPI, UtfFTP
from ..eparser.ecommons import BqServiceType
from ..settings import IS_DARWIN, PlayStreamsMode, IS_LINUX, IS_WIN
class EpgTool(Gtk.Box):
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs)
self._app = app
self._app.connect("fav-changed", self.on_service_changed)
handlers = {"on_epg_press": self.on_epg_press,
"on_timer_add": self.on_timer_add,
"on_epg_filter_changed": self.on_epg_filter_changed,
"on_epg_filter_toggled": self.on_epg_filter_toggled}
builder = get_builder(UI_RESOURCES_PATH + "control.glade", handlers,
objects=("epg_frame", "epg_model", "epg_filter_model", "epg_sort_model"))
self._view = builder.get_object("epg_view")
self._model = builder.get_object("epg_model")
self._filter_model = builder.get_object("epg_filter_model")
self._filter_model.set_visible_func(self.epg_filter_function)
self._filter_entry = builder.get_object("epg_filter_entry")
builder.get_object("epg_filter_button").bind_property("active", self._filter_entry, "visible")
self.pack_start(builder.get_object("epg_frame"), True, True, 0)
self.show()
def on_timer_add(self, action=None, value=None):
model, paths = self._view.get_selection().get_selected_rows()
p_count = len(paths)
if p_count == 1:
dialog = TimerTool.TimerDialog(self._app.app_window, TimerTool.TimerAction.EVENT, model[paths][-1])
response = dialog.run()
if response == Gtk.ResponseType.OK:
gen = self.write_timers_list([dialog.get_request()])
GLib.idle_add(lambda: next(gen, False))
dialog.destroy()
elif p_count > 1:
if show_dialog(DialogType.QUESTION, self._app.app_window,
"Add timers for selected events?") != Gtk.ResponseType.OK:
return True
self.add_timers_list((model[p][-1] for p in paths))
else:
self._app.show_error_message("No selected item!")
def add_timers_list(self, paths):
ref_str = "timeraddbyeventid?sRef={}&eventid={}&justplay=0"
refs = [ref_str.format(ev.get("e2eventservicereference", ""), ev.get("e2eventid", "")) for ev in paths]
gen = self.write_timers_list(refs)
GLib.idle_add(lambda: next(gen, False))
def write_timers_list(self, refs):
self._app.wait_dialog.show()
tasks = list(refs)
for ref in refs:
self._app.send_http_request(HttpAPI.Request.TIMER, ref, lambda x: tasks.pop())
yield True
while tasks:
yield True
self._app.emit("change-page", Page.TIMERS.value)
def on_epg_press(self, view, event):
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS and len(view.get_model()) > 0:
self.on_timer_add()
def on_service_changed(self, app, ref):
self._app.wait_dialog.show()
self._app.send_http_request(HttpAPI.Request.EPG, quote(ref), self.update_epg_data)
@run_idle
def update_epg_data(self, epg):
self._model.clear()
list(map(self._model.append, (self.get_event_row(e) for e in epg.get("event_list", []))))
self._app.wait_dialog.hide()
def get_event_row(self, event):
title = event.get("e2eventtitle", "") or ""
desc = event.get("e2eventdescription", "") or ""
start = int(event.get("e2eventstart", "0"))
start_time = datetime.fromtimestamp(start)
end_time = datetime.fromtimestamp(start + int(event.get("e2eventduration", "0")))
time = "{} - {}".format(start_time.strftime("%A, %H:%M"), end_time.strftime("%H:%M"))
return title, time, desc, event
def on_epg_filter_changed(self, entry):
self._filter_model.refilter()
def on_epg_filter_toggled(self, button):
if not button.get_active():
self._filter_entry.set_text("")
def epg_filter_function(self, model, itr, data):
txt = self._filter_entry.get_text().upper()
return next((s for s in model.get(itr, 0, 1, 2) if txt in s.upper()), False)
class TimerTool(Gtk.Box):
TIME_STR = "%Y-%m-%d %H:%M"
ACTION = {"0": "Record", "1": "Zap"}
AFTER_EVENT = {"0": "Do Nothing",
"1": "Standby",
"2": "Shut down",
"3": "Auto"}
class TimerAction(Enum):
ADD = 0
EVENT = 1
CHANGE = 2
class TimerDialog(Gtk.Dialog):
def __init__(self, parent, action=None, timer_data=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self._action = action or TimerTool.TimerAction.ADD
self._timer_data = timer_data or {}
self._request = ""
handlers = {"on_timer_begins_set": self.on_timer_begins_set,
"on_timer_ends_set": self.on_timer_ends_set}
builder = get_builder(UI_RESOURCES_PATH + "control.glade", handlers,
objects=("timer_dialog_frame", "timer_ends_popover", "end_hour_adjustment",
"min_end_adjustment", "timer_begins_popover", "begins_hour_adjustment",
"min_begins_adjustment"))
self.set_title(get_message("Timer"))
self.set_modal(True)
self.set_skip_pager_hint(True)
self.set_skip_taskbar_hint(True)
self.set_transient_for(parent)
self.set_position(Gtk.WindowPosition.CENTER_ON_PARENT)
self.set_resizable(False)
self._timer_name_entry = builder.get_object("timer_name_entry")
self._timer_desc_entry = builder.get_object("timer_desc_entry")
self._timer_service_entry = builder.get_object("timer_service_entry")
self._timer_service_ref_entry = builder.get_object("timer_service_ref_entry")
self._timer_event_id_entry = builder.get_object("timer_event_id_entry")
self._timer_begins_entry = builder.get_object("timer_begins_entry")
self._timer_ends_entry = builder.get_object("timer_ends_entry")
self._timer_begins_calendar = builder.get_object("timer_begins_calendar")
self._timer_begins_hr_button = builder.get_object("timer_begins_hr_button")
self._timer_begins_min_button = builder.get_object("timer_begins_min_button")
self._timer_ends_calendar = builder.get_object("timer_ends_calendar")
self._timer_ends_hr_button = builder.get_object("timer_ends_hr_button")
self._timer_ends_min_button = builder.get_object("timer_ends_min_button")
self._timer_enabled_switch = builder.get_object("timer_enabled_switch")
self._timer_action_combo_box = builder.get_object("timer_action_combo_box")
self._timer_after_combo_box = builder.get_object("timer_after_combo_box")
self._days_buttons = (builder.get_object("timer_mo_check_button"),
builder.get_object("timer_tu_check_button"),
builder.get_object("timer_we_check_button"),
builder.get_object("timer_th_check_button"),
builder.get_object("timer_fr_check_button"),
builder.get_object("timer_sa_check_button"),
builder.get_object("timer_su_check_button"))
self._timer_location_switch = builder.get_object("timer_location_switch")
self._timer_location_entry = builder.get_object("timer_location_entry")
self._timer_location_switch.bind_property("active", self._timer_location_entry, "sensitive")
# Disable DnD for timer entries.
self._timer_name_entry.drag_dest_unset()
self._timer_desc_entry.drag_dest_unset()
self._timer_service_entry.drag_dest_unset()
self.add_buttons(get_message("Cancel"), Gtk.ResponseType.CLOSE, get_message("Save"), Gtk.ResponseType.OK)
self.get_content_area().pack_start(builder.get_object("timer_dialog_frame"), True, True, 5)
if self._action is TimerTool.TimerAction.ADD:
self.set_timer_for_add()
elif self._action is TimerTool.TimerAction.CHANGE:
self.set_timer_for_edit()
elif self._action is TimerTool.TimerAction.EVENT:
self.set_timer_from_event_data()
else:
log("{} error: No action set for timer!".format(__class__.__name__))
@property
def request(self):
return self._request
def run(self):
resp = super().run()
if resp == Gtk.ResponseType.OK:
self._request = self.get_request()
return resp
def get_request(self):
""" Constructs str representation of add/update request. """
args = []
t_data = self.get_timer_data()
s_ref = quote(t_data.get("sRef", ""))
if self._action is TimerTool.TimerAction.EVENT:
args.append("timeraddbyeventid?sRef={}".format(s_ref))
args.append("eventid={}".format(t_data.get("eit", "0")))
args.append("justplay={}".format(t_data.get("justplay", "")))
args.append("tags={}".format(""))
else:
if self._action is TimerTool.TimerAction.ADD:
args.append("timeradd?sRef={}".format(s_ref))
args.append("deleteOldOnSave={}".format(0))
elif self._action is TimerTool.TimerAction.CHANGE:
args.append("timerchange?sRef={}".format(s_ref))
args.append("channelOld={}".format(s_ref))
args.append("beginOld={}".format(self._timer_data.get("e2timebegin", "0")))
args.append("endOld={}".format(self._timer_data.get("e2timeend", "0")))
args.append("deleteOldOnSave={}".format(1))
args.append("begin={}".format(t_data.get("begin", "")))
args.append("end={}".format(t_data.get("end", "")))
args.append("name={}".format(quote(t_data.get("name", ""))))
args.append("description={}".format(quote(t_data.get("description", ""))))
args.append("tags={}".format(""))
args.append("eit={}".format("0"))
args.append("disabled={}".format(t_data.get("disabled", "1")))
args.append("justplay={}".format(t_data.get("justplay", "1")))
args.append("afterevent={}".format(t_data.get("afterevent", "0")))
args.append("repeated={}".format(TimerTool.get_repetition_flags(self._days_buttons)))
if self._timer_location_switch.get_active():
args.append("dirname={}".format(self._timer_location_entry.get_text()))
return "&".join(args)
def on_timer_begins_set(self, action, value=None):
self.set_begins_date(self.get_begins_date())
def on_timer_ends_set(self, action, value=None):
self.set_ends_date(self.get_ends_date())
def get_begins_date(self):
date = self._timer_begins_calendar.get_date()
return datetime(year=date.year, month=date.month + 1, day=date.day,
hour=int(self._timer_begins_hr_button.get_value()),
minute=int(self._timer_begins_min_button.get_value()))
def set_begins_date(self, date):
hour = date.hour
minute = date.minute
self._timer_begins_hr_button.set_value(hour)
self._timer_begins_min_button.set_value(minute)
self._timer_begins_calendar.select_day(date.day)
self._timer_begins_calendar.select_month(date.month - 1, date.year)
self._timer_begins_entry.set_text(
"{}-{}-{} {}:{:02d}".format(date.year, date.month, date.day, hour, minute))
def get_ends_date(self):
date = self._timer_ends_calendar.get_date()
return datetime(year=date.year, month=date.month + 1, day=date.day,
hour=int(self._timer_ends_hr_button.get_value()),
minute=int(self._timer_ends_min_button.get_value()))
def set_ends_date(self, date):
hour = date.hour
minute = date.minute
self._timer_ends_hr_button.set_value(hour)
self._timer_ends_min_button.set_value(minute)
self._timer_ends_calendar.select_day(date.day)
self._timer_ends_calendar.select_month(date.month - 1, date.year)
self._timer_ends_entry.set_text("{}-{}-{} {}:{:02d}".format(date.year, date.month, date.day, hour, minute))
def set_timer_for_add(self):
self._timer_service_entry.set_text(self._timer_data.get("e2servicename", ""))
self._timer_service_ref_entry.set_text(self._timer_data.get("e2servicereference", ""))
date = datetime.now()
self.set_begins_date(date)
self.set_ends_date(date)
self._timer_event_id_entry.set_text("")
self._timer_location_switch.set_active(False)
TimerTool.set_repetition_flags(0, self._days_buttons)
def set_timer_for_edit(self):
self._timer_name_entry.set_text(self._timer_data.get("e2name", ""))
self._timer_desc_entry.set_text(self._timer_data.get("e2description", "") or "")
self._timer_service_entry.set_text(self._timer_data.get("e2servicename", "") or "")
self._timer_service_ref_entry.set_text(self._timer_data.get("e2servicereference", ""))
self._timer_event_id_entry.set_text(self._timer_data.get("e2eit", ""))
self._timer_enabled_switch.set_active((self._timer_data.get("e2disabled", "0") == "0"))
self._timer_action_combo_box.set_active_id(self._timer_data.get("e2justplay", "0"))
self._timer_after_combo_box.set_active_id(self._timer_data.get("e2afterevent", "0"))
self.set_time_data(int(self._timer_data.get("e2timebegin", "0")),
int(self._timer_data.get("e2timeend", "0")))
location = self._timer_data.get("e2location", "")
self._timer_location_entry.set_text("" if location == "None" else location)
TimerTool.set_repetition_flags(int(self._timer_data.get("e2repeated", "0")), self._days_buttons)
def set_timer_from_event_data(self):
self._timer_name_entry.set_text(self._timer_data.get("e2eventtitle", ""))
self._timer_desc_entry.set_text(self._timer_data.get("e2eventdescription", ""))
self._timer_service_entry.set_text(self._timer_data.get("e2eventservicename", ""))
self._timer_service_ref_entry.set_text(self._timer_data.get("e2eventservicereference", ""))
self._timer_event_id_entry.set_text(self._timer_data.get("e2eventid", ""))
self._timer_action_combo_box.set_active_id("1")
self._timer_after_combo_box.set_active_id("3")
start_time = int(self._timer_data.get("e2eventstart", "0"))
self.set_time_data(start_time, start_time + int(self._timer_data.get("e2eventduration", "0")))
def set_time_data(self, start_time, end_time):
""" Sets values for time widgets. """
ev_time_start = datetime.fromtimestamp(start_time) or datetime.now()
ev_time_end = datetime.fromtimestamp(end_time) or datetime.now()
self._timer_begins_entry.set_text(ev_time_start.strftime(TimerTool.TIME_STR))
self._timer_ends_entry.set_text(ev_time_end.strftime(TimerTool.TIME_STR))
self._timer_begins_calendar.select_day(ev_time_start.day)
self._timer_begins_calendar.select_month(ev_time_start.month - 1, ev_time_start.year)
self._timer_ends_calendar.select_day(ev_time_end.day)
self._timer_ends_calendar.select_month(ev_time_end.month - 1, ev_time_end.year)
self._timer_begins_hr_button.set_value(ev_time_start.hour)
self._timer_begins_min_button.set_value(ev_time_start.minute)
self._timer_ends_hr_button.set_value(ev_time_end.hour)
self._timer_ends_min_button.set_value(ev_time_end.minute)
def get_timer_data(self):
""" Returns timer data as a dict. """
return {"sRef": self._timer_service_ref_entry.get_text(),
"begin": int(
datetime.strptime(self._timer_begins_entry.get_text(), TimerTool.TIME_STR).timestamp()),
"end": int(datetime.strptime(self._timer_ends_entry.get_text(), TimerTool.TIME_STR).timestamp()),
"name": self._timer_name_entry.get_text(),
"description": self._timer_desc_entry.get_text(),
"dirname": "",
"eit": self._timer_event_id_entry.get_text(),
"disabled": int(not self._timer_enabled_switch.get_active()),
"justplay": self._timer_action_combo_box.get_active_id(),
"afterevent": self._timer_after_combo_box.get_active_id(),
"repeated": TimerTool.get_repetition_flags(self._days_buttons)}
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs)
self._app = app
self._app.connect("page-changed", self.update_timer_list)
# Icon.
theme = Gtk.IconTheme.get_default()
icon = "alarm-symbolic"
self._icon = theme.load_icon(icon, 16, 0) if theme.lookup_icon(icon, 16, 0) else None
handlers = {"on_timer_add": self.on_timer_add,
"on_timer_edit": self.on_timer_edit,
"on_timer_remove": self.on_timer_remove,
"on_timers_press": self.on_timers_press,
"on_timers_key_press": self.on_timers_key_press,
"on_timer_cursor_changed": self.on_timer_cursor_changed,
"on_timers_drag_data_received": self.on_timers_drag_data_received}
builder = get_builder(UI_RESOURCES_PATH + "control.glade", handlers, objects=("timers_frame", "timer_model"))
self._view = builder.get_object("timer_view")
self._remove_button = builder.get_object("timer_remove_button")
self._remove_button.bind_property("sensitive", builder.get_object("timer_edit_button"), "sensitive")
self._info_button = builder.get_object("timer_info_check_button")
self._info_button.bind_property("active", builder.get_object("timer_info_frame"), "visible")
self._info_enabled_switch = builder.get_object("timer_info_enabled_switch")
self._ref_info_label = builder.get_object("timer_ref_value_label")
self._event_id_info_label = builder.get_object("timer_event_id_value_label")
self._begins_info_label = builder.get_object("timer_begins_value_label")
self._ends_info_label = builder.get_object("timer_ends_value_label")
self._action_info_label = builder.get_object("timer_action_value_label")
self._after_info_label = builder.get_object("timer_after_value_label")
self._timer_location_switch = builder.get_object("timer_location_switch")
self._info_location_entry = builder.get_object("timer_info_location_entry")
self._days_buttons = (builder.get_object("timer_info_mo_check_button"),
builder.get_object("timer_info_tu_check_button"),
builder.get_object("timer_info_we_check_button"),
builder.get_object("timer_info_th_check_button"),
builder.get_object("timer_info_fr_check_button"),
builder.get_object("timer_info_sa_check_button"),
builder.get_object("timer_info_su_check_button"))
# Disable button presses.
list(map(lambda b: b.connect("button-press-event", lambda bx, e: True), self._days_buttons))
self._info_enabled_switch.connect("button-press-event", lambda b, e: True)
# DnD initialization for the timer list.
self._view.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.DEFAULT | Gdk.DragAction.COPY)
self._view.drag_dest_add_text_targets()
self.pack_start(builder.get_object("timers_frame"), True, True, 0)
self.show()
def update_timer_list(self, app, page):
if page is Page.TIMERS:
self._app.wait_dialog.show()
self._app.send_http_request(HttpAPI.Request.TIMER_LIST, "", self.update_timers_data)
@run_idle
def update_timers_data(self, timers):
model = self._view.get_model()
model.clear()
list(map(model.append, (self.get_timer_row(t) for t in timers.get("timer_list", []))))
self._remove_button.set_sensitive(len(model))
self._app.wait_dialog.hide()
def get_timer_row(self, timer):
disabled = self._icon if timer.get("e2disabled", "0") == "0" else None
name = timer.get("e2name", "") or ""
description = timer.get("e2description", "") or ""
service = timer.get("e2servicename", "") or ""
start_time = datetime.fromtimestamp(int(timer.get("e2timebegin", "0")))
end_time = datetime.fromtimestamp(int(timer.get("e2timeend", "0")))
time = "{} - {}".format(start_time.strftime("%A, %H:%M"), end_time.strftime("%H:%M"))
return disabled, name, service, time, description, timer
def on_timer_add(self, timer=None, value=None):
model, paths = self._app.fav_view.get_selection().get_selected_rows()
p_count = len(paths)
if p_count == 1:
service = self._app.current_services.get(model[paths][Column.FAV_ID], None)
if service:
self.add_timer({"e2servicename": service.service,
"e2servicereference": service.picon_id.rstrip(".png").replace("_", ":")})
elif p_count > 1:
self._app.show_error_message("Please, select only one item!")
else:
self._app.show_error_message("No selected item!")
def add_timer(self, timer_data):
dialog = self.TimerDialog(self._app.app_window, self.TimerAction.ADD, timer_data)
response = dialog.run()
if response == Gtk.ResponseType.OK:
self._app.send_http_request(HttpAPI.Request.TIMER, dialog.request, self.timer_add_edit_callback)
dialog.destroy()
def on_timer_edit(self, action=None, value=None):
model, paths = self._view.get_selection().get_selected_rows()
if len(paths) > 1:
self._app.show_error_message("Please, select only one item!")
return
dialog = self.TimerDialog(self._app.app_window, self.TimerAction.CHANGE, model[paths][-1])
response = dialog.run()
if response == Gtk.ResponseType.OK:
self._app.send_http_request(HttpAPI.Request.TIMER, dialog.request, self.timer_add_edit_callback)
dialog.destroy()
@run_idle
def timer_add_edit_callback(self, resp):
if "error_code" in resp:
msg = "Error getting timer status.\n{}".format(resp.get("error_code"))
self._app.show_error_message(msg)
log(msg)
return
state = resp.get("e2state", None)
if state == "False":
msg = resp.get("e2statetext", "")
self._app.show_error_message(msg)
log(msg)
if state == "True":
msg = resp.get("e2statetext", "")
log(msg)
self._app.show_info_message(msg, Gtk.MessageType.INFO)
self.update_timer_list(self._app, Page.TIMERS)
else:
log("Error getting timer status. No response!")
def on_timer_remove(self, action=None, value=None):
model, paths = self._view.get_selection().get_selected_rows()
if not paths or show_dialog(DialogType.QUESTION, self._app.app_window) != Gtk.ResponseType.OK:
return
refs = {}
for path in paths:
timer = model[path][-1]
ref = "timerdelete?sRef={}&begin={}&end={}".format(quote(timer.get("e2servicereference", "")),
timer.get("e2timebegin", ""),
timer.get("e2timeend", ""))
refs[ref] = model.get_iter(path)
self._app.wait_dialog.show("Deleting data...")
gen = self.remove_timers(refs)
GLib.idle_add(lambda: next(gen, False))
def remove_timers(self, refs):
tasks = list(refs)
removed = set()
for ref in refs:
yield from self.remove_timer(ref, removed, tasks)
while tasks:
yield True
model = self._view.get_model()
list(map(model.remove, (refs[ref] for ref in refs if ref in removed)))
self._app.wait_dialog.hide()
self._remove_button.set_sensitive(len(model))
yield True
def remove_timer(self, ref, removed, tasks=None):
def callback(resp):
if resp.get("e2state", "") == "True":
log(resp.get("e2statetext", ""))
removed.add(ref)
else:
log(resp.get("e2statetext", None) or "Timer deletion error.")
if tasks:
tasks.pop()
self._app.send_http_request(HttpAPI.Request.TIMER, ref, callback)
yield True
def on_timers_press(self, view, event):
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS and len(view.get_model()) > 0:
self.on_timer_edit()
def on_timers_key_press(self, view, event):
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
return
key = KeyboardKey(key_code)
if key is KeyboardKey.DELETE:
self.on_timer_remove()
def on_timer_cursor_changed(self, view):
path, column = view.get_cursor()
if not path:
return
timer = view.get_model()[path][-1]
self._info_enabled_switch.set_active((timer.get("e2disabled", "0") == "0"))
self._ref_info_label.set_text(timer.get("e2servicereference", ""))
self._event_id_info_label.set_text(timer.get("e2eit", ""))
self._action_info_label.set_text(get_message(self.ACTION.get(timer.get("e2justplay", "0"), "0")))
self._after_info_label.set_text(get_message(self.AFTER_EVENT.get(timer.get("e2afterevent", "0"), "0")))
self._begins_info_label.set_text(str(datetime.fromtimestamp(int(timer.get("e2timebegin", "0")))))
self._ends_info_label.set_text(str(datetime.fromtimestamp(int(timer.get("e2timeend", "0")))))
self.set_repetition_flags(int(timer.get("e2repeated", "0")), self._days_buttons)
location = timer.get("e2location", "")
self._info_location_entry.set_text("" if location == "None" else location)
@staticmethod
def get_repetition_flags(boxes):
""" Returns flags for repetition.
@param boxes: Buttons tuple for the days of the week.
"""
day_flags = 0
for i, box in enumerate(boxes):
if box.get_active():
day_flags = day_flags | (1 << i)
return day_flags
@staticmethod
def set_repetition_flags(flags, boxes):
""" Sets flags for repetition.
@param flags: Flags value.
@param boxes: Buttons tuple for the days of the week.
"""
for i, box in enumerate(boxes):
box.set_active(flags & 1 == 1)
flags = flags >> 1
# ***************** Drag-and-drop ********************* #
def on_timers_drag_data_received(self, box, context, x, y, data, info, time):
txt = data.get_text()
if txt:
itr_str, sep, source = txt.partition(self._app.DRAG_SEP)
if not source:
return
itrs = itr_str.split(",")
if len(itrs) > 1:
self._app.show_error_message("Please, select only one item!")
return
fav_id = None
if source == self._app.FAV_MODEL_NAME:
model = self._app.fav_view.get_model()
fav_id = model.get_value(model.get_iter_from_string(itrs[0]), Column.FAV_ID)
elif source == self._app.SERVICE_MODEL_NAME:
model = self._app.services_view.get_model()
fav_id = model.get_value(model.get_iter_from_string(itrs[0]), Column.SRV_FAV_ID)
service = self._app.current_services.get(fav_id, None)
if service:
if service.service_type == BqServiceType.ALT.name:
msg = "Alternative service.\n\n {}".format(get_message("Not implemented yet!"))
show_dialog(DialogType.ERROR, transient=self._app._main_window, text=msg)
context.finish(False, False, time)
return
self.add_timer({"e2servicename": service.service,
"e2servicereference": service.picon_id.rstrip(".png").replace("_", ":")})
context.finish(True, False, time)
class RecordingsTool(Gtk.Box):
ROOT = ".."
DEFAULT_PATH = "/hdd"
def __init__(self, app, settings, *args, **kwargs):
super().__init__(*args, **kwargs)
self._app = app
self._app.connect("profile-changed", self.init)
self._settings = settings
self._ftp = None
# Icon.
theme = Gtk.IconTheme.get_default()
icon = "folder-symbolic" if IS_DARWIN else "folder"
self._icon = theme.load_icon(icon, 24, 0) if theme.lookup_icon(icon, 24, 0) else None
handlers = {"on_path_press": self.on_path_press,
"on_path_activated": self.on_path_activated,
"on_recordings_activated": self.on_recordings_activated,
"on_recording_remove": self.on_recording_remove}
builder = get_builder(UI_RESOURCES_PATH + "control.glade", handlers,
objects=("recordings_frame", "recordings_model", "rec_paths_model"))
self._rec_view = builder.get_object("recordings_view")
self._paths_view = builder.get_object("recordings_paths_view")
self._paned = builder.get_object("recordings_paned")
self.pack_start(builder.get_object("recordings_frame"), True, True, 0)
self.init()
self.show()
def clear_data(self):
self._rec_view.get_model().clear()
self._paths_view.get_model().clear()
@run_task
def init(self, app=None, arg=None):
GLib.idle_add(self.clear_data)
try:
if self._ftp:
self._ftp.close()
self._ftp = UtfFTP(host=self._settings.host, user=self._settings.user, passwd=self._settings.password)
self._ftp.encoding = "utf-8"
except all_errors:
pass # NOP
else:
self.init_paths(self.DEFAULT_PATH)
@run_idle
def init_paths(self, path=None):
self.clear_data()
if not self._ftp:
return
if path:
try:
self._ftp.cwd(path)
except all_errors as e:
pass
files = []
try:
self._ftp.dir(files.append)
except all_errors as e:
log(e)
else:
self.append_paths(files)
@run_idle
def append_paths(self, files):
model = self._paths_view.get_model()
model.clear()
model.append((None, self.ROOT, self._ftp.pwd()))
for f in files:
f_data = f.split()
f_type = f_data[0][0]
if f_type == "d":
model.append((self._icon, f_data[-1], self._ftp.pwd()))
def on_path_activated(self, view, path, column):
row = view.get_model()[path][:]
path = f"{row[-1]}/{row[1]}/"
self._app.send_http_request(HttpAPI.Request.RECORDINGS, quote(path), self.update_recordings_data)
def on_path_press(self, view, event):
target = view.get_path_at_pos(event.x, event.y)
if not target or event.button != Gdk.BUTTON_PRIMARY:
return
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS:
self.init_paths(self._paths_view.get_model()[target[0]][1])
@run_idle
def update_recordings_data(self, recordings):
model = self._rec_view.get_model()
model.clear()
list(map(model.append, (self.get_recordings_row(r) for r in recordings.get("recordings", []))))
def get_recordings_row(self, rec):
service = rec.get("e2servicename")
title = rec.get("e2title", "")
time = datetime.fromtimestamp(int(rec.get("e2time", "0"))).strftime("%A, %H:%M")
length = rec.get("e2length", "0")
file = rec.get("e2filename", "")
desc = rec.get("e2description", "")
return service, title, time, length, file, desc, rec
def on_recordings_activated(self, view, path, column):
rec = view.get_model()[path][-1]
self._app.send_http_request(HttpAPI.Request.STREAM_TS, rec.get("e2filename", ""), self.on_play_recording)
def on_play_recording(self, m3u):
url = self._app.get_url_from_m3u(m3u)
if url:
self._app.emit("play-recording", url)
def on_recording_remove(self, action, value=None):
""" Removes recordings via FTP. """
if show_dialog(DialogType.QUESTION, self._app.app_window) != Gtk.ResponseType.OK:
return
model, paths = self._rec_view.get_selection().get_selected_rows()
if paths and self._ftp:
for file, itr in ((model[p][-1].get("e2filename", ""), model.get_iter(p)) for p in paths):
resp = self._ftp.delete_file(file)
if resp.startswith("2"):
GLib.idle_add(model.remove, itr)
else:
self._app.show_error_message(resp)
break
def on_playback(self, box, state):
""" Updates state of the UI elements for playback mode. """
if self._settings.play_streams_mode is PlayStreamsMode.BUILT_IN:
self._paned.set_orientation(Gtk.Orientation.VERTICAL)
self.update_rec_columns_visibility(False)
def on_playback_close(self, box, state):
""" Restores UI elements state after playback mode. """
self._paned.set_orientation(Gtk.Orientation.HORIZONTAL)
self.update_rec_columns_visibility(True)
def update_rec_columns_visibility(self, state):
for c in (Column.REC_SERVICE, Column.REC_TIME, Column.REC_LEN, Column.REC_FILE, Column.REC_DESC):
self._rec_view.get_column(c).set_visible(state)
from ..connections import HttpAPI
from ..settings import IS_DARWIN, IS_LINUX, IS_WIN
class ControlTool(Gtk.Box):
def __init__(self, app, settings, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, app, settings, **kwargs):
super().__init__(**kwargs)
self._settings = settings
self._app = app
self._app.connect("layout-changed", self.on_layout_changed)
self._pix = None
handlers = {"on_volume_changed": self.on_volume_changed,
"on_screenshot_draw": self.on_screenshot_draw}
"on_screenshot_draw": self.on_screenshot_draw,
"on_network_toggled": self.on_network_toggled,
"on_network_view_query_tooltip": self.on_network_view_query_tooltip}
builder = get_builder(UI_RESOURCES_PATH + "control.glade", handlers,
objects=("control_box", "volume_adjustment"))
builder = get_builder(UI_RESOURCES_PATH + "control.glade", handlers)
self.pack_start(builder.get_object("control_box"), True, True, 0)
self._stack = builder.get_object("stack")
self._remote_box = builder.get_object("remote_box")
self._screenshot_area = builder.get_object("screenshot_area")
self._screenshot_button_box = builder.get_object("screenshot_button_box")
self._screenshot_check_button = builder.get_object("screenshot_check_button")
@@ -818,24 +70,52 @@ class ControlTool(Gtk.Box):
self._ber_level_bar = builder.get_object("ber_level_bar")
self._agc_level_bar = builder.get_object("agc_level_bar")
self._volume_button = builder.get_object("volume_button")
self._header_box = builder.get_object("control_header_box")
self._screenshot_button_box = builder.get_object("screenshot_button_box")
# Network.
self._network_button = builder.get_object("control_network_button")
self._network_model = builder.get_object("network_model")
self.init_actions(app)
if settings.alternate_layout:
self.on_layout_changed(app, True)
self.show()
def init_actions(self, app):
# Remote controller actions.
app.set_action("on_one", lambda a, v: self.on_remote_action(HttpAPI.Remote.ONE))
app.set_action("on_two", lambda a, v: self.on_remote_action(HttpAPI.Remote.TWO))
app.set_action("on_three", lambda a, v: self.on_remote_action(HttpAPI.Remote.THREE))
app.set_action("on_four", lambda a, v: self.on_remote_action(HttpAPI.Remote.FOUR))
app.set_action("on_five", lambda a, v: self.on_remote_action(HttpAPI.Remote.FIVE))
app.set_action("on_six", lambda a, v: self.on_remote_action(HttpAPI.Remote.SIX))
app.set_action("on_seven", lambda a, v: self.on_remote_action(HttpAPI.Remote.SEVEN))
app.set_action("on_eight", lambda a, v: self.on_remote_action(HttpAPI.Remote.EIGHT))
app.set_action("on_nine", lambda a, v: self.on_remote_action(HttpAPI.Remote.NINE))
app.set_action("on_zero", lambda a, v: self.on_remote_action(HttpAPI.Remote.ZERO))
app.set_action("on_up", lambda a, v: self.on_remote_action(HttpAPI.Remote.UP))
app.set_action("on_down", lambda a, v: self.on_remote_action(HttpAPI.Remote.DOWN))
app.set_action("on_left", lambda a, v: self.on_remote_action(HttpAPI.Remote.LEFT))
app.set_action("on_right", lambda a, v: self.on_remote_action(HttpAPI.Remote.RIGHT))
app.set_action("on_next", lambda a, v: self.on_remote_action(HttpAPI.Remote.NEXT))
app.set_action("on_back", lambda a, v: self.on_remote_action(HttpAPI.Remote.BACK))
app.set_action("on_info", lambda a, v: self.on_remote_action(HttpAPI.Remote.INFO))
app.set_action("on_ok", lambda a, v: self.on_remote_action(HttpAPI.Remote.OK))
app.set_action("on_menu", lambda a, v: self.on_remote_action(HttpAPI.Remote.MENU))
app.set_action("on_exit", lambda a, v: self.on_remote_action(HttpAPI.Remote.EXIT))
app.set_action("on_epg", lambda a, v: self.on_remote_action(HttpAPI.Remote.EPG))
app.set_action("on_ch_up", lambda a, v: self.on_remote_action(HttpAPI.Remote.CH_UP))
app.set_action("on_ch_down", lambda a, v: self.on_remote_action(HttpAPI.Remote.CH_DOWN))
app.set_action("on_red", lambda a, v: self.on_remote_action(HttpAPI.Remote.RED))
app.set_action("on_green", lambda a, v: self.on_remote_action(HttpAPI.Remote.GREEN))
app.set_action("on_yellow", lambda a, v: self.on_remote_action(HttpAPI.Remote.YELLOW))
app.set_action("on_blue", lambda a, v: self.on_remote_action(HttpAPI.Remote.BLUE))
app.set_action("on_audio", lambda a, v: self.on_remote_action(HttpAPI.Remote.AUDIO))
app.set_action("on_tv", lambda a, v: self.on_remote_action(HttpAPI.Remote.TV))
app.set_action("on_radio", lambda a, v: self.on_remote_action(HttpAPI.Remote.RADIO))
app.set_action("on_fav", lambda a, v: self.on_remote_action(HttpAPI.Remote.FAV))
# Playback.
app.set_action("on_prev_media", lambda a, v: self.on_player_action(HttpAPI.Request.PLAYER_PREV))
app.set_action("on_play_media", lambda a, v: self.on_player_action(HttpAPI.Request.PLAYER_PLAY))
@@ -852,6 +132,15 @@ class ControlTool(Gtk.Box):
app.set_action("on_screenshot_video", self.on_screenshot_video)
app.set_action("on_screenshot_osd", self.on_screenshot_osd)
def on_layout_changed(self, app, alt_layout):
children = self._remote_box.get_children()
self._remote_box.reorder_child(children[0], len(children) - 1)
self._remote_box.reorder_child(children[-1], 0)
pack_type = Gtk.PackType.END if alt_layout else Gtk.PackType.START
self._header_box.set_child_packing(self._network_button, False, False, 0, pack_type)
pack_type = Gtk.PackType.START if alt_layout else Gtk.PackType.END
self._header_box.set_child_packing(self._screenshot_button_box, False, False, 0, pack_type)
# ***************** Remote controller ********************* #
def on_remote(self, action, state=False):
@@ -871,7 +160,7 @@ class ControlTool(Gtk.Box):
@run_with_delay(0.5)
def on_volume_changed(self, button, value):
self._app.send_http_request(HttpAPI.Request.VOL, "{:.0f}".format(value), self.on_response)
self._app.send_http_request(HttpAPI.Request.VOL, f"{value:.0f}", self.on_response)
def update_volume(self, vol):
if "error_code" in vol:
@@ -913,11 +202,7 @@ class ControlTool(Gtk.Box):
def on_screenshot_draw(self, area, cr):
""" Called to automatically resize the screenshot. """
if self._pix:
cr.scale(area.get_allocated_width() / self._pix.get_width(),
area.get_allocated_height() / self._pix.get_height())
img_surface = Gdk.cairo_surface_create_from_pixbuf(self._pix, 1, None)
cr.set_source_surface(img_surface, 0, 0)
cr.paint()
redraw_image(area, cr, self._pix)
def on_screenshot_all(self, action, value=None):
if self._app.http_api:
@@ -970,13 +255,86 @@ class ControlTool(Gtk.Box):
def update_signal(self, sig):
snr = sig.get("e2snr", "0 %").strip() if sig else "0 %"
snr_db = sig.get("e2snrdb", "0 dB").strip() if sig else "0 dB"
acg = sig.get("e2acg", "0 %").strip() if sig else "0 %"
ber = (sig.get("e2ber", None) or "").strip() if sig else ""
# Labels.
self._snr_value_label.set_text(snr)
self._snr_value_label.set_text(f"{snr_db} ({snr})")
self._agc_value_label.set_text(acg)
self._ber_value_label.set_text(ber)
# Level bars.
self._snr_level_bar.set_value(int(snr.strip("%N/A") or 0))
self._agc_level_bar.set_value(int(acg.rstrip("%N/A") or 0))
self._ber_level_bar.set_value(int(ber.rstrip("N/A") or 0))
# ***************** Network explorer ********************** #
def on_network_toggled(self, button):
self._network_model.clear()
if button.get_active():
self.update_network()
@run_task
def update_network(self):
pattern = re.compile(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})')
ips = [match for match in re.findall(pattern, os.popen("arp -a").read())]
for ip in ips:
if not self._network_button.get_active():
break
url = f"http://{ip}/web/{HttpAPI.Request.INFO.value}"
try:
resp = HttpAPI.get_response(HttpAPI.Request.INFO, url, timeout=5)
except OSError as e:
log(f"{ip} {e}")
else:
if resp.get("e2distroversion", None):
log(f"Receiver found. Model: {resp.get('e2model', 'N/A')} [{ip} ]")
self.append_box_data(resp)
@run_idle
def append_box_data(self, data):
ip = data.get('e2lanip', 'N/A')
itr = self._network_model.append((data.get("e2model", "N/A"), ip, None, data, None))
GLib.timeout_add_seconds(3, self.check_power_state, itr, priority=GLib.PRIORITY_LOW)
def on_network_view_query_tooltip(self, view, x, y, keyboard_mode, tooltip):
result = view.get_dest_row_at_pos(x, y)
if not result:
return False
path, pos = result
model = view.get_model()
data = model[path][3]
dist = data.get("e2distroversion", "N/A")
img = data.get("e2imageversion", "N/A")
txt = f"Distro version: {dist}\nImage version: {img}"
tooltip.set_text(txt)
view.set_tooltip_row(tooltip, path)
return True
def check_power_state(self, itr):
active = self._network_button.get_active()
if not active:
return False
data = self._network_model.get_value(itr, 3)
url = f"http://{data.get('e2lanip', 'N/A')}/web/powerstate"
self.update_power_state(itr, url)
return active
@run_task
def update_power_state(self, itr, url):
try:
resp = HttpAPI.get_response(HttpAPI.Request.POWER, url, timeout=2)
except OSError as e:
log(e)
else:
state = translate("On" if resp.get("e2instandby", "N/A").strip() == "false" else "Standby")
GLib.idle_add(self._network_model.set_value, itr, 2, state)
if __name__ == "__main__":
pass

View File

@@ -3,7 +3,7 @@
The MIT License (MIT)
Copyright (c) 2018-2021 Dmitriy Yefremov
Copyright (c) 2018-2026 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -27,11 +27,11 @@ Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.16"/>
<requires lib="gtk+" version="3.22"/>
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellites list editor. -->
<!-- interface-copyright 2018-2021 Dmitriy Yefremov -->
<!-- interface-copyright 2018-2026 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkAboutDialog" id="about_dialog">
<property name="can_focus">False</property>
@@ -40,8 +40,8 @@ Author: Dmitriy Yefremov
<property name="icon_name">system-help</property>
<property name="type_hint">normal</property>
<property name="program_name">DemonEditor</property>
<property name="version">2.0.0 Alpha</property>
<property name="copyright">2018-2021 Dmitriy Yefremov
<property name="version">3.14.4 Beta</property>
<property name="copyright">2018-2026 Dmitriy Yefremov
</property>
<property name="comments" translatable="yes">Enigma2 channel and satellite list editor.</property>
<property name="website">https://dyefremov.github.io/DemonEditor/</property>
@@ -91,51 +91,36 @@ Author: Dmitriy Yefremov
<property name="type_hint">utility</property>
<property name="skip_taskbar_hint">True</property>
<property name="skip_pager_hint">True</property>
<child type="titlebar">
<placeholder/>
<child type="action">
<object class="GtkButton" id="input_dialog_cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="valign">center</property>
</object>
</child>
<child type="action">
<object class="GtkButton" id="input_dialog_ok_button">
<property name="label" translatable="yes">OK</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="valign">center</property>
<accelerator key="Return" signal="activate"/>
</object>
</child>
<child internal-child="vbox">
<object class="GtkBox">
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="margin_top">4</property>
<property name="orientation">vertical</property>
<property name="spacing">2</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="layout_style">end</property>
<child>
<object class="GtkButton" id="input_dialog_cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="input_dialog_ok_button">
<property name="label" translatable="yes">OK</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="valign">center</property>
<accelerator key="Return" signal="activate"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
@@ -147,10 +132,10 @@ Author: Dmitriy Yefremov
<object class="GtkEntry" id="input_entry">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="margin_left">2</property>
<property name="margin_right">2</property>
<property name="margin_top">2</property>
<property name="margin_bottom">2</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="margin_top">5</property>
<property name="margin_bottom">5</property>
<property name="primary_icon_name">document-edit-symbolic</property>
<property name="primary_icon_activatable">False</property>
<property name="secondary_icon_activatable">False</property>
@@ -173,6 +158,7 @@ Author: Dmitriy Yefremov
<property name="can_focus">False</property>
<property name="resizable">False</property>
<property name="modal">True</property>
<property name="width-request">170</property>
<property name="window_position">center-on-parent</property>
<property name="destroy_with_parent">True</property>
<property name="type_hint">splashscreen</property>
@@ -181,19 +167,19 @@ Author: Dmitriy Yefremov
<property name="decorated">False</property>
<child>
<object class="GtkBox" id="wait_dialog_box">
<property name="width_request">100</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_top">5</property>
<property name="margin_bottom">5</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="margin-start">10</property>
<property name="margin_end">10</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkSpinner" id="spinner">
<property name="width_request">150</property>
<property name="height_request">45</property>
<property name="visible">True</property>
<object class="LoadingProgressBar" id="progress">
<property name="visible" bind-source="wait_dialog" bind-property="visible">True</property>
<property name="can_focus">False</property>
<property name="active">True</property>
<property name="show-text">True</property>
<property name="text" translatable="yes">Loading data...</property>
</object>
<packing>
<property name="expand">False</property>
@@ -201,24 +187,10 @@ Author: Dmitriy Yefremov
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="wait_dialog_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="label" translatable="yes">Loading data...</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child> <!-- NOP -->
<style>
<class name="app-notification"/>
<class name="primary-toolbar"/>
</style>
</object>
</interface>

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -28,26 +28,45 @@
""" Common module for showing dialogs """
import gettext
import xml.etree.ElementTree as ET
from enum import Enum
from functools import lru_cache
from pathlib import Path
import xml.etree.ElementTree as ET
from app.commons import run_idle
from app.settings import SEP, IS_WIN
from .uicommons import Gtk, UI_RESOURCES_PATH, TEXT_DOMAIN, IS_GNOME_SESSION
from app.settings import SEP, USE_HEADER_BAR, IS_LINUX
from .uicommons import Gtk, UI_RESOURCES_PATH, TEXT_DOMAIN
class BaseDialog(Gtk.Dialog):
""" Base dialog class for editing DVB (-> *.xml) data. """
DEFAULT_BUTTONS = (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_OK, Gtk.ResponseType.OK)
def __init__(self, parent, title, buttons=None, *args, **kwargs):
super().__init__(transient_for=parent,
title=translate(title),
modal=True,
resizable=False,
default_width=255,
skip_taskbar_hint=True,
skip_pager_hint=True,
destroy_with_parent=True,
use_header_bar=USE_HEADER_BAR,
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
buttons=buttons or self.DEFAULT_BUTTONS,
*args, **kwargs)
class Dialog(Enum):
MESSAGE = """
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<requires lib="gtk+" version="3.16"/>
<requires lib="gtk+" version="3.22"/>
<object class="GtkMessageDialog" id="message_dialog">
<property name="use-header-bar">{use_header}</property>
<property name="can_focus">False</property>
<property name="modal">True</property>
<property name="width_request">250</property>
<property name="width_request">255</property>
<property name="destroy_with_parent">True</property>
<property name="type_hint">dialog</property>
<property name="skip_taskbar_hint">True</property>
@@ -82,8 +101,8 @@ class WaitDialog:
builder, dialog = get_dialog_from_xml(DialogType.WAIT, transient)
self._dialog = dialog
self._dialog.set_transient_for(transient)
self._label = builder.get_object("wait_dialog_label")
self._default_text = text or self._label.get_text()
self._progress = builder.get_object("progress")
self._default_text = text or self._progress.get_text()
def show(self, text=None):
self.set_text(text)
@@ -91,7 +110,7 @@ class WaitDialog:
@run_idle
def set_text(self, text):
self._label.set_text(get_message(text or self._default_text))
self._progress.set_text(translate(text or self._default_text))
@run_idle
def hide(self):
@@ -135,7 +154,7 @@ def get_chooser_dialog(transient, settings, name, patterns, title=None, file_fil
def get_file_chooser_dialog(transient, text, settings, action_type, file_filter, buttons=None, title=None, dirs=False):
action_type = Gtk.FileChooserAction.SELECT_FOLDER if action_type is None else action_type
dialog = Gtk.FileChooserNative.new(get_message(title) if title else "", transient, action_type)
dialog = Gtk.FileChooserNative.new(translate(title) if title else "", transient, action_type)
dialog.set_create_folders(dirs)
dialog.set_modal(True)
@@ -157,7 +176,7 @@ def get_file_chooser_dialog(transient, text, settings, action_type, file_filter,
def get_input_dialog(transient, text):
builder, dialog = get_dialog_from_xml(DialogType.INPUT, transient, use_header=IS_GNOME_SESSION)
builder, dialog = get_dialog_from_xml(DialogType.INPUT, transient, use_header=USE_HEADER_BAR)
entry = builder.get_object("input_entry")
entry.set_text(text if text else "")
response = dialog.run()
@@ -174,7 +193,7 @@ def get_message_dialog(transient, message_type, buttons_type, text):
builder.add_from_string(dialog_str)
dialog = builder.get_object("message_dialog")
dialog.set_transient_for(transient)
dialog.set_markup(get_message(text))
dialog.set_markup(translate(text))
response = dialog.run()
dialog.destroy()
@@ -202,14 +221,14 @@ def get_dialog_from_xml(dialog_type, transient, use_header=0, title=""):
return builder, dialog
def get_message(message):
def translate(message):
""" returns translated message """
return gettext.dgettext(TEXT_DOMAIN, message)
@lru_cache(maxsize=5)
def get_dialogs_string(path, tag="property"):
if IS_WIN:
if not IS_LINUX:
return translate_xml(path, tag)
else:
with open(path, "r", encoding="utf-8") as f:
@@ -223,9 +242,9 @@ def get_builder(path, handlers=None, use_str=False, objects=None, tag="property"
if use_str:
if objects:
builder.add_objects_from_string(get_dialogs_string(path, tag).format(use_header=IS_GNOME_SESSION), objects)
builder.add_objects_from_string(get_dialogs_string(path, tag).format(use_header=USE_HEADER_BAR), objects)
else:
builder.add_from_string(get_dialogs_string(path, tag).format(use_header=IS_GNOME_SESSION))
builder.add_from_string(get_dialogs_string(path, tag).format(use_header=USE_HEADER_BAR))
else:
if objects:
builder.add_objects_from_string(get_dialogs_string(path, tag), objects)
@@ -238,16 +257,17 @@ def get_builder(path, handlers=None, use_str=False, objects=None, tag="property"
def translate_xml(path, tag="property"):
"""
Used to translate GUI from * .glade files in MS Windows.
""" Used to translate GUI from *.glade files to macOS and MS Windows.
More info: https://gitlab.gnome.org/GNOME/gtk/-/issues/569
"""
et = ET.parse(path)
root = et.getroot()
for e in root.iter(tag):
if e.attrib.get("translatable", None) == "yes":
e.text = get_message(e.text)
for e in root.iter():
if e.tag == tag and e.attrib.get("translatable", None) == "yes":
e.text = translate(e.text)
elif e.tag == "item" and e.attrib.get("translatable", None) == "yes":
e.text = translate(e.text)
return ET.tostring(root, encoding="unicode", method="xml")

View File

@@ -1,542 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.2
The MIT License (MIT)
Copyright (c) 2018-2021 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.16"/>
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellites list editor. -->
<!-- interface-copyright 2018 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkImage" id="download_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">network-receive-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkImage" id="send_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="icon_name">network-transmit-symbolic</property>
<property name="icon_size">1</property>
</object>
<object class="GtkWindow" id="download_dialog_window">
<property name="width_request">550</property>
<property name="can_focus">False</property>
<property name="title" translatable="yes">FTP-transfer</property>
<property name="resizable">False</property>
<property name="modal">True</property>
<property name="window_position">center-on-parent</property>
<property name="icon_name">mail-send-receive-symbolic</property>
<property name="skip_taskbar_hint">True</property>
<property name="skip_pager_hint">True</property>
<child type="titlebar">
<placeholder/>
</child>
<child>
<object class="GtkBox" id="main_dialog_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="header_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="profile_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_top">5</property>
<property name="margin_bottom">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Profile:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkComboBoxText" id="profile_combo_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="focus_on_click">False</property>
<property name="active">0</property>
<property name="has_frame">False</property>
<signal name="changed" handler="on_profile_changed" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="options_button">
<property name="label" translatable="yes">Options</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Options</property>
<signal name="clicked" handler="on_settings" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="selection_data_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="margin_top">10</property>
<property name="margin_bottom">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel" id="label10">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="all_radio_button">
<property name="label" translatable="yes">All</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="draw_indicator">True</property>
<property name="group">satellites_radio_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="bouquets_radio_button">
<property name="label" translatable="yes">Bouquets</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="draw_indicator">True</property>
<property name="group">satellites_radio_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="satellites_radio_button">
<property name="label" translatable="yes">Satellites</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="draw_indicator">True</property>
<property name="group">all_radio_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="webtv_radio_button">
<property name="label" translatable="yes">WebTV</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="draw_indicator">True</property>
<property name="group">all_radio_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<style>
<class name="primary-toolbar"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkFrame" id="main_settings_box_frame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="margin_top">5</property>
<property name="label_xalign">0.019999999552965164</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkBox" id="main_settings_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">10</property>
<property name="margin_right">10</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkGrid" id="main_settings_bo">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="row_spacing">2</property>
<property name="column_spacing">2</property>
<property name="column_homogeneous">True</property>
<child>
<object class="GtkLabel" id="ip_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Receiver IP:</property>
<property name="xalign">0.10000000149011612</property>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="host_entry">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="editable">False</property>
<property name="max_width_chars">10</property>
<property name="text">127.0.0.1</property>
<property name="caps_lock_warning">False</property>
<property name="primary_icon_name">network-transmit-receive-symbolic</property>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="data_path_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Current data path:</property>
<property name="xalign">0.10000000149011612</property>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="data_path_entry">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="editable">False</property>
<property name="text">data/</property>
<property name="caps_lock_warning">False</property>
<property name="primary_icon_name">folder-open-symbolic</property>
</object>
<packing>
<property name="left_attach">1</property>
<property name="top_attach">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="extra_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="margin_top">5</property>
<child>
<object class="GtkCheckButton" id="remove_unused_check_button">
<property name="label" translatable="yes">Remove unused bouquets</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="active">True</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="on_remove_unused_bouquets_toggled" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="use_http_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="spacing">2</property>
<child>
<object class="GtkLabel" id="use_http_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Use HTTP</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkSwitch" id="use_http_switch">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="tooltip_text" translatable="yes">Use http to reload data in the receiver.</property>
<property name="active">True</property>
<signal name="state-set" handler="on_use_http_state_set" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack_type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
<child type="label_item">
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButtonBox" id="button_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="valign">center</property>
<property name="margin_left">20</property>
<property name="margin_right">20</property>
<property name="margin_top">10</property>
<property name="margin_bottom">10</property>
<property name="homogeneous">True</property>
<property name="layout_style">expand</property>
<child>
<object class="GtkButton" id="receive_button">
<property name="label" translatable="yes">Receive</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Receive</property>
<property name="valign">center</property>
<property name="image">download_image</property>
<property name="always_show_image">True</property>
<signal name="clicked" handler="on_receive" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="send_button">
<property name="label" translatable="yes">Send</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Send</property>
<property name="valign">center</property>
<property name="image">send_image</property>
<property name="always_show_image">True</property>
<signal name="clicked" handler="on_send" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">6</property>
</packing>
</child>
<child>
<object class="GtkExpander" id="expander">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="margin_left">1</property>
<property name="margin_right">1</property>
<property name="margin_bottom">1</property>
<property name="resize_toplevel">True</property>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="height_request">120</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkTextView" id="text_view">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="editable">False</property>
<property name="left_margin">5</property>
<property name="right_margin">5</property>
</object>
</child>
</object>
</child>
<child type="label">
<object class="GtkLabel" id="expander_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Extra:</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">7</property>
</packing>
</child>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="can_focus">False</property>
<property name="margin_left">1</property>
<property name="margin_right">1</property>
<property name="margin_bottom">1</property>
<property name="show_close_button">True</property>
<signal name="response" handler="on_info_bar_close" swapped="no"/>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can_focus">False</property>
<property name="homogeneous">True</property>
<property name="layout_style">expand</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child internal-child="content_area">
<object class="GtkBox">
<property name="can_focus">False</property>
<property name="spacing">16</property>
<child>
<object class="GtkLabel" id="info_bar_message_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">Info</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">8</property>
</packing>
</child>
</object>
</child>
</object>
</interface>

View File

@@ -1,197 +0,0 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import os
from gi.repository import GLib
from app.commons import run_idle, run_task, log
from app.connections import download_data, DownloadType, upload_data
from app.settings import SettingsType
from app.ui.backup import backup_data, restore_data
from app.ui.main_helper import append_text_to_tview
from app.ui.settings_dialog import show_settings_dialog
from .dialogs import show_dialog, DialogType, get_message, get_builder
from .uicommons import Gtk, UI_RESOURCES_PATH
class DownloadDialog:
def __init__(self, transient, settings, open_data_callback, update_settings_callback):
self._s_type = settings.setting_type
self._settings = settings
self._open_data_callback = open_data_callback
self._update_settings_callback = update_settings_callback
handlers = {"on_receive": self.on_receive,
"on_send": self.on_send,
"on_settings": self.on_settings,
"on_profile_changed": self.on_profile_changed,
"on_use_http_state_set": self.on_use_http_state_set,
"on_remove_unused_bouquets_toggled": self.on_remove_unused_bouquets_toggled,
"on_info_bar_close": self.on_info_bar_close}
builder = get_builder(UI_RESOURCES_PATH + "download_dialog.glade", handlers)
self._dialog_window = builder.get_object("download_dialog_window")
self._dialog_window.set_transient_for(transient)
self._info_bar = builder.get_object("info_bar")
self._message_label = builder.get_object("info_bar_message_label")
self._text_view = builder.get_object("text_view")
self._expander = builder.get_object("expander")
self._host_entry = builder.get_object("host_entry")
self._data_path_entry = builder.get_object("data_path_entry")
self._remove_unused_check_button = builder.get_object("remove_unused_check_button")
self._all_radio_button = builder.get_object("all_radio_button")
self._bouquets_radio_button = builder.get_object("bouquets_radio_button")
self._satellites_radio_button = builder.get_object("satellites_radio_button")
self._webtv_radio_button = builder.get_object("webtv_radio_button")
self._use_http_switch = builder.get_object("use_http_switch")
self._http_radio_button = builder.get_object("http_radio_button")
self._profile_combo_box = builder.get_object("profile_combo_box")
self.init_settings()
def show(self):
self._dialog_window.show()
def init_settings(self):
self.update_profiles()
self.init_ui_settings()
def init_ui_settings(self):
self._host_entry.set_text(self._settings.host)
self._data_path_entry.set_text(self._settings.profile_data_path)
is_enigma = self._s_type is SettingsType.ENIGMA_2
self._webtv_radio_button.set_visible(not is_enigma)
self._use_http_switch.set_active(self._settings.use_http)
self._remove_unused_check_button.set_active(self._settings.remove_unused_bouquets)
def update_profiles(self):
self._profile_combo_box.remove_all()
for p in self._settings.profiles:
self._profile_combo_box.append(p, p)
self._profile_combo_box.set_active_id(self._settings.current_profile)
@run_idle
def on_receive(self, item):
self.download(True, self.get_download_type())
@run_idle
def on_send(self, item):
if show_dialog(DialogType.QUESTION, self._dialog_window) != Gtk.ResponseType.CANCEL:
self.download(False, self.get_download_type())
def get_download_type(self):
download_type = DownloadType.ALL
if self._bouquets_radio_button.get_active():
download_type = DownloadType.BOUQUETS
elif self._satellites_radio_button.get_active():
download_type = DownloadType.SATELLITES
elif self._webtv_radio_button.get_active():
download_type = DownloadType.WEBTV
return download_type
def destroy(self):
self._dialog_window.destroy()
def on_settings(self, item):
response = show_settings_dialog(self._dialog_window, self._settings)
if response != Gtk.ResponseType.CANCEL:
self._s_type = self._settings.setting_type
self.update_profiles()
gen = self._update_settings_callback()
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
def on_profile_changed(self, box):
active = box.get_active_text()
if active in self._settings.profiles:
self._settings.current_profile = active
self._profile_combo_box.set_active_id(active)
self._s_type = self._settings.setting_type
self.init_ui_settings()
def on_use_http_state_set(self, button, state):
self._settings.use_http = state
def on_remove_unused_bouquets_toggled(self, button):
self._settings.remove_unused_bouquets = button.get_active()
def on_info_bar_close(self, bar=None, resp=None):
self._info_bar.set_visible(False)
@run_task
def download(self, download, d_type):
""" Download/upload data from/to receiver """
GLib.idle_add(self._expander.set_expanded, True)
self.clear_output()
backup, backup_src, data_path = self._settings.backup_before_downloading, None, None
try:
if download:
if backup and d_type is not DownloadType.SATELLITES:
data_path = self._settings.profile_data_path or self._data_path_entry.get_text()
os.makedirs(os.path.dirname(data_path), exist_ok=True)
backup_path = self._settings.profile_backup_path or self._settings.default_backup_path
backup_src = backup_data(data_path, backup_path, d_type is DownloadType.ALL)
download_data(settings=self._settings, download_type=d_type, callback=self.append_output)
else:
self.show_info_message(get_message("Please, wait..."), Gtk.MessageType.INFO)
upload_data(settings=self._settings,
download_type=d_type,
remove_unused=self._remove_unused_check_button.get_active(),
callback=self.append_output,
done_callback=lambda: self.show_info_message(get_message("Done!"), Gtk.MessageType.INFO),
use_http=self._use_http_switch.get_active())
except Exception as e:
msg = "Downloading data error: {}"
log(msg.format(e), debug=self._settings.debug_mode, fmt_message=msg)
self.show_info_message(str(e), Gtk.MessageType.ERROR)
if all((download, backup, data_path)):
restore_data(backup_src, data_path)
else:
if download and d_type is not DownloadType.SATELLITES:
GLib.idle_add(self._open_data_callback)
@run_idle
def show_info_message(self, text, message_type):
self._info_bar.set_visible(True)
self._info_bar.set_message_type(message_type)
self._message_label.set_text(text)
@run_idle
def append_output(self, text):
append_text_to_tview(text, self._text_view)
@run_idle
def clear_output(self):
self._text_view.get_buffer().set_text("")
if __name__ == "__main__":
pass

File diff suppressed because it is too large Load Diff

View File

@@ -1,576 +0,0 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import gzip
import locale
import os
import re
import shutil
import urllib.request
from enum import Enum
from urllib.error import HTTPError, URLError
from gi.repository import GLib
from app.commons import run_idle, run_task
from app.connections import download_data, DownloadType
from app.eparser.ecommons import BouquetService, BqServiceType
from app.settings import SEP
from app.tools.epg import EPG, ChannelsParser
from app.ui.dialogs import get_message, show_dialog, DialogType, get_builder
from .main_helper import on_popup_menu, update_entry_data
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, Column, EPG_ICON, KeyboardKey
class RefsSource(Enum):
SERVICES = 0
XML = 1
class EpgDialog:
def __init__(self, transient, settings, services, bouquet, fav_model, bouquet_name):
handlers = {"on_close_dialog": self.on_close_dialog,
"on_apply": self.on_apply,
"on_update": self.on_update,
"on_save_to_xml": self.on_save_to_xml,
"on_auto_configuration": self.on_auto_configuration,
"on_filter_toggled": self.on_filter_toggled,
"on_filter_changed": self.on_filter_changed,
"on_info_bar_close": self.on_info_bar_close,
"on_popup_menu": on_popup_menu,
"on_bouquet_popup_menu": self.on_bouquet_popup_menu,
"on_copy_ref": self.on_copy_ref,
"on_assign_ref": self.on_assign_ref,
"on_reset": self.on_reset,
"on_list_reset": self.on_list_reset,
"on_drag_begin": self.on_drag_begin,
"on_drag_data_get": self.on_drag_data_get,
"on_drag_data_received": self.on_drag_data_received,
"on_resize": self.on_resize,
"on_names_source_changed": self.on_names_source_changed,
"on_options_save": self.on_options_save,
"on_use_web_source_switch": self.on_use_web_source_switch,
"on_enable_filtering_switch": self.on_enable_filtering_switch,
"on_update_on_start_switch": self.on_update_on_start_switch,
"on_field_icon_press": self.on_field_icon_press,
"on_key_release": self.on_key_release}
self._services = {}
self._ex_services = services
self._ex_fav_model = fav_model
self._settings = settings
self._bouquet = bouquet
self._bouquet_name = bouquet_name
self._current_ref = []
self._enable_dat_filter = False
self._use_web_source = False
self._update_epg_data_on_start = False
self._refs_source = RefsSource.SERVICES
self._show_tooltips = True
self._download_xml_is_active = False
builder = get_builder(UI_RESOURCES_PATH + "epg.glade", handlers)
self._dialog = builder.get_object("epg_dialog_window")
self._dialog.set_transient_for(transient)
self._source_view = builder.get_object("source_view")
self._bouquet_view = builder.get_object("bouquet_view")
self._bouquet_model = builder.get_object("bouquet_list_store")
self._services_model = builder.get_object("services_list_store")
self._info_bar = builder.get_object("info_bar")
self._message_label = builder.get_object("info_bar_message_label")
self._assign_ref_popup_item = builder.get_object("bouquet_assign_ref_popup_item")
self._left_header_box = builder.get_object("left_header_box")
self._xml_download_progress_bar = builder.get_object("xml_download_progress_bar")
# Filter
self._filter_bar = builder.get_object("filter_bar")
self._filter_bar.bind_property("search-mode-enabled", self._filter_bar, "visible")
self._filter_entry = builder.get_object("filter_entry")
self._services_filter_model = builder.get_object("services_filter_model")
self._services_filter_model.set_visible_func(self.services_filter_function)
# Info
self._source_count_label = builder.get_object("source_count_label")
self._source_info_label = builder.get_object("source_info_label")
self._bouquet_count_label = builder.get_object("bouquet_count_label")
self._bouquet_epg_count_label = builder.get_object("bouquet_epg_count_label")
# Options
self._xml_radiobutton = builder.get_object("xml_radiobutton")
self._xml_chooser_button = builder.get_object("xml_chooser_button")
self._names_source_box = builder.get_object("names_source_box")
self._web_source_box = builder.get_object("web_source_box")
self._use_web_source_switch = builder.get_object("use_web_source_switch")
self._url_to_xml_entry = builder.get_object("url_to_xml_entry")
self._enable_filtering_switch = builder.get_object("enable_filtering_switch")
self._epg_dat_path_entry = builder.get_object("epg_dat_path_entry")
self._epg_dat_stb_path_entry = builder.get_object("epg_dat_stb_path_entry")
self._update_on_start_switch = builder.get_object("update_on_start_switch")
self._epg_dat_source_box = builder.get_object("epg_dat_source_box")
# Setting the last size of the dialog window
window_size = self._settings.get("epg_tool_window_size")
if window_size:
self._dialog.resize(*window_size)
self.init_drag_and_drop()
self.on_update()
def show(self):
self._dialog.show()
def on_close_dialog(self, window, event):
self._download_xml_is_active = False
@run_idle
def on_apply(self, item):
if show_dialog(DialogType.QUESTION, self._dialog) == Gtk.ResponseType.CANCEL:
return
self._bouquet.clear()
list(map(self._bouquet.append, [r[Column.FAV_ID] for r in self._bouquet_model]))
for index, row in enumerate(self._ex_fav_model):
fav_id = self._bouquet[index]
row[Column.FAV_ID] = fav_id
if row[Column.FAV_TYPE] == BqServiceType.IPTV.name:
old_fav_id = self._services[fav_id]
srv = self._ex_services.pop(old_fav_id, None)
if srv:
self._ex_services[fav_id] = srv._replace(fav_id=fav_id)
self._dialog.destroy()
@run_idle
def on_update(self, item=None):
self.clear_data()
self.init_options()
gen = self.init_data()
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
def clear_data(self):
self._services_model.clear()
self._bouquet_model.clear()
self._services.clear()
self._source_info_label.set_text("")
self._bouquet_epg_count_label.set_text("")
self.on_info_bar_close()
def init_data(self):
gen = self.init_bouquet_data()
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
refs = None
if self._enable_dat_filter:
if self._update_epg_data_on_start:
try:
self.download_epg_from_stb()
except OSError as e:
self.show_info_message("Download epg.dat file error: {}".format(e), Gtk.MessageType.ERROR)
return
yield True
try:
refs = EPG.get_epg_refs(self._epg_dat_path_entry.get_text() + "epg.dat")
except FileNotFoundError as e:
self.show_info_message("Read data error: {}".format(e), Gtk.MessageType.ERROR)
return
yield True
if self._refs_source is RefsSource.SERVICES:
self.init_lamedb_source(refs)
elif self._refs_source is RefsSource.XML:
xml_gen = self.init_xml_source(refs)
try:
yield from xml_gen
except ValueError as e:
self.show_info_message(str(e), Gtk.MessageType.ERROR)
else:
self.show_info_message("Unknown names source!", Gtk.MessageType.ERROR)
yield True
def init_bouquet_data(self):
for r in self._ex_fav_model:
row = [*r[:]]
fav_id = r[Column.FAV_ID]
self._services[fav_id] = self._ex_services[fav_id].fav_id
yield self._bouquet_model.append(row)
self._bouquet_count_label.set_text(str(len(self._bouquet_model)))
yield True
def init_lamedb_source(self, refs):
srvs = {k[:k.rfind(":")]: v for k, v in self._ex_services.items()}
s_types = (BqServiceType.MARKER.value, BqServiceType.IPTV.value)
filtered = filter(None, [srvs.get(ref) for ref in refs]) if refs else filter(
lambda s: s.service_type not in s_types, self._ex_services.values())
list(map(self._services_model.append, map(lambda s: (s.service, s.fav_id), filtered)))
self.update_source_count_info()
def init_xml_source(self, refs):
path = self._epg_dat_path_entry.get_text() if self._use_web_source else self._xml_chooser_button.get_filename()
if not path:
self.show_info_message("The path to the xml file is not set!", Gtk.MessageType.ERROR)
return
if self._use_web_source:
# Downloading gzipped xml file that contains services names with references from the web.
self._download_xml_is_active = True
self.update_active_header_elements(False)
url = self._url_to_xml_entry.get_text()
try:
with urllib.request.urlopen(url, timeout=2) as fp:
headers = fp.info()
content_type = headers.get("Content-Type", "")
if content_type != "application/gzip":
self._download_xml_is_active = False
raise ValueError("{} {} {}".format(get_message("Download XML file error."),
get_message("Unsupported file type:"),
content_type))
file_name = os.path.basename(url)
data_path = self._epg_dat_path_entry.get_text()
with open(data_path + file_name, "wb") as tfp:
bs = 1024 * 8
size = -1
read = 0
b_num = 0
if "content-length" in headers:
size = int(headers["Content-Length"])
while self._download_xml_is_active:
block = fp.read(bs)
if not block:
break
read += len(block)
tfp.write(block)
b_num += 1
self.update_download_progress(b_num * bs / size)
yield True
path = tfp.name.rstrip(".gz")
except (HTTPError, URLError) as e:
raise ValueError("{} {}".format(get_message("Download XML file error."), e))
else:
try:
with open(path, "wb") as f_out:
with gzip.open(tfp.name, "rb") as f:
shutil.copyfileobj(f, f_out)
os.remove(tfp.name)
except Exception as e:
raise ValueError("{} {}".format(get_message("Unpacking data error."), e))
finally:
self._download_xml_is_active = False
self.update_active_header_elements(True)
try:
s_refs, info = ChannelsParser.get_refs_from_xml(path)
yield True
except Exception as e:
raise ValueError("{} {}".format(get_message("XML parsing error:"), e))
else:
if refs:
s_refs = filter(lambda x: x.num in refs, s_refs)
list(map(lambda s: self._services_model.append((s.name, s.data)), s_refs))
self.update_source_info(info)
self.update_source_count_info()
yield True
def on_key_release(self, view, event):
""" Handling keystrokes """
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
return
key = KeyboardKey(key_code)
ctrl = event.state & Gdk.ModifierType.CONTROL_MASK
if ctrl and key is KeyboardKey.C:
self.on_copy_ref()
elif ctrl and key is KeyboardKey.V:
self.on_assign_ref()
@run_idle
def on_save_to_xml(self, item):
response = show_dialog(DialogType.CHOOSER, self._dialog, settings=self._settings)
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
services = []
iptv_types = (BqServiceType.IPTV.value, BqServiceType.MARKER.value)
for r in self._bouquet_model:
srv_type = r[Column.FAV_TYPE]
if srv_type in iptv_types:
srv = BouquetService(name=r[Column.FAV_SERVICE],
type=BqServiceType(srv_type),
data=r[Column.FAV_ID],
num=r[Column.FAV_NUM])
services.append(srv)
ChannelsParser.write_refs_to_xml("{}{}.xml".format(response, self._bouquet_name), services)
self.show_info_message(get_message("Done!"), Gtk.MessageType.INFO)
@run_idle
def on_auto_configuration(self, item):
""" Simple mapping of services by name. """
use_cyrillic = locale.getdefaultlocale()[0] in ("ru_RU", "be_BY", "uk_UA", "sr_RS")
tr = None
if use_cyrillic:
# may be not entirely correct
symbols = (u"АБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯІÏҐЎЈЂЉЊЋЏTB",
u"ABVGDEEJZIJKLMNOPRSTUFHZCSS_Y_EUAIEGUEDLNCJTV")
tr = {ord(k): ord(v) for k, v in zip(*symbols)}
source = {}
for row in self._services_model:
name = re.sub("\\W+", "", str(row[0])).upper()
name = name.translate(tr) if use_cyrillic else name
source[name] = row[1]
success_count = 0
not_founded = {}
for r in self._bouquet_model:
if r[Column.FAV_TYPE] != BqServiceType.IPTV.value:
continue
name = re.sub("\\W+", "", str(r[Column.FAV_SERVICE])).upper()
if use_cyrillic:
name = name.translate(tr)
ref = source.get(name, None) # Not [pop], because the list may contain duplicates or similar names!
if ref:
self.assign_data(r, ref, True)
success_count += 1
else:
not_founded[name] = r
# Additional attempt to search in the remaining elements
for n in not_founded:
for k in source:
if k.startswith(n):
self.assign_data(not_founded[n], source[k], True)
success_count += 1
break
self.update_epg_count()
self.show_info_message("{} {} {}".format(get_message("Done!"),
get_message("Count of successfully configured services:"),
success_count), Gtk.MessageType.INFO)
def assign_data(self, row, ref, show_error=False):
if row[Column.FAV_TYPE] != BqServiceType.IPTV.value:
if not show_error:
self.show_info_message(get_message("Not allowed in this context!"), Gtk.MessageType.ERROR)
return
fav_id = row[Column.FAV_ID]
fav_id_data = fav_id.split(":")
fav_id_data[3:7] = ref.split(":")
new_fav_id = ":".join(fav_id_data)
service = self._services.pop(fav_id, None)
if service:
self._services[new_fav_id] = service
row[Column.FAV_ID] = new_fav_id
row[Column.FAV_LOCKED] = EPG_ICON
row[Column.FAV_TOOLTIP] = ":".join(fav_id_data[:10]) if self._show_tooltips else None
def on_filter_toggled(self, button: Gtk.ToggleButton):
self._filter_bar.set_search_mode(button.get_active())
def on_filter_changed(self, entry):
self._services_filter_model.refilter()
def services_filter_function(self, model, itr, data):
txt = self._filter_entry.get_text().upper()
return model is None or model == "None" or txt in model.get_value(itr, 0).upper()
def on_info_bar_close(self, bar=None, resp=None):
self._info_bar.set_visible(False)
def on_copy_ref(self, item=None):
model, paths = self._source_view.get_selection().get_selected_rows()
self._current_ref.clear()
if paths:
self._current_ref.append(model[paths][1])
def on_assign_ref(self, item=None):
if self._current_ref:
model, paths = self._bouquet_view.get_selection().get_selected_rows()
self.assign_data(model[paths], self._current_ref.pop())
self.update_epg_count()
@run_idle
def on_reset(self, item):
model, paths = self._bouquet_view.get_selection().get_selected_rows()
if paths:
row = self._bouquet_model[paths]
self.reset_row_data(row)
self.update_epg_count()
@run_idle
def on_list_reset(self, item):
list(map(self.reset_row_data, self._bouquet_model))
self.update_epg_count()
def reset_row_data(self, row):
default_fav_id = self._services.pop(row[Column.FAV_ID], None)
if default_fav_id:
self._services[default_fav_id] = default_fav_id
row[Column.FAV_ID], row[Column.FAV_LOCKED], row[Column.FAV_TOOLTIP] = default_fav_id, None, None
@run_idle
def show_info_message(self, text, message_type):
self._info_bar.set_visible(True)
self._info_bar.set_message_type(message_type)
self._message_label.set_text(text)
@run_idle
def update_source_info(self, info):
lines = info.split("\n")
self._source_info_label.set_text(lines[0] if lines else "")
self._source_view.set_tooltip_text(info)
@run_idle
def update_source_count_info(self):
source_count = len(self._services_model)
self._source_count_label.set_text(str(source_count))
if self._enable_dat_filter and source_count == 0:
msg = get_message("Current epg.dat file does not contains references for the services of this bouquet!")
self.show_info_message(msg, Gtk.MessageType.WARNING)
@run_idle
def update_epg_count(self):
count = len(list((filter(None, [r[Column.FAV_LOCKED] for r in self._bouquet_model]))))
self._bouquet_epg_count_label.set_text(str(count))
@run_idle
def update_active_header_elements(self, state):
self._left_header_box.set_sensitive(state)
self._xml_download_progress_bar.set_visible(not state)
self._source_info_label.set_text("" if state else "Downloading XML:")
@run_idle
def update_download_progress(self, value):
self._xml_download_progress_bar.set_fraction(value)
def on_bouquet_popup_menu(self, menu, event):
self._assign_ref_popup_item.set_sensitive(self._current_ref)
on_popup_menu(menu, event)
# ***************** Drag-and-drop *********************#
def init_drag_and_drop(self):
""" Enable drag-and-drop """
target = []
self._source_view.enable_model_drag_source(Gdk.ModifierType.BUTTON1_MASK, target, Gdk.DragAction.COPY)
self._source_view.drag_source_add_text_targets()
self._bouquet_view.enable_model_drag_dest(target, Gdk.DragAction.DEFAULT | Gdk.DragAction.MOVE)
self._bouquet_view.drag_dest_add_text_targets()
def on_drag_begin(self, view, context):
""" Selects a row under the cursor in the view at the dragging beginning. """
selection = view.get_selection()
if selection.count_selected_rows() > 1:
view.do_toggle_cursor_row(view)
def on_drag_data_get(self, view: Gtk.TreeView, drag_context, data, info, time):
model, paths = view.get_selection().get_selected_rows()
if paths:
val = model.get_value(model.get_iter(paths), 1)
data.set_text(val, -1)
def on_drag_data_received(self, view: Gtk.TreeView, drag_context, x, y, data, info, time):
path, pos = view.get_dest_row_at_pos(x, y)
model = view.get_model()
self.assign_data(model[path], data.get_text())
self.update_epg_count()
return False
# ***************** Options *********************#
def init_options(self):
epg_dat_path = "{}epg{}".format(self._settings.profile_data_path, SEP)
self._epg_dat_path_entry.set_text(epg_dat_path)
default_epg_data_stb_path = "/etc/enigma2"
epg_options = self._settings.epg_options
if epg_options:
self._refs_source = RefsSource.XML if epg_options.get("xml_source", False) else RefsSource.SERVICES
self._xml_radiobutton.set_active(self._refs_source is RefsSource.XML)
self._use_web_source = epg_options.get("use_web_source", False)
self._use_web_source_switch.set_active(self._use_web_source)
self._url_to_xml_entry.set_text(epg_options.get("url_to_xml", ""))
self._enable_dat_filter = epg_options.get("enable_filtering", False)
self._enable_filtering_switch.set_active(self._enable_dat_filter)
epg_dat_path = epg_options.get("epg_dat_path", epg_dat_path)
self._epg_dat_path_entry.set_text(epg_dat_path)
self._epg_dat_stb_path_entry.set_text(epg_options.get("epg_dat_stb_path", default_epg_data_stb_path))
self._update_epg_data_on_start = epg_options.get("epg_data_update_on_start", False)
self._update_on_start_switch.set_active(self._update_epg_data_on_start)
local_xml_path = epg_options.get("local_path_to_xml", None)
if local_xml_path:
self._xml_chooser_button.set_filename(local_xml_path)
os.makedirs(os.path.dirname(self._epg_dat_path_entry.get_text()), exist_ok=True)
def on_options_save(self, item=None):
self._settings.epg_options = {"xml_source": self._xml_radiobutton.get_active(),
"use_web_source": self._use_web_source_switch.get_active(),
"local_path_to_xml": self._xml_chooser_button.get_filename(),
"url_to_xml": self._url_to_xml_entry.get_text(),
"enable_filtering": self._enable_filtering_switch.get_active(),
"epg_dat_path": self._epg_dat_path_entry.get_text(),
"epg_dat_stb_path": self._epg_dat_stb_path_entry.get_text(),
"epg_data_update_on_start": self._update_on_start_switch.get_active()}
def on_resize(self, window):
if self._settings:
self._settings.add("epg_tool_window_size", window.get_size())
def on_names_source_changed(self, button):
self._refs_source = RefsSource.XML if button.get_active() else RefsSource.SERVICES
self._names_source_box.set_sensitive(button.get_active())
def on_enable_filtering_switch(self, switch, state):
self._epg_dat_source_box.set_sensitive(state)
self._update_on_start_switch.set_active(False if not state else self._update_epg_data_on_start)
def on_update_on_start_switch(self, switch, state):
pass
def on_use_web_source_switch(self, switch, state):
self._web_source_box.set_sensitive(state)
self._xml_chooser_button.set_sensitive(not state)
def on_field_icon_press(self, entry, icon, event_button):
update_entry_data(entry, self._dialog, self._settings)
# ***************** Downloads *********************#
@run_task
def download_epg_from_stb(self):
""" Download the epg.dat file via ftp from the receiver. """
download_data(settings=self._settings, download_type=DownloadType.EPG, callback=print)
if __name__ == "__main__":
pass

0
app/ui/epg/__init__.py Normal file
View File

1630
app/ui/epg/dialog.glade Normal file

File diff suppressed because it is too large Load Diff

1566
app/ui/epg/epg.py Normal file

File diff suppressed because it is too large Load Diff

467
app/ui/epg/settings.glade Normal file
View File

@@ -0,0 +1,467 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2
The MIT License (MIT)
Copyright (c) 2018-2023 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.22"/>
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellites list editor for GNU/Linux. -->
<!-- interface-copyright 2018-2023 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkAdjustment" id="interval_adjustment">
<property name="lower">3</property>
<property name="upper">60</property>
<property name="value">3</property>
<property name="step-increment">1</property>
<property name="page-increment">10</property>
</object>
<object class="GtkBox" id="main_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="orientation">vertical</property>
<property name="spacing">2</property>
<child>
<object class="GtkLabel" id="src_label">
<property name="visible" bind-source="source_selection_box" bind-property="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
<property name="label" translatable="yes">Source:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="src_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<child>
<object class="GtkButtonBox" id="source_selection_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkRadioButton" id="http_src_button">
<property name="label" translatable="yes">Receiver</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">dat_src_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="xml_src_button">
<property name="label" translatable="yes">XML TV</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">dat_src_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="dat_src_button">
<property name="label" translatable="yes">*.dat file</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">http_src_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="interval_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel" id="interval_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Update interval (sec):</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkSpinButton" id="interval_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="max-width-chars">4</property>
<property name="adjustment">interval_adjustment</property>
<property name="climb-rate">1</property>
<property name="numeric">True</property>
<property name="value">3</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="xml_source_box">
<property name="visible">True</property>
<property name="sensitive" bind-source="xml_src_button" bind-property="active">False</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel" id="url_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Url to *.xml.gz file:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="url_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkComboBoxText" id="url_combo_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="active">0</property>
<property name="has-entry">True</property>
<child internal-child="entry">
<object class="GtkEntry" id="url_entry">
<property name="can-focus">False</property>
<signal name="focus-out-event" handler="on_url_entry_focus_out" swapped="no"/>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButtonBox" id="url_action_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="homogeneous">True</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkButton" id="remove_url_button">
<property name="visible" bind-source="apply_url_button" bind-property="visible" bind-flags="invert-boolean">True</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Remove</property>
<signal name="clicked" handler="on_remove_url" swapped="no"/>
<child>
<object class="GtkImage" id="remove_url_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">list-remove-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="add_url_button">
<property name="visible" bind-source="apply_url_button" bind-property="visible" bind-flags="invert-boolean">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Add</property>
<signal name="clicked" handler="on_add_url" swapped="no"/>
<child>
<object class="GtkImage" id="add_url_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">list-add-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="cancel_url_button">
<property name="visible" bind-source="apply_url_button" bind-property="visible">False</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Cancel</property>
<child>
<object class="GtkImage" id="cancel_url_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-undo-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButton" id="apply_url_button">
<property name="visible" bind-source="url_entry" bind-property="has-focus">False</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Add</property>
<signal name="clicked" handler="on_apply_url" swapped="no"/>
<child>
<object class="GtkImage" id="apply_url_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">Apply</property>
<property name="icon-name">insert-link-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="download_interval_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel" id="download_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Update:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkComboBoxText" id="download_interval_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="halign">end</property>
<property name="active">0</property>
<property name="active-id">daily</property>
<items>
<item id="daily" translatable="yes">Daily</item>
</items>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="dat_source_box">
<property name="sensitive" bind-source="dat_src_button" bind-property="active">False</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">STB path:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkComboBoxText" id="dat_path_box">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="active">0</property>
<property name="active-id">/etc/enigma2</property>
<items>
<item id="/etc/enigma2/">/etc/enigma2/</item>
<item id="/media/hdd/">/media/hdd/</item>
<item id="/media/usb/">/media/usb/</item>
<item id="/media/mmc/">/media/mmc/</item>
<item id="/media/cf/">/media/cf/</item>
</items>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButtonBox" id="actions_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="spacing">5</property>
<property name="homogeneous">True</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkButton" id="apply_button">
<property name="label" translatable="yes">Apply</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_apply" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="close_button">
<property name="label" translatable="yes">Close</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_close" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">16</property>
</packing>
</child>
</object>
</interface>

574
app/ui/epg/tab.glade Normal file
View File

@@ -0,0 +1,574 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2
The MIT License (MIT)
Copyright (c) 2018-2024 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.22"/>
<!-- interface-css-provider-path style.css -->
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellites list editor for GNU/Linux. -->
<!-- interface-copyright 2018-2023 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkListStore" id="epg_model">
<columns>
<!-- column-name service -->
<column type="gchararray"/>
<!-- column-name title -->
<column type="gchararray"/>
<!-- column-name start -->
<column type="gint"/>
<!-- column-name end -->
<column type="gint"/>
<!-- column-name length -->
<column type="gint"/>
<!-- column-name description -->
<column type="gchararray"/>
<!-- column-name data -->
<column type="PyObject"/>
</columns>
</object>
<object class="GtkTreeModelFilter" id="epg_filter_model">
<property name="child-model">epg_model</property>
</object>
<object class="GtkTreeModelSort" id="epg_sort_model">
<property name="model">epg_filter_model</property>
</object>
<object class="GtkFrame" id="epg_frame">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label-xalign">0.49</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkViewport" id="viewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkBox" id="epg_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">5</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="epg_action_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="spacing">5</property>
<child type="center">
<object class="GtkButtonBox" id="src_box">
<property name="name">header-stack-switcher</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">EPG source</property>
<property name="halign">center</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkRadioButton" id="src_receiver_button">
<property name="label" translatable="yes">Receiver</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">src_xmltv_button</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="src_xmltv_button">
<property name="label" translatable="yes">XML TV</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">src_receiver_button</property>
<signal name="toggled" handler="on_xmltv_toggled" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkToggleButton" id="epg_filter_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Filter</property>
<signal name="toggled" handler="on_epg_filter_toggled" swapped="no"/>
<child>
<object class="GtkImage" id="epg_filter_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-find-replace-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="epg_add_timer_button">
<property name="visible">True</property>
<property name="sensitive" bind-source="src_xmltv_button" bind-property="active" bind-flags="invert-boolean">True</property>
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Add timer</property>
<property name="always-show-image">True</property>
<signal name="clicked" handler="on_timer_add" swapped="no"/>
<child>
<object class="GtkImage" id="add_timer_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">alarm-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkToggleButton" id="multi_epg_button">
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Multi EPG</property>
<signal name="toggled" handler="on_multi_epg_toggled" swapped="no"/>
<child>
<object class="GtkImage" id="multi_epg_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-select-all-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkMenuButton" id="epg_options_button">
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Options</property>
<child>
<object class="GtkImage" id="epg_options_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">applications-system-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">4</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="epg_fs_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="spacing">10</property>
<child>
<object class="GtkSearchEntry" id="epg_filter_entry">
<property name="visible" bind-source="epg_filter_button" bind-property="active">False</property>
<property name="can-focus">True</property>
<property name="primary-icon-name">edit-find-replace-symbolic</property>
<property name="primary-icon-activatable">False</property>
<property name="primary-icon-sensitive">False</property>
<signal name="search-changed" handler="on_epg_filter_changed" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="fav_search_box">
<property name="can-focus">False</property>
<property name="valign">center</property>
<child>
<object class="GtkSearchEntry" id="epg_search_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="primary-icon-name">edit-find-symbolic</property>
<property name="primary-icon-activatable">False</property>
<property name="primary-icon-sensitive">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="epg_search_down_button">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<child>
<object class="GtkArrow" id="epg_down_arrow">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="arrow-type">down</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="epg_search_up_button">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<child>
<object class="GtkArrow" id="epg_up_arrow">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="arrow-type">up</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
<style>
<class name="group"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="epg_view_scrolled_window">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="margin-top">5</property>
<property name="shadow-type">in</property>
<child>
<object class="GtkTreeView" id="epg_view">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="model">epg_sort_model</property>
<property name="fixed-height-mode">True</property>
<property name="rubber-banding">True</property>
<property name="enable-grid-lines">both</property>
<property name="tooltip-column">6</property>
<signal name="button-press-event" handler="on_epg_press" swapped="no"/>
<signal name="query-tooltip" handler="on_view_query_tooltip" swapped="no"/>
<child internal-child="selection">
<object class="GtkTreeSelection" id="epg_view_selection">
<property name="mode">multiple</property>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="epg_service_column">
<property name="visible" bind-source="multi_epg_button" bind-property="active">False</property>
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="fixed-width">100</property>
<property name="min-width">40</property>
<property name="title" translatable="yes">Service</property>
<property name="alignment">0.49000000953674316</property>
<property name="sort-column-id">0</property>
<child>
<object class="GtkCellRendererText" id="epg_service_renderer">
<property name="xpad">5</property>
</object>
<attributes>
<attribute name="text">0</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="epg_title_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="min-width">150</property>
<property name="title" translatable="yes">Title</property>
<property name="alignment">0.49000000953674316</property>
<property name="sort-column-id">1</property>
<child>
<object class="GtkCellRendererText" id="epg_title_renderer">
<property name="xpad">5</property>
<property name="ellipsize">end</property>
</object>
<attributes>
<attribute name="text">1</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="epg_start_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="min-width">50</property>
<property name="title" translatable="yes">Start time</property>
<property name="alignment">0.49000000953674316</property>
<property name="sort-column-id">2</property>
<child>
<object class="GtkCellRendererText" id="epg_start_renderer">
<property name="xpad">5</property>
<property name="xalign">0.49000000953674316</property>
<property name="width-chars">27</property>
</object>
<attributes>
<attribute name="text">2</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="epg_end_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="min-width">50</property>
<property name="title" translatable="yes">End time</property>
<property name="alignment">0.49000000953674316</property>
<property name="sort-column-id">3</property>
<child>
<object class="GtkCellRendererText" id="epg_end_renderer">
<property name="xpad">5</property>
<property name="xalign">0.49000000953674316</property>
<property name="width-chars">27</property>
</object>
<attributes>
<attribute name="text">3</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="epg_length_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="fixed-width">100</property>
<property name="min-width">50</property>
<property name="title" translatable="yes">Length</property>
<property name="alignment">0.49000000953674316</property>
<property name="sort-column-id">4</property>
<child>
<object class="GtkCellRendererText" id="epg_length_renderer">
<property name="xpad">5</property>
<property name="xalign">0.49000000953674316</property>
</object>
<attributes>
<attribute name="text">4</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="epg_desc_column">
<property name="sizing">fixed</property>
<property name="min-width">100</property>
<property name="title" translatable="yes">Description</property>
<property name="expand">True</property>
<property name="alignment">0.49000000953674316</property>
<property name="sort-column-id">5</property>
<child>
<object class="GtkCellRendererText" id="epg_desc_renderer">
<property name="ellipsize">end</property>
</object>
<attributes>
<attribute name="text">5</attribute>
</attributes>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="status_box">
<property name="height-request">26</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkImage" id="event_count_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">document-properties</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="event_count_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">0</property>
<property name="width-chars">4</property>
<property name="xalign">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="epg_cache_info_box">
<property name="visible" bind-source="src_xmltv_button" bind-property="active">False</property>
<property name="can-focus">False</property>
<property name="tooltip-text" translatable="yes">Current EPG cache contents.</property>
<property name="spacing">5</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-select-all-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="cache_info_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
</child>
<child type="label">
<object class="GtkLabel" id="epg_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-bottom">2</property>
<property name="label" translatable="yes">EPG</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>
</interface>

View File

View File

@@ -0,0 +1,385 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2023-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import json
import os
import shutil
from enum import IntEnum
from pathlib import Path
import requests
from gi.repository import Gtk, Gdk, GLib, Pango, GObject
from app.commons import log, run_task, run_idle
from app.ui.dialogs import translate, show_dialog, DialogType
from app.ui.uicommons import HeaderBar
EXT_URL = "https://api.github.com/repos/DYefremov/demoneditor-extensions/contents/extensions/"
EXT_LIST_FILE = "https://raw.githubusercontent.com/DYefremov/demoneditor-extensions/main/extensions/extension-list"
# Config file name. The config file must be in json format!
# E.g. -> {"EXT_URL": "repo URL", "EXT_LIST_FILE": "URL to 'extension-list' file."}
EXT_CONFIG_FILE = "ext_sources"
HEADERS = {"User-Agent": "Mozilla/5.0 (X11; Linux i686; rv:112.0) Gecko/20100101 Firefox/112.0",
"Accept": "application/json"}
class ExtensionManager(Gtk.Window):
ICON_INFO = "emblem-synchronizing-symbolic"
ICON_UPDATE = "network-receive-symbolic"
class Column(IntEnum):
TITLE = 0
DESC = 1
VER = 2
INFO = 3
STATUS = 4
NAME = 5
URL = 6
PATH = 7
def __init__(self, app, **kwargs):
super().__init__(title=translate("Extensions"), icon_name="demon-editor", application=app,
transient_for=app.app_window, destroy_with_parent=True,
window_position=Gtk.WindowPosition.CENTER_ON_PARENT,
default_width=560, default_height=320, modal=True, **kwargs)
self._app = app
self._ext_path = f"{self._app.app_settings.default_data_path}tools{os.sep}extensions"
margin = {"margin_start": 5, "margin_end": 5, "margin_top": 5, "margin_bottom": 5}
base_margin = {"margin_start": 10, "margin_end": 10, "margin_top": 10, "margin_bottom": 10}
# Title, Description, Version, Info, Status, Name, URL, Path.
self._model = Gtk.ListStore.new((str, str, str, str, bool, str, str, object))
self._model.connect("row-deleted", self.on_model_changed)
self._model.connect("row-inserted", self.on_model_changed)
self._view = Gtk.TreeView(activate_on_single_click=True, enable_grid_lines=Gtk.TreeViewGridLines.BOTH)
self._view.set_model(self._model)
self._view.set_tooltip_column(self.Column.DESC)
self._view.connect("row-activated", self.on_row_activated)
# Title
renderer = Gtk.CellRendererText(xalign=0.05, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn(title=translate("Title"), cell_renderer=renderer, text=self.Column.TITLE)
column.set_alignment(0.5)
column.set_min_width(170)
column.set_resizable(True)
self._view.append_column(column)
# Description
renderer = Gtk.CellRendererText(xalign=0.05, ellipsize=Pango.EllipsizeMode.END)
column = Gtk.TreeViewColumn(title=translate("Description"), cell_renderer=renderer, text=self.Column.DESC)
column.set_alignment(0.5)
column.set_resizable(True)
column.set_expand(True)
self._view.append_column(column)
# Version
column = Gtk.TreeViewColumn(translate("Ver."))
column.set_alignment(0.5)
column.set_fixed_width(70)
renderer = Gtk.CellRendererText(xalign=0.5)
column.pack_start(renderer, True)
column.add_attribute(renderer, "text", self.Column.VER)
renderer = Gtk.CellRendererPixbuf()
column.pack_start(renderer, True)
column.add_attribute(renderer, "icon_name", self.Column.INFO)
self._view.append_column(column)
# Status
renderer = Gtk.CellRendererToggle(xalign=0.5)
column = Gtk.TreeViewColumn(title=translate("Installed"), cell_renderer=renderer, active=self.Column.STATUS)
column.set_alignment(0.5)
column.set_fixed_width(100)
self._view.append_column(column)
self._status_column = column
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
frame = Gtk.Frame(shadow_type=Gtk.ShadowType.IN, **base_margin)
frame.get_style_context().add_class("view")
data_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.VERTICAL, **base_margin)
data_box.set_margin_bottom(margin.get("margin_bottom", 5))
# Status bar.
status_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, margin_start=5, margin_end=5)
count_icon = Gtk.Image.new_from_icon_name("document-properties", Gtk.IconSize.SMALL_TOOLBAR)
status_box.pack_start(count_icon, False, False, 0)
self._count_label = Gtk.Label(label="0", width_chars=4, xalign=0)
status_box.pack_start(self._count_label, False, False, 0)
status_box.show_all()
load_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, margin_end=10, no_show_all=True)
load_box.pack_start(Gtk.Label(label=translate("Loading data..."), visible=True), False, False, 0)
self._load_spinner = Gtk.Spinner(visible=True)
self._load_spinner.bind_property("active", load_box, "visible")
self._load_spinner.bind_property("active", self._view, "sensitive", GObject.BindingFlags.INVERT_BOOLEAN)
load_box.pack_end(self._load_spinner, False, False, 0)
status_box.pack_end(load_box, False, False, 0)
data_box.pack_end(status_box, False, True, 0)
scrolled = Gtk.ScrolledWindow(shadow_type=Gtk.ShadowType.IN)
scrolled.add(self._view)
data_box.pack_start(scrolled, True, True, 0)
data_box.set_margin_start(10)
frame.add(data_box)
self.add(main_box)
# Popup menu.
menu = Gtk.Menu()
download_menu_item = Gtk.MenuItem.new_with_label(translate("Download"))
download_menu_item.connect("activate", self.on_download)
menu.append(download_menu_item)
remove_menu_item = Gtk.MenuItem.new_with_label(translate("Remove"))
remove_menu_item.connect("activate", self.on_remove)
menu.append(remove_menu_item)
menu.show_all()
self._view.connect("button-press-event", self.on_view_popup_menu, menu)
# Header and toolbar.
self._download_button = Gtk.Button.new_from_icon_name("go-bottom-symbolic", Gtk.IconSize.BUTTON)
self._download_button.set_tooltip_text(translate("Download"))
self._download_button.set_always_show_image(True)
self._download_button.connect("clicked", self.on_download)
remove_button = Gtk.Button.new_from_icon_name("user-trash-symbolic", Gtk.IconSize.BUTTON)
remove_button.set_tooltip_text(translate("Remove"))
remove_button.set_always_show_image(True)
remove_button.connect("clicked", self.on_remove)
if app.app_settings.use_header_bar:
header = HeaderBar()
header.pack_start(self._download_button)
header.pack_start(remove_button)
self.set_titlebar(header)
header.show_all()
else:
toolbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
toolbar.get_style_context().add_class("primary-toolbar")
margin["margin_start"] = 15
margin["margin_top"] = 10
button_box = Gtk.Box(spacing=5, orientation=Gtk.Orientation.HORIZONTAL, **margin)
button_box.pack_start(self._download_button, False, False, 0)
button_box.pack_start(remove_button, False, False, 0)
toolbar.pack_start(button_box, True, True, 0)
main_box.pack_start(toolbar, False, False, 0)
main_box.pack_start(frame, True, True, 0)
main_box.show_all()
# Connection status.
self._connection_status_image = Gtk.Image.new_from_icon_name("network-offline-symbolic", Gtk.IconSize.BUTTON)
status_box.pack_end(self._connection_status_image, False, False, 0)
self._download_button.bind_property("visible", self._connection_status_image, "visible", 4)
self._download_button.bind_property("visible", download_menu_item, "visible")
ws_property = "extension_manager_window_size"
window_size = self._app.app_settings.get(ws_property, None)
if window_size:
self.resize(*window_size)
self.connect("delete-event", lambda w, e: self._app.app_settings.add(ws_property, w.get_size()))
self.connect("realize", self.init)
self.connect("show", self.on_show)
def on_show(self, window):
enabled = self._app.app_settings.extensions_support
self.set_sensitive(enabled)
if not enabled:
msg = f"\n{translate('Extension support is disabled!')}\n\n\t{translate('Do you want to enable it?')}"
if show_dialog(DialogType.QUESTION, self, msg) != Gtk.ResponseType.OK:
self.close()
return True
self._app.app_settings.extensions_support = True
self._app.show_info_message(translate('Restart the program to apply all changes.'), Gtk.MessageType.WARNING)
self.close()
return False
def init(self, widget):
self._load_spinner.start()
scf = f"{os.path.dirname(__file__)}{os.sep}{EXT_CONFIG_FILE}"
if os.path.isfile(scf):
with (open(scf, "r", encoding="utf-8", errors="ignore") as cf):
config = json.load(cf)
global EXT_URL, EXT_LIST_FILE
EXT_URL = config.get("EXT_URL", EXT_URL)
EXT_LIST_FILE = config.get("EXT_LIST_FILE", EXT_LIST_FILE)
self.update()
def get_installed(self):
import pkgutil
from importlib.util import module_from_spec
ext_paths = [f"{os.path.dirname(__file__)}{os.sep}", self._ext_path, "extensions"]
installed = {}
for importer, name, is_package in pkgutil.iter_modules(ext_paths):
if is_package:
spec = importer.find_spec(name)
if spec is None:
log(f"{self.__class__.__name__} [get installed]: Module {name} not found.")
continue
m = module_from_spec(spec)
spec.loader.exec_module(m)
cls_name = name.capitalize()
if hasattr(m, cls_name):
cls = getattr(m, cls_name)
path = Path(spec.origin).parent
installed[name] = (cls, path)
return installed
@run_task
def update(self):
error_msg = None
try:
with requests.get(url=EXT_LIST_FILE, stream=True) as resp:
if resp.status_code == 200:
try:
self.update_data(resp.json())
except ValueError as e:
error_msg = f"{self.__class__.__name__} [update] error: {e}"
else:
error_msg = f"{self.__class__.__name__} [update] error: {resp.reason}"
GLib.idle_add(self._app.show_error_message, "Data loading error!")
except OSError as e:
error_msg = f"{self.__class__.__name__} [update] error: Connection error. {e}"
if error_msg:
log(error_msg)
self.update_local_data()
@run_idle
def update_data(self, data):
self._model.clear()
gen = self.append_data(data)
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
@run_idle
def update_local_data(self):
self._download_button.set_visible(False)
self._load_spinner.stop()
self._model.clear()
for ext, d in self.get_installed().items():
e, path = d
self._model.append((e.LABEL, None, e.VERSION, None, path, ext, None, path))
def append_data(self, data):
installed = self.get_installed()
for e, d in data.items():
url = f"{EXT_URL}{d.get('ref', '')}"
desc = d.get("description", "")
ver = d.get("version", "1.0")
info = self.ICON_UPDATE
path = None
ext = installed.get(e)
if ext:
info = None
ext_ver = ext[0].VERSION
path = ext[1]
if ext_ver < ver:
desc = f"[ Update -> ver. {ver} ] {desc}"
ver = ext_ver
info = self.ICON_INFO
yield self._model.append((d.get('label'), desc, ver, info, path, e, url, path))
self._load_spinner.stop()
def on_remove(self, item=None):
model, paths = self._view.get_selection().get_selected_rows()
if not paths:
return
itr = model.get_iter(paths)
path = model[itr][self.Column.PATH]
if path:
try:
shutil.rmtree(path)
except OSError as e:
log(f"{self.__class__.__name__} [remove] error: {e}")
else:
model.set(itr, {self.Column.INFO: self.ICON_UPDATE, self.Column.STATUS: None, self.Column.PATH: None})
msg = translate('Restart the program to apply all changes.')
self._app.show_info_message(msg, Gtk.MessageType.WARNING)
@run_task
def on_download(self, item=None):
model, paths = self._view.get_selection().get_selected_rows()
if not paths:
return
itr = model.get_iter(paths)
url = model[itr][self.Column.URL]
ver = model[itr][self.Column.VER]
if not url:
return
GLib.idle_add(self._load_spinner.start)
urls = {}
with requests.get(url=url, headers=HEADERS, stream=True) as resp:
if resp.status_code == 200:
try:
for f in resp.json():
url = f.get("download_url", None)
ver = f.get("version", ver)
if url:
urls[url] = f.get("name", None)
except ValueError as e:
log(f"{self.__class__.__name__} [download] error: {e}")
else:
log(f"{self.__class__.__name__} [download] error: {resp.reason}")
if urls:
path = f"{self._ext_path}{os.sep}{model[paths][self.Column.NAME]}{os.sep}"
os.makedirs(os.path.dirname(path), exist_ok=True)
if all((self.download_file(u, f"{path}{n}") for u, n in urls.items())):
data = {self.Column.VER: ver, self.Column.INFO: None, self.Column.STATUS: True, self.Column.PATH: path}
GLib.idle_add(model.set, itr, data)
msg = translate('Restart the program to apply all changes.')
self._app.show_info_message(msg, Gtk.MessageType.WARNING)
GLib.idle_add(self._load_spinner.stop)
def download_file(self, url, path):
with requests.get(url=url, headers=HEADERS, stream=True) as resp:
if resp.status_code == 200:
with open(path, mode="bw") as f:
for data in resp.iter_content(chunk_size=1024):
f.write(data)
return True
def on_model_changed(self, model, path, itr=None):
self._count_label.set_text(str(len(model)))
def on_row_activated(self, view, path, column):
if column is self._status_column:
self.on_remove() if view.get_model()[path][self.Column.STATUS] else self.on_download()
def on_view_popup_menu(self, view, event, menu):
if event.get_event_type() == Gdk.EventType.BUTTON_PRESS and event.button == Gdk.BUTTON_SECONDARY:
menu.popup(None, None, None, None, event.button, event.time)
return True
return False
if __name__ == "__main__":
pass

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -27,26 +27,189 @@
""" Simple FTP client module. """
import stat
import subprocess
from collections import namedtuple
from datetime import datetime
from enum import IntEnum
from ftplib import all_errors
from io import TextIOWrapper, BytesIO
from pathlib import Path
from shutil import rmtree
from urllib.parse import urlparse, unquote
from gi.repository import GLib
from app.commons import log, run_task, run_idle
from app.commons import log, run_task, run_idle, get_size_from_bytes
from app.connections import UtfFTP
from app.ui.dialogs import show_dialog, DialogType, get_builder
from app.settings import IS_LINUX, IS_DARWIN, IS_WIN, SEP, USE_HEADER_BAR
from app.ui.dialogs import show_dialog, DialogType, get_builder, translate
from app.ui.main_helper import on_popup_menu
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, MOD_MASK
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, MOD_MASK, Page, LINK_ICON, FOLDER_ICON
File = namedtuple("File", ["icon", "name", "size", "date", "attr", "extra"])
class BaseDialog(Gtk.Dialog):
""" Base class for additional FTP dialogs. """
def __init__(self, title, use_header_bar=0, *args, **kwargs):
super().__init__(title=title, use_header_bar=use_header_bar, *args, **kwargs)
self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
self.set_modal(True)
self.set_skip_pager_hint(True)
self.set_skip_taskbar_hint(True)
self.set_position(Gtk.PositionType.BOTTOM)
self.set_default_icon_name("document-properties-symbolic")
class TextEditDialog(BaseDialog):
""" Simple text edit dialog. """
def __init__(self, path, use_header_bar=0, *args, **kwargs):
super().__init__(title=f"DemonEditor [{path}]", use_header_bar=use_header_bar, *args, **kwargs)
content_box = self.get_content_area()
self._search_entry = Gtk.SearchEntry(visible=True, primary_icon_name="system-search-symbolic")
self._search_entry.connect("search-changed", self.on_search_changed)
if use_header_bar:
bar = self.get_header_bar()
bar.pack_start(self._search_entry)
bar.set_title("DemonEditor")
bar.set_subtitle(path)
else:
search_bar = Gtk.SearchBar(visible=True)
search_bar.add(self._search_entry)
search_bar.set_search_mode(True)
content_box.pack_start(search_bar, False, False, 0)
scrolled_window = Gtk.ScrolledWindow(hexpand=True, vexpand=True,
min_content_width=720,
min_content_height=320)
content_box.pack_start(scrolled_window, True, True, 0)
try:
import gi
gi.require_version("GtkSource", "3.0")
from gi.repository import GtkSource
except (ImportError, ValueError) as e:
self._text_view = Gtk.TextView()
self._buf = self._text_view.get_buffer()
log(e)
else:
self._text_view = GtkSource.View(show_line_numbers=True, show_line_marks=True)
self._buf = self._text_view.get_buffer()
self._buf.set_highlight_syntax(True)
self._buf.set_highlight_matching_brackets(True)
lang_manager = GtkSource.LanguageManager.new()
self._buf.set_language(lang_manager.guess_language(path))
# Style
self._buf.set_style_scheme(GtkSource.StyleSchemeManager().get_default().get_scheme("tango"))
self._tag_found = self._buf.create_tag("found", background="yellow")
scrolled_window.add(self._text_view)
self.show_all()
@property
def text(self):
return self._buf.get_text(self._buf.get_start_iter(), self._buf.get_end_iter(), include_hidden_chars=True)
@text.setter
def text(self, value):
self._buf.set_text(value)
def on_search_changed(self, entry):
self._buf.remove_tag(self._tag_found, self._buf.get_start_iter(), self._buf.get_end_iter())
cursor_mark = self._buf.get_insert()
start = self._buf.get_iter_at_mark(cursor_mark)
if start.get_offset() == self._buf.get_char_count():
start = self._buf.get_start_iter()
self.search_and_mark(entry.get_text(), start)
def search_and_mark(self, text, start, first=True):
end = self._buf.get_end_iter()
match = start.forward_search(text, 0, end)
if match is not None:
match_start, match_end = match
self._buf.apply_tag(self._tag_found, match_start, match_end)
if first:
self._text_view.scroll_to_iter(match_start, 0.0, False, 0.0, 0.0)
GLib.idle_add(self.search_and_mark, text, match_end, False)
class AttributesDialog(BaseDialog):
""" Dialog for editing file attributes (permissions). """
def __init__(self, attrs, use_header_bar=0, *args, **kwargs):
super().__init__(title=translate("Permissions"), use_header_bar=use_header_bar, *args, **kwargs)
self.set_default_size(360, 100)
self.set_resizable(False)
builder = get_builder(f"{UI_RESOURCES_PATH}ftp.glade", use_str=True, objects=("attributes_box",))
content_box = self.get_content_area()
content_box.pack_start(builder.get_object("attributes_box"), True, True, 0)
self._num_value_entry = builder.get_object("num_value_entry")
# Buttons.
self._owner_read_button = builder.get_object("owner_read_button")
self._group_read_button = builder.get_object("group_read_button")
self._others_read_button = builder.get_object("others_read_button")
self._owner_write_button = builder.get_object("owner_write_button")
self._group_write_button = builder.get_object("group_write_button")
self._others_write_button = builder.get_object("others_write_button")
self._owner_exec_button = builder.get_object("owner_exec_button")
self._group_exec_button = builder.get_object("group_exec_button")
self._others_exec_button = builder.get_object("others_exec_button")
self.init_attrs(attrs)
for b in (self._owner_read_button, self._group_read_button, self._others_read_button, self._owner_write_button,
self._group_write_button, self._others_write_button, self._owner_exec_button, self._group_exec_button,
self._others_exec_button):
b.connect("toggled", self.update_num_value)
self.show_all()
@property
def permissions(self):
return self._num_value_entry.get_text()
def init_attrs(self, attrs):
# Owner.
self._owner_read_button.set_active(attrs[1] != "-")
self._owner_write_button.set_active(attrs[2] != "-")
self._owner_exec_button.set_active(attrs[3] != "-")
# Group.
self._group_read_button.set_active(attrs[4] != "-")
self._group_write_button.set_active(attrs[5] != "-")
self._group_exec_button.set_active(attrs[6] != "-")
# Others.
self._others_read_button.set_active(attrs[7] != "-")
self._others_write_button.set_active(attrs[8] != "-")
self._others_exec_button.set_active(attrs[9] != "-")
self.update_num_value()
def update_num_value(self, button=None):
val = 0
val |= stat.S_IRUSR if self._owner_read_button.get_active() else val
val |= stat.S_IWUSR if self._owner_write_button.get_active() else val
val |= stat.S_IXUSR if self._owner_exec_button.get_active() else val
val |= stat.S_IRGRP if self._group_read_button.get_active() else val
val |= stat.S_IWGRP if self._group_write_button.get_active() else val
val |= stat.S_IXGRP if self._group_exec_button.get_active() else val
val |= stat.S_IROTH if self._others_read_button.get_active() else val
val |= stat.S_IWOTH if self._others_write_button.get_active() else val
val |= stat.S_IXOTH if self._others_exec_button.get_active() else val
self._num_value_entry.set_text(f"{val:o}")
class FtpClientBox(Gtk.HBox):
""" Simple FTP client base class. """
ROOT = ".."
@@ -69,6 +232,8 @@ class FtpClientBox(Gtk.HBox):
self.set_orientation(Gtk.Orientation.VERTICAL)
self._app = app
self._app.connect("data-receive", self.on_receive)
self._app.connect("data-send", self.on_send)
self._settings = settings
self._ftp = None
self._select_enabled = True
@@ -77,12 +242,18 @@ class FtpClientBox(Gtk.HBox):
"on_disconnect": self.on_disconnect,
"on_ftp_row_activated": self.on_ftp_row_activated,
"on_file_row_activated": self.on_file_row_activated,
"on_bookmark_activated": self.on_bookmark_activated,
"on_ftp_edit": self.on_ftp_edit,
"on_ftp_edited": self.on_ftp_edited,
"on_file_edit": self.on_file_edit,
"on_file_edited": self.on_file_edited,
"on_ftp_rename": self.on_ftp_rename,
"on_ftp_renamed": self.on_ftp_renamed,
"on_ftp_attr_change": self.on_ftp_attr_change,
"on_ftp_copy": self.on_ftp_copy,
"on_file_rename": self.on_file_rename,
"on_file_renamed": self.on_file_renamed,
"on_file_copy": self.on_file_copy,
"on_file_remove": self.on_file_remove,
"on_ftp_remove": self.on_ftp_file_remove,
"on_bookmark_remove": self.on_bookmark_remove,
"on_file_create_folder": self.on_file_create_folder,
"on_ftp_create_folder": self.on_ftp_create_folder,
"on_view_drag_begin": self.on_view_drag_begin,
@@ -91,14 +262,16 @@ class FtpClientBox(Gtk.HBox):
"on_file_drag_data_get": self.on_file_drag_data_get,
"on_file_drag_data_received": self.on_file_drag_data_received,
"on_view_drag_end": self.on_view_drag_end,
"on_bookmark_add": self.on_bookmark_add,
"on_view_popup_menu": on_popup_menu,
"on_view_key_press": self.on_view_key_press,
"on_view_press": self.on_view_press,
"on_view_release": self.on_view_release}
"on_view_release": self.on_view_release,
"on_paned_size_allocate": self.on_paned_size_allocate}
builder = get_builder(UI_RESOURCES_PATH + "ftp.glade", handlers)
builder = get_builder(f"{UI_RESOURCES_PATH}ftp.glade", handlers)
self.add(builder.get_object("main_frame"))
self.add(builder.get_object("main_ftp_box"))
self._ftp_info_label = builder.get_object("ftp_info_label")
self._ftp_view = builder.get_object("ftp_view")
self._ftp_model = builder.get_object("ftp_list_store")
@@ -106,36 +279,47 @@ class FtpClientBox(Gtk.HBox):
self._file_view = builder.get_object("file_view")
self._file_model = builder.get_object("file_list_store")
self._file_name_renderer = builder.get_object("file_name_column_renderer")
self._bookmark_view = builder.get_object("bookmarks_view")
self._bookmark_model = builder.get_object("bookmarks_list_store")
# Buttons
self._connect_button = builder.get_object("connect_button")
disconnect_button = builder.get_object("disconnect_button")
disconnect_button.bind_property("visible", builder.get_object("ftp_actions_box"), "sensitive")
disconnect_button.bind_property("visible", builder.get_object("ftp_create_folder_menu_item"), "sensitive")
disconnect_button.bind_property("visible", builder.get_object("ftp_edit_menu_item"), "sensitive")
disconnect_button.bind_property("visible", builder.get_object("ftp_rename_menu_item"), "sensitive")
disconnect_button.bind_property("visible", builder.get_object("ftp_remove_menu_item"), "sensitive")
disconnect_button.bind_property("visible", builder.get_object("add_ftp_bookmark_button"), "sensitive")
self._connect_button.bind_property("visible", builder.get_object("disconnect_button"), "visible", 4)
self._bookmarks_button = builder.get_object("bookmarks_button")
self._bookmarks_button.bind_property("active", builder.get_object("bookmarks_box"), "visible")
# Force Ctrl
self._ftp_view.connect("key-press-event", self._app.force_ctrl)
self._file_view.connect("key-press-event", self._app.force_ctrl)
# Icons
theme = Gtk.IconTheme.get_default()
folder_icon = "folder-symbolic" if settings.is_darwin else "folder"
self._folder_icon = theme.load_icon(folder_icon, 16, 0) if theme.lookup_icon(folder_icon, 16, 0) else None
self._link_icon = theme.load_icon("emblem-symbolic-link", 16, 0) if theme.lookup_icon("emblem-symbolic-link",
16, 0) else None
# Initialization
self.init_drag_and_drop()
self.init_ftp()
self.init_file_data()
self.show()
def on_receive(self, app, page):
if page is Page.FTP:
self.on_ftp_copy()
def on_send(self, app, page):
if page is Page.FTP:
self.on_file_copy()
@run_task
def init_ftp(self):
self.init_bookmarks()
GLib.idle_add(self._ftp_model.clear)
try:
if self._ftp:
self._ftp.close()
self._ftp = UtfFTP(host=self._settings.host, user=self._settings.user, passwd=self._settings.password)
host, port = self._settings.host, self._settings.port
self._ftp = UtfFTP(host=host, port=port, user=self._settings.user, passwd=self._settings.password)
self._ftp.encoding = "utf-8"
self.update_ftp_info(self._ftp.getwelcome())
except all_errors as e:
@@ -188,12 +372,12 @@ class FtpClientBox(Gtk.HBox):
icon = None
if is_dir:
r_size = self.FOLDER
icon = self._folder_icon
icon = FOLDER_ICON
elif p.is_symlink():
r_size = self.LINK
icon = self._link_icon
icon = LINK_ICON
else:
r_size = self.get_size_from_bytes(size)
r_size = get_size_from_bytes(size)
self._file_model.append(File(icon, p.name, r_size, date, str(p.resolve()), size))
@@ -203,7 +387,7 @@ class FtpClientBox(Gtk.HBox):
self._ftp_model.append(File(None, self.ROOT, None, None, self._ftp.pwd(), "0"))
for f in files:
f_data = f.split()
f_data = self._ftp.get_file_data(f)
f_type = f_data[0][0]
is_dir = f_type == "d"
is_link = f_type == "l"
@@ -212,15 +396,15 @@ class FtpClientBox(Gtk.HBox):
icon = None
if is_dir:
r_size = self.FOLDER
icon = self._folder_icon
icon = FOLDER_ICON
elif is_link:
r_size = self.LINK
icon = self._link_icon
icon = LINK_ICON
else:
r_size = self.get_size_from_bytes(size)
r_size = get_size_from_bytes(size)
date = "{}, {} {}".format(f_data[5], f_data[6], f_data[7])
self._ftp_model.append(File(icon, " ".join(f_data[8:]), r_size, date, f_data[0], size))
date = f"{f_data[5]}, {f_data[6]} {f_data[7]}"
self._ftp_model.append(File(icon, f_data[8], r_size, date, f_data[0], size))
def on_connect(self, item=None):
self.init_ftp()
@@ -238,6 +422,10 @@ class FtpClientBox(Gtk.HBox):
if size == self.FOLDER or f_path == self.ROOT:
self.init_ftp_data(f_path)
elif size == self.LINK:
name, sep, f_path = f_path.partition("->")
if f_path:
self.init_ftp_data(f_path.strip())
else:
b_size = row[self.Column.EXTRA]
if b_size.isdigit() and int(b_size) > self.MAX_SIZE:
@@ -259,23 +447,22 @@ class FtpClientBox(Gtk.HBox):
def open_file(self, path):
GLib.idle_add(self._file_view.set_sensitive, False)
try:
cmd = ["open" if self._settings.is_darwin else "xdg-open", path]
subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
cmd = [self.get_open_file_cmd(), path]
subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=IS_WIN).communicate()
finally:
GLib.idle_add(self._file_view.set_sensitive, True)
@run_task
def open_ftp_file(self, f_path):
is_darwin = self._settings.is_darwin
GLib.idle_add(self._ftp_view.set_sensitive, False)
try:
import tempfile
import os
path = os.path.expanduser("~/Desktop") if is_darwin else None
path = os.path.expanduser("~/Desktop") if not IS_LINUX else None
with tempfile.NamedTemporaryFile(mode="wb", dir=path, delete=not is_darwin) as tf:
msg = "Downloading file: {}. Status: {}"
with tempfile.NamedTemporaryFile(mode="wb", dir=path, delete=IS_LINUX) as tf:
msg = "Downloading file: {}. Status: {}"
try:
status = self._ftp.retrbinary("RETR " + f_path, tf.write)
self.update_ftp_info(msg.format(f_path, status))
@@ -283,12 +470,74 @@ class FtpClientBox(Gtk.HBox):
self.update_ftp_info(msg.format(f_path, e))
tf.flush()
cmd = ["open" if is_darwin else "xdg-open", tf.name]
subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE).communicate()
cmd = [self.get_open_file_cmd(), tf.name]
subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=IS_WIN).communicate()
finally:
GLib.idle_add(self._ftp_view.set_sensitive, True)
def on_ftp_edit(self, renderer):
@staticmethod
def get_open_file_cmd():
if IS_DARWIN:
return "open"
elif IS_WIN:
return "start"
return "xdg-open"
@run_task
def on_ftp_edit(self, item=None):
path = self.get_ftp_edit_path()
if path:
row = self._ftp_model[path]
f_path = row[self.Column.NAME]
size = row[self.Column.SIZE]
if size == self.FOLDER or f_path == self.ROOT:
self._app.show_error_message("Not allowed in this context!")
else:
b_size = row[self.Column.EXTRA]
if b_size.isdigit() and int(b_size) > self.MAX_SIZE / 5:
self._app.show_error_message("The file size is too large!")
else:
msg = "Retrieving file: {}. Status: {}"
io = BytesIO()
try:
status = self._ftp.retrbinary("RETR " + f_path, io.write)
self.update_ftp_info(msg.format(f_path, status))
except all_errors as e:
self.update_ftp_info(msg.format(f_path, e))
else:
io.seek(0)
self.show_edit_dialog(f_path, TextIOWrapper(io, errors="ignore").read())
def on_ftp_edited(self, f_path, txt_data):
buf = BytesIO()
buf.write(txt_data.encode())
buf.seek(0)
msg = "Uploading file: {}. Status: {}"
try:
status = self._ftp.storbinary(f"STOR {f_path}", buf)
self.update_ftp_info(msg.format(f_path, status))
except all_errors as e:
self.update_ftp_info(msg.format(f_path, e))
@run_idle
def show_edit_dialog(self, f_path, data):
dialog = TextEditDialog(f_path, USE_HEADER_BAR)
dialog.text = data
ok = Gtk.ResponseType.OK
if dialog.run() == ok and show_dialog(DialogType.QUESTION, self._app.app_window) == ok:
self.on_ftp_edited(f_path, dialog.text)
dialog.destroy()
def on_ftp_rename(self, renderer):
path = self.get_ftp_edit_path()
if path:
renderer.set_property("editable", True)
self._ftp_view.set_cursor(path, self._ftp_view.get_column(0), True)
def get_ftp_edit_path(self):
model, paths = self._ftp_view.get_selection().get_selected_rows()
if not paths:
return
@@ -296,11 +545,9 @@ class FtpClientBox(Gtk.HBox):
if len(paths) > 1:
self._app.show_error_message("Please, select only one item!")
return
return paths
renderer.set_property("editable", True)
self._ftp_view.set_cursor(paths, self._ftp_view.get_column(0), True)
def on_ftp_edited(self, renderer, path, new_value):
def on_ftp_renamed(self, renderer, path, new_value):
renderer.set_property("editable", False)
row = self._ftp_model[path]
old_name = row[self.Column.NAME]
@@ -308,11 +555,33 @@ class FtpClientBox(Gtk.HBox):
return
resp = self._ftp.rename_file(old_name, new_value)
self.update_ftp_info("{} Status: {}".format(old_name, resp))
self.update_ftp_info(f"{old_name} Status: {resp}")
if resp[0] == "2":
row[self.Column.NAME] = new_value
def on_file_edit(self, renderer):
def on_ftp_attr_change(self, item):
path = self.get_ftp_edit_path()
if path:
row = self._ftp_model[path]
file = row[self.Column.NAME]
if file == self.ROOT:
self._app.show_error_message("Not allowed in this context!")
return
attrs = row[self.Column.ATTR]
if len(attrs) != 10:
log(f"Init attributes error [{attrs}]. Invalid length!")
return
dialog = AttributesDialog(attrs, USE_HEADER_BAR)
ok = Gtk.ResponseType.OK
if dialog.run() == ok and show_dialog(DialogType.QUESTION, self._app.app_window) == ok:
log(self._ftp.sendcmd(f"SITE CHMOD {dialog.permissions} {file}"))
f_data = self._ftp.sendcmd(f"STAT {file}").split()
row[self.Column.ATTR] = f_data[2] if len(f_data) > 3 else attrs
dialog.destroy()
def on_file_rename(self, renderer):
model, paths = self._file_view.get_selection().get_selected_rows()
if len(paths) > 1:
self._app.show_error_message("Please, select only one item!")
@@ -321,7 +590,7 @@ class FtpClientBox(Gtk.HBox):
renderer.set_property("editable", True)
self._file_view.set_cursor(paths, self._file_view.get_column(0), True)
def on_file_edited(self, renderer, path, new_value):
def on_file_renamed(self, renderer, path, new_value):
renderer.set_property("editable", False)
row = self._file_model[path]
old_name = row[self.Column.NAME]
@@ -331,7 +600,7 @@ class FtpClientBox(Gtk.HBox):
path = Path(row[self.Column.ATTR])
if path.exists():
try:
new_path = path.rename("{}/{}".format(path.parent, new_value))
new_path = path.rename(f"{path.parent}/{new_value}")
except ValueError as e:
log(e)
self._app.show_error_message(str(e))
@@ -340,8 +609,16 @@ class FtpClientBox(Gtk.HBox):
row[self.Column.NAME] = new_value
row[self.Column.ATTR] = str(new_path.resolve())
def on_file_copy(self, item=None):
uris = self.get_file_uris()
self.copy_to_ftp(uris) if uris else None
def on_ftp_copy(self, item=None):
uris = self.get_ftp_uris()
self.copy_to_pc(uris) if uris else None
def on_file_remove(self, item=None):
if show_dialog(DialogType.QUESTION, self._app._main_window) != Gtk.ResponseType.OK:
if show_dialog(DialogType.QUESTION, self._app.app_window) != Gtk.ResponseType.OK:
return
model, paths = self._file_view.get_selection().get_selected_rows()
@@ -359,7 +636,7 @@ class FtpClientBox(Gtk.HBox):
list(map(model.remove, to_delete))
def on_ftp_file_remove(self, item=None):
if show_dialog(DialogType.QUESTION, self._app._main_window) != Gtk.ResponseType.OK:
if show_dialog(DialogType.QUESTION, self._app.app_window) != Gtk.ResponseType.OK:
return
model, paths = self._ftp_view.get_selection().get_selected_rows()
@@ -385,7 +662,7 @@ class FtpClientBox(Gtk.HBox):
name = self.get_new_folder_name(self._file_model)
cur_path = self._file_model.get_value(itr, self.Column.ATTR)
path = Path("{}/{}".format(cur_path, name))
path = Path(f"{cur_path}/{name}")
try:
path.mkdir()
@@ -393,7 +670,7 @@ class FtpClientBox(Gtk.HBox):
log(e)
self._app.show_error_message(str(e))
else:
itr = self._file_model.append(File(self._folder_icon, path.name, self.FOLDER, "", str(path.resolve()), "0"))
itr = self._file_model.append(File(FOLDER_ICON, path.name, self.FOLDER, "", str(path.resolve()), "0"))
renderer.set_property("editable", True)
self._file_view.set_cursor(self._file_model.get_path(itr), self._file_view.get_column(0), True)
@@ -406,14 +683,14 @@ class FtpClientBox(Gtk.HBox):
name = self.get_new_folder_name(self._ftp_model)
try:
folder = "{}/{}".format(cur_path, name)
folder = f"{cur_path}/{name}"
resp = self._ftp.mkd(folder)
except all_errors as e:
self.update_ftp_info(str(e))
log(e)
else:
if resp == "{}/{}".format(cur_path, name):
itr = self._ftp_model.append(File(self._folder_icon, name, self.FOLDER, "", "drwxr-xr-x", "0"))
if resp == f"{cur_path}/{name}":
itr = self._ftp_model.append(File(FOLDER_ICON, name, self.FOLDER, "", "drwxr-xr-x", "0"))
renderer.set_property("editable", True)
self._ftp_view.set_cursor(self._ftp_model.get_path(itr), self._ftp_view.get_column(0), True)
@@ -424,7 +701,7 @@ class FtpClientBox(Gtk.HBox):
count = 0
while name in names:
count += 1
name = "{}{}".format(name, count)
name = f"{name}{count}"
return name
# ***************** Drag-and-drop ********************* #
@@ -453,30 +730,43 @@ class FtpClientBox(Gtk.HBox):
return True
def on_ftp_drag_data_get(self, view, context, data, info, time):
model, paths = view.get_selection().get_selected_rows()
uris = self.get_ftp_uris()
data.set_uris(uris) if uris else None
def get_ftp_uris(self):
""" Returns the selected paths in FTP view as a list containing uris string or None. """
model, paths = self._ftp_view.get_selection().get_selected_rows()
if len(paths) > 0:
sep = self.URI_SEP if self._settings.is_darwin else "\n"
uris = []
for r in [model[p][:] for p in paths]:
if r[self.Column.SIZE] != self.LINK and r[self.Column.NAME] != self.ROOT:
uris.append(Path("/{}:{}".format(r[self.Column.NAME], r[self.Column.ATTR])).as_uri())
data.set_uris([sep.join(uris)])
path = Path(f"/{r[self.Column.NAME]}:{r[self.Column.ATTR]}")
uris.append(str(path.resolve()) if IS_WIN else path.as_uri())
return [sep.join(uris)]
@run_task
def on_ftp_drag_data_received(self, view, context, x, y, data: Gtk.SelectionData, info, time):
if not self._ftp:
return
self.copy_to_ftp(data.get_uris())
Gtk.drag_finish(context, True, False, time)
return True
@run_task
def copy_to_ftp(self, uris):
resp = "2"
try:
GLib.idle_add(self._app._wait_dialog.show)
GLib.idle_add(self._app.wait_dialog.show)
uris = data.get_uris()
if self._settings.is_darwin and len(uris) == 1:
uris = uris[0].split(self.URI_SEP)
if len(uris) == 1:
uris = uris[0].split(self.URI_SEP if self._settings.is_darwin else "\n")
for uri in uris:
uri = urlparse(unquote(uri)).path
if IS_WIN:
uri = uri.lstrip("/")
path = Path(uri)
if path.is_dir():
try:
@@ -484,34 +774,40 @@ class FtpClientBox(Gtk.HBox):
except all_errors as e:
pass # NOP
self._ftp.cwd(path.name)
resp = self._ftp.upload_dir(str(path.resolve()) + "/", self.update_ftp_info)
resp = self._ftp.upload_dir(str(path.resolve()) + SEP, self.update_ftp_info)
else:
resp = self._ftp.send_file(path.name, str(path.parent) + "/", callback=self.update_ftp_info)
resp = self._ftp.send_file(path.name, str(path.parent) + SEP, callback=self.update_ftp_info)
finally:
GLib.idle_add(self._app._wait_dialog.hide)
GLib.idle_add(self._app.wait_dialog.hide)
if resp and resp[0] == "2":
itr = self._ftp_model.get_iter_first()
if itr:
self.init_ftp_data(self._ftp_model.get_value(itr, self.Column.ATTR))
def on_file_drag_data_get(self, view, context, data, info, time):
uris = self.get_file_uris()
data.set_uris(uris) if uris else None
def get_file_uris(self):
""" Returns the selected paths in the file view as a list containing uris string or None. """
model, paths = self._file_view.get_selection().get_selected_rows()
if len(paths) > 0:
sep = self.URI_SEP if self._settings.is_darwin else "\n"
return [sep.join([Path(model[p][self.Column.ATTR]).as_uri() for p in paths])]
def on_file_drag_data_received(self, view, context, x, y, data, info, time):
self.copy_to_pc(data.get_uris())
Gtk.drag_finish(context, True, False, time)
return True
def on_file_drag_data_get(self, view, context, data: Gtk.SelectionData, info, time):
model, paths = view.get_selection().get_selected_rows()
if len(paths) > 0:
sep = self.URI_SEP if self._settings.is_darwin else "\n"
uris = [sep.join([Path(model[p][self.Column.ATTR]).as_uri() for p in paths])]
data.set_uris(uris)
@run_task
def on_file_drag_data_received(self, view, context, x, y, data, info, time):
def copy_to_pc(self, uris):
cur_path = self._file_model.get_value(self._file_model.get_iter_first(), self.Column.ATTR) + "/"
try:
GLib.idle_add(self._app._wait_dialog.show)
GLib.idle_add(self._app.wait_dialog.show)
uris = data.get_uris()
if self._settings.is_darwin and len(uris) == 1:
uris = uris[0].split(self.URI_SEP)
if len(uris) == 1:
uris = uris[0].split(self.URI_SEP if self._settings.is_darwin else "\n")
for uri in uris:
name, sep, attr = unquote(Path(uri).name).partition(":")
@@ -525,12 +821,9 @@ class FtpClientBox(Gtk.HBox):
except OSError as e:
log(e)
finally:
GLib.idle_add(self._app._wait_dialog.hide)
GLib.idle_add(self._app.wait_dialog.hide)
self.init_file_data(cur_path)
Gtk.drag_finish(context, True, False, time)
return True
def on_view_drag_end(self, view, context):
self._select_enabled = True
view.get_selection().unselect_all()
@@ -541,12 +834,32 @@ class FtpClientBox(Gtk.HBox):
self._ftp_info_label.set_text(message)
self._ftp_info_label.set_tooltip_text(message)
# **************** Bookmarks ***************** #
@run_idle
def init_bookmarks(self):
self._bookmark_model.clear()
list(map(lambda b: self._bookmark_model.append((b,)), self._settings.ftp_bookmarks))
def on_bookmark_activated(self, view, path, column):
self.init_ftp_data(self._bookmark_model[path][0])
def on_bookmark_add(self, item=None):
self._bookmarks_button.set_active(True)
self._bookmark_model.append((self._ftp_model.get_value(self._ftp_model.get_iter_first(), 4),))
self._settings.ftp_bookmarks = [r[0] for r in self._bookmark_model]
def on_bookmark_remove(self, item=None):
model, paths = self._bookmark_view.get_selection().get_selected_rows()
if paths and show_dialog(DialogType.QUESTION, self._app.app_window) == Gtk.ResponseType.OK:
list(map(lambda p: model.remove(model.get_iter(p)), paths))
self._settings.ftp_bookmarks = [r[0] for r in self._bookmark_model]
def on_view_key_press(self, view, event):
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
key = KeyboardKey(key_code)
ctrl = event.state & MOD_MASK
if key is KeyboardKey.F7:
@@ -556,14 +869,28 @@ class FtpClientBox(Gtk.HBox):
self.on_file_create_folder(self._file_name_renderer)
elif key is KeyboardKey.F2 or ctrl and KeyboardKey.R:
if self._ftp_view.is_focus():
self.on_ftp_edit(self._ftp_name_renderer)
self.on_ftp_rename(self._ftp_name_renderer)
elif self._file_view.is_focus():
self.on_file_edit(self._file_name_renderer)
self.on_file_rename(self._file_name_renderer)
elif key is KeyboardKey.F4:
if self._ftp_view.is_focus():
self.on_ftp_edit()
elif key is KeyboardKey.F5:
if self._ftp_view.is_focus():
self.on_ftp_copy()
elif self._file_view.is_focus():
self.on_file_copy()
elif key is KeyboardKey.DELETE:
if self._ftp_view.is_focus():
self.on_ftp_file_remove()
elif self._file_view.is_focus():
self.on_file_remove()
elif self._bookmark_view.is_focus():
self.on_bookmark_remove()
elif key is KeyboardKey.RETURN:
path, column = view.get_cursor()
if path:
view.emit("row-activated", path, column)
def on_view_press(self, view, event):
if event.get_event_type() == Gdk.EventType.BUTTON_PRESS and event.button == Gdk.BUTTON_PRIMARY:
@@ -577,24 +904,11 @@ class FtpClientBox(Gtk.HBox):
# Enable selection.
self._select_enabled = True
def get_size_from_bytes(self, size):
""" Simple convert function from bytes to other units like K, M or G. """
try:
b = float(size)
except ValueError:
return size
else:
kb, mb, gb = 1024.0, 1048576.0, 1073741824.0
if b < kb:
return str(b)
elif kb <= b < mb:
return "{0:.1f} K".format(b / kb)
elif mb <= b < gb:
return "{0:.1f} M".format(b / mb)
elif gb <= b:
return "{0:.1f} G".format(b / gb)
@staticmethod
def on_paned_size_allocate(paned, allocation):
""" Sets default homogeneous sizes. """
paned.set_position(0.5 * allocation.width)
if __name__ == '__main__':
if __name__ == "__main__":
pass

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,32 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
from collections import defaultdict
from contextlib import suppress
from pathlib import Path
@@ -5,22 +34,25 @@ from app.commons import run_idle, log
from app.eparser import get_bouquets, get_services, BouquetsReader
from app.eparser.ecommons import BqType, BqServiceType, Bouquet
from app.eparser.neutrino.bouquets import parse_webtv, parse_bouquets as get_neutrino_bouquets
from app.settings import SettingsType
from app.ui.dialogs import show_dialog, DialogType, get_chooser_dialog, get_message, get_builder
from app.ui.main_helper import on_popup_menu
from .uicommons import Gtk, UI_RESOURCES_PATH, KeyboardKey, Column
from app.settings import SettingsType, IS_DARWIN, SEP
from app.ui.dialogs import show_dialog, DialogType, get_chooser_dialog, translate, get_builder
from app.ui.main_helper import on_popup_menu, get_iptv_data, show_info_bar_message
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, Column, Page, HeaderBar
def import_bouquet(transient, model, path, settings, services, appender, file_path=None):
def import_bouquet(app, model, path, appender, file_path=None):
""" Import of single bouquet """
itr = model.get_iter(path)
bq_type = BqType(model.get(itr, Column.BQ_TYPE)[0])
pattern, f_pattern = None, None
settings = app.app_settings
transient = app.app_window
services = app.current_services
profile = settings.setting_type
if profile is SettingsType.ENIGMA_2:
pattern = ".{}".format(bq_type.value)
f_pattern = "userbouquet.*{}".format(pattern)
pattern = f".{bq_type.value}"
f_pattern = f"*{pattern}"
elif profile is SettingsType.NEUTRINO_MP:
pattern = "webtv.xml" if bq_type is BqType.WEBTV else "bouquets.xml"
f_pattern = "bouquets.xml"
@@ -38,6 +70,10 @@ def import_bouquet(transient, model, path, settings, services, appender, file_pa
return
if profile is SettingsType.ENIGMA_2:
if IS_DARWIN and file_path.rfind("userbouquet.") < 0:
show_dialog(DialogType.ERROR, transient, text="Not allowed in this context!")
return
bq = get_enigma2_bouquet(file_path)
imported = list(filter(lambda x: x.data in services or x.type is BqServiceType.IPTV, bq.services))
@@ -55,50 +91,93 @@ def import_bouquet(transient, model, path, settings, services, appender, file_pa
bqs = parse_webtv(file_path, "WEBTV", bq_type.value)
else:
bqs = get_neutrino_bouquets(file_path, "", bq_type.value)
file_path = "{}/".format(Path(file_path).parent)
ImportDialog(transient, file_path, settings, services.keys(), lambda b, s: appender(b), (bqs,)).show()
file_path = f"{Path(file_path).parent}{SEP}"
ImportDialog(app, file_path, lambda b, s: appender(b), (bqs,)).show()
def get_enigma2_bouquet(path):
path, sep, f_name = path.rpartition("userbouquet.")
name, sep, suf = f_name.rpartition(".")
bq = BouquetsReader.get_bouquet(path, name, suf)
bouquet = Bouquet(name=bq[0], type=BqType(suf).value, services=bq[1], locked=None, hidden=None)
p = Path(path)
bq = BouquetsReader().get_bouquet(f"{p.parent}{SEP}", f"{p.stem}{p.suffix}", p.stem)
bouquet = Bouquet(name=bq[0], type=BqType(p.suffix.lstrip(".")).value, services=bq[1], locked=None, hidden=None)
return bouquet
class ImportDialog:
def __init__(self, transient, path, settings, service_ids, appender, bouquets=None):
def __init__(self, app, path, appender, bouquets=None):
handlers = {"on_import": self.on_import,
"on_cursor_changed": self.on_cursor_changed,
"on_selected_toggled": self.on_selected_toggled,
"on_service_changed": self.on_service_changed,
"on_bq_selected_toggled": self.on_bq_selected_toggled,
"on_sat_selected_toggled": self.on_sat_selected_toggled,
"on_service_selected_toggled": self.on_service_selected_toggled,
"on_services_model_changed": self.on_services_model_changed,
"on_info_bar_close": self.on_info_bar_close,
"on_select_all": self.on_select_all,
"on_unselect_all": self.on_unselect_all,
"on_sat_view_realize": self.on_sat_view_realize,
"on_services_view_realize": self.on_services_view_realize,
"on_popup_menu": on_popup_menu,
"on_resize": self.on_resize,
"on_main_paned_realize": self.on_main_paned_realize,
"on_visible_page": self.on_visible_page,
"on_bouquets_only_switch": self.on_bouquets_only_switch,
"on_key_press": self.on_key_press}
builder = get_builder(UI_RESOURCES_PATH + "imports.glade", handlers)
builder = get_builder(f"{UI_RESOURCES_PATH}imports.glade", handlers)
self._bq_services = {}
self._app = app
self._services = {}
self._service_ids = service_ids
self._bq_services = {}
self._sat_services = defaultdict(list)
self._ids = self._app.current_services.keys()
self._skip_import = defaultdict(set)
self._append = appender
self._profile = settings.setting_type
self._settings = settings
self._profile = app.app_settings.setting_type
self._settings = app.app_settings
self._bouquets = bouquets
self._current_bq = None
self._current_sat = None
self._existing_srv_background = None
self._page = Page.SERVICES
self._dialog_window = builder.get_object("dialog_window")
self._dialog_window.set_transient_for(transient)
self._main_model = builder.get_object("main_list_store")
self._main_view = builder.get_object("main_view")
self._services_view = builder.get_object("services_view")
self._services_model = builder.get_object("services_list_store")
self._info_check_button = builder.get_object("info_check_button")
self._info_check_button.bind_property("active", builder.get_object("services_box_frame"), "visible")
self._dialog_window.set_transient_for(app.app_window)
self._info_bar = builder.get_object("info_bar")
self._message_label = builder.get_object("message_label")
# Options.
self._replace_existing_switch = builder.get_object("replace_existing_switch")
self._bouquets_only_switch = builder.get_object("bouquets_only_switch")
self._bouquets_settings_box = builder.get_object("bouquets_settings_box")
# Bouquets page.
self._bq_model = builder.get_object("bq_list_store")
self._bq_view = builder.get_object("bq_view")
self._services_view = builder.get_object("services_view")
self._services_model = builder.get_object("services_list_store")
self._bouquets_count_label = builder.get_object("bouquets_count_label")
self._services_count_label = builder.get_object("services_count_label")
self._service_info_label = builder.get_object("service_info_label")
self._service_exists_frame = builder.get_object("service_exists_frame")
# Satellites page.
self._sat_view = builder.get_object("sat_view")
self._sat_model = builder.get_object("sat_list_store")
self._sat_count_label = builder.get_object("sat_count_label")
if self._settings.use_header_bar:
actions_box = builder.get_object("actions_box")
builder.get_object("toolbar_box").set_visible(False)
header_bar = HeaderBar()
stack_switcher = builder.get_object("stack_switcher")
actions_box.remove(stack_switcher)
header_bar.set_custom_title(stack_switcher)
button = builder.get_object("import_button")
actions_box.remove(button)
header_bar.pack_start(button)
extra_box = builder.get_object("extra_header_box")
actions_box.remove(extra_box)
header_bar.pack_end(extra_box)
self._dialog_window.set_titlebar(header_bar)
window_size = self._settings.get("import_dialog_window_size")
if window_size:
self._dialog_window.resize(*window_size)
@@ -110,16 +189,21 @@ class ImportDialog:
@run_idle
def init_data(self, path):
self._main_model.clear()
self._bq_model.clear()
self._services_model.clear()
try:
if not self._bouquets:
log("Import [init data]: getting bouquets...")
self._bouquets = get_bouquets(path, self._profile)
self._bouquets, errors = get_bouquets(path, self._profile)
if errors:
msg = translate('There were errors [%s] during bouquets loading!') % errors
self.show_info_message(f"{msg} {translate('Check the log for more info.')}",
Gtk.MessageType.WARNING)
for bqs in self._bouquets:
for bq in bqs.bouquets:
self._main_model.append((bq.name, bq.type, True))
self._bq_model.append((bq.name, bq.type, True))
self._bq_services[(bq.name, bq.type)] = bq.services
self._bouquets_count_label.set_text(str(len(self._bq_model)))
if self._profile is SettingsType.ENIGMA_2:
services = get_services(path, self._profile, 5 if self._settings.v5_support else 4)
@@ -132,21 +216,24 @@ class ImportDialog:
for srv in services:
self._services[srv.fav_id] = srv
except FileNotFoundError as e:
log("Import error [init data]: {}".format(e))
log(f"Import error [init data]: {e}")
self.show_info_message(str(e), Gtk.MessageType.ERROR)
def on_import(self, item):
if not any(r[-1] for r in self._main_model):
self.show_info_message(get_message("No selected item!"), Gtk.MessageType.ERROR)
return
if self._page is Page.SERVICES:
if not any(r[-1] for r in self._bq_model):
self.show_info_message(translate("No selected item!"), Gtk.MessageType.ERROR)
return
if not self._bouquets or show_dialog(DialogType.QUESTION, self._dialog_window) == Gtk.ResponseType.CANCEL:
return
if not self._bouquets or show_dialog(DialogType.QUESTION, self._dialog_window) != Gtk.ResponseType.OK:
return
self.import_data()
self.import_bouquets_data()
else:
self.import_satellites_data()
@run_idle
def import_data(self):
def import_bouquets_data(self):
""" Importing data into models. """
if not self._bouquets:
return
@@ -154,12 +241,13 @@ class ImportDialog:
log("Importing data...")
services = set()
to_delete = set()
for row in self._main_model:
for row in self._bq_model:
bq = (row[0], row[1])
if row[-1]:
skip = self._skip_import[bq]
for bq_srv in self._bq_services.get(bq, []):
srv = self._services.get(bq_srv.data, None)
if srv:
if srv and srv.fav_id not in skip:
services.add(srv)
else:
to_delete.add(bq)
@@ -168,41 +256,128 @@ class ImportDialog:
for bq in bqs.bouquets:
if (bq.name, bq.type) in to_delete:
bqs_to_delete.append(bq)
else:
skip = self._skip_import[(bq.name, bq.type)]
bq_services = [srv for srv in bq.services if srv.data not in skip]
bq.services.clear()
bq.services.extend(bq_services)
for bqs in self._bouquets:
bq = bqs.bouquets
for b in bqs_to_delete:
with suppress(ValueError):
bq.remove(b)
self._append(self._bouquets, list(filter(lambda s: s.fav_id not in self._service_ids, services)))
if self._bouquets_only_switch.get_active():
services = ()
else:
services = list(filter(lambda s: s.fav_id not in self._ids, services))
self._append(self._bouquets, services)
if self._replace_existing_switch.get_active():
self._app.emit("services_update", {s.fav_id: s for s in filter(lambda s: s.fav_id in self._ids, services)})
self._dialog_window.destroy()
def import_satellites_data(self):
if show_dialog(DialogType.QUESTION, self._dialog_window) != Gtk.ResponseType.OK:
return
replace_existing = self._replace_existing_switch.get_active()
services = []
current_services = self._app.current_services
to_replace = {}
for row in self._sat_model:
if row[-1]:
sat = (row[0], row[1])
skip = self._skip_import[sat]
for s in filter(lambda srv: srv.fav_id not in skip, self._sat_services.get(sat[0], ())):
if replace_existing and s.fav_id in self._ids:
current_services[s.fav_id] = s
to_replace[s.fav_id] = s
elif s.fav_id not in self._ids:
services.append(s)
self._append((), services)
if to_replace:
self._app.emit("services_update", to_replace)
self._dialog_window.destroy()
@run_idle
def on_cursor_changed(self, view):
if not self._info_check_button.get_active():
return
self._services_model.clear()
self._service_info_label.set_text("")
model, paths = view.get_selection().get_selected_rows()
if not paths:
return
bq_services = self._bq_services.get(model.get(model.get_iter(paths[0]), 0, 1))
if self._page is Page.SERVICES:
self._current_bq = model.get(model.get_iter(paths[0]), 0, 1)
self.update_bq_services()
else:
self._current_sat = model.get(model.get_iter(paths[0]), 0, 1)
self.update_sat_services()
self._services_count_label.set_text(str(len(self._services_model)))
def update_bq_services(self):
bq_services = self._bq_services.get(self._current_bq)
skip = self._skip_import[self._current_bq]
for bq_srv in bq_services:
if bq_srv.type is BqServiceType.DEFAULT:
srv = self._services.get(bq_srv.data, None)
if srv:
self._services_model.append((srv.service, srv.service_type))
bg = self._existing_srv_background if srv.fav_id in self._ids else None
self._services_model.append((srv.service, srv.service_type, srv.fav_id not in skip, bg, srv.fav_id))
else:
self._services_model.append((bq_srv.name, bq_srv.type.value))
bg = self._existing_srv_background if bq_srv.data in self._ids else None
self._services_model.append((bq_srv.name, bq_srv.type.value, bq_srv.data not in skip, bg, bq_srv.data))
def on_selected_toggled(self, toggle, path):
self._main_model.set_value(self._main_model.get_iter(path), 2, not toggle.get_active())
def update_sat_services(self):
sat_services = self._sat_services.get(self._current_sat[0])
skip = self._skip_import[self._current_sat]
for srv in sat_services:
bg = self._existing_srv_background if srv.fav_id in self._ids else None
self._services_model.append((srv.service, srv.service_type, srv.fav_id not in skip, bg, srv.fav_id))
def on_service_changed(self, view):
path, column = view.get_cursor()
if path:
row = self._services_model[path][:]
if row[1] == "IPTV":
ref, url = get_iptv_data(row[-1])
ref = f"{translate('Service reference')}: {ref}"
info = f"{translate('Name')}: {row[0]}\n{ref}\nURL: {url}"
self._service_info_label.set_text(info)
else:
srv = self._services.get(row[-1], None)
self._service_info_label.set_text(self._app.get_hint_for_fav_list(srv) if srv else "")
def on_bq_selected_toggled(self, toggle, path):
self._bq_model.set_value(self._bq_model.get_iter(path), 2, not toggle.get_active())
def on_sat_selected_toggled(self, toggle, path):
self._sat_model.set_value(self._sat_model.get_iter(path), 2, not toggle.get_active())
def on_service_selected_toggled(self, toggle, path):
self._services_model.set_value(self._services_model.get_iter(path), 2, not toggle.get_active())
def on_services_model_changed(self, model, path, itr):
row = model[itr][:]
fav_id = row[-1]
skip = self._skip_import[self._current_bq if self._page is Page.SERVICES else self._current_sat]
if row[2]:
if fav_id in skip:
skip.remove(fav_id)
else:
skip.add(fav_id)
@run_idle
def show_info_message(self, text, message_type):
self._info_bar.set_visible(True)
self._info_bar.set_message_type(message_type)
self._message_label.set_text(text)
show_info_bar_message(self._info_bar, self._message_label, text, message_type)
@run_idle
def on_info_bar_close(self, bar=None, resp=None):
@@ -217,22 +392,50 @@ class ImportDialog:
def update_selection(self, view, select):
view.get_model().foreach(lambda mod, path, itr: mod.set_value(itr, 2, select))
def on_sat_view_realize(self, view):
if not self._services:
return True
for srv in self._services.values():
self._sat_services[srv.pos].append(srv)
list(map(lambda s: self._sat_model.append((s, None, True)), self._sat_services))
self._sat_count_label.set_text(str(len(self._sat_model)))
def on_services_view_realize(self, view):
if self._settings.use_colors:
background = Gdk.RGBA()
self._existing_srv_background = background if background.parse(self._settings.extra_color) else None
self._service_exists_frame.modify_bg(Gtk.StateType.NORMAL, background.to_color())
def on_resize(self, window):
if self._settings:
self._settings.add("import_dialog_window_size", window.get_size())
def on_main_paned_realize(self, paned):
width = paned.get_allocated_width()
paned.set_position(width * 0.35)
def on_visible_page(self, stack, param):
self._page = Page(stack.get_visible_child_name())
self._bouquets_settings_box.set_sensitive(self._page is Page.SERVICES)
def on_bouquets_only_switch(self, switch, state):
if state:
self._replace_existing_switch.set_active(False)
def on_key_press(self, view, event):
""" Handling keystrokes """
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
key = KeyboardKey(key_code)
if key is KeyboardKey.SPACE:
model = view.get_model()
path, column = view.get_cursor()
itr = self._main_model.get_iter(path)
selected = self._main_model.get_value(itr, 2)
self._main_model.set_value(itr, 2, not selected)
itr = model.get_iter(path)
selected = model.get_value(itr, 2)
model.set_value(itr, 2, not selected)
if __name__ == "__main__":

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

163
app/ui/logs.glade Normal file
View File

@@ -0,0 +1,163 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2
The MIT License (MIT)
Copyright (c) 2018-2022 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.18"/>
<!-- interface-css-provider-path style.css -->
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellite list editor. -->
<!-- interface-copyright 2018-2022 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkFrame" id="log_frame">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label-xalign">0.49000000953674316</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkBox" id="main_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<child>
<object class="GtkBox" id="header_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkButton" id="clear_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Clear</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="on_clear" swapped="no"/>
<child>
<object class="GtkImage" id="clear_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="stock">gtk-clear</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="close_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Close</property>
<property name="always-show-image">True</property>
<signal name="clicked" handler="on_close" swapped="no"/>
<child>
<object class="GtkImage" id="close_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="stock">gtk-close</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="scrolled_window">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="shadow-type">in</property>
<child>
<object class="GtkTextView" id="log_view">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="editable">False</property>
<property name="left-margin">5</property>
<property name="right-margin">5</property>
<property name="top-margin">5</property>
<property name="bottom-margin">5</property>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<style>
<class name="view"/>
</style>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
</child>
<child type="label">
<object class="GtkLabel" id="log_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-bottom">2</property>
<property name="label" translatable="yes">Logs</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>
</interface>

74
app/ui/logs.py Normal file
View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2022 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import logging
from gi.repository import GLib
from app.commons import LOGGER_NAME, LOG_FORMAT, LOG_DATE_FORMAT
from app.ui.dialogs import get_builder
from app.ui.main_helper import append_text_to_tview
from app.ui.uicommons import Gtk, UI_RESOURCES_PATH
class LogsClient(Gtk.Box):
""" Logger GUI client. """
class LogHandler(logging.Handler):
def __init__(self, view):
logging.Handler.__init__(self)
self._view = view
self.setFormatter(logging.Formatter(fmt=LOG_FORMAT, datefmt=LOG_DATE_FORMAT))
def handle(self, rec: logging.LogRecord):
GLib.idle_add(append_text_to_tview, f"{self.format(rec)}\n", self._view)
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs)
self._app = app
handlers = {"on_clear": self.on_clear, "on_close": self.on_close}
builder = get_builder(UI_RESOURCES_PATH + "logs.glade", handlers)
self._log_view = builder.get_object("log_view")
self.pack_start(builder.get_object("log_frame"), True, True, 0)
logger = logging.getLogger(LOGGER_NAME)
logger.addHandler(LogsClient.LogHandler(self._log_view))
self.show()
def on_clear(self, button):
GLib.idle_add(self._log_view.get_buffer().set_text, "")
def on_close(self, button):
self._app.change_action_state("on_logs_show", GLib.Variant.new_boolean(False))
if __name__ == "__main__":
pass

783
app/ui/m3u.glade Normal file
View File

@@ -0,0 +1,783 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.40.0
The MIT License (MIT)
Copyright (c) 2018-2024 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.22"/>
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellite list editor. -->
<!-- interface-copyright 2018-2024 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkBox" id="export_m3u_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="margin-bottom">5</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<child>
<object class="GtkViewport" id="export_viewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<!-- n-columns=2 n-rows=3 -->
<object class="GtkGrid" id="export_m3u_grid">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="row-spacing">5</property>
<property name="column-spacing">5</property>
<child>
<object class="GtkLabel" id="export_port_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Port:</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="export_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<property name="label" translatable="yes">Export:</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkButtonBox" id="export_types_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkRadioButton" id="export_all_button">
<property name="label" translatable="yes">All types</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="export_iptv_button">
<property name="label" translatable="yes">IPTV only</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">export_all_button</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkButtonBox" id="export_grp_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkRadioButton" id="export_grp_bq_button">
<property name="label" translatable="yes">Bouquets</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="export_grp_markers_button">
<property name="label" translatable="yes">Markers</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">export_grp_bq_button</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="export_grp_no_button">
<property name="label" translatable="yes">No</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">export_grp_markers_button</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="export_port_box">
<property name="visible">True</property>
<property name="sensitive" bind-source="export_iptv_button" bind-property="active" bind-flags="invert-boolean">True</property>
<property name="can-focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkEntry" id="export_port_entry">
<property name="visible">True</property>
<property name="sensitive" bind-source="export_auto_button" bind-property="active" bind-flags="invert-boolean">True</property>
<property name="can-focus">True</property>
<property name="width-chars">10</property>
<property name="primary-icon-name">document-edit-symbolic</property>
<property name="placeholder-text" translatable="yes">8001</property>
<property name="input-purpose">digits</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkToggleButton" id="export_auto_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkImage">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">emblem-synchronizing-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Auto</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">start</property>
<child>
<object class="GtkLabel" id="export_group_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Group by</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label">:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">2</property>
</packing>
</child>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="export_status_box">
<property name="height-request">26</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkImage" id="export_info_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="icon-name">document-properties</property>
<property name="icon_size">2</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="export_bq_info_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">2</property>
<child>
<object class="GtkLabel" id="export_bq_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Bouquets</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label">:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="export_bq_count_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="label">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="export_service_info_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="spacing">1</property>
<child>
<object class="GtkLabel" id="export_services_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Services</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label">:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="export_services_count_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="label">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<object class="GtkBox" id="import_m3u_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkFrame" id="import_m3u_frame">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="label-xalign">0.02</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkViewport" id="import_viewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkBox" id="import_main_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="orientation">vertical</property>
<property name="spacing">10</property>
<child>
<object class="GtkButtonBox" id="import_type_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="layout-style">expand</property>
<child>
<object class="GtkRadioButton" id="current_bq_button">
<property name="label" translatable="yes">Current bouquet</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">sub_bq_button</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="single_bq_button">
<property name="label" translatable="yes">Single bouquet</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">current_bq_button</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="group_bq_button">
<property name="label" translatable="yes">Split by groups</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">sub_bq_button</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkRadioButton" id="sub_bq_button">
<property name="label" translatable="yes">Create sub-bouquets</property>
<property name="name">header-button</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">False</property>
<property name="active">True</property>
<property name="draw-indicator">False</property>
<property name="group">current_bq_button</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="load_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">15</property>
<child type="center">
<object class="GtkProgressBar" id="progress_bar">
<property name="can-focus">False</property>
<property name="valign">center</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="info_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel" id="info_label">
<property name="visible" bind-source="spinner" bind-property="visible" bind-flags="invert-boolean">True</property>
<property name="can-focus">False</property>
<property name="ellipsize">end</property>
<property name="max-width-chars">30</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="load_label">
<property name="visible" bind-source="spinner" bind-property="visible">False</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Loading data...</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkSpinner" id="spinner">
<property name="visible" bind-source="spinner" bind-property="active">False</property>
<property name="can-focus">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="picon_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel" id="picon_switch_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Download picons</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkSwitch" id="picon_switch">
<property name="visible">True</property>
<property name="can-focus">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkBox" id="epg_src_box">
<property name="height-request">30</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkLabel" id="epg_source_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">EPG source</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label">:</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkComboBoxText" id="epg_links_box">
<property name="can-focus">True</property>
<property name="halign">start</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="active">0</property>
<property name="has-entry">True</property>
<child internal-child="entry">
<object class="GtkEntry">
<property name="can-focus">False</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="epg_info_label">
<property name="visible" bind-source="epg_links_box" bind-property="visible" bind-flags="invert-boolean">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="label" translatable="yes">Not found.</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkBox" id="add_epg_src_box">
<property name="visible" bind-source="epg_links_box" bind-property="visible">False</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkLabel" id="add_epg_src_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">Add to EPG sources list</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkSwitch" id="add_epg_src_switch">
<property name="visible">True</property>
<property name="can-focus">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="pack-type">end</property>
<property name="position">4</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
</child>
<child type="label">
<object class="GtkLabel" id="import_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-bottom">2</property>
<property name="label" translatable="yes">Import</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
</interface>

View File

@@ -1,18 +1,37 @@
* {
-GtkDialog-action-area-border: 5em;
background-clip: padding-box;
-GtkScrolledWindow-scrollbar-spacing: 0;
-GtkToolItemGroup-expander-size: 11;
-GtkWidget-text-handle-width: 20;
-GtkWidget-text-handle-height: 20;
-GtkDialog-button-spacing: 12;
-GtkDialog-action-area-border: 6;
}
entry {
min-height: 2.0em;
padding: 0.2em;
}
entry > image {
padding-left: 0.3em;
padding-right: 0.3em;
}
button {
min-height: 1.5em;
min-width: 1em;
padding-left: 0.4em;
padding-right: 0.4em;
padding-top: 0.1em;
padding-bottom: 0.1em;
min-height: 1.2em;
min-width: 1.5em;
padding-top: 0.3em;
padding-bottom: 0.3em;
}
button:active, button:checked {
color: @theme_selected_fg_color;
background-image: linear-gradient(@theme_selected_bg_color, @theme_selected_bg_color);
}
combobox {
min-height: 2.2em;
}
spinbutton {
@@ -32,7 +51,21 @@ infobar {
min-height: 2em;
}
revealer > box > button {
padding: 0.2em;
}
switch slider {
min-height: 1.5em;
min-width: 1.5em;
}
.font > box {
min-height: 1.5em;
padding-top: 0.1em;
padding-bottom: 0.1em;
}
.dialog-action-area button {
margin-bottom: 0.6em;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2024 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -26,19 +26,32 @@
#
""" Helper module for the ui. """
""" Helper module for the GUI. """
__all__ = ("insert_marker", "move_items", "rename", "ViewTarget", "set_flags", "locate_in_services",
"scroll_to", "get_base_model", "get_base_paths", "copy_reference", "assign_picons", "remove_picon",
"is_only_one_item_selected", "gen_bouquets", "BqGenType", "get_selection", "get_service_reference",
"get_model_data", "remove_all_unused_picons", "get_picon_pixbuf", "get_base_itrs", "get_iptv_url",
"get_iptv_data", "update_entry_data", "append_text_to_tview", "on_popup_menu", "get_picon_file_name",
"update_toggle_model", "update_popup_filter_model", "update_filter_sat_positions", "get_pos_num",
"show_info_bar_message", "gen_bouquet_name")
import os
import re
import shutil
from collections import defaultdict
import unicodedata
from functools import lru_cache
from itertools import groupby
from pathlib import Path
from urllib.parse import unquote
from gi.repository import GdkPixbuf, GLib
from gi.repository import GdkPixbuf, GLib, Gio
from app.eparser import Service
from app.eparser.ecommons import Flag, BouquetService, Bouquet, BqType
from app.eparser.enigma.bouquets import BqServiceType, to_bouquet_id
from app.settings import SettingsType, SEP, IS_WIN
from .dialogs import show_dialog, DialogType, get_chooser_dialog
from app.eparser.enigma.bouquets import BqServiceType
from app.settings import SettingsType, SEP, IS_WIN, IS_DARWIN, IS_LINUX
from .dialogs import show_dialog, DialogType, translate
from .uicommons import ViewTarget, BqGenType, Gtk, Gdk, HIDE_ICON, LOCKED_ICON, KeyboardKey, Column
@@ -57,7 +70,7 @@ def insert_marker(view, bouquets, selected_bouquet, services, parent_window, m_t
show_dialog(DialogType.ERROR, parent_window, "The text of marker is empty, please try again!")
return
fav_id = "1:64:0:0:0:0:0:0:0:0::{}\n#DESCRIPTION {}\n".format(response, response)
fav_id = f"1:64:0:0:0:0:0:0:0:0::{response}\n#DESCRIPTION {response}\n"
text = response
s_type = m_type.name
@@ -103,8 +116,8 @@ def move_items(key, view: Gtk.TreeView):
children_num = model.iter_n_children(parent_itr)
if key in (KeyboardKey.PAGE_DOWN, KeyboardKey.END, KeyboardKey.END_KP, KeyboardKey.PAGE_DOWN_KP):
children_num -= 1
min_path = Gtk.TreePath.new_from_string("{}:{}".format(parent_index, 0))
max_path = Gtk.TreePath.new_from_string("{}:{}".format(parent_index, children_num))
min_path = Gtk.TreePath.new_from_string(f"{parent_index}:{0}")
max_path = Gtk.TreePath.new_from_string(f"{parent_index}:{children_num}")
is_tree_store = True
if key is KeyboardKey.UP:
@@ -274,14 +287,14 @@ def set_lock(blacklist, services, model, paths, target, services_model):
locked = has_locked_hide(model, paths, col_num)
ids = []
skip_type = {BqServiceType.MARKER.name, BqServiceType.SPACE.name}
skip_type = {BqServiceType.MARKER.name, BqServiceType.SPACE.name, BqServiceType.ALT.name}
for path in paths:
itr = model.get_iter(path)
fav_id = model.get_value(itr, Column.SRV_FAV_ID if target is ViewTarget.SERVICES else Column.FAV_ID)
srv = services.get(fav_id, None)
if srv and srv.service_type not in skip_type:
bq_id = srv.data_id if srv.service_type == BqServiceType.IPTV.name else to_bouquet_id(srv)
bq_id = srv.data_id if srv.service_type == BqServiceType.IPTV.name else srv.fav_id
if not bq_id:
continue
blacklist.discard(bq_id) if locked else blacklist.add(bq_id)
@@ -352,7 +365,7 @@ def has_locked_hide(model, paths, col_num):
# ***************** Location *******************#
def locate_in_services(fav_view, services_view, parent_window):
def locate_in_services(fav_view, services_view, column, parent_window):
""" Locating and scrolling to the service """
model, paths = fav_view.get_selection().get_selected_rows()
@@ -364,7 +377,7 @@ def locate_in_services(fav_view, services_view, parent_window):
fav_id = model.get_value(model.get_iter(paths[0]), Column.FAV_ID)
for index, row in enumerate(services_view.get_model()):
if row[Column.SRV_FAV_ID] == fav_id:
if row[column] == fav_id:
scroll_to(index, services_view)
break
@@ -379,26 +392,36 @@ def scroll_to(index, view, paths=None):
selection.select_path(index)
# ***************** Picons *********************#
# ***************** Picons ********************* #
def update_picons_data(path, picons, size=32):
if not os.path.exists(path):
return
def get_picon_dialog(transient, title, button_text, multiple=True):
""" Returns a copy dialog with a preview of images [picons -> *.png]. """
dialog = Gtk.FileChooserNative.new(title, transient, Gtk.FileChooserAction.OPEN, button_text)
dialog.set_select_multiple(multiple)
dialog.set_modal(True)
# Filter.
file_filter = Gtk.FileFilter()
file_filter.set_name("*.png")
file_filter.add_pattern("*.png")
file_filter.add_mime_type("image/png") if IS_DARWIN else None
dialog.add_filter(file_filter)
for file in os.listdir(path):
pf = get_picon_pixbuf(path + file, size)
if pf:
picons[file] = pf
if IS_LINUX:
preview_image = Gtk.Image(margin_right=10)
dialog.set_preview_widget(preview_image)
def update_preview_widget(dlg):
path = dialog.get_preview_filename()
if not path:
return
def append_picons(picons, model):
def append_picons_data(pcs, mod):
for r in mod:
mod.set_value(mod.get_iter(r.path), Column.SRV_PICON, pcs.get(r[Column.SRV_PICON_ID], None))
yield True
pix = get_picon_pixbuf(path, 220)
preview_image.set_from_pixbuf(pix)
dlg.set_preview_widget_active(bool(pix))
app = append_picons_data(picons, model)
GLib.idle_add(lambda: next(app, False), priority=GLib.PRIORITY_LOW)
dialog.connect("update-preview", update_preview_widget)
return dialog
def assign_picons(target, srv_view, fav_view, transient, picons, settings, services, src_path=None, dst_path=None):
@@ -408,10 +431,12 @@ def assign_picons(target, srv_view, fav_view, transient, picons, settings, servi
picons_files = []
if not src_path:
src_path = get_chooser_dialog(transient, settings, "*.png files", ("*.png",))
if src_path == Gtk.ResponseType.CANCEL:
dialog = get_picon_dialog(transient, translate("Picon selection"), translate("Open"), False)
if dialog.run() in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT) or not dialog.get_filenames():
return picons_files
src_path = dialog.get_filenames()[0]
if IS_WIN:
src_path = src_path.lstrip("/")
dst_path = dst_path.lstrip("/") if dst_path else dst_path
@@ -443,7 +468,7 @@ def assign_picons(target, srv_view, fav_view, transient, picons, settings, servi
pass # NOP
else:
picons_files.append(picon_file)
picon = get_picon_pixbuf(picon_file)
picon = get_picon_pixbuf(picon_file, settings.list_picon_size)
picons[picon_id] = picon
model.set_value(itr, p_pos, picon)
if target is ViewTarget.SERVICES:
@@ -503,36 +528,17 @@ def remove_picon(target, srv_view, fav_view, picons, settings):
remove_picons(settings, picon_ids, picons)
def copy_picon_reference(target, view, services, clipboard, transient):
""" Copying picon id to clipboard """
model, paths = view.get_selection().get_selected_rows()
if not is_only_one_item_selected(paths, transient):
return
if target is ViewTarget.SERVICES:
picon_id = model.get_value(model.get_iter(paths), Column.SRV_PICON_ID)
if picon_id:
clipboard.set_text(picon_id.rstrip(".png"), -1)
else:
show_dialog(DialogType.ERROR, transient, "No reference is present!")
elif target is ViewTarget.FAV:
fav_id = model.get_value(model.get_iter(paths), Column.FAV_ID)
srv = services.get(fav_id, None)
if srv and srv.picon_id:
clipboard.set_text(srv.picon_id.rstrip(".png"), -1)
else:
show_dialog(DialogType.ERROR, transient, "No reference is present!")
def remove_all_unused_picons(settings, picons, services):
def remove_all_unused_picons(settings, services):
""" Removes picons from profile picons folder if there are no services for these picons. """
ids = {s.picon_id for s in services}
pcs = list(filter(lambda x: x not in ids, picons))
remove_picons(settings, pcs, picons)
for p in Path(settings.profile_picons_path).glob("*.png"):
if p.name not in ids and p.is_file():
p.unlink()
def remove_picons(settings, picon_ids, picons):
pions_path = settings.profile_picons_path
backup_path = "{}{}{}".format(settings.profile_backup_path, "picons", SEP)
backup_path = f"{settings.profile_backup_path}picons{SEP}"
os.makedirs(os.path.dirname(backup_path), exist_ok=True)
for p_id in picon_ids:
picons[p_id] = None
@@ -541,13 +547,13 @@ def remove_picons(settings, picon_ids, picons):
shutil.move(src, backup_path + p_id)
def is_only_one_item_selected(paths, transient):
def is_only_one_item_selected(paths, app):
if len(paths) > 1:
show_dialog(DialogType.ERROR, transient, "Please, select only one item!")
app.show_error_message("Please, select only one item!")
return False
if not paths:
show_dialog(DialogType.ERROR, transient, "No selected item!")
app.show_error_message("No selected item!")
return False
return True
@@ -556,50 +562,115 @@ def is_only_one_item_selected(paths, transient):
def get_picon_pixbuf(path, size=32):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(path, width=size, height=size, preserve_aspect_ratio=True)
except GLib.GError as e:
except GLib.GError:
pass # NOP
def get_pixbuf_from_data(img_data, w=48, h=32):
if img_data:
f = Gio.MemoryInputStream.new_from_data(img_data)
return GdkPixbuf.Pixbuf.new_from_stream_at_scale(f, w, h, True, None)
def get_pixbuf_at_scale(path, width, height, p_ratio):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(path, width, height, p_ratio)
except GLib.GError:
pass
@lru_cache(50)
def get_picon_file_name(service_name):
""" Returns picon file name by service name. """
name = unicodedata.normalize("NFKD", service_name).encode("ASCII", errors="ignore").decode(errors="ignore")
return f"{re.sub('[^a-z0-9]', '', name.replace('&', 'and').replace('+', 'plus').replace('*', 'star').lower())}.png"
# ***************** Bouquets ********************* #
def gen_bouquets(view, bq_view, transient, gen_type, s_type, callback):
def gen_bouquets(app, gen_type):
""" Auto-generate and append list of bouquets. """
model, paths = view.get_selection().get_selected_rows()
single_types = (BqGenType.SAT, BqGenType.PACKAGE, BqGenType.TYPE)
if gen_type in single_types:
if not is_only_one_item_selected(paths, transient):
return
model, paths = app.services_view.get_selection().get_selected_rows()
single_types = {BqGenType.SAT, BqGenType.PACKAGE, BqGenType.TYPE}
if gen_type in single_types and not is_only_one_item_selected(paths, app):
return
fav_id_index = Column.SRV_FAV_ID
index = Column.SRV_TYPE
if gen_type in (BqGenType.PACKAGE, BqGenType.EACH_PACKAGE):
index = Column.SRV_PACKAGE
elif gen_type in (BqGenType.SAT, BqGenType.EACH_SAT):
index = Column.SRV_POS
# Splitting services [caching] by column value.
s_data = defaultdict(list)
for row in model:
s_data[row[index]].append(BouquetService(None, BqServiceType.DEFAULT, row[fav_id_index], 0))
ids = {row[Column.SRV_FAV_ID] for row in model}
services = [v for k, v in app.current_services.items() if k in ids]
bq_type = BqType.BOUQUET.value if s_type is SettingsType.NEUTRINO_MP else BqType.TV.value
bq_index = 0 if s_type is SettingsType.ENIGMA_2 else 1
bq_root_iter = bq_view.get_model().get_iter(bq_index)
srv = Service(*model[paths][:Column.SRV_TOOLTIP])
cond = srv.package if gen_type is BqGenType.PACKAGE else srv.pos if gen_type is BqGenType.SAT else srv.service_type
bq_view.expand_row(Gtk.TreePath(bq_index), 0)
if gen_type is BqGenType.TYPE and cond == "Data":
msg = f"{translate('Selected type:')} '{cond}'\n\n{translate('Are you sure?')}"
if show_dialog(DialogType.QUESTION, app.app_window, msg) != Gtk.ResponseType.OK:
return
def grouper(s):
data = s[index]
return data if data else "None"
services = {k: list(v) for k, v in groupby(sorted(services, key=grouper), key=grouper)}
bq_view = app.bouquets_view
bq_type = BqType.TV.value if app.is_enigma else BqType.BOUQUET.value
bq_index = 0 if app.is_enigma else 1
bq_root_iter = bq_view.get_model().get_iter(bq_index)
bq_names = get_bouquets_names(bq_view.get_model())
if gen_type in single_types:
if cond in bq_names:
show_dialog(DialogType.ERROR, transient, "A bouquet with that name exists!")
else:
callback(Bouquet(cond, bq_type, s_data.get(cond)), bq_root_iter)
app.show_error_message("A bouquet with that name exists!")
return
bq_services = get_services_type_groups(services.get(cond, []))
if app.is_enigma:
if srv.service_type == "Radio":
bq_index = 1
bq_type = BqType.RADIO.value
bq_root_iter = bq_view.get_model().get_iter(bq_index)
bq_view.expand_row(Gtk.TreePath(bq_index), 1)
bq_services = bq_services.get("Radio", [])
else:
bq_view.expand_row(Gtk.TreePath(bq_index), 0)
bq_services = bq_services.get("Data" if srv.service_type == "Data" else "TV", [])
app.append_bouquet(Bouquet(cond, bq_type, get_bouquet_services(bq_services)), bq_root_iter)
else:
bq_view.expand_row(Gtk.TreePath(bq_index), 0)
# We add a bouquet only if the given name is missing [keys - names]!
for name in sorted(s_data.keys() - bq_names):
callback(Bouquet(name, BqType.TV.value, s_data.get(name)), bq_root_iter)
if gen_type is BqGenType.EACH_SAT:
bq_names = sorted(services.keys() - bq_names, key=get_pos_num, reverse=True)
else:
bq_names = sorted(services.keys() - bq_names)
tv_bqs = []
radio_bqs = []
for n in bq_names:
bqs = services.get(n, [])
# TV and Radio separation.
bq_grp = get_services_type_groups(bqs)
tv_bq = bq_grp.get("TV", [])
tv_bqs.append(Bouquet(n, BqType.TV.value, get_bouquet_services(tv_bq))) if tv_bq else None
radio_bq = bq_grp.get("Radio", [])
radio_bqs.append(Bouquet(n, BqType.RADIO.value, get_bouquet_services(radio_bq))) if radio_bq else None
[app.append_bouquet(b, bq_root_iter) for b in tv_bqs]
if app.is_enigma:
bq_root_iter = bq_view.get_model().get_iter(bq_index + 1)
bq_view.expand_row(Gtk.TreePath(bq_index + 1), 0)
[app.append_bouquet(b, bq_root_iter) for b in radio_bqs]
def get_bouquet_services(services):
services.sort(key=lambda s: s.service)
return [BouquetService(None, BqServiceType.DEFAULT, s.fav_id, 0) for s in services]
def get_bouquets_names(model):
@@ -615,7 +686,66 @@ def get_bouquets_names(model):
return bouquets_names
# ***************** Others *********************#
def gen_bouquet_name(bouquets, base_name, bq_type):
""" Generates a name for new bouquets. """
count = 0
key = f"{base_name}:{bq_type}"
bq_name = base_name
while key in bouquets:
count += 1
bq_name = f"{base_name}{count}"
key = f"{bq_name}:{bq_type}"
return bq_name
def get_services_type_groups(services):
""" Returns services grouped by main types [TV, Radio, Data]. -> dict """
def type_grouper(s):
s_type = s.service_type
if s_type == "Data":
return s_type
elif s_type == "Radio":
return s_type
else:
return "TV"
return {k: list(v) for k, v in groupby(sorted(services, key=type_grouper), key=type_grouper)}
# ***************** Others ********************* #
def copy_reference(view, app):
""" Copying picon id to clipboard. """
model, paths = view.get_selection().get_selected_rows()
if not is_only_one_item_selected(paths, app):
return
target = app.get_target_view(view)
clipboard = app._clipboard
if target is ViewTarget.SERVICES:
picon_id = model.get_value(model.get_iter(paths), Column.SRV_PICON_ID)
if picon_id:
clipboard.set_text(picon_id.rstrip(".png"), -1)
else:
app.show_error_message("No reference is present!")
elif target is ViewTarget.FAV:
fav_id = model.get_value(model.get_iter(paths), Column.FAV_ID)
srv = app.current_services.get(fav_id, None)
if srv and srv.picon_id:
clipboard.set_text(get_service_reference(srv), -1)
else:
app.show_error_message("No reference is present!")
app.emit("clipboard-changed", clipboard.wait_is_text_available())
def get_service_reference(srv):
return srv.picon_id.rstrip(".png")
def update_entry_data(entry, dialog, settings):
""" Updates value in text entry from chooser dialog. """
@@ -656,6 +786,47 @@ def get_model_data(view):
return model_name, model
def update_toggle_model(model, path, toggle):
""" Updates the toggle state for the model. """
active = not toggle.get_active()
if path == "0":
model.foreach(lambda m, p, i: m.set_value(i, 1, active))
else:
model.set_value(model.get_iter(path), 1, active)
if active:
model.set_value(model.get_iter_first(), 1, len({r[0] for r in model if r[1]}) == len(model) - 1)
else:
model.set_value(model.get_iter_first(), 1, False)
def update_popup_filter_model(model, elements: set):
first = model[model.get_iter_first()][:]
model.clear()
model.append((first[0], True))
elements.discard(first[0])
def update_filter_sat_positions(model, sat_positions):
""" Updates the values for the satellite positions button model. """
update_popup_filter_model(model, sat_positions)
list(map(lambda pos: model.append((pos, True)), sorted(sat_positions, key=get_pos_num, reverse=True)))
def get_pos_num(pos):
""" Returns num [float] representation of satellite position. """
if not pos:
return -183.0
if len(pos) > 1:
m = -1 if pos[-1] == "W" else 1
try:
return float(pos[:-1]) * m
except ValueError:
return -183
return -181.0 if pos == "T" else -182.0
def append_text_to_tview(char, view):
""" Appending text and scrolling to a given line in the text view. """
buf = view.get_buffer()
@@ -664,21 +835,50 @@ def append_text_to_tview(char, view):
view.scroll_to_mark(insert, 0.0, True, 0.0, 1.0)
def get_iptv_url(row, s_type):
""" Returns url from iptv type row """
data = row[Column.FAV_ID].split(":" if s_type is SettingsType.ENIGMA_2 else "::")
if s_type is SettingsType.ENIGMA_2:
data = list(filter(lambda x: "http" in x, data))
def get_iptv_url(row, s_type, column=Column.FAV_ID):
""" Returns URL from IPTV type row. """
data = row[column].split(":" if s_type is SettingsType.ENIGMA_2 else "::")
if data:
url = data[0]
return unquote(url) if s_type is SettingsType.ENIGMA_2 else url
if s_type is SettingsType.ENIGMA_2:
if len(data) > 10 and "http" in data[10]:
url, sep, desc = data[10].partition("#DESCRIPTION")
return unquote(url.strip())
else:
return data[0]
def get_iptv_data(fav_id):
""" Returns the reference and URL as a tuple from the fav_id. """
data, sep, desc = fav_id.partition("#DESCRIPTION")
data = data.split(":")
if len(data) < 11:
return None, desc
return ":".join(data[:10]), unquote(data[10].strip())
def on_popup_menu(menu, event):
""" Shows popup menu for the view """
""" Shows popup menu for the view. """
if event.get_event_type() == Gdk.EventType.BUTTON_PRESS and event.button == Gdk.BUTTON_SECONDARY:
menu.popup(None, None, None, None, event.button, event.time)
def show_info_bar_message(bar, label, text, message_type=Gtk.MessageType.INFO):
""" Shows a message for info bars. """
bar.set_visible(False)
label.set_text(translate(text))
bar.set_message_type(message_type)
bar.set_visible(True)
def redraw_image(area, cr, pixbuf):
""" Helper method to redraw (auto resize) image in the Gtk DrawingArea. """
cr.scale(area.get_allocated_width() / pixbuf.get_width(),
area.get_allocated_height() / pixbuf.get_height())
img_surface = Gdk.cairo_surface_create_from_pixbuf(pixbuf, 1, None)
cr.set_source_surface(img_surface, 0, 0)
cr.paint()
if __name__ == "__main__":
pass

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -30,21 +30,22 @@ import os
import re
import shutil
from enum import Enum
from html import escape
from pathlib import Path
from urllib.parse import urlparse, unquote
from gi.repository import GLib, GdkPixbuf, Gio
from gi.repository import GLib
from app.commons import run_idle, run_task, run_with_delay
from app.commons import run_idle, run_task, run_with_delay, log
from app.connections import upload_data, DownloadType, download_data, remove_picons
from app.settings import SettingsType, Settings, SEP
from app.settings import SettingsType, Settings, SEP, IS_DARWIN
from app.tools.picons import (PiconsParser, parse_providers, Provider, convert_to, download_picon, PiconsCzDownloader,
PiconsError)
PiconsError, PiconFormat)
from app.tools.satellites import SatellitesParser, SatelliteSource
from .dialogs import show_dialog, DialogType, get_message, get_builder
from .main_helper import (update_entry_data, append_text_to_tview, scroll_to, on_popup_menu, get_base_model, set_picon,
get_picon_pixbuf)
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, TV_ICON, Column, KeyboardKey, Page
from .dialogs import show_dialog, DialogType, translate, get_builder, get_chooser_dialog
from .main_helper import (scroll_to, on_popup_menu, get_base_model, set_picon, get_picon_pixbuf, get_picon_dialog,
get_picon_file_name, get_pixbuf_from_data, get_pixbuf_at_scale, get_pos_num)
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, TV_ICON, Column, KeyboardKey, Page, ViewTarget
class PiconManager(Gtk.Box):
@@ -52,12 +53,17 @@ class PiconManager(Gtk.Box):
LYNG_SAT = "lyngsat"
PICON_CZ = "piconcz"
def __init__(self, app, settings, picon_ids, sat_positions, *args, **kwargs):
super().__init__(*args, **kwargs)
def __init__(self, app, settings, picon_ids, sat_positions, **kwargs):
super().__init__(**kwargs)
self._app = app
self._app.connect("data-open", self.on_open)
self._app.connect("data-receive", self.on_download)
self._app.connect("data-send", self.on_send)
self._app.connect("page-changed", self.update_picons_dest)
self._app.connect("filter-toggled", self.on_app_filter_toggled)
self._app.connect("profile-changed", self.on_profile_changed)
self._app.connect("picon-assign", self.on_picon_assign)
self._app.fav_view.connect("row-activated", self.on_fav_changed)
self._picon_ids = picon_ids
self._sat_positions = sat_positions
@@ -78,12 +84,11 @@ class PiconManager(Gtk.Box):
self._picon_cz_downloader = None
handlers = {"on_tool_switched": self.on_tool_switched,
"on_add": self.on_add,
"on_extract": self.on_extract,
"on_receive": self.on_receive,
"on_cancel": self.on_cancel,
"on_send": self.on_send,
"on_download": self.on_download,
"on_remove": self.on_remove,
"on_picons_dir_open": self.on_picons_dir_open,
"on_selected_toggled": self.on_selected_toggled,
"on_url_changed": self.on_url_changed,
"on_picons_filter_changed": self.on_picons_filter_changed,
@@ -94,9 +99,6 @@ class PiconManager(Gtk.Box):
"on_picons_view_drag_data_received": self.on_picons_view_drag_data_received,
"on_picons_view_drag_end": self.on_picons_view_drag_end,
"on_picon_info_image_drag_data_received": self.on_picon_info_image_drag_data_received,
"on_send_button_drag_data_received": self.on_send_button_drag_data_received,
"on_download_button_drag_data_received": self.on_download_button_drag_data_received,
"on_remove_button_drag_data_received": self.on_remove_button_drag_data_received,
"on_selective_send": self.on_selective_send,
"on_selective_download": self.on_selective_download,
"on_selective_remove": self.on_selective_remove,
@@ -127,15 +129,10 @@ class PiconManager(Gtk.Box):
self._picons_src_filter_model.set_visible_func(self.picons_src_filter_function)
self._picons_dst_filter_model = builder.get_object("picons_dst_filter_model")
self._picons_dst_filter_model.set_visible_func(self.picons_dst_filter_function)
self._expander = builder.get_object("expander")
self._text_view = builder.get_object("text_view")
self._src_filter_button = builder.get_object("src_filter_button")
self._dst_filter_button = builder.get_object("dst_filter_button")
self._picons_filter_entry = builder.get_object("picons_filter_entry")
self._picons_dir_entry = builder.get_object("picons_dir_entry")
self._info_check_button = builder.get_object("info_check_button")
self._picon_info_image = builder.get_object("picon_info_image")
self._picon_info_label = builder.get_object("picon_info_label")
self._current_path_label = builder.get_object("current_path_label")
self._download_source_button = builder.get_object("download_source_button")
self._receive_button = builder.get_object("receive_button")
self._convert_button = builder.get_object("convert_button")
@@ -156,51 +153,43 @@ class PiconManager(Gtk.Box):
self._bouquet_filter_switch = builder.get_object("bouquet_filter_switch")
self._providers_header_box = builder.get_object("providers_header_box")
self._header_download_box = builder.get_object("header_download_box")
self._satellite_label.bind_property("visible", builder.get_object("loading_data_label"), "visible", 4)
self._satellite_label.bind_property("visible", builder.get_object("loading_data_spinner"), "visible", 4)
self._satellite_label.bind_property("visible", self._download_source_button, "sensitive")
self._satellite_label.bind_property("visible", self._satellites_view, "sensitive")
self._cancel_button.bind_property("visible", self._header_download_box, "visible", 4)
self._convert_button.bind_property("visible", self._header_download_box, "visible", 4)
self._download_source_button.bind_property("visible", self._receive_button, "visible")
self._converter_sc_button = builder.get_object("converter_sc_button")
self._converter_nt_button = builder.get_object("converter_nt_button")
self._converter_bq_button = builder.get_object("converter_bq_button")
# Info.
self._dst_count_label = builder.get_object("dst_count_label")
self._info_check_button = builder.get_object("info_check_button")
self._picon_info_image = builder.get_object("picon_info_image")
self._picon_info_label = builder.get_object("picon_info_label")
# Filter.
self._filter_bar = builder.get_object("filter_bar")
self._auto_filer_switch = builder.get_object("auto_filer_switch")
self._auto_filter_switch = builder.get_object("auto_filter_switch")
self._filter_button = builder.get_object("filter_button")
self._filter_button.bind_property("active", self._filter_bar, "visible")
self._filter_button.bind_property("active", self._src_filter_button, "visible")
self._filter_button.bind_property("active", self._dst_filter_button, "visible")
self._filter_button.bind_property("visible", self._info_check_button, "visible")
self._filter_button.bind_property("visible", self._send_button, "visible")
self._filter_button.bind_property("visible", self._download_button, "visible")
self._filter_button.bind_property("visible", self._remove_button, "visible")
self._src_button = builder.get_object("src_button")
self._src_button.bind_property("active", builder.get_object("explorer_dst_label"), "visible")
self._src_button.bind_property("active", builder.get_object("src_picon_box_frame"), "visible")
self._filter_button.bind_property("visible", self._src_button, "visible")
explorer_info_bar = builder.get_object("explorer_info_bar")
explorer_info_bar.bind_property("visible", builder.get_object("explorer_info_bar_frame"), "visible")
self._info_check_button.bind_property("active", explorer_info_bar, "visible")
# Header buttons. -> Used instead stack switcher.
self._manager_button = builder.get_object("manager_button")
self._manager_button.bind_property("active", builder.get_object("manager_label"), "visible")
self._downloader_button = builder.get_object("downloader_button")
self._downloader_button.bind_property("active", builder.get_object("downloader_label"), "visible")
self._converter_button = builder.get_object("converter_button")
self._converter_button.bind_property("active", builder.get_object("converter_label"), "visible")
# Init drag-and-drop
self.init_drag_and_drop()
# Rendering.
column = builder.get_object("dest_picon_column")
column.set_cell_data_func(builder.get_object("picons_dest_renderer"), self.picon_data_func)
column = builder.get_object("src_picon_column")
column.set_cell_data_func(builder.get_object("picons_src_renderer"), self.picon_data_func)
column = builder.get_object("dest_title_column")
column.set_cell_data_func(builder.get_object("title_dest_renderer"), self.title_data_func)
# Settings
self._settings = settings
self._s_type = settings.setting_type
self._picons_dir_entry.set_text(self._settings.profile_picons_path)
self._current_path_label.set_text(self._settings.profile_picons_path)
self.pack_start(builder.get_object("picon_manager_frame"), True, True, 0)
self.pack_start(builder.get_object("main_frame"), True, True, 0)
self.show()
if not len(self._picon_ids) and self._s_type is SettingsType.ENIGMA_2:
message = get_message("To automatically set the identifiers for picons,\n"
"first load the required services list into the main application window.")
message = translate("To automatically set the identifiers for picons,\n"
"first load the required services list into the main application window.")
self.show_info_message(message, Gtk.MessageType.WARNING)
self._satellite_label.show()
@@ -217,6 +206,8 @@ class PiconManager(Gtk.Box):
name = "downloader"
elif is_converter:
name = "converter"
if not self._enigma2_path_button.get_filename():
self._enigma2_path_button.set_filename(self._settings.profile_picons_path)
self._stack.set_visible_child_name(name)
@@ -226,8 +217,11 @@ class PiconManager(Gtk.Box):
if is_explorer:
self.update_picons_data(self._picons_dest_view)
def on_open(self):
def on_open(self, app, page):
""" Opens picons from local path [in src view]. """
if page is not Page.PICONS:
return
response = show_dialog(DialogType.CHOOSER, self._app.app_window, settings=self._settings, title="Open folder")
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
@@ -240,6 +234,24 @@ class PiconManager(Gtk.Box):
self._services = {s.picon_id: s for s in self._app.current_services.values() if s.picon_id}
self.update_picons_data(self._picons_dest_view)
def on_profile_changed(self, app, data):
self._current_path_label.set_text(self._settings.profile_picons_path)
self.update_picons_dest(app, self._app.page)
self._enigma2_path_button.set_filename(self._settings.profile_picons_path)
def on_picon_assign(self, app, target):
if target is ViewTarget.SERVICES:
model, paths = app.services_view.get_selection().get_selected_rows()
ids = {model[p][Column.SRV_FAV_ID] for p in paths}
else:
model, paths = app.fav_view.get_selection().get_selected_rows()
ids = {model[p][Column.FAV_ID] for p in paths}
self._filter_button.set_active(True)
self._dst_filter_button.set_active(True)
self._picons_filter_entry.set_text(
"|".join(s.service for f, s in self._app.current_services.items() if f in ids))
def update_picons_data(self, view, path=None):
if view is self._picons_dest_view:
self.update_picon_info()
@@ -250,27 +262,41 @@ class PiconManager(Gtk.Box):
def update_picons(self, path, view):
p_model = view.get_model()
model = get_base_model(p_model)
factor = self._app.DEL_FACTOR
factor = self._app.DEL_FACTOR * 2
for index, itr in enumerate([row.iter for row in model]):
model.remove(itr)
if index % factor == 0:
yield True
if not os.path.isdir(path):
return
for file in os.listdir(path):
self._dst_count_label.set_text("0")
os.makedirs(os.path.dirname(path), exist_ok=True)
for index, file in enumerate(os.listdir(path)):
if self._terminate:
return
p_path = "{}{}{}".format(path, SEP, file)
p = self.get_pixbuf_at_scale(p_path, 72, 48, True)
if p:
yield model.append((p, file, p_path))
model.append((None, file, f"{path}{SEP}{file}"))
if index % factor == 0:
self._dst_count_label.set_text(str(len(model)))
yield True
self._dst_count_label.set_text(str(len(model)))
yield True
def picon_data_func(self, column, renderer, model, itr, data):
renderer.set_property("pixbuf", get_pixbuf_at_scale(model.get_value(itr, 2), 72, 48, True))
def title_data_func(self, column, renderer, model, itr, data):
srv = self._services.get(model[itr][1], None)
if srv:
renderer.set_property("markup", self.get_picon_info_markup(srv))
def get_picon_info_markup(self, srv):
ext_info = "" if srv.service_type == "IPTV" else f" {srv.pos} {srv.freq}"
return (f'{escape(srv.picon_id)}\n\n'
f'<span size="small" weight="bold">{translate("Service")}: {escape(srv.service)}</span>\n'
f'<span size="small" style="italic">{srv.service_type}{ext_info}</span>')
def update_picons_from_file(self, view, uri):
""" Adds picons in the view on dragging from file system. """
path = Path(urlparse(unquote(uri)).path.strip())
@@ -281,18 +307,12 @@ class PiconManager(Gtk.Box):
model = get_base_model(view.get_model())
if path.is_file():
p = self.get_pixbuf_at_scale(f_path, 72, 48, True)
p = get_pixbuf_at_scale(f_path, 72, 48, True)
if p:
model.append((p, path.name, f_path))
elif path.is_dir():
self.update_picons_data(view, f_path)
def get_pixbuf_at_scale(self, path, width, height, p_ratio):
try:
return GdkPixbuf.Pixbuf.new_from_file_at_scale(path, width, height, p_ratio)
except GLib.GError:
pass
# ***************** Drag-and-drop ********************* #
def init_drag_and_drop(self):
@@ -311,20 +331,14 @@ class PiconManager(Gtk.Box):
self._picon_info_image.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
self._picon_info_image.drag_dest_add_uri_targets()
self._send_button.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
self._send_button.drag_dest_add_uri_targets()
self._download_button.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
self._download_button.drag_dest_add_uri_targets()
self._remove_button.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY)
self._remove_button.drag_dest_add_uri_targets()
def on_picons_view_drag_data_get(self, view, drag_context, data, info, time):
model, path = view.get_selection().get_selected_rows()
if path:
data.set_uris([Path(model[path][-1]).as_uri(),
Path(self._settings.profile_picons_path).as_uri()])
dest_uri = Path(self._settings.profile_picons_path).as_uri()
if IS_DARWIN:
data.set_uris([f"{Path(model[path][-1]).as_uri()}{self._app.DRAG_SEP}{dest_uri}"])
else:
data.set_uris([Path(model[path][-1]).as_uri(), dest_uri])
def on_picons_view_drag_drop(self, view, drag_context, x, y, time):
view.stop_emission_by_name("drag_drop")
@@ -341,8 +355,8 @@ class PiconManager(Gtk.Box):
self.update_picons_from_file(view, txt)
return
itr_str, sep, src = txt.partition("::::")
if src == self._app.BQ_MODEL_NAME:
itr_str, sep, src = txt.partition(self._app.DRAG_SEP)
if src == self._app.BQ_MODEL:
return
path, pos = view.get_dest_row_at_pos(x, y) or (None, None)
@@ -350,7 +364,7 @@ class PiconManager(Gtk.Box):
return
model = view.get_model()
if src == self._app.FAV_MODEL_NAME:
if src == self._app.FAV_MODEL:
target_view = self._app.fav_view
c_id = Column.FAV_ID
else:
@@ -359,7 +373,7 @@ class PiconManager(Gtk.Box):
t_mod = target_view.get_model()
dest_path = self._settings.profile_picons_path
self.update_picons_dest_view(self._app.on_assign_picon(target_view, model[path][-1], dest_path))
self.update_picons_dest_view(self._app.on_assign_picon_file(target_view, model[path][-1], dest_path))
self.show_assign_info([t_mod.get_value(t_mod.get_iter_from_string(itr), c_id) for itr in itr_str.split(",")])
@run_idle
@@ -370,7 +384,7 @@ class PiconManager(Gtk.Box):
paths = {r[1]: r.iter for r in dest_model}
for p_path in picons:
p = self.get_pixbuf_at_scale(p_path, 72, 48, True)
p = get_pixbuf_at_scale(p_path, 72, 48, True)
if p:
p_name = Path(p_path).name
itr = paths.get(p_name, None)
@@ -380,15 +394,16 @@ class PiconManager(Gtk.Box):
itr = dest_model.append((p, p_name, p_path))
scroll_to(dest_model.get_path(itr), self._picons_dest_view)
self._dst_count_label.set_text(str(len(dest_model)))
@run_idle
def show_assign_info(self, fav_ids):
self._expander.set_expanded(True)
self._text_view.get_buffer().set_text("")
self._app.change_action_state("on_logs_show", GLib.Variant.new_boolean(True))
for i in fav_ids:
srv = self._app.current_services.get(i, None)
if srv:
info = self._app.get_hint_for_srv_list(srv)
self.append_output("Picon assignment for the service:\n{}\n{}\n".format(info, " * " * 30))
log(f"Picon assignment for the service:\n{info}\n{' * ' * 30}\n")
def on_picons_view_drag_end(self, view, drag_context):
self.update_picons_dest_view(self._app.picons_buffer)
@@ -402,32 +417,17 @@ class PiconManager(Gtk.Box):
if len(uris) == 2:
name, fav_id = self._current_picon_info
src = urlparse(unquote(uris[0])).path
dst = "{}{}{}".format(urlparse(unquote(uris[1])).path, SEP, name)
dst = f"{urlparse(unquote(uris[1])).path}{SEP}{name}"
if src != dst:
shutil.copy(src, dst)
for row in get_base_model(self._picons_dest_view.get_model()):
if name == row[1]:
row[0] = self.get_pixbuf_at_scale(row[-1], 72, 48, True)
img.set_from_pixbuf(self.get_pixbuf_at_scale(row[-1], 100, 60, True))
row[0] = get_pixbuf_at_scale(row[-1], 72, 48, True)
img.set_from_pixbuf(get_pixbuf_at_scale(row[-1], 100, 60, True))
gen = self.update_picon_in_lists(dst, fav_id)
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
def on_send_button_drag_data_received(self, button, drag_context, x, y, data, info, time):
path = self.get_path_from_uris(data)
if path:
self.on_send(files_filter={path.name}, path=path.parent)
def on_download_button_drag_data_received(self, button, drag_context, x, y, data, info, time):
path = self.get_path_from_uris(data)
if path:
self.on_download(files_filter={path.name})
def on_remove_button_drag_data_received(self, button, drag_context, x, y, data, info, time):
path = self.get_path_from_uris(data)
if path:
self.on_remove(files_filter={path.name})
def get_path_from_uris(self, data):
uris = data.get_uris()
if len(uris) == 2:
@@ -439,17 +439,62 @@ class PiconManager(Gtk.Box):
yield set_picon(fav_id, get_base_model(self._app.services_view.get_model()), picon, Column.SRV_FAV_ID, p_pos)
yield set_picon(fav_id, get_base_model(self._app.fav_view.get_model()), picon, Column.FAV_ID, p_pos)
# ************************ Add/Extract ******************************** #
def on_add(self, item):
""" Adds (copies) picons from an external folder to the profile picons folder. """
dialog = get_picon_dialog(self._app_window, translate("Add picons"), translate("Add"))
if dialog.run() in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
self.copy_picons_file(dialog.get_filenames())
def on_extract(self, item):
""" Extracts picons from an archives to the profile picons folder. """
file_filter = None
if IS_DARWIN:
file_filter = Gtk.FileFilter()
file_filter.set_name("*.zip, *.gz")
file_filter.add_mime_type("application/zip")
file_filter.add_mime_type("application/gzip")
response = get_chooser_dialog(self._app_window, self._settings, "*.zip, *.gz files",
("*.zip", "*.gz"), "Extract picons", file_filter)
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
arch_path = self._app.get_archive_path(response)
if arch_path:
self.copy_picons_file(Path(arch_path.name).glob("*.png"), arch_path.cleanup)
def copy_picons_file(self, files, callback=None):
""" Copies files to the profile picons folder. """
picon_path = self._settings.profile_picons_path
os.makedirs(os.path.dirname(picon_path), exist_ok=True)
try:
picons = [shutil.copy(p, picon_path) for p in files]
except shutil.SameFileError as e:
log(e)
self.show_info_message(str(e), Gtk.MessageType.ERROR)
else:
self.update_picons_dest_view(picons)
self._app.update_picons()
finally:
if callback:
callback()
# ******************** Download/Upload/Remove ************************* #
def on_selective_send(self, view):
path = self.get_selected_path(view)
if path:
self.on_send(files_filter={path.name}, path=path.parent)
self.on_picons_send(files_filter={path.name}, path=path.parent)
def on_selective_download(self, view):
path = self.get_selected_path(view)
if path:
self.on_download(files_filter={path.name})
self.on_picons_download(files_filter={path.name})
def on_selective_remove(self, view):
path = self.get_selected_path(view)
@@ -459,34 +504,56 @@ class PiconManager(Gtk.Box):
def on_local_remove(self, view):
model, paths = view.get_selection().get_selected_rows()
if paths and show_dialog(DialogType.QUESTION, self._app_window) == Gtk.ResponseType.OK:
itr = model.get_iter(paths.pop())
p_path = Path(model.get_value(itr, 2)).resolve()
if p_path.is_file():
p_path.unlink()
base_model = get_base_model(model)
filter_model = model.get_model()
itr = filter_model.convert_iter_to_child_iter(model.convert_iter_to_child_iter(itr))
base_model.remove(itr)
base_model = get_base_model(model)
filter_model = model.get_model()
to_del = []
def on_send(self, item=None, files_filter=None, path=None):
for p in paths:
itr = model.get_iter(p)
p_path = Path(model.get_value(itr, 2)).resolve()
if p_path.is_file():
p_path.unlink()
to_del.append(filter_model.convert_iter_to_child_iter(model.convert_iter_to_child_iter(itr)))
list(map(base_model.remove, to_del))
self._app.update_picons()
if view is self._picons_dest_view:
self._dst_count_label.set_text(str(len(model)))
def on_send(self, app, page):
if page is Page.PICONS:
view = self._picons_src_view if self._picons_src_view.is_focus() else self._picons_dest_view
model, paths = view.get_selection().get_selected_rows()
if paths:
self.on_picons_send(files_filter={Path(model[p][-1]).resolve().name for p in paths})
else:
self._app.show_error_message("No selected item!")
def on_picons_send(self, item=None, files_filter=None, path=None):
dest_path = path or self._settings.profile_picons_path
settings = Settings(self._settings.settings)
settings.profile_picons_path = "{}{}".format(dest_path, SEP)
self.show_info_message(get_message("Please, wait..."), Gtk.MessageType.INFO)
settings.profile_picons_path = f"{dest_path}{SEP}"
settings.current_profile = self._settings.current_profile
self.show_info_message(translate("Please, wait..."), Gtk.MessageType.INFO)
self.run_func(lambda: upload_data(settings=settings,
download_type=DownloadType.PICONS,
callback=self.append_output,
done_callback=lambda: self.show_info_message(get_message("Done!"),
done_callback=lambda: self.show_info_message(translate("Done!"),
Gtk.MessageType.INFO),
files_filter=files_filter))
def on_download(self, item=None, files_filter=None, path=None):
def on_download(self, app, page):
if page is Page.PICONS:
self._app.picons.clear()
self.on_picons_download()
def on_picons_download(self, item=None, files_filter=None, path=None):
path = path or self._settings.profile_picons_path
settings = Settings(self._settings.settings)
settings.profile_picons_path = path + SEP
settings.current_profile = self._settings.current_profile
self.run_func(lambda: download_data(settings=settings,
download_type=DownloadType.PICONS,
callback=self.append_output,
files_filter=files_filter), True)
def on_remove(self, item=None, files_filter=None):
@@ -494,8 +561,7 @@ class PiconManager(Gtk.Box):
return
self.run_func(lambda: remove_picons(settings=self._settings,
callback=self.append_output,
done_callback=lambda: self.show_info_message(get_message("Done!"),
done_callback=lambda: self.show_info_message(translate("Done!"),
Gtk.MessageType.INFO),
files_filter=files_filter))
@@ -535,10 +601,10 @@ class PiconManager(Gtk.Box):
if logo_url:
pix_data = self._picon_cz_downloader.get_logo_data(logo_url)
if pix_data:
pix = self.get_pixbuf(pix_data)
pix = get_pixbuf_from_data(pix_data)
model.set_value(itr, 0, pix if pix else TV_ICON)
size = self._settings.tooltip_logo_size
tooltip.set_icon(self.get_pixbuf(pix_data, size, size))
tooltip.set_icon(get_pixbuf_from_data(pix_data, size, size))
else:
self.update_logo_data(itr, model, logo_url)
tooltip.set_text(model.get_value(itr, 1))
@@ -549,7 +615,7 @@ class PiconManager(Gtk.Box):
def update_logo_data(self, itr, model, url):
pix_data = self._picon_cz_downloader.get_provider_logo(url)
if pix_data:
pix = self.get_pixbuf(pix_data)
pix = get_pixbuf_from_data(pix_data)
GLib.idle_add(model.set_value, itr, 0, pix if pix else TV_ICON)
@run_idle
@@ -559,7 +625,7 @@ class PiconManager(Gtk.Box):
tooltip = f"{link} (by Chocholoušek)"
elif self._download_src is self.DownloadSource.LYNG_SAT:
link = "https://www.lyngsat.com"
tooltip = f"{get_message('Providers')} [{link}]"
tooltip = f"{translate('Providers')} [{link}]"
else:
link = ""
tooltip = ""
@@ -575,7 +641,7 @@ class PiconManager(Gtk.Box):
self.show_info_message("Getting satellites list error!", Gtk.MessageType.ERROR)
self._sat_names = {s[1]: s[0] for s in self._sats} # position -> satellite name
self._picon_cz_downloader = PiconsCzDownloader(self._picon_ids, self.append_output)
self._picon_cz_downloader = PiconsCzDownloader(self._picon_ids)
self.init_satellites(view)
@run_task
@@ -600,9 +666,9 @@ class PiconManager(Gtk.Box):
model.clear()
try:
for sat in sorted(sats):
for sat in sorted(sats, key=lambda s: get_pos_num(s[1]), reverse=True):
pos = sat[1]
name = "{} ({})".format(sat[0], pos)
name = f"{sat[0]} ({pos})"
if is_filter and pos not in self._sat_positions:
continue
if not model:
@@ -632,20 +698,15 @@ class PiconManager(Gtk.Box):
def append_providers(self, providers, model):
if self._download_src is self.DownloadSource.LYNG_SAT:
for p in providers:
model.append(p._replace(logo=self.get_pixbuf(p.logo) if p.logo else TV_ICON))
model.append(p._replace(logo=get_pixbuf_from_data(p.logo) if p.logo else TV_ICON))
elif self._download_src is self.DownloadSource.PICON_CZ:
for p in providers:
logo_data = self._picon_cz_downloader.get_logo_data(p.ssid)
model.append(p._replace(logo=self.get_pixbuf(logo_data) if logo_data else TV_ICON))
model.append(p._replace(logo=get_pixbuf_from_data(logo_data) if logo_data else TV_ICON))
self.update_receive_button_state()
GLib.idle_add(self._satellite_label.set_visible, True)
def get_pixbuf(self, img_data, w=48, h=32):
if img_data:
f = Gio.MemoryInputStream.new_from_data(img_data)
return GdkPixbuf.Pixbuf.new_from_stream_at_scale(f, w, h, True, None)
def on_receive(self, item):
if self._is_downloading:
self._app.show_error_message("The task is already running!")
@@ -663,19 +724,19 @@ class PiconManager(Gtk.Box):
@run_task
def start_download(self, providers):
self._is_downloading = True
GLib.idle_add(self._expander.set_expanded, True)
self._app.change_action_state("on_logs_show", GLib.Variant.new_boolean(True))
for prv in providers:
if self._download_src is self.DownloadSource.LYNG_SAT and not self._POS_PATTERN.match(prv[2]):
self.show_info_message(
get_message("Specify the correct position value for the provider!"), Gtk.MessageType.ERROR)
translate("Specify the correct position value for the provider!"), Gtk.MessageType.ERROR)
scroll_to(prv.path, self._providers_view)
return
try:
picons_path = self._picons_dir_entry.get_text()
picons_path = self._current_path_label.get_text()
os.makedirs(os.path.dirname(picons_path), exist_ok=True)
self.show_info_message(get_message("Please, wait..."), Gtk.MessageType.INFO)
self.show_info_message(translate("Please, wait..."), Gtk.MessageType.INFO)
providers = (Provider(*p) for p in providers)
if self._download_src is self.DownloadSource.LYNG_SAT:
@@ -709,7 +770,7 @@ class PiconManager(Gtk.Box):
if pic:
picons.extend(pic)
# Getting picon images.
futures = {executor.submit(download_picon, *pic, self.append_output): pic for pic in picons}
futures = {executor.submit(download_picon, *pic): pic for pic in picons}
done, not_done = concurrent.futures.wait(futures, timeout=0)
while self._is_downloading and not_done:
done, not_done = concurrent.futures.wait(not_done, timeout=5)
@@ -717,7 +778,7 @@ class PiconManager(Gtk.Box):
for future in not_done:
future.cancel()
concurrent.futures.wait(not_done)
self.show_info_message(get_message("Done!"), Gtk.MessageType.INFO)
self.show_info_message(translate("Done!"), Gtk.MessageType.INFO)
def get_picons_for_picon_cz(self, path, providers):
p_ids = None
@@ -728,13 +789,12 @@ class PiconManager(Gtk.Box):
try:
# We download it sequentially.
for p in providers:
self._picon_cz_downloader.download(p, path, p_ids)
[self._picon_cz_downloader.download(p, path, p_ids) for p in providers]
except PiconsError as e:
self.append_output("Error: {}\n".format(str(e)))
log(f"Error: {str(e)}\n")
self.show_info_message(str(e), Gtk.MessageType.ERROR)
else:
self.show_info_message(get_message("Done!"), Gtk.MessageType.INFO)
self.show_info_message(translate("Done!"), Gtk.MessageType.INFO)
def get_bouquet_picon_ids(self):
""" Returns picon ids for selected bouquet or None. """
@@ -749,25 +809,27 @@ class PiconManager(Gtk.Box):
fav_bouquet = self._app.current_bouquets[bq_selected]
services = self._app.current_services
return {services.get(fav_id).picon_id for fav_id in fav_bouquet}
ids = set()
for s in (services.get(fav_id, None) for fav_id in fav_bouquet):
if s:
ids.add(s.picon_id)
ids.add(get_picon_file_name(s.service))
return ids
def process_provider(self, prv, picons_path):
self.append_output("Getting links to picons for: {}.\n".format(prv.name))
log(f"Getting links to picons for: {prv.name}.\n")
return PiconsParser.parse(prv, picons_path, self._picon_ids, self.get_picons_format())
@run_idle
def append_output(self, char):
append_text_to_tview(char, self._text_view)
@run_task
def resize(self, path):
self.show_info_message(get_message("Resizing..."), Gtk.MessageType.INFO)
self.show_info_message(translate("Resizing..."), Gtk.MessageType.INFO)
try:
from pathlib import Path
from PIL import Image
except ImportError as e:
self.show_info_message("{} {}".format(get_message("Conversion error."), e), Gtk.MessageType.ERROR)
self.show_info_message(f"{translate('Conversion error.')} {e}", Gtk.MessageType.ERROR)
else:
res = (220, 132) if self._resize_220_132_radio_button.get_active() else (100, 60)
@@ -776,7 +838,7 @@ class PiconManager(Gtk.Box):
img = img.resize(res, Image.ANTIALIAS)
img.save(img_file, "PNG", optimize=True)
self.show_info_message(get_message("Done!"), Gtk.MessageType.INFO)
self.show_info_message(translate("Done!"), Gtk.MessageType.INFO)
def on_cancel(self, item=None):
if self._is_downloading and show_dialog(DialogType.QUESTION, self._app_window) == Gtk.ResponseType.CANCEL:
@@ -788,21 +850,12 @@ class PiconManager(Gtk.Box):
def terminate_task(self):
self._terminate = True
self._is_downloading = False
self.show_info_message(get_message("The task is canceled!"), Gtk.MessageType.WARNING)
def on_close(self, window, event):
if self.on_cancel():
return True
self._terminate = True
self._is_downloading = False
self._app.update_picons()
GLib.idle_add(self._app_window.destroy)
self.show_info_message(translate("The task is canceled!"), Gtk.MessageType.WARNING)
@run_task
def run_func(self, func, update=False):
try:
GLib.idle_add(self._expander.set_expanded, True)
self._app.change_action_state("on_logs_show", GLib.Variant.new_boolean(True))
GLib.idle_add(self._header_download_box.set_sensitive, False)
func()
except OSError as e:
@@ -815,9 +868,6 @@ class PiconManager(Gtk.Box):
def show_info_message(self, text, message_type):
self._app.show_info_message(text, message_type)
def on_picons_dir_open(self, entry, icon, event_button):
update_entry_data(entry, self._app_window, settings=self._settings)
@run_idle
def on_selected_toggled(self, toggle, path):
model = self._providers_view.get_model()
@@ -841,7 +891,7 @@ class PiconManager(Gtk.Box):
self._filter_button.set_active(not self._filter_button.get_active())
def on_fav_changed(self, view, path, column):
if self._app.page is Page.PICONS and self._auto_filer_switch.get_active():
if self._app.page is Page.PICONS and self._auto_filter_switch.get_active():
model = view.get_model()
self._picons_filter_entry.set_text(model.get_value(model.get_iter(path), Column.FAV_SERVICE))
@@ -856,10 +906,10 @@ class PiconManager(Gtk.Box):
@run_with_delay(0.5)
def on_picons_filter_changed(self, entry):
txt = entry.get_text().upper()
self._filter_cache.clear()
txt = entry.get_text().upper().split("|")
for s in self._app.current_services.values():
self._filter_cache[s.picon_id] = txt in s.service.upper()
self._filter_cache[s.picon_id] = any(t in s.service.upper() or t in str(s.picon_id) for t in txt)
GLib.idle_add(self._picons_src_filter_model.refilter, priority=GLib.PRIORITY_LOW)
GLib.idle_add(self._picons_dst_filter_model.refilter, priority=GLib.PRIORITY_LOW)
@@ -894,7 +944,7 @@ class PiconManager(Gtk.Box):
self.update_picon_info(name, path, srv)
def update_picon_info(self, name=None, path=None, srv=None):
self._picon_info_image.set_from_pixbuf(self.get_pixbuf_at_scale(path, 100, 60, True) if path else None)
self._picon_info_image.set_from_pixbuf(get_pixbuf_at_scale(path, 100, 60, True) if path else None)
self._picon_info_label.set_text(self.get_service_info(srv))
self._current_picon_info = (name, srv.fav_id) if srv else None
@@ -907,8 +957,8 @@ class PiconManager(Gtk.Box):
return self._app.get_hint_for_srv_list(srv)
header, ref = self._app.get_hint_header_info(srv)
return "{} {}: {}\n{}: {} {}: {}\n{}".format(header.rstrip(), get_message("Package"), srv.package,
get_message("System"), srv.system, get_message("Freq"), srv.freq,
return "{} {}: {}\n{}: {} {}: {}\n{}".format(header.rstrip(), translate("Package"), srv.package,
translate("System"), srv.system, translate("Freq"), srv.freq,
ref)
def on_view_query_tooltip(self, view, x, y, keyboard_mode, tooltip):
@@ -926,11 +976,10 @@ class PiconManager(Gtk.Box):
return True
def on_tree_view_key_press(self, view, event):
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
key = KeyboardKey(key_code)
if key is KeyboardKey.DELETE:
self.on_local_remove(view)
@@ -954,12 +1003,27 @@ class PiconManager(Gtk.Box):
self._app.show_error_message("Select paths!")
return
self._expander.set_expanded(True)
convert_to(src_path=picons_path,
dest_path=save_path,
s_type=SettingsType.ENIGMA_2,
callback=self.append_output,
done_callback=lambda: self.show_info_message(get_message("Done!"), Gtk.MessageType.INFO))
self._app.change_action_state("on_logs_show", GLib.Variant.new_boolean(True))
ids = None
p_format = PiconFormat.NEUTRINO if self._converter_nt_button.get_active() else PiconFormat.OSCAM
if p_format is PiconFormat.OSCAM:
try:
from PIL import Image
except ImportError as e:
self.show_info_message(f"{translate('Conversion error.')} {e}", Gtk.MessageType.ERROR)
return
if self._converter_bq_button.get_active():
bq_selected = self._app.check_bouquet_selection()
if not bq_selected:
return
services = self._app.current_services
ids = {services.get(s).picon_id for s in self._app.current_bouquets.get(bq_selected) if s in services}
convert_to(src_path=picons_path, dest_path=save_path, p_format=p_format, ids=ids, services=self._services,
done_callback=lambda: self.show_info_message(translate("Done!"), Gtk.MessageType.INFO))
@run_idle
def update_receive_button_state(self):
@@ -977,12 +1041,7 @@ class PiconManager(Gtk.Box):
show_dialog(dialog_type, self._app_window, message)
def get_picons_format(self):
picon_format = SettingsType.ENIGMA_2
if self._neutrino_mp_radio_button.get_active():
picon_format = SettingsType.NEUTRINO_MP
return picon_format
return SettingsType.NEUTRINO_MP if self._neutrino_mp_radio_button.get_active() else SettingsType.ENIGMA_2
if __name__ == "__main__":

View File

@@ -1,241 +1,370 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.2 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<object class="GtkEventBox" id="event_box">
<!-- Generated with glade 3.38.2
The MIT License (MIT)
Copyright (c) 2018-2023 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.22"/>
<!-- interface-css-provider-path style.css -->
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellite list editor. -->
<!-- interface-copyright 2018-2023 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkStack" id="stack">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<signal name="button-press-event" handler="on_press" swapped="no"/>
<signal name="realize" handler="on_realize" swapped="no"/>
<child>
<placeholder/>
<object class="GtkDrawingArea" id="playback_area">
<property name="visible">True</property>
<property name="can-focus">False</property>
<signal name="draw" handler="on_draw" swapped="no"/>
<signal name="realize" handler="on_realize" swapped="no"/>
</object>
<packing>
<property name="name">playback</property>
<property name="title" translatable="yes">Playback</property>
</packing>
</child>
<child>
<object class="GtkSpinner" id="spinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="active">True</property>
</object>
<packing>
<property name="name">load</property>
<property name="title" translatable="yes">Load</property>
<property name="position">1</property>
</packing>
</child>
<style>
<class name="playback"/>
</style>
</object>
<object class="GtkToolbar" id="tool_bar">
<object class="GtkBox" id="tool_bar">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<property name="halign">center</property>
<property name="valign">end</property>
<property name="margin-start">12</property>
<property name="margin-end">12</property>
<property name="margin-top">12</property>
<property name="margin-bottom">12</property>
<property name="spacing">5</property>
<child>
<object class="GtkToolButton" id="prev_button">
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Previous stream in the list</property>
<property name="use_underline">True</property>
<property name="stock_id">gtk-media-previous</property>
<object class="GtkButton" id="prev_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Previous stream in the list</property>
<signal name="clicked" handler="on_previous" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">False</property>
</packing>
</child>
<child>
<object class="GtkToolButton" id="play_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Play</property>
<property name="action_name">app.on_play</property>
<property name="stock_id">gtk-media-play</property>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">False</property>
</packing>
</child>
<child>
<object class="GtkToolButton" id="stop_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Stop playback</property>
<property name="action_name">app.on_stop</property>
<property name="use_underline">True</property>
<property name="stock_id">gtk-media-stop</property>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">False</property>
</packing>
</child>
<child>
<object class="GtkToolButton" id="next_button">
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Next stream in the list</property>
<property name="use_underline">True</property>
<property name="stock_id">gtk-media-next</property>
<signal name="clicked" handler="on_next" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">False</property>
</packing>
</child>
<child>
<object class="GtkToolItem" id="player_item">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkBox" id="rewind_box">
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="spacing">2</property>
<child>
<object class="GtkLabel" id="current_time_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScale" id="scale">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="restrict_to_fill_level">False</property>
<property name="fill_level">0</property>
<property name="draw_value">False</property>
<property name="has_origin">False</property>
<signal name="change-value" handler="on_rewind" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="full_time_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label" translatable="yes">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<object class="GtkImage" id="prev_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">media-skip-backward-symbolic</property>
<property name="icon_size">2</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="play_button">
<property name="visible" bind-source="stop_button" bind-property="visible" bind-flags="invert-boolean">False</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<signal name="clicked" handler="on_play" swapped="no"/>
<child>
<object class="GtkImage" id="play_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">media-playback-start-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="stop_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Stop playback</property>
<signal name="clicked" handler="on_stop" swapped="no"/>
<child>
<object class="GtkImage" id="stop_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">media-playback-stop-symbolic</property>
<property name="icon_size">2</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkButton" id="pause_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Pause</property>
<signal name="clicked" handler="on_pause" swapped="no"/>
<child>
<object class="GtkImage" id="pause_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">media-playback-pause-symbolic</property>
<property name="icon_size">2</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkButton" id="next_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Next stream in the list</property>
<signal name="clicked" handler="on_next" swapped="no"/>
<child>
<object class="GtkImage" id="next_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">media-skip-forward-symbolic</property>
<property name="icon_size">2</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkBox" id="rewind_box">
<property name="width-request">175</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="spacing">2</property>
<child>
<object class="GtkLabel" id="current_time_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">0</property>
<attributes>
<attribute name="foreground" value="#ffffffffffff"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScale" id="scale">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="restrict-to-fill-level">False</property>
<property name="fill-level">0</property>
<property name="draw-value">False</property>
<property name="has-origin">False</property>
<signal name="change-value" handler="on_rewind" swapped="no"/>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="full_time_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">0</property>
<attributes>
<attribute name="foreground" value="#ffffffffffff"/>
</attributes>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="homogeneous">True</property>
<property name="fill">True</property>
<property name="position">5</property>
</packing>
</child>
<child>
<object class="GtkToolItem" id="extras_item">
<object class="GtkBox" id="extras_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkBox" id="extras_box">
<object class="GtkMenuButton" id="audio_menu_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="spacing">2</property>
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Audio Track</property>
<child>
<object class="GtkMenuButton" id="audio_menu_button">
<object class="GtkImage" id="audio_menu_button_image">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">True</property>
<property name="relief">none</property>
<child>
<object class="GtkImage" id="audio_menu_button_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Audio Track</property>
<property name="icon_name">multimedia-volume-control</property>
</object>
</child>
<property name="can-focus">False</property>
<property name="icon-name">audio-card-symbolic</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkMenuButton" id="video_menu_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Aspect ratio</property>
<child>
<object class="GtkMenuButton" id="video_menu_button">
<object class="GtkImage" id="video_menu_button_image">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">True</property>
<property name="relief">none</property>
<child>
<object class="GtkImage" id="video_menu_button_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Aspect ratio</property>
<property name="icon_name">view-restore</property>
</object>
</child>
<property name="can-focus">False</property>
<property name="icon-name">zoom-best-fit-symbolic</property>
<property name="icon_size">2</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkMenuButton" id="subtitle_menu_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Subtitle Track</property>
<child>
<object class="GtkMenuButton" id="subtitle_menu_button">
<object class="GtkImage" id="subtitle_menu_button_image">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">True</property>
<property name="relief">none</property>
<child>
<object class="GtkImage" id="subtitle_menu_button_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Subtitle Track</property>
<property name="icon_name">format-text-underline</property>
</object>
</child>
<property name="can-focus">False</property>
<property name="icon-name">format-text-underline-symbolic</property>
<property name="icon_size">2</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">6</property>
</packing>
</child>
<child>
<object class="GtkButton" id="full_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Toggle in fullscreen</property>
<signal name="clicked" handler="on_full_screen" swapped="no"/>
<child>
<object class="GtkImage" id="full_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">view-fullscreen-symbolic</property>
<property name="icon_size">2</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">False</property>
<property name="fill">True</property>
<property name="position">7</property>
</packing>
</child>
<child>
<object class="GtkToolButton" id="full_button">
<object class="GtkButton" id="close_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Toggle in fullscreen</property>
<property name="use_underline">True</property>
<property name="stock_id">gtk-fullscreen</property>
<signal name="clicked" handler="on_full_screen" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">False</property>
</packing>
</child>
<child>
<object class="GtkToolButton" id="close_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="tooltip_text" translatable="yes">Close playback</property>
<property name="use_underline">True</property>
<property name="stock_id">gtk-close</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Close playback</property>
<signal name="clicked" handler="on_close" swapped="no"/>
<child>
<object class="GtkImage" id="close_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">window-close-symbolic</property>
<property name="icon_size">2</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="homogeneous">False</property>
<property name="fill">True</property>
<property name="position">8</property>
</packing>
</child>
</object>

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2024 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -27,6 +27,7 @@
""" Additional module for playback. """
from enum import Enum
from functools import lru_cache
from gi.repository import GLib, GObject, Gio
@@ -34,17 +35,23 @@ from gi.repository import GLib, GObject, Gio
from app.commons import run_idle, run_with_delay
from app.connections import HttpAPI
from app.eparser.ecommons import BqServiceType
from app.settings import PlayStreamsMode, IS_DARWIN, SettingsType
from app.settings import PlayStreamsMode, PlaybackMode, IS_DARWIN, SettingsType, USE_HEADER_BAR
from app.tools.media import Player
from app.ui.dialogs import get_builder
from app.ui.dialogs import get_builder, translate
from app.ui.main_helper import get_iptv_url
from app.ui.uicommons import Gtk, Gdk, UI_RESOURCES_PATH, FavClickMode, Column, IS_GNOME_SESSION
from app.ui.uicommons import Gtk, Gdk, UI_RESOURCES_PATH, Column, Page
class PlayerBox(Gtk.Box):
class PlayerBox(Gtk.Overlay):
class Page(str, Enum):
LOAD = "load"
PLAYBACK = "playback"
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs)
def __str__(self):
return self.value
def __init__(self, app, **kwargs):
super().__init__(**kwargs)
# Signals.
GObject.signal_new("playback-full-screen", self, GObject.SIGNAL_RUN_LAST,
GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,))
@@ -54,41 +61,58 @@ class PlayerBox(Gtk.Box):
GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,))
GObject.signal_new("stop", self, GObject.SIGNAL_RUN_LAST,
GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,))
GObject.signal_new("pause", self, GObject.SIGNAL_RUN_LAST,
GObject.TYPE_PYOBJECT, (GObject.TYPE_PYOBJECT,))
self._app = app
self._app.connect("fav-clicked", self.on_fav_clicked)
self._app.connect("srv-clicked", self.on_srv_clicked)
self._app.connect("iptv-clicked", self.on_iptv_clicked)
self._app.connect("page-changed", self.on_page_changed)
self._app.connect("play-current", self.on_play_current)
self._app.connect("play-recording", self.on_play_recording)
self._s_type = self._app.app_settings.setting_type
self._fav_view = app.fav_view
self._page = None
self._player = None
self._current_mrl = None
self._full_screen = False
self._playback_window = None
self._audio_track_menu = None
self._subtitle_track_menu = None
self._play_mode = self._app.app_settings.play_streams_mode
self._is_cursor_visible = True
self._play_mode = PlayStreamsMode(self._app.app_settings.play_streams_mode)
handlers = {"on_realize": self.on_realize,
"on_draw": self.on_draw,
"on_mouse_motion": self.on_mouse_motion,
"on_press": self.on_press,
"on_play": self.on_play,
"on_pause": self.on_pause,
"on_stop": self.on_stop,
"on_next": self.on_next,
"on_previous": self.on_previous,
"on_rewind": self.on_rewind,
"on_full_screen": self.on_full_screen,
"on_close": self.on_close}
builder = get_builder(UI_RESOURCES_PATH + "playback.glade", handlers)
self.set_spacing(5)
self.set_orientation(Gtk.Orientation.VERTICAL)
self._event_box = builder.get_object("event_box")
self.pack_start(self._event_box, True, True, 0)
builder = get_builder(f"{UI_RESOURCES_PATH}playback.glade", handlers)
self._stack = builder.get_object("stack")
self._playback_area = builder.get_object("playback_area")
self._playback_area.set_events(Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.POINTER_MOTION_MASK)
self.connect("motion-notify-event", self.on_mouse_motion)
self.add(self._stack)
if not IS_DARWIN:
self.pack_end(builder.get_object("tool_bar"), False, True, 0)
self.add_overlay(builder.get_object("tool_bar"))
self._scale = builder.get_object("scale")
self._full_time_label = builder.get_object("full_time_label")
self._current_time_label = builder.get_object("current_time_label")
self._rewind_box = builder.get_object("rewind_box")
self._tool_bar = builder.get_object("tool_bar")
self.bind_property("is_cursor_visible", self._tool_bar, "visible")
self._stop_button = builder.get_object("stop_button")
self._prev_button = builder.get_object("prev_button")
self._next_button = builder.get_object("next_button")
self._audio_menu_button = builder.get_object("audio_menu_button")
@@ -99,43 +123,90 @@ class PlayerBox(Gtk.Box):
self.connect("delete-event", self.on_delete)
self.connect("show", self.set_player_area_size)
self.connect("unrealize", self.on_unrealize)
@property
def playback_widget(self):
return self._playback_area
@GObject.Property(type=bool, default=True)
def is_cursor_visible(self):
return self._is_cursor_visible
@is_cursor_visible.setter
def is_cursor_hidden(self, value):
self._is_cursor_visible = value
def on_fav_clicked(self, app, mode):
if mode is not FavClickMode.STREAM and not self._app.http_api:
if mode is not PlaybackMode.STREAM and not self._app.http_api:
return
self._fav_view.set_sensitive(False)
if mode is FavClickMode.STREAM:
self.on_play_stream()
elif mode is FavClickMode.ZAP_PLAY:
self._app.on_zap(self.on_watch)
elif mode is FavClickMode.PLAY:
self.on_play_service()
if len(self._fav_view.get_model()) == 0:
return
self.start_playback(mode)
def on_srv_clicked(self, app, mode):
if not self._app.http_api:
return
view = self._app.services_view
path, column = view.get_cursor()
if path:
srv = self._app.current_services.get(view.get_model()[path][Column.SRV_FAV_ID], None)
if not srv or not srv.picon_id:
return
ref = self._app.get_service_ref_data(srv)
if mode is PlaybackMode.PLAY:
self.play_service(ref)
elif mode is PlaybackMode.ZAP:
self.zap(ref)
elif mode is PlaybackMode.ZAP_PLAY:
self.zap(ref, self.play_current)
elif mode is PlaybackMode.STREAM:
self._app.show_error_message("Not allowed in this context!")
def on_iptv_clicked(self, app, mode):
if not self._app.http_api:
return
view = self._app.iptv_services_view
path, column = view.get_cursor()
if path:
row = view.get_model()[path][:]
url = get_iptv_url(row, self._app.app_settings.setting_type, Column.IPTV_FAV_ID)
self.play(url, row[Column.IPTV_SERVICE]) if url else self.on_error(None, "No reference is present!")
def on_play_current(self, app, url):
self.on_watch()
self.play_current()
def on_play_recording(self, app, url):
self.play(url)
def on_page_changed(self, app, page):
self.on_close()
self.set_visible(False)
self._page = page
if self._player and self.is_visible():
self.update_buttons() if not IS_DARWIN else None
self.on_close()
self.set_visible(False)
def on_realize(self, box):
def on_realize(self, area):
if not self._player:
settings = self._app.app_settings
self._stack.set_visible_child_name(self.Page.LOAD)
try:
self._player = Player.make(settings.stream_lib, settings.play_streams_mode, self._event_box)
self._player = Player.make(settings.stream_lib, settings.play_streams_mode, self)
except (ImportError, NameError) as e:
self._app.show_error_message(str(e))
return True
else:
self.init_playback_elements()
self.emit("play", self._current_mrl)
finally:
if settings.play_streams_mode is PlayStreamsMode.BUILT_IN:
self.set_player_area_size(box)
self.on_play()
def on_unrealize(self, box):
if self._player:
self._player.release()
def init_playback_elements(self):
self._player.connect("error", self.on_error)
@@ -144,14 +215,14 @@ class PlayerBox(Gtk.Box):
self._player.connect("subtitle-track", self.on_subtitle_track_changed)
self._app.app_window.connect("key-press-event", self.on_key_press)
builder = get_builder(UI_RESOURCES_PATH + "app_menu.ui")
builder = get_builder(f"{UI_RESOURCES_PATH}app_menu.ui")
self._audio_track_menu = builder.get_object("audio_track_menu")
self._subtitle_track_menu = builder.get_object("subtitle_track_menu")
audio_menu = builder.get_object("audio_menu")
video_menu = builder.get_object("video_menu")
subtitle_menu = builder.get_object("subtitle_menu")
if not IS_GNOME_SESSION:
if not USE_HEADER_BAR:
menu_bar = self._app.get_menubar()
menu_bar.insert_section(1, None, audio_menu)
menu_bar.insert_section(2, None, video_menu)
@@ -178,18 +249,28 @@ class PlayerBox(Gtk.Box):
subtitle_track_action.connect("activate", self.on_set_subtitle_track)
self._app.add_action(subtitle_track_action)
@run_idle
def on_play(self, action=None, value=None):
self.emit("play", None)
self._stack.set_visible_child_name(self.Page.LOAD)
self.emit("play", self._current_mrl)
def on_pause(self, action=None, value=None):
self.emit("pause", None)
def on_stop(self, action=None, value=None):
self._stop_button.set_visible(False) if not IS_DARWIN else None
self.emit("stop", None)
def on_next(self, button):
if self._fav_view.do_move_cursor(self._fav_view, Gtk.MovementStep.DISPLAY_LINES, 1):
self.set_player_action()
self.switch_service(1)
def on_previous(self, button):
if self._fav_view.do_move_cursor(self._fav_view, Gtk.MovementStep.DISPLAY_LINES, -1):
self.switch_service(-1)
def switch_service(self, count):
self._fav_view.grab_focus()
if self._fav_view.do_move_cursor(self._fav_view, Gtk.MovementStep.DISPLAY_LINES, count):
self.update_buttons() if not IS_DARWIN else None
self.set_player_action()
def on_rewind(self, scale, scroll_type, value):
@@ -198,11 +279,8 @@ class PlayerBox(Gtk.Box):
def on_full_screen(self, item=None):
self._full_screen = not self._full_screen
if self._play_mode is PlayStreamsMode.BUILT_IN:
self._tool_bar.set_visible(not self._full_screen)
self.emit("playback-full-screen", not self._full_screen)
elif self._playback_window:
if not IS_DARWIN:
self._tool_bar.set_visible(not self._full_screen)
self._playback_window.fullscreen() if self._full_screen else self._playback_window.unfullscreen()
def on_close(self, action=None, value=None):
@@ -210,6 +288,9 @@ class PlayerBox(Gtk.Box):
self._app.app_settings.add("playback_window_size", self._playback_window.get_size())
self._playback_window.hide()
if self._full_screen:
GLib.idle_add(self.on_full_screen)
self.on_stop()
self.hide()
self.emit("playback-close", None)
@@ -246,7 +327,9 @@ class PlayerBox(Gtk.Box):
def on_press(self, area, event):
if event.button == Gdk.BUTTON_PRIMARY:
if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS:
if event.type == Gdk.EventType.BUTTON_PRESS:
self.emit("pause", None)
elif event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS:
self.on_full_screen()
def on_key_press(self, widget, event):
@@ -261,21 +344,18 @@ class PlayerBox(Gtk.Box):
@run_with_delay(1)
def set_player_action(self):
click_mode = self._app.app_settings.fav_click_mode
self._fav_view.set_sensitive(False)
if click_mode is FavClickMode.PLAY:
self.on_play_service()
elif click_mode is FavClickMode.ZAP_PLAY:
self.on_zap(self.on_watch)
elif click_mode is FavClickMode.STREAM:
self.on_play_stream()
self.start_playback(PlaybackMode(self._app.app_settings.fav_click_mode))
def update_buttons(self):
if self._player:
path, column = self._fav_view.get_cursor()
current_index = path[0]
self._player_prev_button.set_sensitive(current_index != 0)
self._player_next_button.set_sensitive(len(self._fav_model) != current_index + 1)
if path:
current_index = path[0]
self._prev_button.set_sensitive(current_index != 0)
self._next_button.set_sensitive(len(self._fav_view.get_model()) != current_index + 1)
self._prev_button.set_visible(self._page is Page.SERVICES)
self._next_button.set_visible(self._page is Page.SERVICES)
@lru_cache(maxsize=1)
def on_duration_changed(self, duration):
@@ -295,29 +375,31 @@ class PlayerBox(Gtk.Box):
""" Returns a string representation of time from duration in milliseconds """
m, s = divmod(duration // 1000, 60)
h, m = divmod(m, 60)
return "{}{:02d}:{:02d}".format(str(h) + ":" if h else "", m, s)
return f"{str(h) + ':' if h else ''}{m:02d}:{s:02d}"
def set_player_area_size(self, widget):
w, h = self._app.app_window.get_size()
widget.set_size_request(w * 0.6, -1)
self._stack.set_visible_child_name(self.Page.PLAYBACK)
@run_idle
def show_playback_window(self):
def show_playback_window(self, title=None):
width, height = 480, 240
size = self._app.app_settings.get("playback_window_size")
if size:
width, height = size
if self._playback_window:
self._playback_window.show()
self._playback_window.present()
self._playback_window.set_title(title or self.get_playback_title())
else:
self._playback_window = Gtk.Window(title=self.get_playback_title(),
self._playback_window = Gtk.Window(title=title or self.get_playback_title(),
window_position=Gtk.WindowPosition.CENTER,
icon_name="demon-editor")
self._playback_window.connect("delete-event", self.on_close)
self._playback_window.connect("key-press-event", self.on_key_press)
self._playback_window.bind_property("visible", self._event_box, "visible")
self._playback_window.bind_property("visible", self._stack, "visible")
if not IS_DARWIN:
self._prev_button.set_visible(False)
@@ -331,10 +413,26 @@ class PlayerBox(Gtk.Box):
self._playback_window.show()
def get_playback_title(self):
path, column = self._fav_view.get_cursor()
if path:
return "DemonEditor [{}]".format(self._app.fav_view.get_model()[path][:][Column.FAV_SERVICE])
return "DemonEditor [Playback]"
if self._app.page is not Page.RECORDINGS:
path, column = self._fav_view.get_cursor()
if path:
return f"DemonEditor [{self._app.fav_view.get_model()[path][:][Column.FAV_SERVICE]}]"
else:
return f"DemonEditor [{translate('Recordings')}]"
return f"DemonEditor [{translate('Playback')}]"
def start_playback(self, mode):
self.on_stop() if mode is not PlaybackMode.ZAP else None
self._stack.set_visible_child_name(self.Page.LOAD)
if mode is PlaybackMode.PLAY:
self.on_play_service()
elif mode is PlaybackMode.ZAP:
self.on_zap()
elif mode is PlaybackMode.ZAP_PLAY:
self.on_zap(self.play_current)
elif mode is PlaybackMode.STREAM:
self.on_play_stream()
def on_play_stream(self):
path, column = self._fav_view.get_cursor()
@@ -348,36 +446,64 @@ class PlayerBox(Gtk.Box):
self.play(url) if url else self.on_error(None, "No reference is present!")
def on_play_service(self, item=None):
path, column = self._fav_view.get_cursor()
if not path or not self._app.http_api:
return
ref = self._app.get_service_ref(path)
""" Playback without switching channel on the Box."""
ref, path = self.get_ref()
if not ref:
return
if self._player and self._player.is_playing():
self.emit("stop", None)
self.play_service(ref)
def play_service(self, ref):
s_type = self._app.app_settings.setting_type
req = HttpAPI.Request.STREAM if s_type is SettingsType.ENIGMA_2 else HttpAPI.Request.N_STREAM
self._app.http_api.send(req, ref, self.watch)
def on_watch(self, item=None):
""" Switch to the channel and watch in the player. """
s_type = self._app.app_settings.setting_type
if s_type is SettingsType.ENIGMA_2:
self._app.http_api.send(HttpAPI.Request.STREAM_CURRENT, "", self.watch)
elif s_type is SettingsType.NEUTRINO_MP:
self._app.http_api.send(HttpAPI.Request.N_ZAP, "",
lambda rf: self._app.http_api.send(HttpAPI.Request.N_STREAM, rf.get("data", ""),
self.watch))
def on_zap(self, callback=None):
""" Switch(zap) the channel. """
ref, path = self.get_ref()
if not ref:
return
# IPTV type checking
row = self._fav_view.get_model()[path][:]
if row[Column.FAV_TYPE] == BqServiceType.IPTV.name and callback:
callback = self.play(get_iptv_url(row, self._s_type))
self.zap(ref, callback)
def get_ref(self):
""" Returns reference and currently selected path as a tuple. """
path, column = self._fav_view.get_cursor()
if not path or not self._app.http_api:
return
return self._app.get_service_ref(path), path
def zap(self, ref, callback=None):
if self._s_type is SettingsType.ENIGMA_2:
def zp(rq):
if rq and rq.get("e2state", False):
if callback:
callback()
else:
self._app.show_error_message("No connection to the receiver!")
self._app.http_api.send(HttpAPI.Request.ZAP, ref, zp)
elif self._s_type is SettingsType.NEUTRINO_MP:
def zp(rq):
if rq and rq.get("data", None) == "ok":
if callback:
callback()
else:
self._app.show_error_message("No connection to the receiver!")
self._app.http_api.send(HttpAPI.Request.N_ZAP, f"?{ref}", zp)
else:
self._app.show_error_message("This type of settings is not supported!")
def watch(self, data):
url = self._app.get_url_from_m3u(data)
GLib.timeout_add_seconds(1, self.play, url) if url else self.on_error(None, "Can't Playback!")
self.play(self._app.get_url_from_m3u(data))
def play(self, url):
def play(self, url, title=None):
if self._play_mode is PlayStreamsMode.M3U:
self._app.save_stream_to_m3u(url)
return
@@ -389,21 +515,51 @@ class PlayerBox(Gtk.Box):
if self._play_mode is PlayStreamsMode.BUILT_IN:
self.show()
elif self._play_mode is PlayStreamsMode.WINDOW:
self.show_playback_window()
self.show_playback_window(title)
self._current_mrl = url
if self._player:
self.emit("play", url)
else:
self._current_mrl = url
def play_current(self):
if self._s_type is SettingsType.ENIGMA_2:
self._app.http_api.send(HttpAPI.Request.STREAM_CURRENT, "", self.watch)
elif self._s_type is SettingsType.NEUTRINO_MP:
self._app.http_api.send(HttpAPI.Request.N_ZAP, "",
lambda rf: self._app.http_api.send(HttpAPI.Request.N_STREAM, rf.get("data", ""),
self.watch))
@run_with_delay(1)
def on_played(self, player, duration):
GLib.idle_add(self._fav_view.set_sensitive, True)
self._stack.set_visible_child_name(self.Page.PLAYBACK)
if not IS_DARWIN:
self._stop_button.set_visible(True)
self.on_duration_changed(duration)
@run_idle
def on_error(self, player, msg):
self._app.show_error_message(msg)
self._fav_view.set_sensitive(True)
self._stack.set_visible_child_name(self.Page.PLAYBACK)
def on_draw(self, widget, cr):
""" Used for black background drawing in the player drawing area. """
cr.set_source_rgb(0, 0, 0)
cr.paint()
def on_mouse_motion(self, widget, event):
display = widget.get_display()
window = widget.get_window()
cursor = Gdk.Cursor.new_from_name(display, "default")
window.set_cursor(cursor)
self.hide_mouse_cursor(window, display)
self.is_cursor_visible = True
@run_with_delay(3)
def hide_mouse_cursor(self, window, display):
cursor = Gdk.Cursor.new_for_display(display, Gdk.CursorType.BLANK_CURSOR)
window.set_cursor(cursor)
self.is_cursor_visible = False
if __name__ == "__main__":

598
app/ui/recordings.glade Normal file
View File

@@ -0,0 +1,598 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.2
The MIT License (MIT)
Copyright (c) 2018-2022 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.20"/>
<!-- interface-css-provider-path style.css -->
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-description Enigma2 channel and satellites list editor. -->
<!-- interface-copyright 2018-2022 Dmitriy Yefremov -->
<!-- interface-authors Dmitriy Yefremov -->
<object class="GtkMenu" id="popup_menu">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkImageMenuItem" id="play_menu_item">
<property name="label">gtk-media-play</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="use-underline">True</property>
<property name="use-stock">True</property>
<signal name="activate" handler="on_play" swapped="no"/>
</object>
</child>
<child>
<object class="GtkSeparatorMenuItem" id="menu_separator">
<property name="visible">True</property>
<property name="can-focus">False</property>
</object>
</child>
<child>
<object class="GtkImageMenuItem" id="remove_menu_item">
<property name="label">gtk-remove</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="use-underline">True</property>
<property name="use-stock">True</property>
<signal name="activate" handler="on_recording_remove" swapped="no"/>
<accelerator key="Delete" signal="activate"/>
</object>
</child>
</object>
<object class="GtkListStore" id="rec_paths_model">
<columns>
<!-- column-name icon -->
<column type="GdkPixbuf"/>
<!-- column-name title -->
<column type="gchararray"/>
<!-- column-name path -->
<column type="gchararray"/>
</columns>
</object>
<object class="GtkListStore" id="recordings_model">
<columns>
<!-- column-name logo -->
<column type="GdkPixbuf"/>
<!-- column-name service -->
<column type="gchararray"/>
<!-- column-name title -->
<column type="gchararray"/>
<!-- column-name time -->
<column type="gchararray"/>
<!-- column-name length -->
<column type="gchararray"/>
<!-- column-name file -->
<column type="gchararray"/>
<!-- column-name desc -->
<column type="gchararray"/>
<!-- column-name data -->
<column type="PyObject"/>
</columns>
</object>
<object class="GtkTreeModelFilter" id="recordings_filter_model">
<property name="child-model">recordings_model</property>
</object>
<object class="GtkTreeModelSort" id="recordings_sort_model">
<property name="model">recordings_filter_model</property>
<signal name="row-deleted" handler="on_recordings_model_changed" swapped="no"/>
<signal name="row-inserted" handler="on_recordings_model_changed" swapped="no"/>
</object>
<object class="GtkBox" id="recordings_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkPaned" id="recordings_paned">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="wide-handle">True</property>
<child>
<object class="GtkFrame" id="recordings_frame">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label-xalign">0.49000000953674316</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkViewport" id="recordings_viewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkBox" id="recordings_main_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">5</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkBox" id="recordings_header_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-top">5</property>
<property name="margin-bottom">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkToggleButton" id="recordings_filter_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="focus-on-click">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Filter</property>
<signal name="toggled" handler="on_recordings_filter_toggled" swapped="no"/>
<child>
<object class="GtkImage" id="recordings_filter_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">edit-find-replace-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="recordings_remove_button">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Remove</property>
<property name="always-show-image">True</property>
<signal name="clicked" handler="on_recording_remove" swapped="no"/>
<child>
<object class="GtkImage" id="remove_recording_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">user-trash-symbolic</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="recordings_fs_box">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-bottom">5</property>
<property name="spacing">10</property>
<child>
<object class="GtkSearchEntry" id="recordings_filter_entry">
<property name="visible" bind-source="recordings_filter_button" bind-property="active">False</property>
<property name="can-focus">True</property>
<property name="primary-icon-name">edit-find-replace-symbolic</property>
<property name="primary-icon-activatable">False</property>
<property name="primary-icon-sensitive">False</property>
<signal name="search-changed" handler="on_recordings_filter_changed" swapped="no"/>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkBox" id="recordings_search_box">
<property name="can-focus">False</property>
<property name="valign">center</property>
<child>
<object class="GtkSearchEntry" id="recordings_search_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="primary-icon-name">edit-find-symbolic</property>
<property name="primary-icon-activatable">False</property>
<property name="primary-icon-sensitive">False</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="recordings_search_down_button">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<child>
<object class="GtkArrow" id="recordings_down_arrow">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="arrow-type">down</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="recordings_search_up_button">
<property name="visible">True</property>
<property name="sensitive">False</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<child>
<object class="GtkArrow" id="recordings_up_arrow">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="arrow-type">up</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">2</property>
</packing>
</child>
<style>
<class name="group"/>
</style>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="recordings_view_scrolled_window">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="shadow-type">in</property>
<child>
<object class="GtkTreeView" id="recordings_view">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="model">recordings_sort_model</property>
<property name="fixed-height-mode">True</property>
<property name="rubber-banding">True</property>
<property name="enable-grid-lines">both</property>
<property name="tooltip-column">6</property>
<signal name="button-press-event" handler="on_popup_menu" object="popup_menu" swapped="no"/>
<signal name="key-press-event" handler="on_recordings_key_press" swapped="no"/>
<signal name="row-activated" handler="on_recordings_activated" swapped="no"/>
<child>
<object class="GtkTreeViewColumn" id="rec_service_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="min-width">150</property>
<property name="title" translatable="yes">Service</property>
<property name="alignment">0.5</property>
<property name="sort-column-id">1</property>
<child>
<object class="GtkCellRendererPixbuf" id="rec_log_renderer">
<property name="xpad">5</property>
<property name="ypad">2</property>
</object>
<attributes>
<attribute name="pixbuf">0</attribute>
</attributes>
</child>
<child>
<object class="GtkCellRendererText" id="rec_service_renderer">
<property name="xalign">0.49000000953674316</property>
</object>
<attributes>
<attribute name="text">1</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="rec_title_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="min-width">150</property>
<property name="title" translatable="yes">Title</property>
<property name="expand">True</property>
<property name="alignment">0.5</property>
<property name="sort-column-id">2</property>
<child>
<object class="GtkCellRendererText" id="rec_title_renderer">
<property name="xpad">5</property>
<property name="ellipsize">end</property>
</object>
<attributes>
<attribute name="text">2</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="rec_time_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="fixed-width">180</property>
<property name="min-width">100</property>
<property name="title" translatable="yes">Time</property>
<property name="alignment">0.5</property>
<property name="sort-column-id">3</property>
<child>
<object class="GtkCellRendererText" id="rec_time_renderer">
<property name="xpad">5</property>
<property name="xalign">0.49000000953674316</property>
</object>
<attributes>
<attribute name="text">3</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="rec_len_column">
<property name="sizing">fixed</property>
<property name="min-width">100</property>
<property name="title" translatable="yes">Length</property>
<property name="alignment">0.5</property>
<property name="sort-column-id">4</property>
<child>
<object class="GtkCellRendererText" id="rec_len_renderer">
<property name="xalign">0.49000000953674316</property>
</object>
<attributes>
<attribute name="text">4</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="rec_file_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="min-width">100</property>
<property name="title" translatable="yes">File</property>
<property name="expand">True</property>
<property name="alignment">0.5</property>
<property name="sort-column-id">5</property>
<child>
<object class="GtkCellRendererText" id="rec_file_renderer">
<property name="ellipsize">end</property>
</object>
<attributes>
<attribute name="text">5</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="rec_desc_column">
<property name="resizable">True</property>
<property name="sizing">fixed</property>
<property name="title" translatable="yes">Description</property>
<property name="expand">True</property>
<property name="alignment">0.5</property>
<property name="sort-column-id">6</property>
<child>
<object class="GtkCellRendererText" id="rec_desc_renderer">
<property name="ellipsize">end</property>
</object>
<attributes>
<attribute name="text">6</attribute>
</attributes>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
<child>
<object class="GtkBox" id="recordings_status_box">
<property name="height-request">26</property>
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkImage" id="recordings_count_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="icon-name">document-properties</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="recordings_count_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label">0</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">3</property>
</packing>
</child>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
</child>
<child type="label">
<object class="GtkLabel" id="recordings_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-bottom">2</property>
<property name="label" translatable="yes">Recordings</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>
<packing>
<property name="resize">True</property>
<property name="shrink">True</property>
</packing>
</child>
<child>
<object class="GtkFrame" id="recordings_paths_frame">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label-xalign">0.49000000953674316</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkViewport" id="paths_viewport">
<property name="visible">True</property>
<property name="can-focus">False</property>
<child>
<object class="GtkScrolledWindow" id="paths_view_scrolled_window">
<property name="width-request">250</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="margin-bottom">10</property>
<property name="shadow-type">in</property>
<property name="min-content-height">100</property>
<child>
<object class="GtkTreeView" id="recordings_paths_view">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="model">rec_paths_model</property>
<property name="headers-visible">False</property>
<property name="search-column">1</property>
<property name="rubber-banding">True</property>
<property name="activate-on-single-click">True</property>
<signal name="button-press-event" handler="on_path_press" swapped="no"/>
<signal name="row-activated" handler="on_path_activated" swapped="no"/>
<child>
<object class="GtkTreeViewColumn" id="rec_paths_column">
<property name="resizable">True</property>
<property name="min-width">100</property>
<property name="title" translatable="yes">Paths</property>
<property name="expand">True</property>
<property name="clickable">True</property>
<property name="alignment">0.5</property>
<property name="sort-column-id">1</property>
<child>
<object class="GtkCellRendererPixbuf" id="ftp_icon_column_renderer">
<property name="xalign">0.019999999552965164</property>
</object>
<attributes>
<attribute name="pixbuf">0</attribute>
</attributes>
</child>
<child>
<object class="GtkCellRendererText" id="ftp_name_column_renderer">
<property name="xalign">0.019999999552965164</property>
<property name="ellipsize">end</property>
</object>
<attributes>
<attribute name="text">1</attribute>
</attributes>
</child>
</object>
</child>
</object>
</child>
</object>
</child>
<style>
<class name="view"/>
</style>
</object>
</child>
<child type="label">
<object class="GtkLabel" id="recordings_path_label">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="margin-bottom">2</property>
<property name="label" translatable="yes">Paths</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>
<packing>
<property name="resize">False</property>
<property name="shrink">True</property>
</packing>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</interface>

342
app/ui/recordings.py Normal file
View File

@@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2025 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
""" Module for working with recordings. """
import os
from datetime import datetime
from ftplib import all_errors
from io import BytesIO, TextIOWrapper
from urllib.parse import quote
from app.ui.tasks import BGTaskWidget
from .dialogs import get_builder, show_dialog, DialogType
from .main_helper import get_base_paths, get_base_model, on_popup_menu
from .uicommons import Gtk, Gdk, GLib, UI_RESOURCES_PATH, Column, KeyboardKey, Page
from ..commons import run_task, run_idle, log
from ..connections import UtfFTP, HttpAPI
from ..settings import IS_DARWIN, PlayStreamsMode
class RecordingsTool(Gtk.Box):
ROOT = ".."
DEFAULT_PATH = "/hdd"
def __init__(self, app, **kwargs):
super().__init__(**kwargs)
self._app = app
self._app.connect("layout-changed", self.on_layout_changed)
self._app.connect("data-receive", self.on_data_receive)
self._app.connect("profile-changed", self.init)
self._app.connect("filter-toggled", self.on_filter_toggled)
self._settings = app.app_settings
self._ftp = None
self._logos = {}
# Icon.
theme = Gtk.IconTheme.get_default()
icon = "folder-symbolic" if IS_DARWIN else "folder"
self._icon = theme.load_icon(icon, 24, 0) if theme.lookup_icon(icon, 24, 0) else None
handlers = {"on_path_press": self.on_path_press,
"on_path_activated": self.on_path_activated,
"on_recordings_activated": self.on_recordings_activated,
"on_play": self.on_play,
"on_recording_remove": self.on_recording_remove,
"on_recordings_model_changed": self.on_recordings_model_changed,
"on_recordings_filter_changed": self.on_recordings_filter_changed,
"on_recordings_filter_toggled": self.on_recordings_filter_toggled,
"on_recordings_key_press": self.on_recordings_key_press,
"on_popup_menu": on_popup_menu}
builder = get_builder(f"{UI_RESOURCES_PATH}recordings.glade", handlers)
self._rec_view = builder.get_object("recordings_view")
self._paths_view = builder.get_object("recordings_paths_view")
self._paned = builder.get_object("recordings_paned")
self._model = builder.get_object("recordings_model")
self._filter_model = builder.get_object("recordings_filter_model")
self._filter_model.set_visible_func(self.recordings_filter_function)
self._filter_entry = builder.get_object("recordings_filter_entry")
self._recordings_filter_button = builder.get_object("recordings_filter_button")
self._recordings_count_label = builder.get_object("recordings_count_label")
self.pack_start(builder.get_object("recordings_box"), True, True, 0)
self._rec_view.get_model().set_sort_func(3, self.time_sort_func, 3)
srv_column = builder.get_object("rec_service_column")
renderer = builder.get_object("rec_log_renderer")
size = self._app.app_settings.list_picon_size
renderer.set_fixed_size(size, size * 0.65)
srv_column.set_cell_data_func(renderer, self.logo_data_func)
if self._settings.alternate_layout:
self.on_layout_changed(app, True)
self.init()
self.show()
def clear_data(self):
self._model.clear()
self._paths_view.get_model().clear()
def on_layout_changed(self, app, alt_layout):
ch1 = self._paned.get_child1()
ch2 = self._paned.get_child2()
self._paned.remove(ch1)
self._paned.remove(ch2)
self._paned.add1(ch2)
self._paned.add(ch1)
def on_data_receive(self, app, page):
if page is Page.RECORDINGS:
model, paths = self._rec_view.get_selection().get_selected_rows()
if not paths:
self._app.show_error_message("No selected item!")
return
response = show_dialog(DialogType.CHOOSER, self._app.app_window, settings=self._settings,
title="Open folder", create_dir=True)
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
files = (model[p][5] for p in paths)
bgw = BGTaskWidget(self._app, "Downloading recordings...", self.download_recordings, files, response)
self._app.emit("add-background-task", bgw)
def download_recordings(self, files, dst):
for file in files:
try:
with open(os.path.join(dst, os.path.basename(file)), "wb") as f:
log(f"Downloading recording: {file}. Status: {self._ftp.download_binary(file, f)}".rstrip())
except OSError as e:
log(str(e))
@run_task
def init(self, app=None, arg=None):
GLib.idle_add(self.clear_data)
try:
if self._ftp:
self._ftp.close()
host, port = self._settings.host, self._settings.port
self._ftp = UtfFTP(host=host, port=port, user=self._settings.user, passwd=self._settings.password)
self._ftp.encoding = "utf-8"
except all_errors:
pass # NOP
else:
self.init_paths(self.DEFAULT_PATH)
@run_idle
def init_paths(self, path=None):
self.clear_data()
if not self._ftp:
return
if path:
try:
self._ftp.cwd(path)
except all_errors as e:
pass
files = []
try:
self._ftp.dir(files.append)
except all_errors as e:
log(e)
else:
self.append_paths(files)
@run_idle
def append_paths(self, files):
model = self._paths_view.get_model()
model.clear()
model.append((None, self.ROOT, self._ftp.pwd()))
for f in files:
f_data = self._ftp.get_file_data(f)
if len(f_data) < 9:
log(f"{__class__.__name__}. Folder data parsing error. [{f}]")
continue
f_type = f_data[0][0]
if f_type == "d":
model.append((self._icon, f_data[8], self._ftp.pwd()))
def on_path_activated(self, view, path, column):
row = view.get_model()[path][:]
path = f"{row[-1]}/{row[1]}/"
self._app.send_http_request(HttpAPI.Request.RECORDINGS, quote(path), self.update_recordings_data)
def on_path_press(self, view, event):
target = view.get_path_at_pos(event.x, event.y)
if not target or event.button != Gdk.BUTTON_PRIMARY:
return
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS:
self.init_paths(self._paths_view.get_model()[target[0]][1])
@run_idle
def update_recordings_data(self, recordings):
self._model.clear()
recs = recordings.get("recordings", [])
list(map(self._model.append, (self.get_recordings_row(r) for r in recs)))
list(map(self.get_rec_service_logo, recs))
def get_recordings_row(self, rec):
service = rec.get("e2servicename")
title = rec.get("e2title", "")
r_time = datetime.fromtimestamp(int(rec.get("e2time", "0"))).strftime("%a, %x, %H:%M")
length = rec.get("e2length", "0")
file = rec.get("e2filename", "")
desc = rec.get("e2description", "")
return None, service, title, r_time, length, file, desc, rec
def get_rec_service_logo(self, rec_data):
if not rec_data.get("e2servicename", None):
return
ref = rec_data.get("e2servicereference", None)
logo = self._logos.get(rec_data.get("e2servicereference", None))
if not logo:
file = rec_data.get("e2filename", None)
if file:
meta = f"RETR {file}.meta"
io = BytesIO()
try:
self._ftp.retrbinary(meta, io.write)
except all_errors:
pass
else:
io.seek(0)
f_ref, sep, name = TextIOWrapper(io, errors="ignore").readline().partition("::")
self._logos[ref] = self._app.picons.get(f"{f_ref.replace(':', '_')}.png")
def on_recordings_activated(self, view, path, column):
rec = view.get_model()[path][-1]
self._app.send_http_request(HttpAPI.Request.STREAM_TS, rec.get("e2filename", ""), self.on_play_recording)
def on_play(self, item):
path, column = self._rec_view.get_cursor()
if not path:
self._app.show_error_message("No selected item!")
return
self.on_recordings_activated(self._rec_view, path, column)
def on_play_recording(self, m3u):
url = self._app.get_url_from_m3u(m3u)
if url:
self._app.emit("play-recording", url)
def on_recording_remove(self, action=None, value=None):
""" Removes recordings via FTP. """
model, paths = self._rec_view.get_selection().get_selected_rows()
if not paths:
self._app.show_error_message("No selected item!")
return
if show_dialog(DialogType.QUESTION, self._app.app_window) != Gtk.ResponseType.OK:
return
paths = get_base_paths(paths, model)
model = get_base_model(model)
to_delete = []
if paths and self._ftp:
for file, itr in ((model[p][-1].get("e2filename", ""), model.get_iter(p)) for p in paths):
resp = self._ftp.delete_file(file)
if resp.startswith("2"):
to_delete.append((itr, file))
else:
self._app.show_error_message(resp)
break
[self.remove_meta_files(f) for i, f in to_delete if model.remove(i) or True]
@run_task
def remove_meta_files(self, file):
name, ex = os.path.splitext(file)
[self._ftp.delete_file(f"{name}{suf}") for suf in (f"{ex}.ap", f"{ex}.cuts", f"{ex}.meta", f"{ex}.sc", ".eit")]
def on_recordings_model_changed(self, model, path, itr=None):
self._recordings_count_label.set_text(str(len(model)))
def on_recordings_filter_changed(self, entry):
self._filter_model.refilter()
def recordings_filter_function(self, model, itr, data):
txt = self._filter_entry.get_text().upper()
return next((s for s in model.get(itr, 1, 2, 3, 5, 6) if s and txt in s.upper()), False)
def on_filter_toggled(self, app, value):
if self._app.page is Page.RECORDINGS:
self._recordings_filter_button.set_active(not self._recordings_filter_button.get_active())
def on_recordings_filter_toggled(self, button):
self._filter_entry.grab_focus() if button.get_active() else self._filter_entry.set_text("")
def on_recordings_key_press(self, view, event):
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
if key is KeyboardKey.DELETE:
self.on_recording_remove()
def on_playback(self, box, state):
""" Updates state of the UI elements for playback mode. """
if self._settings.play_streams_mode is PlayStreamsMode.BUILT_IN:
self._paned.set_orientation(Gtk.Orientation.VERTICAL)
self.update_rec_columns_visibility(False)
def on_playback_close(self, box, state):
""" Restores UI elements state after playback mode. """
self._paned.set_orientation(Gtk.Orientation.HORIZONTAL)
self.update_rec_columns_visibility(True)
def update_rec_columns_visibility(self, state):
for c in (Column.REC_TIME, Column.REC_LEN, Column.REC_FILE, Column.REC_DESC):
self._rec_view.get_column(c).set_visible(state)
def logo_data_func(self, column, renderer, model, itr, data):
rec_data = model.get_value(itr, 7)
renderer.set_property("pixbuf", self._logos.get(rec_data.get("e2servicereference", None)))
def time_sort_func(self, model, iter1, iter2, column):
""" Custom sort function for time column. """
rec1 = model.get_value(iter1, 7)
rec2 = model.get_value(iter2, 7)
return int(rec1.get("e2time", "0")) - int(rec2.get("e2time", "0"))
if __name__ == "__main__":
pass

File diff suppressed because it is too large Load Diff

View File

@@ -1,929 +0,0 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import concurrent.futures
import re
import time
from math import fabs
from pyexpat import ExpatError
from gi.repository import GLib
from app.commons import run_idle, run_task, log
from app.eparser import get_satellites, write_satellites, Satellite, Transponder
from app.eparser.ecommons import PLS_MODE, get_key_by_value
from app.tools.satellites import SatellitesParser, SatelliteSource, ServicesParser
from .dialogs import show_dialog, DialogType, get_chooser_dialog, get_message, get_builder
from .main_helper import move_items, append_text_to_tview, get_base_model, on_popup_menu
from .search import SearchProvider
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, MOVE_KEYS, KeyboardKey, MOD_MASK
_UI_PATH = UI_RESOURCES_PATH + "satellites.glade"
class SatellitesTool(Gtk.Box):
_aggr = [None for x in range(9)] # aggregate
def __init__(self, app, settings, *args, **kwargs):
super().__init__(*args, **kwargs)
self._app = app
self._settings = settings
self._current_sat_path = None
handlers = {"on_remove": self.on_remove,
"on_update": self.on_update,
"on_up": self.on_up,
"on_down": self.on_down,
"on_button_press": self.on_button_press,
"on_satellite_add": self.on_satellite_add,
"on_transponder_add": self.on_transponder_add,
"on_edit": self.on_edit,
"on_key_release": self.on_key_release,
"on_satellite_selection": self.on_satellite_selection}
builder = get_builder(_UI_PATH, handlers, use_str=True,
objects=("satellite_editor_box", "satellite_view_model", "transponder_view_model",
"satellite_popup_menu", "transponder_popup_menu", "left_header_menu",
"popup_menu_add_image", "popup_menu_add_image_2"))
self._satellite_view = builder.get_object("satellite_view")
self._transponder_view = builder.get_object("transponder_view")
builder.get_object("sat_pos_column").set_cell_data_func(builder.get_object("sat_pos_renderer"),
self.sat_pos_func)
self._stores = {3: builder.get_object("pol_store"),
4: builder.get_object("fec_store"),
5: builder.get_object("system_store"),
6: builder.get_object("mod_store")}
self.pack_start(builder.get_object("satellite_editor_box"), True, True, 0)
self._app.connect("profile-changed", lambda a, m: self.load_satellites_list())
self.show()
self.load_satellites_list()
def load_satellites_list(self, path=None):
gen = self.on_satellites_list_load(path)
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
@run_idle
def on_open(self):
response = get_chooser_dialog(self._app.app_window, self._settings, "satellites.xml", ("*.xml",))
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
if not str(response).endswith("satellites.xml"):
self._app.show_error_message("No satellites.xml file is selected!")
return
self.load_satellites_list(response)
def on_satellite_selection(self, view):
model = self._transponder_view.get_model()
model.clear()
self._current_sat_path, column = view.get_cursor()
if self._current_sat_path:
list(map(model.append, view.get_model()[self._current_sat_path][-1]))
def on_up(self, item):
move_items(KeyboardKey.UP, self._satellite_view)
def on_down(self, item):
move_items(KeyboardKey.DOWN, self._satellite_view)
def on_button_press(self, menu, event):
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS:
self.on_edit(self._satellite_view if self._satellite_view.is_focus() else self._transponder_view)
else:
on_popup_menu(menu, event)
def on_key_release(self, view, event):
""" Handling keystrokes """
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
return
key = KeyboardKey(key_code)
ctrl = event.state & MOD_MASK
if key is KeyboardKey.DELETE:
self.on_remove(view)
elif key is KeyboardKey.INSERT:
pass
elif ctrl and key is KeyboardKey.E:
self.on_edit(view)
elif ctrl and key is KeyboardKey.S:
self.on_satellite()
elif ctrl and key is KeyboardKey.T:
self.on_transponder()
elif ctrl and key in MOVE_KEYS:
move_items(key, self._satellite_view)
elif key is KeyboardKey.LEFT or key is KeyboardKey.RIGHT:
view.do_unselect_all(view)
def on_satellites_list_load(self, path=None):
""" Load satellites data into model """
model = self._satellite_view.get_model()
model.clear()
try:
path = path or self._settings.profile_data_path + "satellites.xml"
satellites = get_satellites(path)
yield True
except FileNotFoundError as e:
msg = get_message("Please, download files from receiver or setup your path for read data!")
self._app.show_error_message(f"{e}\n{msg}")
except ExpatError as e:
msg = f"The file [{path}] is not formatted correctly or contains invalid characters! Cause: {e}"
self._app.show_error_message(msg)
else:
for sat in satellites:
yield model.append(sat)
def on_add(self, view):
""" Common adding """
self.on_edit(view, force=True)
def on_satellite_add(self, item):
self.on_satellite(None)
def on_transponder_add(self, item):
self.on_transponder(None)
def on_edit(self, view, force=False):
""" Common edit """
paths = self.check_selection(view, "Please, select only one item!")
if not paths:
return
model = view.get_model()
row = model[paths][:]
itr = model.get_iter(paths)
if view is self._satellite_view:
self.on_satellite(None if force else Satellite(*row), itr)
elif view is self._transponder_view:
self.on_transponder(None if force else Transponder(*row), itr)
def on_satellite(self, satellite=None, edited_itr=None):
""" Create or edit satellite"""
sat_dialog = SatelliteDialog(self._app.get_active_window(), satellite)
sat = sat_dialog.run()
sat_dialog.destroy()
if sat:
model, paths = self._satellite_view.get_selection().get_selected_rows()
if satellite and edited_itr:
model.set(edited_itr, {i: v for i, v in enumerate(sat)})
else:
if len(model):
index = paths[0].get_indices()[0] + 1
model.insert(index, sat)
else:
model.append(sat)
def on_transponder(self, transponder=None, edited_itr=None):
""" Create or edit transponder """
paths = self.check_selection(self._satellite_view, "Please, select only one satellite!")
if paths is None:
return
elif len(paths) == 0:
self._app.show_error_message("No satellite is selected!")
return
dialog = TransponderDialog(self._app.get_active_window(), transponder)
tr = dialog.run()
dialog.destroy()
if tr:
sat_model = self._satellite_view.get_model()
transponders = sat_model[paths][-1]
tr_model, tr_paths = self._transponder_view.get_selection().get_selected_rows()
if transponder and edited_itr:
tr_model.set(edited_itr, {i: v for i, v in enumerate(tr)})
transponders[tr_model.get_path(edited_itr).get_indices()[0]] = tr
else:
index = paths[0].get_indices()[0] + 1
tr_model.insert(index, tr)
transponders.insert(index, tr)
def check_selection(self, view, message):
""" Checks if any row is selected. Shows error dialog if selected more than one.
Returns selected path or None.
"""
model, paths = view.get_selection().get_selected_rows()
if len(paths) > 1:
self._app.show_error_message(message)
return
return paths
def on_remove(self, view):
""" Removes selected satellites and transponders. """
selection = view.get_selection()
model, paths = selection.get_selected_rows()
if view is self._satellite_view:
list(map(model.remove, [model.get_iter(path) for path in paths]))
elif view is self._transponder_view:
if self._current_sat_path:
trs = self._satellite_view.get_model()[self._current_sat_path][-1]
list(map(trs.pop, sorted(map(lambda p: p.get_indices()[0], paths), reverse=True)))
list(map(model.remove, [model.get_iter(path) for path in paths]))
else:
self._app.show_error_message("No satellite is selected!")
def sat_pos_func(self, column, renderer, model, itr, data):
""" Converts and sets the satellite position value to a readable format. """
pos = int(model.get_value(itr, 2))
renderer.set_property("text", f"{abs(pos / 10):0.1f}{'W' if pos < 0 else 'E'}")
@run_idle
def on_save(self):
if show_dialog(DialogType.QUESTION, self._app.app_window) == Gtk.ResponseType.CANCEL:
return
write_satellites((Satellite(*r) for r in self._satellite_view.get_model()),
self._settings.profile_data_path + "satellites.xml")
def on_save_as(self):
show_dialog(DialogType.ERROR, transient=self._app.app_window, text="Not implemented yet!")
@run_idle
def on_update(self, item):
SatellitesUpdateDialog(self._app.get_active_window(), self._settings, self._satellite_view.get_model()).show()
# ***************** Transponder dialog *******************#
class TransponderDialog:
""" Shows dialog for adding or edit transponder """
def __init__(self, transient, transponder: Transponder = None):
handlers = {"on_entry_changed": self.on_entry_changed}
objects = ("transponder_dialog", "pol_store", "fec_store", "mod_store", "system_store", "pls_mode_store")
builder = get_builder(_UI_PATH, handlers, use_str=True, objects=objects)
self._dialog = builder.get_object("transponder_dialog")
self._dialog.set_transient_for(transient)
self._freq_entry = builder.get_object("freq_entry")
self._rate_entry = builder.get_object("rate_entry")
self._pol_box = builder.get_object("pol_box")
self._fec_box = builder.get_object("fec_box")
self._sys_box = builder.get_object("sys_box")
self._mod_box = builder.get_object("mod_box")
self._pls_mode_box = builder.get_object("pls_mode_box")
self._pls_code_entry = builder.get_object("pls_code_entry")
self._is_id_entry = builder.get_object("is_id_entry")
# pattern for frequency and rate entries (only digits)
self._pattern = re.compile(r"\D")
# style
self._style_provider = Gtk.CssProvider()
self._style_provider.load_from_path(UI_RESOURCES_PATH + "style.css")
self._freq_entry.get_style_context().add_provider_for_screen(Gdk.Screen.get_default(), self._style_provider,
Gtk.STYLE_PROVIDER_PRIORITY_USER)
self._rate_entry.get_style_context().add_provider_for_screen(Gdk.Screen.get_default(), self._style_provider,
Gtk.STYLE_PROVIDER_PRIORITY_USER)
if transponder:
self.init_transponder(transponder)
def run(self):
while self._dialog.run() != Gtk.ResponseType.CANCEL:
tr = self.to_transponder()
if self.is_accept(tr):
return tr
show_dialog(DialogType.ERROR, self._dialog, "Please check your parameters and try again.")
def destroy(self):
self._dialog.destroy()
def init_transponder(self, transponder):
self._freq_entry.set_text(transponder.frequency)
self._rate_entry.set_text(transponder.symbol_rate)
self._pol_box.set_active_id(transponder.polarization)
self._fec_box.set_active_id(transponder.fec_inner)
self._sys_box.set_active_id(transponder.system)
self._mod_box.set_active_id(transponder.modulation)
self._pls_mode_box.set_active_id(PLS_MODE.get(transponder.pls_mode, None))
self._is_id_entry.set_text(transponder.is_id if transponder.is_id else "")
self._pls_code_entry.set_text(transponder.pls_code if transponder.pls_code else "")
def to_transponder(self):
return Transponder(frequency=self._freq_entry.get_text(),
symbol_rate=self._rate_entry.get_text(),
polarization=self._pol_box.get_active_id(),
fec_inner=self._fec_box.get_active_id(),
system=self._sys_box.get_active_id(),
modulation=self._mod_box.get_active_id(),
pls_mode=get_key_by_value(PLS_MODE, self._pls_mode_box.get_active_id()),
pls_code=self._pls_code_entry.get_text(),
is_id=self._is_id_entry.get_text())
def on_entry_changed(self, entry):
entry.set_name("digit-entry" if self._pattern.search(entry.get_text()) else "GtkEntry")
def is_accept(self, tr):
if self._pattern.search(tr.frequency) or not tr.frequency:
return False
elif self._pattern.search(tr.symbol_rate) or not tr.symbol_rate:
return False
elif None in (tr.polarization, tr.fec_inner, tr.system, tr.modulation):
return False
elif self._pattern.search(tr.pls_code) or self._pattern.search(tr.is_id):
return False
return True
# ***************** Satellite dialog *******************#
class SatelliteDialog:
""" Shows dialog for adding or edit satellite """
def __init__(self, transient, satellite=None):
builder = get_builder(_UI_PATH, use_str=True, objects=("satellite_dialog", "side_store", "pos_adjustment"))
self._dialog = builder.get_object("satellite_dialog")
self._dialog.set_transient_for(transient)
self._sat_name = builder.get_object("sat_name_entry")
self._sat_position = builder.get_object("sat_position_button")
self._side = builder.get_object("side_box")
self._transponders = satellite.transponders if satellite else []
if satellite:
self._sat_name.set_text(satellite.name)
pos = satellite.position
pos = float(f"{pos[:-1]}.{pos[-1:]}")
self._sat_position.set_value(fabs(pos))
self._side.set_active(0 if pos >= 0 else 1) # E or W
def run(self):
if self._dialog.run() == Gtk.ResponseType.CANCEL:
return
return self.to_satellite()
def destroy(self):
self._dialog.destroy()
def to_satellite(self):
name = self._sat_name.get_text()
pos = round(self._sat_position.get_value(), 1)
side = self._side.get_active()
pos = "{}{}{}".format("-" if side == 1 else "", *str(pos).split("."))
return Satellite(name=name, flags="0", position=pos, transponders=self._transponders)
# ********************** Update dialogs ************************ #
class UpdateDialog:
""" Base dialog for update satellites, transponders and services from the web."""
def __init__(self, transient, settings, title=None):
handlers = {"on_update_satellites_list": self.on_update_satellites_list,
"on_receive_data": self.on_receive_data,
"on_cancel_receive": self.on_cancel_receive,
"on_satellite_toggled": self.on_satellite_toggled,
"on_satellite_changed": self.on_satellite_changed,
"on_transponder_toggled": self.on_transponder_toggled,
"on_info_bar_close": self.on_info_bar_close,
"on_filter_toggled": self.on_filter_toggled,
"on_find_toggled": self.on_find_toggled,
"on_popup_menu": on_popup_menu,
"on_select_all": self.on_select_all,
"on_unselect_all": self.on_unselect_all,
"on_filter": self.on_filter,
"on_quit": self.on_quit}
self._settings = settings
self._download_task = False
self._parser = None
self._size_name = f"{'_'.join(re.findall('[A-Z][^A-Z]*', self.__class__.__name__))}_window_size".lower()
builder = get_builder(UI_RESOURCES_PATH + "satellites.glade", handlers,
objects=("satellites_update_window", "update_source_store", "update_sat_list_store",
"update_sat_list_model_filter", "update_sat_list_model_sort", "side_store",
"pos_adjustment", "pos_adjustment2", "satellites_update_popup_menu",
"remove_selection_image", "sat_update_cancel_image", "sat_receive_image",
"sat_update_filter_image", "sat_update_search_image", "sat_update_image",
"update_transponder_store", "update_service_store"))
self._window = builder.get_object("satellites_update_window")
self._window.set_transient_for(transient)
if title:
self._window.set_title(title)
self._transponder_frame = builder.get_object("sat_update_tr_frame")
self._sat_view = builder.get_object("sat_update_tree_view")
self._transponder_view = builder.get_object("sat_update_tr_view")
self._service_view = builder.get_object("sat_update_srv_view")
self._source_box = builder.get_object("source_combo_box")
self._sat_update_expander = builder.get_object("sat_update_expander")
self._text_view = builder.get_object("text_view")
self._receive_button = builder.get_object("receive_data_button")
self._sat_update_info_bar = builder.get_object("sat_update_info_bar")
self._info_bar_message_label = builder.get_object("info_bar_message_label")
self._receive_button.bind_property("visible", builder.get_object("cancel_data_button"), "visible", 4)
update_button = builder.get_object("sat_update_button")
self._sat_view.bind_property("sensitive", update_button, "sensitive")
self._sat_view.bind_property("sensitive", self._source_box, "sensitive")
self._sat_view.bind_property("sensitive", self._source_box, "sensitive")
self._sat_view.bind_property("sensitive", self._receive_button, "sensitive")
self._receive_button.bind_property("visible", update_button, "visible")
# Filter
self._filter_bar = builder.get_object("sat_update_filter_bar")
self._from_pos_button = builder.get_object("from_pos_button")
self._to_pos_button = builder.get_object("to_pos_button")
self._filter_from_combo_box = builder.get_object("filter_from_combo_box")
self._filter_to_combo_box = builder.get_object("filter_to_combo_box")
self._filter_model = builder.get_object("update_sat_list_model_filter")
self._filter_model.set_visible_func(self.filter_function)
self._filter_positions = (0, 0)
self._filter_bar.bind_property("search-mode-enabled", self._filter_bar, "visible")
# Search
self._search_bar = builder.get_object("sat_update_search_bar")
self._search_bar.bind_property("search-mode-enabled", self._search_bar, "visible")
search_provider = SearchProvider(self._sat_view,
builder.get_object("sat_update_search_entry"),
builder.get_object("sat_update_search_down_button"),
builder.get_object("sat_update_search_up_button"))
builder.get_object("sat_update_find_button").connect("toggled", search_provider.on_search_toggled)
window_size = self._settings.get(self._size_name)
if window_size:
self._window.resize(*window_size)
def show(self):
self._window.show()
@property
def is_download(self):
return self._download_task
@is_download.setter
def is_download(self, value):
self._download_task = value
self._receive_button.set_visible(not value)
@run_idle
def on_update_satellites_list(self, item=None):
if self.is_download:
show_dialog(DialogType.ERROR, self._window, "The task is already running!")
return
get_base_model(self._sat_view.get_model()).clear()
self._transponder_view.get_model().clear()
self._service_view.get_model().clear()
self.is_download = True
self._sat_view.set_sensitive(False)
src = self._source_box.get_active()
if not self._parser:
self._parser = SatellitesParser()
self.get_sat_list(src, self.append_satellites)
@run_task
def get_sat_list(self, src, callback):
sat_src = SatelliteSource.FLYSAT
if src == 1:
sat_src = SatelliteSource.LYNGSAT
elif src == 2:
sat_src = SatelliteSource.KINGOFSAT
sats = self._parser.get_satellites_list(sat_src)
if sats:
callback(sats)
self.is_download = False
@run_idle
def append_satellites(self, sats):
model = get_base_model(self._sat_view.get_model())
for sat in sats:
model.append(sat)
self._sat_view.set_sensitive(True)
@run_idle
def on_receive_data(self, item):
if self.is_download:
show_dialog(DialogType.ERROR, self._window, "The task is already running!")
return
@run_idle
def update_expander(self):
self._sat_update_expander.set_expanded(True)
self._text_view.get_buffer().set_text("", 0)
def append_output(self):
@run_idle
def append(t):
append_text_to_tview(t, self._text_view)
while True:
text = yield
append(text)
def on_cancel_receive(self, item=None):
self._download_task = False
def on_satellite_changed(self, box):
self.on_update_satellites_list()
def on_satellite_toggled(self, toggle, path):
model = self._sat_view.get_model()
self.update_state(model, path, not toggle.get_active())
self.update_receive_button_state(self._filter_model)
def on_transponder_toggled(self, toggle, path):
model = self._transponder_view.get_model()
model.set_value(model.get_iter(path), 2, not toggle.get_active())
@run_idle
def update_receive_button_state(self, model):
self._receive_button.set_sensitive((any(r[4] for r in model)))
@run_idle
def show_info_message(self, text, message_type):
self._sat_update_info_bar.set_visible(True)
self._sat_update_info_bar.set_message_type(message_type)
self._info_bar_message_label.set_text(text)
def on_info_bar_close(self, bar=None, resp=None):
self._sat_update_info_bar.set_visible(False)
def on_find_toggled(self, button: Gtk.ToggleToolButton):
self._search_bar.set_search_mode(button.get_active())
def on_filter_toggled(self, button: Gtk.ToggleToolButton):
self._filter_bar.set_search_mode(button.get_active())
@run_idle
def on_filter(self, item):
self._filter_positions = self.get_positions()
self._filter_model.refilter()
def filter_function(self, model, itr, data):
if self._filter_model is None or self._filter_model == "None":
return True
from_pos, to_pos = self._filter_positions
if from_pos == 0 and to_pos == 0:
return True
if from_pos > to_pos:
from_pos, to_pos = to_pos, from_pos
return from_pos <= float(self._parser.get_position(model.get(itr, 1)[0])) <= to_pos
def get_positions(self):
from_pos = round(self._from_pos_button.get_value(), 1) * (-1 if self._filter_from_combo_box.get_active() else 1)
to_pos = round(self._to_pos_button.get_value(), 1) * (-1 if self._filter_to_combo_box.get_active() else 1)
return from_pos, to_pos
def on_select_all(self, view):
self.update_selection(view, True)
def on_unselect_all(self, view):
self.update_selection(view, False)
def update_selection(self, view, select):
model = view.get_model()
view.get_model().foreach(lambda mod, path, itr: self.update_state(model, path, select))
self.update_receive_button_state(self._filter_model)
def update_state(self, model, path, select):
""" Updates checkbox state by given path in the list """
itr = self._filter_model.convert_iter_to_child_iter(model.convert_iter_to_child_iter(model.get_iter(path)))
self._filter_model.get_model().set_value(itr, 4, select)
def on_quit(self, window, event):
self._settings.add(self._size_name, window.get_size())
self.is_download = False
class SatellitesUpdateDialog(UpdateDialog):
""" Dialog for update satellites from the web. """
def __init__(self, transient, settings, main_model):
super().__init__(transient=transient, settings=settings)
self._main_model = main_model
self._source_box.connect("changed", self.on_update_satellites_list)
@run_idle
def on_receive_data(self, item):
if self.is_download:
show_dialog(DialogType.ERROR, self._window, "The task is already running!")
return
self.receive_satellites()
@run_task
def receive_satellites(self):
self.is_download = True
self.update_expander()
model = self._sat_view.get_model()
start = time.time()
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
text = "Processing: {}\n"
sats = []
appender = self.append_output()
next(appender)
futures = {executor.submit(self._parser.get_satellite, sat[:-1]): sat for sat in [r for r in model if r[4]]}
for future in concurrent.futures.as_completed(futures):
if not self.is_download:
self.is_download = True
executor.shutdown()
appender.send("\nCanceled\n")
appender.close()
self.is_download = False
return
data = future.result()
appender.send(text.format(data[0]))
sats.append(data)
appender.send("-" * 75 + "\n")
sat_count = len(sats)
sats = {s[2]: s for s in sats} # key = position, v = satellite
for row in self._main_model:
pos = row[2]
if pos in sats:
sat = sats.pop(pos)
appender.send(f"Updating satellite: {row[0]}\n")
GLib.idle_add(self._main_model.set, row.iter, {i: v for i, v in enumerate(sat)})
for p, s in sats.items():
appender.send(f"Adding satellite: {s.name}\n")
self.append_satellite(s)
appender.send("-" * 75 + "\n")
appender.send(f"Consumed: {time.time() - start:0.0f}s, {sat_count} satellites received.\n")
appender.close()
self.is_download = False
@run_idle
def append_satellite(self, sat):
self._main_model.append(sat)
class ServicesUpdateDialog(UpdateDialog):
""" Dialog for updating services from the web. """
def __init__(self, transient, settings, callback):
super().__init__(transient=transient, settings=settings, title="Services update")
self._callback = callback
self._satellite_paths = {}
self._transponders = {}
self._services = {}
self._selected_transponders = set()
self._services_parser = ServicesParser(source=SatelliteSource.LYNGSAT)
# Transponder view popup menu
tr_popup_menu = Gtk.Menu()
select_all_item = Gtk.ImageMenuItem.new_from_stock("gtk-select-all")
select_all_item.connect("activate", lambda w: self.update_transponder_selection(True))
tr_popup_menu.append(select_all_item)
remove_selection_item = Gtk.ImageMenuItem.new_from_stock("gtk-undo")
remove_selection_item.set_label(get_message("Remove selection"))
remove_selection_item.connect("activate", lambda w: self.update_transponder_selection(False))
tr_popup_menu.append(remove_selection_item)
tr_popup_menu.show_all()
self._sat_view.connect("row-activated", self.on_activate_satellite)
self._transponder_view.connect("row-activated", self.on_activate_transponder)
self._transponder_view.connect("button-press-event", lambda w, e: on_popup_menu(tr_popup_menu, e))
self._transponder_view.connect("select_all", lambda w: self.update_transponder_selection(True))
self._transponder_frame.set_visible(True)
self._source_box.remove(0)
self._source_box.connect("changed", self.on_update_satellites_list)
self._source_box.set_active(0)
@run_idle
def on_receive_data(self, item):
if self.is_download:
show_dialog(DialogType.ERROR, self._window, "The task is already running!")
return
self.receive_services()
@run_task
def receive_services(self):
self.is_download = True
self.update_expander()
model = self._sat_view.get_model()
appender = self.append_output()
next(appender)
start = time.time()
non_cached_sats = []
sat_names = {}
t_names = {}
t_urls = []
services = []
for r in (r for r in model if r[-1]):
if not self.is_download:
appender.send("\nCanceled\n")
return
sat, url = r[0], r[3]
trs = self._transponders.get(url, None)
if trs:
for t in filter(lambda tp: tp.url in self._selected_transponders, trs):
t_urls.append(t.url)
t_names[t.url] = t.text
else:
non_cached_sats.append(url)
sat_names[url] = sat
if non_cached_sats:
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
futures = {executor.submit(self._services_parser.get_transponders_links, u): u for u in non_cached_sats}
for future in concurrent.futures.as_completed(futures):
if not self.is_download:
appender.send("\nCanceled.\n")
self.is_download = False
return
appender.send(f"Getting transponders for: {sat_names.get(futures[future])}.\n")
for t in future.result():
t_urls.append(t.url)
t_names[t.url] = t.text
appender.send("-" * 75 + "\n")
appender.send(f"{len(t_urls)} transponders received.\n\n")
non_cached_ts = []
for tr in t_urls:
srvs = self._services.get(tr)
services.extend(srvs) if srvs else non_cached_ts.append(tr)
if non_cached_ts:
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
futures = {executor.submit(self._services_parser.get_transponder_services, u): u for u in non_cached_ts}
for future in concurrent.futures.as_completed(futures):
if not self.is_download:
appender.send("\nCanceled.\n")
self.is_download = False
return
appender.send(f"Getting services for: {t_names.get(futures[future], '')}.\n")
list(map(services.append, future.result()))
appender.send("-" * 75 + "\n")
appender.send(f"Consumed: {time.time() - start:0.0f}s, {len(services)} services received.")
try:
from app.eparser.enigma.lamedb import LameDbReader
# Used for double checking!
reader = LameDbReader(path=None)
srvs = reader.get_services_list("".join(reader.get_services_lines(services)))
except ValueError as e:
log(f"ServicesUpdateDialog [on receive data] error: {e}")
else:
self._callback(srvs)
self.is_download = False
@run_task
def get_sat_list(self, src, callback):
sat_src = SatelliteSource.LYNGSAT
if src == 1:
sat_src = SatelliteSource.KINGOFSAT
self._services_parser.source = sat_src
sats = self._parser.get_satellites_list(sat_src)
if sats:
callback(sats)
self.is_download = False
def on_satellite_toggled(self, toggle, path):
model = self._sat_view.get_model()
self.update_state(model, path, not toggle.get_active())
self.update_receive_button_state(self._filter_model)
url = model.get_value(model.get_iter(path), 3)
selected = toggle.get_active()
transponders = self._transponders.get(url, None)
if transponders:
for t in transponders:
self._selected_transponders.add(t.url) if selected else self._selected_transponders.discard(t.url)
def on_transponder_toggled(self, toggle, path):
model = self._transponder_view.get_model()
itr = model.get_iter(path)
active = not toggle.get_active()
url = self.update_transponder_state(itr, model, active)
s_path = self._satellite_paths.get(url, None)
if s_path:
self.update_sat_state(model, s_path, active)
def update_sat_state(self, model, path, active):
sat_model = self._sat_view.get_model()
if active:
self.update_state(sat_model, path, active)
else:
self.update_state(sat_model, path, any((r[-1] for r in model)))
self.update_receive_button_state(self._filter_model)
def update_transponder_state(self, itr, model, active):
model.set_value(itr, 2, active)
url = model.get_value(itr, 1)
self._selected_transponders.add(url) if active else self._selected_transponders.discard(url)
return url
@run_task
def on_activate_satellite(self, view, path, column):
GLib.idle_add(self._transponder_view.get_model().clear)
GLib.idle_add(self._service_view.get_model().clear)
model = view.get_model()
itr = model.get_iter(path)
url, selected = model.get_value(itr, 3), model.get_value(itr, 4)
transponders = self._transponders.get(url, None)
if transponders is None:
GLib.idle_add(view.set_sensitive, False)
transponders = self._services_parser.get_transponders_links(url)
self._transponders[url] = transponders
for t in transponders:
t_url = t.url
self._satellite_paths[t_url] = path
self._selected_transponders.add(t_url) if selected else self._selected_transponders.discard(t_url)
self.append_transponders(self._transponder_view.get_model(), transponders)
@run_idle
def append_transponders(self, model, trs_list):
model.clear()
list(map(model.append, [(t.text, t.url, t.url in self._selected_transponders) for t in trs_list]))
self._sat_view.set_sensitive(True)
@run_task
def on_activate_transponder(self, view, path, column):
url = view.get_model()[path][1]
services = self._services.get(url, None)
if services is None:
GLib.idle_add(view.set_sensitive, False)
services = self._services_parser.get_transponder_services(url)
self._services[url] = services
self.append_services(self._service_view.get_model(), services)
@run_idle
def append_services(self, model, srv_list):
model.clear()
for s in srv_list:
model.append((None, s.service, s.package, s.service_type, str(s.ssid), None))
self._transponder_view.set_sensitive(True)
def update_transponder_selection(self, select):
m = self._transponder_view.get_model()
if not len(m):
return
s_path = self._satellite_paths.get({self.update_transponder_state(r.iter, m, select) for r in m}.pop(), None)
if s_path:
self.update_sat_state(m, s_path, select)
if __name__ == "__main__":
pass

File diff suppressed because it is too large Load Diff

1946
app/ui/service_dialog.glade Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -38,16 +38,16 @@ from app.eparser.ecommons import (MODULATION, Inversion, ROLL_OFF, Pilot, Flag,
from app.eparser.neutrino import get_attributes, SP, KSP
from app.settings import SettingsType
from .dialogs import show_dialog, DialogType, Action, get_builder
from .main_helper import get_base_model
from .main_helper import get_base_model, scroll_to
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, HIDE_ICON, CODED_ICON, Column
_UI_PATH = UI_RESOURCES_PATH + "service_details_dialog.glade"
_UI_PATH = f"{UI_RESOURCES_PATH}service_dialog.glade"
class ServiceDetailsDialog:
_ENIGMA2_DATA_ID = "{:04x}:{:08x}:{:04x}:{:04x}:{}:{}"
_ENIGMA2_FAV_ID = "{:X}:{:X}:{:X}:{:X}"
_ENIGMA2_FAV_ID = "1:0:{:X}:{:X}:{:X}:{:X}:{:X}:0:0:0"
_ENIGMA2_TRANSPONDER_DATA = "{} {}:{}:{}:{}:{}:{}:{}"
@@ -62,10 +62,9 @@ class ServiceDetailsDialog:
_DIGIT_ENTRY_NAME = "digit-entry"
def __init__(self, transient, settings, srv_view, fav_view, services, bouquets, new_color, action=Action.EDIT):
def __init__(self, app, action=Action.EDIT, tr_type=TrType.Satellite):
handlers = {"on_system_changed": self.on_system_changed,
"on_save": self.on_save,
"on_create_new": self.on_create_new,
"on_tr_edit_toggled": self.on_tr_edit_toggled,
"update_reference": self.update_reference,
"on_cas_entry_changed": self.on_cas_entry_changed,
@@ -76,32 +75,32 @@ class ServiceDetailsDialog:
builder = get_builder(_UI_PATH, handlers, use_str=True)
self._builder = builder
settings = app.app_settings
self._dialog = builder.get_object("service_details_dialog")
self._dialog.set_transient_for(transient)
self._dialog.set_transient_for(app.app_window)
self._s_type = settings.setting_type
self._tr_type = TrType.Satellite
self._satellites_xml_path = settings.profile_data_path + "satellites.xml"
self._tr_type = tr_type
self._picons_path = settings.profile_picons_path
self._services_view = srv_view
self._fav_view = fav_view
self._services_view = app.services_view
self._fav_view = app.fav_view
self._action = action
self._old_service = None
self._services = services
self._bouquets = bouquets
self._new_color = new_color
self._services = app.current_services
self._bouquets = app.current_bouquets
self._new_color = app._NEW_COLOR
self._transponder_services_iters = None
self._current_model = None
self._current_itr = None
# Patterns
# Patterns.
self._DIGIT_PATTERN = re.compile("\\D")
self._NON_EMPTY_PATTERN = re.compile("(?:^[\\s]*$|\\D)")
self._CAID_PATTERN = re.compile("(?:^[\\s]*$)|(C:[0-9a-fA-F]{1,4})(,C:[0-9a-fA-F]{1,4})*")
self._PIDS_PATTERN = re.compile("(?:^[\\s]*$)|(c:[0-9]{2}[0-9a-fA-F]{4})(,c:[0-9]{2}[0-9a-fA-F]{4})*")
# Buttons
self._PIDS_PATTERN = re.compile("(?:^[\\s]*$)|(c:[0-9]{2}[0-9a-fA-F]{1,4})(,c:[0-9]{2}[0-9a-fA-F]{1,4})*?")
# Buttons.
self._apply_button = builder.get_object("apply_button")
self._create_button = builder.get_object("create_button")
# style
# Style.
self._style_provider = Gtk.CssProvider()
self._style_provider.load_from_path(UI_RESOURCES_PATH + "style.css")
# initialization only digit elements
@@ -140,7 +139,7 @@ class ServiceDetailsDialog:
self._srv_type_entry = self._non_empty_elements.get("srv_type_entry")
self._service_type_combo_box = builder.get_object("service_type_combo_box")
self._cas_entry = builder.get_object("cas_entry")
self._reference_entry = builder.get_object("reference_entry")
self._reference_label = builder.get_object("reference_label")
self._keep_check_button = builder.get_object("keep_check_button")
self._hide_check_button = builder.get_object("hide_check_button")
self._use_pids_check_button = builder.get_object("use_pids_check_button")
@@ -159,7 +158,6 @@ class ServiceDetailsDialog:
self._pilot_combo_box = builder.get_object("pilot_combo_box")
self._pls_mode_combo_box = builder.get_object("pls_mode_combo_box")
self._tr_edit_switch = builder.get_object("tr_edit_switch")
self._tr_extra_expander = builder.get_object("tr_extra_expander")
self._DVB_S2_ELEMENTS = (self._mod_combo_box, self._rolloff_combo_box, self._pilot_combo_box,
self._pls_mode_combo_box, self._pls_code_entry, self._stream_id_entry)
@@ -176,22 +174,53 @@ class ServiceDetailsDialog:
def show(self):
self._dialog.show()
@run_idle
def init_default_data_elements(self):
srv_data = [None] * 20
srv_data[Column.SRV_CAS_FLAGS] = "f:40"
srv_data[Column.SRV_SERVICE] = "New"
srv_data[Column.SRV_PACKAGE] = "New"
srv_data[Column.SRV_SSID] = "0"
srv_data[Column.SRV_PICON_ID] = "1_0_1_0_0_0_000000_0_0_0.png"
srv_data[Column.SRV_FAV_ID] = "1:0:1:0:0:0:000000:0:0:0::0:0:0:0"
if self._tr_type is TrType.Cable:
srv_data[Column.SRV_STANDARD] = "c"
srv_data[Column.SRV_FREQ] = "300"
srv_data[Column.SRV_RATE] = "6000"
srv_data[Column.SRV_SYSTEM] = "DVB-C"
srv_data[Column.SRV_POS] = "C"
srv_data[Column.SRV_DATA_ID] = "0000:00000000:0:0:1:0"
srv_data[Column.SRV_TRANSPONDER] = "t 300000000:0:0:0:0:0:0:0:0:0:0:0"
elif self._tr_type is TrType.Terrestrial:
srv_data[Column.SRV_STANDARD] = "t"
srv_data[Column.SRV_FREQ] = "420000"
srv_data[Column.SRV_RATE] = "0"
srv_data[Column.SRV_SYSTEM] = "DVB-T2"
srv_data[Column.SRV_POS] = "T"
srv_data[Column.SRV_DATA_ID] = "0000:00000000:0:0:1:0"
srv_data[Column.SRV_TRANSPONDER] = "t 420000000:0:5:5:3:2:4:4:2:1:0:0"
else:
srv_data[Column.SRV_STANDARD] = "s"
srv_data[Column.SRV_FREQ] = "10720"
srv_data[Column.SRV_RATE] = "27500"
srv_data[Column.SRV_POL] = "H"
srv_data[Column.SRV_FEC] = "Auto"
srv_data[Column.SRV_SYSTEM] = "DVB-S"
srv_data[Column.SRV_POS] = "0.0E"
srv_data[Column.SRV_DATA_ID] = "0:00000000:0:0000:1:0:0:0:0:0"
srv_data[Column.SRV_TRANSPONDER] = "s 10720000:27500000:0:1:0:0:0:0:0"
srv = Service(*srv_data)
self._old_service = srv
self._apply_button.set_visible(False)
self._create_button.set_visible(True)
self._tr_edit_switch.set_sensitive(False)
self.on_tr_edit_toggled(self._tr_edit_switch.set_active(True), True)
for elem in self._non_empty_elements.values():
elem.set_text(" ")
elem.set_text("")
self._new_check_button.set_active(True)
self._tr_extra_expander.activate()
self._service_type_combo_box.set_active(0)
self._pol_combo_box.set_active(0)
self._fec_combo_box.set_active(0)
self._sys_combo_box.set_active(0)
self._invertion_combo_box.set_active(2)
self.init_service_data(srv)
self._current_model = get_base_model(self._services_view.get_model())
def update_data_elements(self):
model, paths = self._services_view.get_selection().get_selected_rows()
@@ -217,6 +246,9 @@ class ServiceDetailsDialog:
srv = Service(*self._current_model[itr][: Column.SRV_TOOLTIP])
self._old_service = srv
self._current_itr = itr
self.init_service_data(srv)
def init_service_data(self, srv):
# Service
self._name_entry.set_text(srv.service)
self._package_entry.set_text(srv.package)
@@ -228,6 +260,15 @@ class ServiceDetailsDialog:
self.select_active_text(self._pol_combo_box, srv.pol)
self.select_active_text(self._fec_combo_box, srv.fec)
self.select_active_text(self._sys_combo_box, srv.system)
self.update_ui(srv)
if self._s_type is SettingsType.ENIGMA_2:
self.init_enigma2_service_data(srv)
self.init_enigma2_transponder_data(srv)
elif self._s_type is SettingsType.NEUTRINO_MP:
self.init_neutrino_data(srv)
self.init_neutrino_ui_elements()
def update_ui(self, srv):
if self._tr_type is TrType.Terrestrial:
self.update_ui_for_terrestrial()
elif self._tr_type is TrType.Cable:
@@ -237,13 +278,6 @@ class ServiceDetailsDialog:
else:
self.set_sat_positions(srv.pos)
if self._s_type is SettingsType.ENIGMA_2:
self.init_enigma2_service_data(srv)
self.init_enigma2_transponder_data(srv)
elif self._s_type is SettingsType.NEUTRINO_MP:
self.init_neutrino_data(srv)
self.init_neutrino_ui_elements()
# ***************** Init Enigma2 data *********************#
@run_idle
@@ -259,7 +293,7 @@ class ServiceDetailsDialog:
def init_enigma2_flags(self, flags):
f_flags = list(filter(lambda x: x.startswith("f:"), flags))
if f_flags:
value = int(f_flags[0][2:])
value = Flag.parse(f_flags[0])
self._keep_check_button.set_active(Flag.is_keep(value))
self._hide_check_button.set_active(Flag.is_hide(value))
self._use_pids_check_button.set_active(Flag.is_pids(value))
@@ -305,6 +339,7 @@ class ServiceDetailsDialog:
data = srv.data_id.split(":")
tr_data = srv.transponder.split(":")
tr_type = TrType(srv.transponder_type)
data_len = len(tr_data)
self._namespace_entry.set_text(str(int(data[1], 16)))
self._transponder_id_entry.set_text(str(int(data[2], 16)))
@@ -313,11 +348,12 @@ class ServiceDetailsDialog:
if tr_type is TrType.Satellite:
self.select_active_text(self._invertion_combo_box, Inversion(tr_data[5]).name)
if srv.system == "DVB-S2":
self.select_active_text(self._mod_combo_box, MODULATION.get(tr_data[8]))
self.select_active_text(self._rolloff_combo_box, ROLL_OFF.get(tr_data[9]))
self.select_active_text(self._pilot_combo_box, Pilot(tr_data[10]).name)
self._tr_flag_entry.set_text(tr_data[7])
if len(tr_data) > 12:
if data_len > 9:
self.select_active_text(self._mod_combo_box, MODULATION.get(tr_data[8]))
self.select_active_text(self._rolloff_combo_box, ROLL_OFF.get(tr_data[9]))
self.select_active_text(self._pilot_combo_box, Pilot(tr_data[10]).name)
self._tr_flag_entry.set_text(tr_data[6])
if data_len > 12:
self._stream_id_entry.set_text(tr_data[11])
self._pls_code_entry.set_text(tr_data[12])
self.select_active_text(self._pls_mode_combo_box, PLS_MODE.get(tr_data[13]))
@@ -366,8 +402,7 @@ class ServiceDetailsDialog:
tr_grid = self._builder.get_object("tr_grid")
tr_grid.remove_column(7)
tr_grid.set_margin_bottom(5)
self._builder.get_object("tr_extra_expander").set_visible(False)
self._builder.get_object("srv_separator").set_visible(False)
self._builder.get_object("extra_transponder_grid").set_visible(False)
self._package_entry.set_sensitive(False)
# ***************** Init Sat positions *********************#
@@ -376,6 +411,12 @@ class ServiceDetailsDialog:
""" Sat positions initialisation """
self._sat_pos_button.set_value(float(sat_pos[:-1]))
self._pos_side_box.set_active_id(sat_pos[-1:])
self._sat_pos_button.connect("value-changed", self.on_sat_value_changed)
def on_sat_value_changed(self, button):
pos = int(self.get_sat_position())
namespace = int(f"{3600 - abs(pos) if pos < 0 else pos:04x}0000", 16)
self._namespace_entry.set_text(str(namespace))
def on_system_changed(self, box):
if not self._tr_edit_switch.get_active():
@@ -404,11 +445,8 @@ class ServiceDetailsDialog:
def on_save(self, item):
self.save_data()
def on_create_new(self, item):
self.save_data()
def save_data(self):
if self._s_type is SettingsType.NEUTRINO_MP and self._tr_type is not TrType.Satellite:
if self._s_type is SettingsType.NEUTRINO_MP:
show_dialog(DialogType.ERROR, transient=self._dialog, text="Not implemented yet!")
return
@@ -424,12 +462,25 @@ class ServiceDetailsDialog:
def on_new(self):
""" Create new service. """
service = self.get_service(*self.get_srv_data(), self.get_satellite_transponder_data())
show_dialog(DialogType.ERROR, transient=self._dialog, text="Not implemented yet!")
srv_data = self.update_service_data()
if not srv_data:
return False
service, data = srv_data
itr = self._current_model.append(service + (None, data.get(Column.SRV_BACKGROUND, None)))
scroll_to(self._current_model.get_path(itr), self._services_view)
return True
def on_edit(self):
""" Edit current service. """
service, extra_data = self.update_service_data()
self._current_model.set(self._current_itr, extra_data)
self._current_model.set(self._current_itr, {i: v for i, v in enumerate(service)})
self.update_fav_view(self._old_service, service)
return True
def update_service_data(self):
fav_id, data_id = self.get_srv_data()
# Transponder
transponder = self._old_service.transponder
@@ -444,8 +495,9 @@ class ServiceDetailsDialog:
elif self._tr_type is TrType.ATSC:
transponder = self.get_atsc_transponder_data()
except Exception as e:
log("Edit service error: {}".format(e))
log(f"Edit service error: {e}")
show_dialog(DialogType.ERROR, transient=self._dialog, text="Error getting transponder parameters!")
return False
else:
if self._transponder_services_iters:
self.update_transponder_services(transponder, self.get_sat_position())
@@ -468,14 +520,12 @@ class ServiceDetailsDialog:
extra_data = {Column.SRV_TOOLTIP: None, Column.SRV_BACKGROUND: None}
if self._s_type is SettingsType.ENIGMA_2 and flags:
f_flags = list(filter(lambda x: x.startswith("f:"), flags.split(",")))
if f_flags and Flag.is_new(int(f_flags[0][2:])):
if f_flags and Flag.is_new(Flag.parse(f_flags[0])):
extra_data[Column.SRV_BACKGROUND] = self._new_color
self._current_model.set(self._current_itr, extra_data)
self._current_model.set(self._current_itr, {i: v for i, v in enumerate(service)})
self.update_fav_view(self._old_service, service)
self._old_service = service
return True
return service, extra_data
def update_bouquets(self, fav_id, old_fav_id):
self._services.pop(old_fav_id, None)
@@ -527,7 +577,7 @@ class ServiceDetailsDialog:
package=self._package_entry.get_text(),
service_type=SERVICE_TYPE.get(self._srv_type_entry.get_text(), SERVICE_TYPE["3"]),
picon=self._old_service.picon,
picon_id=self._reference_entry.get_text().replace(":", "_") + ".png",
picon_id=self._reference_label.get_text().replace(":", "_") + ".png",
ssid="{:04x}".format(int(self._sid_entry.get_text())),
freq=freq,
rate=rate,
@@ -596,7 +646,7 @@ class ServiceDetailsDialog:
if self._s_type is SettingsType.ENIGMA_2:
namespace = int(self._namespace_entry.get_text())
data_id = self._ENIGMA2_DATA_ID.format(ssid, namespace, tr_id, net_id, service_type, 0)
fav_id = self._ENIGMA2_FAV_ID.format(ssid, tr_id, net_id, namespace)
fav_id = f"{self._reference_label.get_text()}:"
return fav_id, data_id
elif self._s_type is SettingsType.NEUTRINO_MP:
data = get_attributes(self._old_service.data_id)
@@ -618,7 +668,7 @@ class ServiceDetailsDialog:
freq = self._freq_entry.get_text()
rate = self._rate_entry.get_text()
pol = self._pol_combo_box.get_active_id()
pos = "{}{}".format(round(self._sat_pos_button.get_value(), 1), self._pos_side_box.get_active_id())
pos = f"{round(self._sat_pos_button.get_value(), 1)}{self._pos_side_box.get_active_id()}"
return freq, rate, pol, fec, system, pos
elif self._tr_type in (TrType.Terrestrial, TrType.ATSC):
return freq, o_srv.rate, o_srv.pol, fec, system, o_srv.pos
@@ -627,30 +677,30 @@ class ServiceDetailsDialog:
def get_satellite_transponder_data(self):
sys = self._sys_combo_box.get_active_id()
freq = "{}000".format(self._freq_entry.get_text())
rate = "{}000".format(self._rate_entry.get_text())
freq = f"{self._freq_entry.get_text()}000"
rate = f"{self._rate_entry.get_text()}000"
pol = self.get_value_from_combobox_id(self._pol_combo_box, POLARIZATION)
fec = self.get_value_from_combobox_id(self._fec_combo_box, FEC_DEFAULT)
sat_pos = self.get_sat_position()
inv = get_value_by_name(Inversion, self._invertion_combo_box.get_active_id())
srv_sys = "0" # !!!
flag = self._tr_flag_entry.get_text() or "0"
if self._s_type is SettingsType.ENIGMA_2:
dvb_s_tr = self._ENIGMA2_TRANSPONDER_DATA.format("s", freq, rate, pol, fec, sat_pos, inv, srv_sys)
dvb_s_tr = self._ENIGMA2_TRANSPONDER_DATA.format("s", freq, rate, pol, fec, sat_pos, inv, flag)
if sys == "DVB-S":
return dvb_s_tr
if sys == "DVB-S2":
flag = self._tr_flag_entry.get_text()
mod = self.get_value_from_combobox_id(self._mod_combo_box, MODULATION)
roll_off = self.get_value_from_combobox_id(self._rolloff_combo_box, ROLL_OFF)
pilot = get_value_by_name(Pilot, self._pilot_combo_box.get_active_id())
pls_mode = self.get_value_from_combobox_id(self._pls_mode_combo_box, PLS_MODE)
pls_code = self._pls_code_entry.get_text()
st_id = self._stream_id_entry.get_text()
pls = ":{}:{}:{}".format(st_id, pls_code, pls_mode) if pls_mode and pls_code and st_id else ""
pls = f":{st_id}:{pls_code}:{pls_mode}" if pls_mode and pls_code and st_id else ""
return f"{dvb_s_tr}:1:{mod}:{roll_off}:{pilot}{pls}"
return "{}:{}:{}:{}:{}{}".format(dvb_s_tr, flag, mod, roll_off, pilot, pls)
elif self._s_type is SettingsType.NEUTRINO_MP:
tr_data = get_attributes(self._old_service.transponder)
tr_data["frq"] = freq
@@ -661,7 +711,7 @@ class ServiceDetailsDialog:
tr_data["id"] = "{:04x}".format(int(self._transponder_id_entry.get_text()))
tr_data["inv"] = inv
return SP.join("{}{}{}".format(k, KSP, v) for k, v in tr_data.items())
return SP.join(f"{k}{KSP}{v}" for k, v in tr_data.items())
def get_sat_position(self):
sat_pos = self._sat_pos_button.get_value() * (-1 if self._pos_side_box.get_active_id() == "W" else 1)
@@ -669,11 +719,11 @@ class ServiceDetailsDialog:
return sat_pos
def get_terrestrial_transponder_data(self):
tr_data = re.split("\s|:", self._old_service.transponder)
tr_data = re.split(r"\s|:", self._old_service.transponder)
# frequency, bandwidth, code rate HP, code rate LP, modulation, transmission mode, guard interval, hierarchy,
# inversion, system, plp_id
# Bandwidth -> Pol, Rate HP -> FEC, TransmissionMode -> Roll off, GuardInterval -> Pilot, Hierarchy -> Pls Mode
tr_data[1] = "{}000".format(self._freq_entry.get_text())
tr_data[1] = f"{self._freq_entry.get_text()}000"
tr_data[2] = self.get_value_from_combobox_id(self._pol_combo_box, BANDWIDTH)
tr_data[3] = self.get_value_from_combobox_id(self._fec_combo_box, T_FEC)
tr_data[4] = self.get_value_from_combobox_id(self._rate_lp_combo_box, T_FEC)
@@ -684,28 +734,28 @@ class ServiceDetailsDialog:
tr_data[9] = get_value_by_name(Inversion, self._invertion_combo_box.get_active_id())
tr_data[10] = self.get_value_from_combobox_id(self._sys_combo_box, T_SYSTEM)
return "{} {}".format(tr_data[0], ":".join(tr_data[1:]))
return f"{tr_data[0]} {':'.join(tr_data[1:])}"
def get_cable_transponder_data(self):
tr_data = re.split("\s|:", self._old_service.transponder)
tr_data = re.split(r"\s|:", self._old_service.transponder)
# frequency, symbol_rate, modulation, inversion, fec_inner, system;
tr_data[1] = "{}000".format(self._freq_entry.get_text())
tr_data[2] = "{}000".format(self._rate_entry.get_text())
tr_data[1] = f"{self._freq_entry.get_text()}000"
tr_data[2] = f"{self._rate_entry.get_text()}000"
tr_data[3] = get_value_by_name(Inversion, self._invertion_combo_box.get_active_id())
tr_data[4] = self.get_value_from_combobox_id(self._mod_combo_box, C_MODULATION)
tr_data[5] = self.get_value_from_combobox_id(self._fec_combo_box, FEC_DEFAULT)
tr_data[6] = get_value_by_name(SystemCable, self._sys_combo_box.get_active_id())
return "{} {}".format(tr_data[0], ":".join(tr_data[1:]))
return f"{tr_data[0]} {':'.join(tr_data[1:])}"
def get_atsc_transponder_data(self):
tr_data = re.split("\s|:", self._old_service.transponder)
tr_data = re.split(r"\s|:", self._old_service.transponder)
# frequency, inversion, modulation, system
tr_data[1] = "{}000".format(self._freq_entry.get_text())
tr_data[1] = f"{self._freq_entry.get_text()}000"
tr_data[2] = get_value_by_name(Inversion, self._invertion_combo_box.get_active_id())
tr_data[3] = self.get_value_from_combobox_id(self._mod_combo_box, A_MODULATION)
return "{} {}".format(tr_data[0], ":".join(tr_data[1:]))
return f"{tr_data[0]} {':'.join(tr_data[1:])}"
def update_transponder_services(self, transponder, sat_pos):
for itr in self._transponder_services_iters:
@@ -717,13 +767,13 @@ class ServiceDetailsDialog:
fav_id = srv[Column.SRV_FAV_ID]
old_srv = self._services.pop(fav_id, None)
if not old_srv:
log("Update transponder services error: No service found for ID {}".format(srv[Column.SRV_FAV_ID]))
log(f"Update transponder services error: No service found for ID {srv[Column.SRV_FAV_ID]}")
continue
if self._s_type is SettingsType.NEUTRINO_MP:
flags = get_attributes(srv[Column.SRV_CAS_FLAGS])
flags["position"] = sat_pos
srv[Column.SRV_CAS_FLAGS] = SP.join("{}{}{}".format(k, KSP, v) for k, v in flags.items())
srv[Column.SRV_CAS_FLAGS] = SP.join(f"{k}{KSP}{v}" for k, v in flags.items())
self._services[fav_id] = Service(*srv[:Column.SRV_TOOLTIP])
self._current_model.set_row(itr, srv)
@@ -797,10 +847,9 @@ class ServiceDetailsDialog:
nid = int(self._network_id_entry.get_text())
if self._s_type is SettingsType.ENIGMA_2:
on_id = int(self._namespace_entry.get_text())
ref = "1:0:{:X}:{:X}:{:X}:{:X}:{:X}:0:0:0".format(srv_type, ssid, tid, nid, on_id)
self._reference_entry.set_text(ref)
self._reference_label.set_text(self._ENIGMA2_FAV_ID.format(srv_type, ssid, tid, nid, on_id))
else:
self._reference_entry.set_text("{:x}{:04x}{:04x}".format(tid, nid, ssid))
self._reference_label.set_text(f"{tid:x}{nid:04x}{ssid:04x}")
def update_ui_for_terrestrial(self):
tr_grid = self.get_transponder_grid_for_non_satellite()
@@ -891,7 +940,6 @@ class ServiceDetailsDialog:
# FEC
fec_model.append(("None",))
# Extra
tr_box.remove(self._tr_extra_expander)
tr_grid.set_margin_bottom(5)
self._freq_entry.set_width_chars(10)
self._freq_entry.set_max_width_chars(10)

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2021 Dmitriy Yefremov
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
@@ -28,17 +28,14 @@
import os
import re
from collections import Counter
from app.commons import run_task, run_idle, log
from app.connections import test_telnet, test_ftp, TestException, test_http, HttpApiException
from app.settings import SettingsType, Settings, PlayStreamsMode, IS_LINUX, SEP, IS_WIN
from app.ui.dialogs import show_dialog, DialogType, get_message, get_chooser_dialog, get_builder
from .main_helper import update_entry_data, scroll_to, get_picon_pixbuf
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, FavClickMode, DEFAULT_ICON, APP_FONT
def show_settings_dialog(transient, options):
return SettingsDialog(transient, options).show()
from app.settings import SettingsType, Settings, PlayStreamsMode, PlaybackMode, IS_LINUX, SEP, IS_WIN
from app.ui.dialogs import show_dialog, DialogType, translate, get_chooser_dialog, get_builder
from .main_helper import update_entry_data, scroll_to, get_picon_pixbuf, show_info_bar_message
from .uicommons import Gtk, Gdk, UI_RESOURCES_PATH, DEFAULT_ICON, APP_FONT, HeaderBar
class SettingsDialog:
@@ -46,18 +43,16 @@ class SettingsDialog:
_DIGIT_PATTERN = re.compile("(?:^[\\s]*$|\\D)")
def __init__(self, transient, settings: Settings):
handlers = {"on_field_icon_press": self.on_field_icon_press,
handlers = {"on_field_button_press": self.on_field_button_press,
"on_settings_type_changed": self.on_settings_type_changed,
"on_reset": self.on_reset,
"on_response": self.on_response,
"apply_settings": self.apply_settings,
"on_connection_test": self.on_connection_test,
"on_info_bar_close": self.on_info_bar_close,
"on_set_color_switch": self.on_set_color_switch,
"on_force_bq_name": self.on_force_bq_name,
"on_http_mode_switch": self.on_http_mode_switch,
"on_experimental_switch": self.on_experimental_switch,
"on_yt_dl_switch": self.on_yt_dl_switch,
"on_default_path_mode_switch": self.on_default_path_mode_switch,
"on_profile_add": self.on_profile_add,
"on_profile_edit": self.on_profile_edit,
@@ -67,6 +62,10 @@ class SettingsDialog:
"on_profile_edited": self.on_profile_edited,
"on_profile_selected": self.on_profile_selected,
"on_profile_set_default": self.on_profile_set_default,
"on_host_focus_in": self.on_host_focus_in,
"on_host_focus_out": self.on_host_focus_out,
"on_add_host": self.on_add_host,
"on_remove_host": self.on_remove_host,
"on_add_picon_path": self.on_add_picon_path,
"on_remove_picon_path": self.on_remove_picon_path,
"on_lang_changed": self.on_lang_changed,
@@ -86,11 +85,12 @@ class SettingsDialog:
"on_icon_theme_add": self.on_icon_theme_add,
"on_icon_theme_remove": self.on_icon_theme_remove}
# Settings
# Settings.
self._ext_settings = settings
self._settings = Settings(settings.settings)
self._profiles = self._settings.profiles
self._s_type = self._settings.setting_type
self._updated = False
builder = get_builder(UI_RESOURCES_PATH + "settings_dialog.glade", handlers)
@@ -100,7 +100,10 @@ class SettingsDialog:
self._dialog.set_margin_left(0)
self._main_stack = builder.get_object("main_stack")
# Network.
self._host_iter = None
self._host_field = builder.get_object("host_field")
self._hosts_box = builder.get_object("hosts_box")
self._remove_host_button = builder.get_object("remove_host_button")
self._port_field = builder.get_object("port_field")
self._login_field = builder.get_object("login_field")
self._password_field = builder.get_object("password_field")
@@ -116,26 +119,25 @@ class SettingsDialog:
self._services_field = builder.get_object("services_field")
self._user_bouquet_field = builder.get_object("user_bouquet_field")
self._satellites_xml_field = builder.get_object("satellites_xml_field")
self._epg_dat_box = builder.get_object("epg_dat_box")
self._picons_paths_box = builder.get_object("picons_paths_box")
self._remove_picon_path_button = builder.get_object("remove_picon_path_button")
# Paths.
self._picons_path_field = builder.get_object("picons_path_field")
self._data_path_field = builder.get_object("data_path_field")
self._backup_path_field = builder.get_object("backup_path_field")
self._record_data_path_field = builder.get_object("record_data_path_field")
self._recordings_path_field = builder.get_object("recordings_path_field")
self._default_data_paths_switch = builder.get_object("default_data_paths_switch")
self._default_data_paths_switch.bind_property("active", self._backup_path_field, "sensitive", 4)
self._default_data_paths_switch.bind_property("active", self._picons_path_field, "sensitive", 4)
self._use_common_picon_path_switch = builder.get_object("use_common_picon_path_switch")
# Info bar.
self._info_bar = builder.get_object("info_bar")
self._message_label = builder.get_object("info_bar_message_label")
self._test_spinner = builder.get_object("test_spinner")
# Settings type.
self._settings_type_box = builder.get_object("settings_type_combo_box")
self._enigma_radio_button = builder.get_object("enigma_radio_button")
self._neutrino_radio_button = builder.get_object("neutrino_radio_button")
self._support_ver5_switch = builder.get_object("support_ver5_switch")
self._force_bq_name_switch = builder.get_object("force_bq_name_switch")
# Streaming
# Streaming.
self._apply_presets_button = builder.get_object("apply_presets_button")
self._transcoding_switch = builder.get_object("transcoding_switch")
self._edit_preset_switch = builder.get_object("edit_preset_switch")
@@ -149,14 +151,12 @@ class SettingsDialog:
self._audio_codec_combo_box = builder.get_object("audio_codec_combo_box")
self._transcoding_switch.bind_property("active", builder.get_object("record_box"), "sensitive")
self._edit_preset_switch.bind_property("active", self._apply_presets_button, "sensitive")
self._edit_preset_switch.bind_property("active", builder.get_object("video_options_frame"), "sensitive")
self._edit_preset_switch.bind_property("active", builder.get_object("audio_options_frame"), "sensitive")
self._play_in_built_radio_button = builder.get_object("play_in_built_radio_button")
self._play_in_window_radio_button = builder.get_object("play_in_window_radio_button")
self._get_m3u_radio_button = builder.get_object("get_m3u_radio_button")
self._gst_lib_button = builder.get_object("gst_lib_button")
self._vlc_lib_button = builder.get_object("vlc_lib_button")
self._mpv_lib_button = builder.get_object("mpv_lib_button")
self._edit_preset_switch.bind_property("active", builder.get_object("video_options_grid"), "sensitive")
self._edit_preset_switch.bind_property("active", builder.get_object("audio_options_grid"), "sensitive")
self._play_streams_combo_box = builder.get_object("play_streams_combo_box")
self._stream_lib_combo_box = builder.get_object("stream_lib_combo_box")
self._double_click_combo_box = builder.get_object("double_click_combo_box")
self._allow_main_list_playback_switch = builder.get_object("allow_main_list_playback_switch")
# Program.
self._before_save_switch = builder.get_object("before_save_switch")
self._before_downloading_switch = builder.get_object("before_downloading_switch")
@@ -173,71 +173,69 @@ class SettingsDialog:
self._new_color_button = builder.get_object("new_color_button")
self._extra_color_button = builder.get_object("extra_color_button")
# Extra.
self._use_http_switch = builder.get_object("use_http_switch")
self._remove_unused_bq_switch = builder.get_object("remove_unused_bq_switch")
self._keep_power_mode_switch = builder.get_object("keep_power_mode_switch")
self._compress_picons_switch = builder.get_object("compress_picons_switch")
self._force_bq_name_switch = builder.get_object("force_bq_name_switch")
self._support_ver5_switch = builder.get_object("support_ver5_switch")
self._unlimited_buffer_switch = builder.get_object("unlimited_buffer_switch")
self._enable_extensions_switch = builder.get_object("enable_extensions_switch")
self._support_http_api_switch = builder.get_object("support_http_api_switch")
self._enable_yt_dl_switch = builder.get_object("enable_yt_dl_switch")
self._enable_update_yt_dl_switch = builder.get_object("enable_update_yt_dl_switch")
self._enable_send_to_switch = builder.get_object("enable_send_to_switch")
self._click_mode_disabled_button = builder.get_object("click_mode_disabled_button")
self._click_mode_stream_button = builder.get_object("click_mode_stream_button")
self._click_mode_play_button = builder.get_object("click_mode_play_button")
self._click_mode_zap_button = builder.get_object("click_mode_zap_button")
self._click_mode_zap_and_play_button = builder.get_object("click_mode_zap_and_play_button")
self._click_mode_zap_button.bind_property("sensitive", self._click_mode_play_button, "sensitive")
self._click_mode_zap_button.bind_property("sensitive", self._click_mode_zap_and_play_button, "sensitive")
# EXPERIMENTAL.
self._enable_epg_name_cache_switch = builder.get_object("enable_epg_name_cache_switch")
self._enable_exp_switch = builder.get_object("enable_experimental_switch")
self._enable_exp_switch.bind_property("active", builder.get_object("yt_dl_box"), "sensitive")
self._enable_yt_dl_switch.bind_property("active", builder.get_object("yt_dl_update_box"), "sensitive")
self._enable_exp_switch.bind_property("active", builder.get_object("v5_support_box"), "sensitive")
self._enable_exp_switch.bind_property("active", builder.get_object("enable_direct_playback_box"), "sensitive")
# Enigma2 only.
self._enigma_radio_button.bind_property("active", builder.get_object("bq_naming_grid"), "sensitive")
self._enigma_radio_button.bind_property("active", builder.get_object("enable_http_box"), "sensitive")
self._enigma_radio_button.bind_property("active", builder.get_object("enable_experimental_box"), "sensitive")
self._enigma_radio_button.bind_property("active", builder.get_object("program_frame"), "sensitive")
self._enigma_radio_button.bind_property("active", builder.get_object("experimental_box"), "sensitive")
# Profiles.
self._profile_view = builder.get_object("profile_tree_view")
self._profile_add_button = builder.get_object("profile_add_button")
self._profile_remove_button = builder.get_object("profile_remove_button")
# Style.
self._style_provider = Gtk.CssProvider()
self._style_provider.load_from_path(UI_RESOURCES_PATH + "style.css")
style_provider = Gtk.CssProvider()
style_provider.load_from_path(f"{UI_RESOURCES_PATH}style.css")
screen = Gdk.Screen.get_default()
self._digit_elems = (self._port_field, self._http_port_field, self._telnet_port_field, self._video_width_field,
self._video_bitrate_field, self._video_height_field, self._audio_bitrate_field)
for el in self._digit_elems:
el.get_style_context().add_provider_for_screen(Gdk.Screen.get_default(), self._style_provider,
Gtk.STYLE_PROVIDER_PRIORITY_USER)
[self.init_element_style(el, screen, style_provider) for el in self._digit_elems]
self.init_element_style(self._host_field, screen, style_provider)
if self._settings.use_header_bar:
switcher = builder.get_object("main_stack_switcher")
switcher.set_margin_top(0)
switcher.set_margin_bottom(0)
builder.get_object("main_box").remove(switcher)
header_bar = HeaderBar()
header_bar.set_custom_title(switcher)
self._dialog.set_titlebar(header_bar)
self.init_ui_elements()
self.init_profiles()
if not IS_LINUX:
# Themes.
builder.get_object("style_frame").set_visible(IS_WIN)
builder.get_object("themes_support_frame").set_visible(True)
self._layout_switch = builder.get_object("layout_switch")
self._layout_switch.set_active(self._ext_settings.alternate_layout)
self._theme_frame = builder.get_object("theme_frame")
self._theme_frame.set_visible(True)
builder.get_object("dark_mode_box").set_visible(IS_WIN)
builder.get_object("style_box_view").set_visible(True)
self._theme_view = builder.get_object("theme_view")
self._theme_view.set_visible(True)
self._theme_thumbnail_image = builder.get_object("theme_thumbnail_image")
self._theme_combo_box = builder.get_object("theme_combo_box")
self._icon_theme_combo_box = builder.get_object("icon_theme_combo_box")
self._dark_mode_switch = builder.get_object("dark_mode_switch")
self._dark_mode_switch.set_active(self._ext_settings.dark_mode)
self._themes_support_switch = builder.get_object("themes_support_switch")
self._themes_support_switch.bind_property("active", self._theme_frame, "sensitive")
self._themes_support_switch.bind_property("active", self._theme_view, "sensitive")
self.init_themes()
def init_ui_elements(self):
is_enigma_profile = self._s_type is SettingsType.ENIGMA_2
self._neutrino_radio_button.set_active(self._s_type is SettingsType.NEUTRINO_MP)
self.update_picon_paths()
self.update_title()
http_active = self._support_http_api_switch.get_active()
self._click_mode_zap_button.set_sensitive(is_enigma_profile and http_active)
self._dialog.set_title(f"{translate('Options')} [{self._settings_type_box.get_active_text()}]")
self._lang_combo_box.set_active_id(self._ext_settings.language)
self.on_info_bar_close() if is_enigma_profile else self.show_info_message(
is_enigma = self._s_type is SettingsType.ENIGMA_2
self.on_info_bar_close() if is_enigma else self.show_info_message(
"The Neutrino has only experimental support. Not all features are supported!", Gtk.MessageType.WARNING)
self._epg_dat_box.set_sensitive(is_enigma)
def init_profiles(self):
p_def = self._settings.default_profile
@@ -250,12 +248,8 @@ class SettingsDialog:
self.on_profile_selected(self._profile_view, False)
self._profile_remove_button.set_sensitive(len(self._profile_view.get_model()) > 1)
def update_title(self):
title = "{} [{}]"
if self._s_type is SettingsType.ENIGMA_2:
self._dialog.set_title(title.format(get_message("Options"), self._enigma_radio_button.get_label()))
elif self._s_type is SettingsType.NEUTRINO_MP:
self._dialog.set_title(title.format(get_message("Options"), self._neutrino_radio_button.get_label()))
def init_element_style(self, elem, screen, provider):
elem.get_style_context().add_provider_for_screen(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
def update_picon_paths(self):
model = self._picons_paths_box.get_model()
@@ -267,20 +261,27 @@ class SettingsDialog:
self._picons_paths_box.set_active(0)
def show(self):
self._dialog.run()
return self._dialog.run()
def is_updated(self):
return self._updated
def on_response(self, dialog, resp):
if resp == Gtk.ResponseType.OK and not self.apply_settings():
return
if resp == Gtk.ResponseType.ACCEPT:
self._updated = self.on_save_settings()
if not self._updated:
return True
self._dialog.destroy()
return resp
if resp == Gtk.ResponseType.DELETE_EVENT or resp == Gtk.ResponseType.ACCEPT:
dialog.destroy()
def on_field_icon_press(self, entry, icon, event_button):
return False
def on_field_button_press(self, entry):
update_entry_data(entry, self._dialog, self._settings)
def on_settings_type_changed(self, item):
s_type = SettingsType.ENIGMA_2 if self._enigma_radio_button.get_active() else SettingsType.NEUTRINO_MP
s_type = SettingsType(int(self._settings_type_box.get_active_id()))
if s_type is not self._s_type:
self._settings.setting_type = s_type
self._s_type = s_type
@@ -293,46 +294,58 @@ class SettingsDialog:
def set_settings(self):
self._s_type = self._settings.setting_type
self._host_field.set_text(self._settings.host)
self._port_field.set_text(self._settings.port)
self._hosts_box.remove_all()
self._remove_host_button.set_sensitive(len([self._hosts_box.append(h, h) for h in self._settings.hosts]) > 1)
self._hosts_box.set_active_id(self._settings.host)
self._port_field.set_text(str(self._settings.port))
self._login_field.set_text(self._settings.user)
self._password_field.set_text(self._settings.password)
self._http_port_field.set_text(self._settings.http_port)
self._http_port_field.set_text(str(self._settings.http_port))
self._http_use_ssl_check_button.set_active(self._settings.http_use_ssl)
self._telnet_port_field.set_text(self._settings.telnet_port)
self._telnet_port_field.set_text(str(self._settings.telnet_port))
self._telnet_timeout_spin_button.set_value(self._settings.telnet_timeout)
self._services_field.set_text(self._settings.services_path)
self._user_bouquet_field.set_text(self._settings.user_bouquet_path)
self._satellites_xml_field.set_text(self._settings.satellites_xml_path)
self._epg_dat_box.set_active_id(self._settings.epg_dat_path)
self._picons_paths_box.set_active_id(self._settings.picons_path)
self._data_path_field.set_text(self._settings.default_data_path)
self._picons_path_field.set_text(self._settings.default_picon_path)
self._backup_path_field.set_text(self._settings.default_backup_path)
self._record_data_path_field.set_text(self._settings.records_path)
self._recordings_path_field.set_text(self._settings.recordings_path)
self._before_save_switch.set_active(self._settings.backup_before_save)
self._before_downloading_switch.set_active(self._settings.backup_before_downloading)
self.set_fav_click_mode(self._settings.fav_click_mode)
self.set_play_stream_mode(self._settings.play_streams_mode)
self.set_stream_lib(self._settings.stream_lib)
self._play_streams_combo_box.set_active_id(str(self._settings.play_streams_mode.value))
self._stream_lib_combo_box.set_active_id(self._settings.stream_lib)
self._double_click_combo_box.set_active_id(str(self._settings.fav_click_mode))
self._allow_main_list_playback_switch.set_active(self._settings.main_list_playback)
self._load_on_startup_switch.set_active(self._settings.load_last_config)
self._bouquet_hints_switch.set_active(self._settings.show_bq_hints)
self._services_hints_switch.set_active(self._settings.show_srv_hints)
self._default_data_paths_switch.set_active(self._settings.profile_folder_is_default)
self._use_common_picon_path_switch.set_active(self._settings.use_common_picon_path)
self._transcoding_switch.set_active(self._settings.activate_transcoding)
self._presets_combo_box.set_active_id(self._settings.active_preset)
self.on_transcoding_preset_changed(self._presets_combo_box)
self._picons_size_button.set_active_id(str(self._settings.list_picon_size))
self._tooltip_logo_size_button.set_active_id(str(self._settings.tooltip_logo_size))
self._list_font_button.set_font(self._settings.list_font)
self._support_http_api_switch.set_active(self._settings.http_api_support)
if self._s_type is SettingsType.ENIGMA_2:
self._enable_exp_switch.set_active(self._settings.is_enable_experimental)
self._support_ver5_switch.set_active(self._settings.v5_support)
self._unlimited_buffer_switch.set_active(self._settings.unlimited_copy_buffer)
self._enable_extensions_switch.set_active(self._settings.extensions_support)
self._use_http_switch.set_active(self._settings.use_http)
self._remove_unused_bq_switch.set_active(self._settings.remove_unused_bouquets)
self._keep_power_mode_switch.set_active(self._settings.keep_power_mode)
self._compress_picons_switch.set_active(self._settings.compress_picons)
self._force_bq_name_switch.set_active(self._settings.force_bq_names)
self._support_http_api_switch.set_active(self._settings.http_api_support)
self._enable_yt_dl_switch.set_active(self._settings.enable_yt_dl)
self._enable_update_yt_dl_switch.set_active(self._settings.enable_yt_dl_update)
self._enable_send_to_switch.set_active(self._settings.enable_send_to)
self._enable_epg_name_cache_switch.set_active(self._settings.enable_epg_name_cache)
self._set_color_switch.set_active(self._settings.use_colors)
new_rgb = Gdk.RGBA()
new_rgb.parse(self._settings.new_color)
@@ -341,60 +354,65 @@ class SettingsDialog:
self._new_color_button.set_rgba(new_rgb)
self._extra_color_button.set_rgba(extra_rgb)
if self._s_type is SettingsType.ENIGMA_2:
self._enigma_radio_button.activate()
else:
self._neutrino_radio_button.activate()
self._settings_type_box.set_active_id(str(self._s_type.value))
def on_apply_profile_settings(self, item=None):
if not self.is_data_correct(self._digit_elems):
show_dialog(DialogType.ERROR, self._dialog, "Error. Verify the data!")
return
return False
self._s_type = SettingsType.ENIGMA_2 if self._enigma_radio_button.get_active() else SettingsType.NEUTRINO_MP
self._s_type = SettingsType(int(self._settings_type_box.get_active_id()))
self._settings.setting_type = self._s_type
self._settings.host = self._host_field.get_text()
self._settings.port = self._port_field.get_text()
self._settings.hosts = [h[1] for h in self._hosts_box.get_model()]
self._settings.port = int(self._port_field.get_text())
self._settings.user = self._login_field.get_text()
self._settings.password = self._password_field.get_text()
self._settings.http_port = self._http_port_field.get_text()
self._settings.http_port = int(self._http_port_field.get_text())
self._settings.http_use_ssl = self._http_use_ssl_check_button.get_active()
self._settings.telnet_port = self._telnet_port_field.get_text()
self._settings.telnet_port = int(self._telnet_port_field.get_text())
self._settings.telnet_timeout = int(self._telnet_timeout_spin_button.get_value())
self._settings.services_path = self._services_field.get_text()
self._settings.user_bouquet_path = self._user_bouquet_field.get_text()
self._settings.satellites_xml_path = self._satellites_xml_field.get_text()
self._settings.user_bouquet_path = self._user_bouquet_field.get_text()
self._settings.epg_dat_path = self._epg_dat_box.get_active_id()
self._settings.picons_path = self._picons_paths_box.get_active_id()
def apply_settings(self, item=None):
if show_dialog(DialogType.QUESTION, self._dialog) != Gtk.ResponseType.OK:
return
return True
def on_save_settings(self, item=None):
if show_dialog(DialogType.QUESTION, self._dialog) != Gtk.ResponseType.OK:
return False
if not self.on_apply_profile_settings():
return False
self.on_apply_profile_settings()
self._ext_settings.profiles = self._settings.profiles
self._ext_settings.backup_before_save = self._before_save_switch.get_active()
self._ext_settings.backup_before_downloading = self._before_downloading_switch.get_active()
self._ext_settings.fav_click_mode = self.get_fav_click_mode()
self._ext_settings.play_streams_mode = self.get_play_stream_mode()
self._ext_settings.stream_lib = self.get_stream_lib()
self._ext_settings.play_streams_mode = PlayStreamsMode(int(self._play_streams_combo_box.get_active_id()))
self._ext_settings.stream_lib = self._stream_lib_combo_box.get_active_id()
self._ext_settings.fav_click_mode = int(self._double_click_combo_box.get_active_id())
self._ext_settings.main_list_playback = self._allow_main_list_playback_switch.get_active()
self._ext_settings.language = self._lang_combo_box.get_active_id()
self._ext_settings.load_last_config = self._load_on_startup_switch.get_active()
self._ext_settings.show_bq_hints = self._bouquet_hints_switch.get_active()
self._ext_settings.show_srv_hints = self._services_hints_switch.get_active()
self._ext_settings.profile_folder_is_default = self._default_data_paths_switch.get_active()
self._ext_settings.use_common_picon_path = self._use_common_picon_path_switch.get_active()
self._ext_settings.default_data_path = self._data_path_field.get_text()
self._ext_settings.default_backup_path = self._backup_path_field.get_text()
self._ext_settings.default_picon_path = self._picons_path_field.get_text()
self._ext_settings.records_path = self._record_data_path_field.get_text()
self._ext_settings.recordings_path = self._recordings_path_field.get_text()
self._ext_settings.activate_transcoding = self._transcoding_switch.get_active()
self._ext_settings.active_preset = self._presets_combo_box.get_active_id()
self._ext_settings.list_picon_size = int(self._picons_size_button.get_active_id())
self._ext_settings.tooltip_logo_size = int(self._tooltip_logo_size_button.get_active_id())
self._ext_settings.list_font = self._list_font_button.get_font()
self._ext_settings.http_api_support = self._support_http_api_switch.get_active()
if not IS_LINUX:
self._ext_settings.dark_mode = self._dark_mode_switch.get_active()
self._ext_settings.alternate_layout = self._layout_switch.get_active()
self._ext_settings.is_themes_support = self._themes_support_switch.get_active()
self._ext_settings.theme = self._theme_combo_box.get_active_id()
self._ext_settings.icon_theme = self._icon_theme_combo_box.get_active_id()
@@ -405,20 +423,32 @@ class SettingsDialog:
self._ext_settings.new_color = self._new_color_button.get_rgba().to_string()
self._ext_settings.extra_color = self._extra_color_button.get_rgba().to_string()
self._ext_settings.v5_support = self._support_ver5_switch.get_active()
self._ext_settings.unlimited_copy_buffer = self._unlimited_buffer_switch.get_active()
self._ext_settings.extensions_support = self._enable_extensions_switch.get_active()
self._ext_settings.use_http = self._use_http_switch.get_active()
self._ext_settings.remove_unused_bouquets = self._remove_unused_bq_switch.get_active()
self._ext_settings.keep_power_mode = self._keep_power_mode_switch.get_active()
self._ext_settings.compress_picons = self._compress_picons_switch.get_active()
self._ext_settings.force_bq_names = self._force_bq_name_switch.get_active()
self._ext_settings.http_api_support = self._support_http_api_switch.get_active()
self._ext_settings.enable_yt_dl = self._enable_yt_dl_switch.get_active()
self._ext_settings.enable_yt_dl_update = self._enable_update_yt_dl_switch.get_active()
self._ext_settings.enable_send_to = self._enable_send_to_switch.get_active()
self._ext_settings.enable_epg_name_cache = self._enable_epg_name_cache_switch.get_active()
self._ext_settings.default_profile = list(filter(lambda r: r[1], self._profile_view.get_model()))[0][0]
self._ext_settings.save()
return True
@run_task
def on_connection_test(self, item):
if self._test_spinner.get_state() is Gtk.StateType.ACTIVE:
return
if not self.is_data_correct((self._port_field, self._http_port_field, self._telnet_port_field)):
show_dialog(DialogType.ERROR, self._dialog, "Error. Verify the data!")
return
self.show_spinner(True)
if self._ftp_radio_button.get_active():
self.test_ftp()
@@ -429,7 +459,7 @@ class SettingsDialog:
def test_http(self):
user, password = self._login_field.get_text(), self._password_field.get_text()
host, port = self._host_field.get_text(), self._http_port_field.get_text()
host, port = self._host_field.get_text(), int(self._http_port_field.get_text())
use_ssl = self._http_use_ssl_check_button.get_active()
try:
self.show_info_message(test_http(host, port, user, password, use_ssl=use_ssl, s_type=self._s_type),
@@ -443,7 +473,7 @@ class SettingsDialog:
def test_telnet(self):
timeout = int(self._telnet_timeout_spin_button.get_value())
host, port = self._host_field.get_text(), self._telnet_port_field.get_text()
host, port = self._host_field.get_text(), int(self._telnet_port_field.get_text())
user, password = self._login_field.get_text(), self._password_field.get_text()
try:
self.show_info_message(test_telnet(host, port, user, password, timeout), Gtk.MessageType.INFO)
@@ -453,7 +483,7 @@ class SettingsDialog:
self.show_spinner(False)
def test_ftp(self):
host, port = self._host_field.get_text(), self._port_field.get_text()
host, port = self._host_field.get_text(), int(self._port_field.get_text())
user, password = self._login_field.get_text(), self._password_field.get_text()
try:
self.show_info_message(f"OK. {test_ftp(host, port, user, password)}", Gtk.MessageType.INFO)
@@ -464,10 +494,7 @@ class SettingsDialog:
@run_idle
def show_info_message(self, text, message_type):
self._info_bar.set_visible(False)
self._info_bar.set_message_type(message_type)
self._message_label.set_text(get_message(text))
self._info_bar.set_visible(True)
show_info_bar_message(self._info_bar, self._message_label, text, message_type)
@run_idle
def show_spinner(self, show):
@@ -481,16 +508,15 @@ class SettingsDialog:
self._colors_grid.set_sensitive(state)
def on_http_mode_switch(self, switch, state):
self._click_mode_zap_button.set_sensitive(state)
if any((self._click_mode_play_button.get_active(),
self._click_mode_zap_button.get_active(),
self._click_mode_zap_and_play_button.get_active())):
self._click_mode_disabled_button.set_active(True)
if self._main_stack.get_visible_child_name() == "program" and not state:
self.show_info_message("May affect some features availability! ", Gtk.MessageType.WARNING)
def on_experimental_switch(self, switch, state):
if not state:
self._support_ver5_switch.set_active(state)
self._unlimited_buffer_switch.set_active(state)
self._enable_send_to_switch.set_active(state)
self._enable_epg_name_cache_switch.set_active(state)
self._enable_yt_dl_switch.set_active(state)
def on_force_bq_name(self, switch, state):
@@ -503,11 +529,8 @@ class SettingsDialog:
else:
self.on_info_bar_close()
def on_yt_dl_switch(self, switch, state):
self.show_info_message("Not implemented yet!", Gtk.MessageType.WARNING)
def on_default_path_mode_switch(self, switch, state):
self._settings.profile_folder_is_default = state
self._use_common_picon_path_switch.set_active(False) if state else None
def on_profile_add(self, item):
model = self._profile_view.get_model()
@@ -515,7 +538,7 @@ class SettingsDialog:
name = "profile"
while name in self._profiles:
count += 1
name = "profile{}".format(count)
name = f"profile{count}"
self._profiles[name] = self._s_type.get_default_settings()
model.append((name, None))
@@ -578,23 +601,61 @@ class SettingsDialog:
def on_profile_inserted(self, model, path, itr):
self._profile_remove_button.set_sensitive(len(model) > 1)
def on_host_focus_in(self, entry, event):
self._host_iter = self._hosts_box.get_active_iter()
def on_host_focus_out(self, entry, event=None):
if self._host_iter:
model = self._hosts_box.get_model()
host = entry.get_text()
model.set_value(self._host_iter, 0, host)
model.set_value(self._host_iter, 1, host)
if Counter(r[0] for r in model).get(host, 0) > 1:
self._host_field.set_name(self._DIGIT_ENTRY_NAME)
self.show_info_message("The host already exists!", Gtk.MessageType.WARNING)
else:
self._host_field.set_name("GtkEntry")
self.on_info_bar_close()
def on_add_host(self, button):
model = self._hosts_box.get_model()
count = 1
host = "127.0.0.1"
hosts = {r[0] for r in model}
while host in hosts:
count += 1
host = f"127.0.0.{count}"
self._hosts_box.append(host, host)
self._hosts_box.set_active_id(host)
self._remove_host_button.set_sensitive(len(model) > 1)
def on_remove_host(self, button):
self._hosts_box.remove(self._hosts_box.get_active())
self._hosts_box.set_active(0)
self._remove_host_button.set_sensitive(len(self._hosts_box.get_model()) > 1)
def on_add_picon_path(self, button):
response = show_dialog(DialogType.INPUT, self._dialog, self._settings.picons_path)
if response is Gtk.ResponseType.CANCEL:
return
if response in self._settings.picons_paths:
sep = "/"
path = response if response.endswith(sep) else response + sep
if path in self._settings.picons_paths:
self.show_info_message("This path already exists!", Gtk.MessageType.ERROR)
return
path = response if response.endswith(SEP) else response + SEP
model = self._picons_paths_box.get_model()
model.append((path, path))
self._picons_paths_box.set_active_id(path)
self._ext_settings.picons_paths = tuple(r[0] for r in model)
def on_remove_picon_path(self, button):
msg = f"{get_message('This may change the settings of other profiles!')}\n\n\t\t{'Are you sure?'}"
msg = f"{translate('This may change the settings of other profiles!')}\n\n\t\t{translate('Are you sure?')}"
if show_dialog(DialogType.QUESTION, self._dialog, msg) != Gtk.ResponseType.OK:
return
@@ -624,78 +685,26 @@ class SettingsDialog:
self._settings.http_port = port
def on_click_mode_togged(self, button):
if self._main_stack.get_visible_child_name() != "extra":
if self._main_stack.get_visible_child_name() != "streaming":
return
mode = self.get_fav_click_mode()
if mode is FavClickMode.PLAY:
mode = PlaybackMode(int(self._double_click_combo_box.get_active_id()))
if mode is PlaybackMode.PLAY:
self.show_info_message("Operates in standby mode or current active transponder!", Gtk.MessageType.WARNING)
elif mode is PlaybackMode.STREAM:
self.show_info_message("Playback of IPTV streams only!", Gtk.MessageType.WARNING)
elif mode is PlaybackMode.DISABLED:
self._allow_main_list_playback_switch.set_active(False)
else:
self.on_info_bar_close()
@run_idle
def set_fav_click_mode(self, mode):
mode = FavClickMode(mode)
self._click_mode_disabled_button.set_active(mode is FavClickMode.DISABLED)
self._click_mode_stream_button.set_active(mode is FavClickMode.STREAM)
self._click_mode_play_button.set_active(mode is FavClickMode.PLAY)
self._click_mode_zap_button.set_active(mode is FavClickMode.ZAP)
self._click_mode_zap_and_play_button.set_active(mode is FavClickMode.ZAP_PLAY)
def get_fav_click_mode(self):
if self._click_mode_zap_button.get_active():
return FavClickMode.ZAP
if self._click_mode_play_button.get_active():
return FavClickMode.PLAY
if self._click_mode_zap_and_play_button.get_active():
return FavClickMode.ZAP_PLAY
if self._click_mode_stream_button.get_active():
return FavClickMode.STREAM
return FavClickMode.DISABLED
self._allow_main_list_playback_switch.set_sensitive(mode is not PlaybackMode.DISABLED)
def on_play_mode_changed(self, button):
if self._main_stack.get_visible_child_name() != "streaming" or not button.get_active():
if self._main_stack.get_visible_child_name() != "streaming":
return
if self._settings.is_darwin:
is_gst = self._gst_lib_button.get_active()
self._play_in_built_radio_button.set_sensitive(is_gst)
self._play_in_window_radio_button.set_active(not is_gst and self._play_in_built_radio_button.get_active())
if button.get_active():
self.show_info_message("Save and restart the program to apply the settings.", Gtk.MessageType.WARNING)
@run_idle
def set_play_stream_mode(self, mode):
self._play_in_built_radio_button.set_active(mode is PlayStreamsMode.BUILT_IN)
self._play_in_window_radio_button.set_active(mode is PlayStreamsMode.WINDOW)
self._get_m3u_radio_button.set_active(mode is PlayStreamsMode.M3U)
if self._settings.is_darwin and self._settings.stream_lib != "gst":
self._play_in_built_radio_button.set_sensitive(False)
def get_play_stream_mode(self):
if self._play_in_built_radio_button.get_active():
return PlayStreamsMode.BUILT_IN
if self._play_in_window_radio_button.get_active():
return PlayStreamsMode.WINDOW
if self._get_m3u_radio_button.get_active():
return PlayStreamsMode.M3U
return self._settings.play_streams_mode
def set_stream_lib(self, mode):
self._vlc_lib_button.set_active(mode == "vlc")
self._gst_lib_button.set_active(mode == "gst")
self._mpv_lib_button.set_active(mode == "mpv")
def get_stream_lib(self):
if self._gst_lib_button.get_active():
return "gst"
elif self._vlc_lib_button.get_active():
return "vlc"
return "mpv"
self.show_info_message("Save and restart the program to apply the settings.", Gtk.MessageType.WARNING)
def on_transcoding_preset_changed(self, button):
presets = self._settings.transcoding_presets
@@ -784,21 +793,22 @@ class SettingsDialog:
response = get_chooser_dialog(self._dialog, self._settings, "Themes Archive [*.xz, *.zip]", ("*.xz", "*.zip"))
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
self._theme_frame.set_sensitive(False)
self._theme_view.set_sensitive(False)
self.unpack_theme(response, path, button)
@run_task
def unpack_theme(self, src, dst, button):
try:
os.makedirs(os.path.dirname(dst), exist_ok=True)
from shutil import unpack_archive
import subprocess
log("Unpacking '{}' started...".format(src))
p = subprocess.Popen(["tar", "-xvf", src, "-C", dst],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT)
p.communicate()
log(f"Unpacking '{src}' started...")
os.makedirs(os.path.dirname(dst), exist_ok=True)
unpack_archive(src, dst)
log("Unpacking end.")
except (ValueError, OSError) as e:
msg = f"Unpacking error: {e}"
log(msg)
self.show_info_message(msg, Gtk.MessageType.ERROR)
finally:
self.update_theme_button(button, dst)
@@ -813,7 +823,7 @@ class SettingsDialog:
button.append(theme, theme)
button.set_active_id(theme)
self.show_info_message("Done!", Gtk.MessageType.INFO)
self._theme_frame.set_sensitive(True)
self._theme_view.set_sensitive(True)
@run_idle
def remove_theme(self, button, path):

View File

@@ -1,3 +1,7 @@
* {
-GtkDialog-action-area-border: 6;
}
#digit-entry {
border-color: Red;
}
@@ -10,12 +14,45 @@
margin: 1px;
}
#task-button {
padding: 0;
}
#header-button {
padding-top: 0;
padding-bottom: 0;
}
#header-entry {
min-height: 0;
}
#header-stack-switcher > button {
padding-top: 0;
padding-bottom: 0;
}
buttonbox {
padding: 0;
}
paned > separator {
background-repeat: no-repeat;
background-position: center;
}
paned.horizontal > separator {
background-size: 2px 24px;
}
paned.vertical > separator {
background-size: 24px 2px;
}
progressbar > trough {
min-width: 75px;
}
.red-button {
background-image: none;
background-color: red;
@@ -61,12 +98,18 @@ paned > separator {
}
.stack-switcher > button > label {
padding-left: 2px;
padding-right: 2px;
min-width: 75px;
padding-left: 5px;
padding-right: 5px;
min-width: 50px;
}
.stack-switcher > button.text-button {
padding-left: 2px;
padding-right: 2px;
padding-left: 5px;
padding-right: 5px;
min-width: 50px;
}
.playback {
background-color: #000000;
color: #ffffff;
}

90
app/ui/tasks.py Normal file
View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
from app.ui.dialogs import translate
from .uicommons import Gtk, GLib
class BGTaskWidget(Gtk.Box):
""" Widget for displaying and running background tasks. """
TASK_LIMIT = 1
def __init__(self, app, text, target, *args):
super().__init__(spacing=2, orientation=Gtk.Orientation.HORIZONTAL, valign=Gtk.Align.CENTER)
self._app = app
self._label = Gtk.Label(translate(text))
self.pack_start(self._label, False, False, 0)
self._spinner = Gtk.Spinner(active=True)
self.pack_start(self._spinner, False, False, 0)
close_button = Gtk.Button.new_from_icon_name("window-close", Gtk.IconSize.MENU)
close_button.set_relief(Gtk.ReliefStyle.NONE)
close_button.set_valign(Gtk.Align.CENTER)
close_button.set_tooltip_text(translate("Cancel"))
close_button.set_name("task-button")
close_button.connect("clicked", lambda b: self._app.emit("task-cancel", self))
self.pack_start(close_button, False, False, 0)
self.show_all()
# Just prototype. -> It may not work properly!
from gi.repository.Gio import Task, Cancellable
self._task = Task.new(self, Cancellable.new(), lambda s, t: GLib.idle_add(self._app.emit, "task-done", self))
self._task.set_priority(GLib.PRIORITY_LOW)
self._task.set_return_on_cancel(True)
self._task.run_in_thread(lambda t, s, d, c: target(*args))
@property
def text(self):
return self._label.get_text()
@text.setter
def text(self, value):
self._label.set_text(value)
@property
def tooltip(self):
return self.get_tooltip_text()
@tooltip.setter
def tooltip(self, value):
self.set_tooltip_text(value)
def cancel(self):
cancelable = self._task.get_cancellable()
if cancelable:
cancelable.cancel()
self._app.emit("task-canceled", None)
if __name__ == '__main__':
pass

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.22.2
<!-- Generated with glade 3.38.2
The MIT License (MIT)
@@ -27,7 +27,7 @@ Author: Dmitriy Yefremov
-->
<interface domain="demon-editor">
<requires lib="gtk+" version="3.16"/>
<requires lib="gtk+" version="3.18"/>
<!-- interface-license-type mit -->
<!-- interface-name DemonEditor -->
<!-- interface-copyright 2018-2021 Dmitriy Yefremov -->
@@ -41,49 +41,98 @@ Author: Dmitriy Yefremov
</child>
</object>
<object class="GtkTextBuffer" id="text_buffer">
<property name="tag_table">tag_table</property>
<property name="tag-table">tag_table</property>
</object>
<object class="GtkFrame" id="telnet_frame">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="margin_top">5</property>
<property name="margin_bottom">5</property>
<property name="label_xalign">0.49000000953674316</property>
<property name="shadow_type">in</property>
<property name="can-focus">False</property>
<property name="label-xalign">0.49000000953674316</property>
<property name="shadow-type">none</property>
<child>
<object class="GtkBox" id="telnet_main_box">
<property name="width_request">480</property>
<property name="height_request">180</property>
<object class="GtkViewport" id="viewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="margin_bottom">5</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<property name="can-focus">False</property>
<child>
<object class="GtkBox" id="commands_entry">
<object class="GtkBox" id="telnet_main_box">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="margin_left">5</property>
<property name="margin_right">5</property>
<property name="margin_bottom">2</property>
<property name="can-focus">False</property>
<property name="margin-start">10</property>
<property name="margin-end">10</property>
<property name="margin-top">10</property>
<property name="margin-bottom">5</property>
<property name="orientation">vertical</property>
<property name="spacing">5</property>
<child>
<object class="GtkButton" id="connect_button">
<object class="GtkBox" id="commands_entry">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Connect</property>
<signal name="clicked" handler="on_connect" swapped="no"/>
<property name="can-focus">False</property>
<property name="margin-start">5</property>
<property name="margin-end">5</property>
<property name="margin-bottom">5</property>
<property name="spacing">5</property>
<child>
<object class="GtkImage" id="connect_button_image">
<object class="GtkButton" id="connect_button">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-connect</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Connect</property>
<signal name="clicked" handler="on_connect" swapped="no"/>
<child>
<object class="GtkImage" id="connect_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="stock">gtk-connect</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkButton" id="disconnect_button">
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Disconnect</property>
<signal name="clicked" handler="on_disconnect" swapped="no"/>
<child>
<object class="GtkImage" id="disconnect_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="stock">gtk-disconnect</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="clear_button">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="tooltip-text" translatable="yes">Clear</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="on_clear" swapped="no"/>
<child>
<object class="GtkImage" id="clear_button_image">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="stock">gtk-clear</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
@@ -93,90 +142,49 @@ Author: Dmitriy Yefremov
</packing>
</child>
<child>
<object class="GtkButton" id="disconnect_button">
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Disconnect</property>
<signal name="clicked" handler="on_disconnect" swapped="no"/>
<object class="GtkScrolledWindow" id="telnet_scrolled_window">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="shadow-type">in</property>
<child>
<object class="GtkImage" id="disconnect_button_image">
<object class="GtkTextView" id="text_view">
<property name="name">textview-large</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-disconnect</property>
<property name="can-focus">True</property>
<property name="wrap-mode">char</property>
<property name="left-margin">5</property>
<property name="right-margin">5</property>
<property name="buffer">text_buffer</property>
<property name="overwrite">True</property>
<property name="input-hints">GTK_INPUT_HINT_WORD_COMPLETION | GTK_INPUT_HINT_NONE</property>
<property name="monospace">True</property>
<signal name="key-press-event" handler="on_view_key_press" swapped="no"/>
<signal name="realize" handler="on_text_view_realize" swapped="no"/>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkButton" id="clear_button">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="tooltip_text" translatable="yes">Clear</property>
<property name="halign">center</property>
<property name="valign">center</property>
<signal name="clicked" handler="on_clear" swapped="no"/>
<child>
<object class="GtkImage" id="clear_button_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="stock">gtk-clear</property>
</object>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow" id="telnet_scrolled_window">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkTextView" id="text_view">
<property name="name">textview-large</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="wrap_mode">char</property>
<property name="left_margin">5</property>
<property name="right_margin">5</property>
<property name="buffer">text_buffer</property>
<property name="overwrite">True</property>
<property name="input_hints">GTK_INPUT_HINT_WORD_COMPLETION | GTK_INPUT_HINT_NONE</property>
<property name="monospace">True</property>
<signal name="key-press-event" handler="on_view_key_press" swapped="no"/>
<signal name="realize" handler="on_text_view_realize" swapped="no"/>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<style>
<class name="view"/>
</style>
</object>
</child>
<child type="label">
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="can-focus">False</property>
<property name="margin-bottom">2</property>
<property name="label" translatable="yes">Telnet</property>
<attributes>
<attribute name="weight" value="bold"/>
</attributes>
</object>
</child>
</object>

View File

@@ -1,41 +1,42 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import re
import selectors
import socket
from collections import deque
from telnetlib import Telnet
from gi.repository import GLib
from app.commons import run_task, run_idle, log
from app.connections import ExtTelnet
from app.ui.uicommons import Gtk, Gdk, UI_RESOURCES_PATH, KeyboardKey, MOD_MASK
class ExtTelnet(Telnet):
def __init__(self, output_callback, **kwargs):
super().__init__(**kwargs)
self._output_callback = output_callback
def interact(self):
""" Interaction function, emulates a very dumb telnet client. """
with selectors.DefaultSelector() as selector:
selector.register(self, selectors.EVENT_READ)
while True:
for key, events in selector.select():
if key.fileobj is self:
try:
text = self.read_very_eager()
except EOFError as e:
msg = "\n*** Connection closed by remote host ***\n"
self._output_callback(msg)
log(msg)
raise e
else:
if text:
self._output_callback(text)
class TelnetClient(Gtk.Box):
""" Very simple telnet client. """
_COLOR_PATTERN = re.compile("\x1b.*?m") # Color info
@@ -44,7 +45,7 @@ class TelnetClient(Gtk.Box):
_ALL_PATTERN = re.compile(r'(\x1b\[|\x9b)[0-?]*[@-~]')
_NOT_SUPPORTED = {"mc", "mcedit", "vi", "nano"}
def __init__(self, app, settings, *args, **kwargs):
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs)
self._app = app
self._app.connect("profile-changed", self.on_profile_changed)
@@ -128,11 +129,10 @@ class TelnetClient(Gtk.Box):
self.do_command()
return True
key_code = event.hardware_keycode
if not KeyboardKey.value_exist(key_code):
return
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return False
key = KeyboardKey(key_code)
ctrl = event.state & MOD_MASK
if ctrl and key is KeyboardKey.C:
if self._tn and self._tn.sock:
@@ -153,6 +153,7 @@ class TelnetClient(Gtk.Box):
self._commands.append(cmd)
self._buf.insert_at_cursor(cmd, -1)
return True
return False
def delete_last_command(self):
end = self._buf.get_end_iter()

1798
app/ui/timers.glade Normal file

File diff suppressed because it is too large Load Diff

554
app/ui/timers.py Normal file
View File

@@ -0,0 +1,554 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
""" Module for working with timers. """
from datetime import datetime, timedelta
from enum import Enum
from urllib.parse import quote
from app.ui.main_helper import on_popup_menu
from .dialogs import get_builder, translate, show_dialog, DialogType, BaseDialog
from .uicommons import Gtk, Gdk, GLib, UI_RESOURCES_PATH, Page, Column, KeyboardKey, MOD_MASK
from ..commons import run_idle, log
from ..connections import HttpAPI
from ..eparser.ecommons import BqServiceType
class TimerTool(Gtk.Box):
TIME_STR = "%Y-%m-%d %H:%M"
ACTION = {"0": "Record", "1": "Zap"}
AFTER_EVENT = {"0": "Do Nothing",
"1": "Standby",
"2": "Shut down",
"3": "Auto"}
class TimerAction(Enum):
ADD = 0
EVENT = 1
CHANGE = 2
class TimerDialog(BaseDialog):
def __init__(self, parent, action=None, timer_data=None, *args, **kwargs):
super().__init__(parent=parent, title="Timer",
buttons=(translate("Cancel"), Gtk.ResponseType.CANCEL,
translate("Save"), Gtk.ResponseType.OK), *args, **kwargs)
self._action = action or TimerTool.TimerAction.ADD
self._timer_data = timer_data or {}
self._request = ""
handlers = {"on_timer_begins_set": self.on_timer_begins_set,
"on_timer_ends_set": self.on_timer_ends_set}
builder = get_builder(f"{UI_RESOURCES_PATH}timers.glade", handlers,
objects=("timer_dialog_frame", "timer_ends_popover", "end_hour_adjustment",
"min_end_adjustment", "timer_begins_popover", "begins_hour_adjustment",
"min_begins_adjustment"))
self._timer_name_entry = builder.get_object("timer_name_entry")
self._timer_desc_entry = builder.get_object("timer_desc_entry")
self._timer_service_entry = builder.get_object("timer_service_entry")
self._timer_service_ref_entry = builder.get_object("timer_service_ref_entry")
self._timer_event_id_entry = builder.get_object("timer_event_id_entry")
self._timer_begins_entry = builder.get_object("timer_begins_entry")
self._timer_ends_entry = builder.get_object("timer_ends_entry")
self._timer_begins_calendar = builder.get_object("timer_begins_calendar")
self._timer_begins_hr_button = builder.get_object("timer_begins_hr_button")
self._timer_begins_min_button = builder.get_object("timer_begins_min_button")
self._timer_ends_calendar = builder.get_object("timer_ends_calendar")
self._timer_ends_hr_button = builder.get_object("timer_ends_hr_button")
self._timer_ends_min_button = builder.get_object("timer_ends_min_button")
self._timer_enabled_switch = builder.get_object("timer_enabled_switch")
self._timer_action_combo_box = builder.get_object("timer_action_combo_box")
self._timer_after_combo_box = builder.get_object("timer_after_combo_box")
self._days_buttons = (builder.get_object("timer_mo_check_button"),
builder.get_object("timer_tu_check_button"),
builder.get_object("timer_we_check_button"),
builder.get_object("timer_th_check_button"),
builder.get_object("timer_fr_check_button"),
builder.get_object("timer_sa_check_button"),
builder.get_object("timer_su_check_button"))
self._timer_location_switch = builder.get_object("timer_location_switch")
self._timer_location_entry = builder.get_object("timer_location_entry")
self._timer_location_switch.bind_property("active", self._timer_location_entry, "sensitive")
# Disable DnD for timer entries.
self._timer_name_entry.drag_dest_unset()
self._timer_desc_entry.drag_dest_unset()
self._timer_service_entry.drag_dest_unset()
self.get_content_area().pack_start(builder.get_object("timer_dialog_frame"), True, True, 0)
if self._action is TimerTool.TimerAction.ADD:
self.set_timer_for_add()
elif self._action is TimerTool.TimerAction.CHANGE:
self.set_timer_for_edit()
elif self._action is TimerTool.TimerAction.EVENT:
self.set_timer_from_event_data()
else:
log(f"{__class__.__name__} error: No action set for timer!")
@property
def request(self):
return self._request
def run(self):
resp = super().run()
if resp == Gtk.ResponseType.OK:
self._request = self.get_request()
return resp
def get_request(self):
""" Constructs str representation of add/update request. """
args = []
t_data = self.get_timer_data()
s_ref = quote(t_data.get("sRef", ""))
if self._action is TimerTool.TimerAction.EVENT:
args.append(f"timeraddbyeventid?sRef={s_ref}")
args.append(f"eventid={t_data.get('eit', '0')}")
args.append(f"justplay={t_data.get('justplay', '')}")
args.append(f"tags={''}")
else:
if self._action is TimerTool.TimerAction.ADD:
args.append(f"timeradd?sRef={s_ref}")
args.append(f"deleteOldOnSave={0}")
elif self._action is TimerTool.TimerAction.CHANGE:
args.append(f"timerchange?sRef={s_ref}")
args.append(f"channelOld={s_ref}")
args.append(f"beginOld={self._timer_data.get('e2timebegin', '0')}")
args.append(f"endOld={self._timer_data.get('e2timeend', '0')}")
args.append(f"deleteOldOnSave={1}")
args.append(f"begin={t_data.get('begin', '')}")
args.append(f"end={t_data.get('end', '')}")
args.append(f"name={quote(t_data.get('name', ''))}")
args.append(f"description={quote(t_data.get('description', ''))}")
args.append(f"tags={''}")
args.append(f"eit={'0'}")
args.append(f"disabled={t_data.get('disabled', '1')}")
args.append(f"justplay={t_data.get('justplay', '1')}")
args.append(f"afterevent={t_data.get('afterevent', '0')}")
args.append(f"repeated={TimerTool.get_repetition_flags(self._days_buttons)}")
if self._timer_location_switch.get_active():
args.append(f"dirname={self._timer_location_entry.get_text()}")
return "&".join(args)
def on_timer_begins_set(self, action, value=None):
b_date = self.get_begins_date()
if b_date > self.get_ends_date():
self.set_ends_date(b_date + timedelta(hours=1))
self.set_begins_date(b_date)
def on_timer_ends_set(self, action, value=None):
self.set_ends_date(self.get_ends_date())
def get_begins_date(self):
date = self._timer_begins_calendar.get_date()
return datetime(year=date.year, month=date.month + 1, day=date.day,
hour=int(self._timer_begins_hr_button.get_value()),
minute=int(self._timer_begins_min_button.get_value()))
def set_begins_date(self, date):
hour = date.hour
minute = date.minute
self._timer_begins_hr_button.set_value(hour)
self._timer_begins_min_button.set_value(minute)
self._timer_begins_calendar.select_day(date.day)
self._timer_begins_calendar.select_month(date.month - 1, date.year)
self._timer_begins_entry.set_text(f"{date.year}-{date.month:02d}-{date.day:02d} {hour:02d}:{minute:02d}")
def get_ends_date(self):
date = self._timer_ends_calendar.get_date()
return datetime(year=date.year, month=date.month + 1, day=date.day,
hour=int(self._timer_ends_hr_button.get_value()),
minute=int(self._timer_ends_min_button.get_value()))
def set_ends_date(self, date):
hour = date.hour
minute = date.minute
self._timer_ends_hr_button.set_value(hour)
self._timer_ends_min_button.set_value(minute)
self._timer_ends_calendar.select_day(date.day)
self._timer_ends_calendar.select_month(date.month - 1, date.year)
self._timer_ends_entry.set_text(f"{date.year}-{date.month:02d}-{date.day:02d} {hour:02d}:{minute:02d}")
def set_timer_for_add(self):
self._timer_service_entry.set_text(self._timer_data.get("e2servicename", ""))
self._timer_service_ref_entry.set_text(self._timer_data.get("e2servicereference", ""))
date = datetime.now()
self.set_begins_date(date)
self.set_ends_date(date + timedelta(hours=1))
self._timer_event_id_entry.set_text("")
self._timer_location_switch.set_active(False)
TimerTool.set_repetition_flags(0, self._days_buttons)
def set_timer_for_edit(self):
self._timer_name_entry.set_text(self._timer_data.get("e2name", ""))
self._timer_desc_entry.set_text(self._timer_data.get("e2description", "") or "")
self._timer_service_entry.set_text(self._timer_data.get("e2servicename", "") or "")
self._timer_service_ref_entry.set_text(self._timer_data.get("e2servicereference", ""))
self._timer_event_id_entry.set_text(self._timer_data.get("e2eit", ""))
self._timer_enabled_switch.set_active((self._timer_data.get("e2disabled", "0") == "0"))
self._timer_action_combo_box.set_active_id(self._timer_data.get("e2justplay", "0"))
self._timer_after_combo_box.set_active_id(self._timer_data.get("e2afterevent", "0"))
self.set_time_data(int(self._timer_data.get("e2timebegin", "0")),
int(self._timer_data.get("e2timeend", "0")))
location = self._timer_data.get("e2location", "")
self._timer_location_entry.set_text("" if location == "None" else location)
TimerTool.set_repetition_flags(int(self._timer_data.get("e2repeated", "0")), self._days_buttons)
def set_timer_from_event_data(self):
self._timer_name_entry.set_text(self._timer_data.get("e2eventtitle", None) or "")
self._timer_desc_entry.set_text(self._timer_data.get("e2eventdescription", None) or "")
self._timer_service_entry.set_text(self._timer_data.get("e2eventservicename", None) or "")
self._timer_service_ref_entry.set_text(self._timer_data.get("e2eventservicereference", None) or "")
self._timer_event_id_entry.set_text(self._timer_data.get("e2eventid", None) or "")
self._timer_action_combo_box.set_active_id("1")
self._timer_after_combo_box.set_active_id("3")
start_time = int(self._timer_data.get("e2eventstart", "0") or "0")
self.set_time_data(start_time, start_time + int(self._timer_data.get("e2eventduration", "0") or "0"))
def set_time_data(self, start_time, end_time):
""" Sets values for time widgets. """
now = datetime.now()
ev_time_start = datetime.fromtimestamp(start_time) or now
ev_time_end = datetime.fromtimestamp(end_time) or now + timedelta(hours=1)
self._timer_begins_entry.set_text(ev_time_start.strftime(TimerTool.TIME_STR))
self._timer_ends_entry.set_text(ev_time_end.strftime(TimerTool.TIME_STR))
self._timer_begins_calendar.select_day(ev_time_start.day)
self._timer_begins_calendar.select_month(ev_time_start.month - 1, ev_time_start.year)
self._timer_ends_calendar.select_day(ev_time_end.day)
self._timer_ends_calendar.select_month(ev_time_end.month - 1, ev_time_end.year)
self._timer_begins_hr_button.set_value(ev_time_start.hour)
self._timer_begins_min_button.set_value(ev_time_start.minute)
self._timer_ends_hr_button.set_value(ev_time_end.hour)
self._timer_ends_min_button.set_value(ev_time_end.minute)
def get_timer_data(self):
""" Returns timer data as a dict. """
return {"sRef": self._timer_service_ref_entry.get_text(),
"begin": int(
datetime.strptime(self._timer_begins_entry.get_text(), TimerTool.TIME_STR).timestamp()),
"end": int(datetime.strptime(self._timer_ends_entry.get_text(), TimerTool.TIME_STR).timestamp()),
"name": self._timer_name_entry.get_text(),
"description": self._timer_desc_entry.get_text(),
"dirname": "",
"eit": self._timer_event_id_entry.get_text(),
"disabled": int(not self._timer_enabled_switch.get_active()),
"justplay": self._timer_action_combo_box.get_active_id(),
"afterevent": self._timer_after_combo_box.get_active_id(),
"repeated": TimerTool.get_repetition_flags(self._days_buttons)}
def __init__(self, app, *args, **kwargs):
super().__init__(*args, **kwargs)
self._app = app
self._app.connect("page-changed", self.update_timer_list)
# Icon.
theme = Gtk.IconTheme.get_default()
icon = "alarm-symbolic"
self._icon = theme.load_icon(icon, 16, 0) if theme.lookup_icon(icon, 16, 0) else None
handlers = {"on_timer_add": self.on_timer_add,
"on_timer_edit": self.on_timer_edit,
"on_timer_remove": self.on_timer_remove,
"on_model_changed": self.on_model_changed,
"on_timers_press": self.on_timers_press,
"on_timers_key_release": self.on_timers_key_release,
"on_timer_cursor_changed": self.on_timer_cursor_changed,
"on_timers_drag_data_received": self.on_timers_drag_data_received}
builder = get_builder(f"{UI_RESOURCES_PATH}timers.glade", handlers,
objects=("timers_frame", "timer_model", "popup_menu", "popup_menu_add_image"))
self._view = builder.get_object("timer_view")
self._remove_button = builder.get_object("timer_remove_button")
self._remove_button.bind_property("sensitive", builder.get_object("timer_edit_button"), "sensitive")
self._remove_button.bind_property("sensitive", builder.get_object("edit_menu_item"), "sensitive")
self._remove_button.bind_property("sensitive", builder.get_object("remove_menu_item"), "sensitive")
self._info_button = builder.get_object("timer_info_check_button")
self._info_button.bind_property("active", builder.get_object("timer_info_frame"), "visible")
self._info_enabled_switch = builder.get_object("timer_info_enabled_switch")
self._timers_count_label = builder.get_object("timers_count_label")
self._ref_info_label = builder.get_object("timer_ref_value_label")
self._event_id_info_label = builder.get_object("timer_event_id_value_label")
self._begins_info_label = builder.get_object("timer_begins_value_label")
self._ends_info_label = builder.get_object("timer_ends_value_label")
self._action_info_label = builder.get_object("timer_action_value_label")
self._after_info_label = builder.get_object("timer_after_value_label")
self._timer_location_switch = builder.get_object("timer_location_switch")
self._info_location_entry = builder.get_object("timer_info_location_entry")
self._days_buttons = (builder.get_object("timer_info_mo_check_button"),
builder.get_object("timer_info_tu_check_button"),
builder.get_object("timer_info_we_check_button"),
builder.get_object("timer_info_th_check_button"),
builder.get_object("timer_info_fr_check_button"),
builder.get_object("timer_info_sa_check_button"),
builder.get_object("timer_info_su_check_button"))
# Disable button presses.
list(map(lambda b: b.connect("button-press-event", lambda bx, e: True), self._days_buttons))
self._info_enabled_switch.connect("button-press-event", lambda b, e: True)
# DnD initialization for the timer list.
self._view.drag_dest_set(Gtk.DestDefaults.ALL, [], Gdk.DragAction.DEFAULT | Gdk.DragAction.COPY)
self._view.drag_dest_add_text_targets()
self.pack_start(builder.get_object("timers_frame"), True, True, 0)
self.show()
def update_timer_list(self, app, page):
if page is Page.TIMERS:
self._app.wait_dialog.show()
self._app.send_http_request(HttpAPI.Request.TIMER_LIST, "", self.update_timers_data)
@run_idle
def update_timers_data(self, timers):
model = self._view.get_model()
model.clear()
list(map(model.append, (self.get_timer_row(t) for t in timers.get("timer_list", []))))
self._remove_button.set_sensitive(len(model))
self._app.wait_dialog.hide()
def get_timer_row(self, timer):
disabled = self._icon if timer.get("e2disabled", "0") == "0" else None
name = timer.get("e2name", "") or ""
description = timer.get("e2description", "") or ""
service = timer.get("e2servicename", "") or ""
start_time = datetime.fromtimestamp(int(timer.get("e2timebegin", "0")))
end_time = datetime.fromtimestamp(int(timer.get("e2timeend", "0")))
time = f"{start_time.strftime('%a, %x, %H:%M')} - {end_time.strftime('%H:%M')}"
return disabled, name, service, time, description, timer
def on_timer_add(self, timer=None, value=None):
model, paths = self._app.fav_view.get_selection().get_selected_rows()
p_count = len(paths)
if p_count == 1:
service = self._app.current_services.get(model[paths][Column.FAV_ID], None)
if service and service.picon_id:
self.add_timer({"e2servicename": service.service,
"e2servicereference": service.picon_id.rstrip(".png").replace("_", ":")})
else:
self._app.show_error_message("Not allowed in this context!")
elif p_count > 1:
self._app.show_error_message("Please, select only one item!")
else:
self._app.show_error_message("No selected item!")
def add_timer(self, timer_data):
dialog = self.TimerDialog(self._app.app_window, self.TimerAction.ADD, timer_data)
response = dialog.run()
if response == Gtk.ResponseType.OK:
self._app.send_http_request(HttpAPI.Request.TIMER, dialog.request, self.timer_add_edit_callback)
dialog.destroy()
def on_timer_edit(self, action=None, value=None):
model, paths = self._view.get_selection().get_selected_rows()
if len(paths) > 1:
self._app.show_error_message("Please, select only one item!")
return
dialog = self.TimerDialog(self._app.app_window, self.TimerAction.CHANGE, model[paths][-1])
response = dialog.run()
if response == Gtk.ResponseType.OK:
self._app.send_http_request(HttpAPI.Request.TIMER, dialog.request, self.timer_add_edit_callback)
dialog.destroy()
@run_idle
def timer_add_edit_callback(self, resp):
if "error_code" in resp:
msg = f"Error getting timer status.\n{resp.get('error_code')}"
self._app.show_error_message(msg)
log(msg)
return
state = resp.get("e2state", None)
if state == "False":
msg = resp.get("e2statetext", "")
self._app.show_error_message(msg)
log(msg)
if state == "True":
msg = resp.get("e2statetext", "")
log(msg)
self._app.show_info_message(msg, Gtk.MessageType.INFO)
self.update_timer_list(self._app, Page.TIMERS)
else:
log("Error getting timer status. No response!")
def on_timer_remove(self, action=None, value=None):
model, paths = self._view.get_selection().get_selected_rows()
if not paths or show_dialog(DialogType.QUESTION, self._app.app_window) != Gtk.ResponseType.OK:
return
refs = {}
for path in paths:
timer = model[path][-1]
ref = "timerdelete?sRef={}&begin={}&end={}".format(quote(timer.get("e2servicereference", "")),
timer.get("e2timebegin", ""),
timer.get("e2timeend", ""))
refs[ref] = model.get_iter(path)
self._app.wait_dialog.show("Deleting data...")
gen = self.remove_timers(refs)
GLib.idle_add(lambda: next(gen, False))
def remove_timers(self, refs):
tasks = list(refs)
removed = set()
for ref in refs:
yield from self.remove_timer(ref, removed, tasks)
while tasks:
yield True
model = self._view.get_model()
list(map(model.remove, (refs[ref] for ref in refs if ref in removed)))
self._app.wait_dialog.hide()
self._remove_button.set_sensitive(len(model))
yield True
def remove_timer(self, ref, removed, tasks=None):
def callback(resp):
if resp.get("e2state", "") == "True":
log(resp.get("e2statetext", ""))
removed.add(ref)
else:
log(resp.get("e2statetext", None) or "Timer deletion error.")
if tasks:
tasks.pop()
self._app.send_http_request(HttpAPI.Request.TIMER, ref, callback)
yield True
def on_model_changed(self, model, path, itr=None):
self._timers_count_label.set_text(str(len(model)))
def on_timers_press(self, menu, event):
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS and len(self._view.get_model()) > 0:
self.on_timer_edit()
else:
on_popup_menu(menu, event)
def on_timers_key_release(self, view, event):
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
ctrl = event.state & MOD_MASK
if key is KeyboardKey.DELETE:
self.on_timer_remove()
elif ctrl and key is KeyboardKey.E:
self.on_timer_edit()
elif ctrl and key is KeyboardKey.INSERT:
self.on_timer_add()
def on_timer_cursor_changed(self, view):
path, column = view.get_cursor()
if not path:
return
timer = view.get_model()[path][-1]
self._info_enabled_switch.set_active((timer.get("e2disabled", "0") == "0"))
self._ref_info_label.set_text(timer.get("e2servicereference", ""))
self._event_id_info_label.set_text(timer.get("e2eit", ""))
self._action_info_label.set_text(translate(self.ACTION.get(timer.get("e2justplay", "0"), "0")))
self._after_info_label.set_text(translate(self.AFTER_EVENT.get(timer.get("e2afterevent", "0"), "0")))
self._begins_info_label.set_text(str(datetime.fromtimestamp(int(timer.get("e2timebegin", "0")))))
self._ends_info_label.set_text(str(datetime.fromtimestamp(int(timer.get("e2timeend", "0")))))
self.set_repetition_flags(int(timer.get("e2repeated", "0")), self._days_buttons)
location = timer.get("e2location", "")
self._info_location_entry.set_text("" if location == "None" else location)
@staticmethod
def get_repetition_flags(boxes):
""" Returns flags for repetition.
@param boxes: Buttons tuple for the days of the week.
"""
day_flags = 0
for i, box in enumerate(boxes):
if box.get_active():
day_flags = day_flags | (1 << i)
return day_flags
@staticmethod
def set_repetition_flags(flags, boxes):
""" Sets flags for repetition.
@param flags: Flags value.
@param boxes: Buttons tuple for the days of the week.
"""
for i, box in enumerate(boxes):
box.set_active(flags & 1 == 1)
flags = flags >> 1
# ***************** Drag-and-drop ********************* #
def on_timers_drag_data_received(self, box, context, x, y, data, info, time):
txt = data.get_text()
if txt:
itr_str, sep, source = txt.partition(self._app.DRAG_SEP)
if not source:
return
itrs = itr_str.split(",")
if len(itrs) > 1:
self._app.show_error_message("Please, select only one item!")
return
fav_id = None
if source == self._app.FAV_MODEL:
model = self._app.fav_view.get_model()
fav_id = model.get_value(model.get_iter_from_string(itrs[0]), Column.FAV_ID)
elif source == self._app.SERVICE_MODEL:
model = self._app.services_view.get_model()
fav_id = model.get_value(model.get_iter_from_string(itrs[0]), Column.SRV_FAV_ID)
service = self._app.current_services.get(fav_id, None)
if service:
if service.service_type == BqServiceType.ALT.name:
msg = "Alternative service.\n\n {get_message('Not implemented yet!')}"
show_dialog(DialogType.ERROR, transient=self._app.app_window, text=msg)
context.finish(False, False, time)
return
self.add_timer({"e2servicename": service.service,
"e2servicereference": service.picon_id.rstrip(".png").replace("_", ":")})
context.finish(True, False, time)
if __name__ == "__main__":
pass

View File

@@ -1,3 +1,31 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
import locale
import os
from enum import Enum, IntEnum
@@ -7,9 +35,9 @@ import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
from gi.repository import Gtk, Gdk
from gi.repository import Gtk, Gdk, GLib
from app.settings import Settings, SettingsException, IS_DARWIN, GTK_PATH, IS_LINUX
from app.settings import Settings, SettingsException, IS_DARWIN, IS_LINUX, GTK_PATH
# Setting mod mask for keyboard depending on platform
MOD_MASK = Gdk.ModifierType.MOD2_MASK if IS_DARWIN else Gdk.ModifierType.CONTROL_MASK
@@ -24,7 +52,6 @@ TEXT_DOMAIN = "demon-editor"
NOTIFY_IS_INIT = False
APP_FONT = None
IS_GNOME_SESSION = int(bool(os.environ.get("GNOME_DESKTOP_SESSION_ID")))
try:
settings = Settings.get_instance()
@@ -52,13 +79,14 @@ else:
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
if IS_LINUX:
locale.bindtextdomain(TEXT_DOMAIN, LANG_PATH)
if UI_RESOURCES_PATH == BASE_PATH:
locale.bindtextdomain(TEXT_DOMAIN, LANG_PATH)
# Init notify
try:
gi.require_version("Notify", "0.7")
from gi.repository import Notify
except ImportError:
pass
except (ImportError, ValueError):
pass # NOP
else:
NOTIFY_IS_INIT = Notify.init("DemonEditor")
elif IS_DARWIN:
@@ -77,16 +105,24 @@ else:
theme = Gtk.IconTheme.get_default()
theme.append_search_path(UI_RESOURCES_PATH + "icons")
_IMAGE_MISSING = theme.load_icon("image-missing", 16, 0) if theme.lookup_icon("image-missing", 16, 0) else None
CODED_ICON = theme.load_icon("emblem-readonly", 16, 0) if theme.lookup_icon(
"emblem-readonly", 16, 0) else _IMAGE_MISSING
LOCKED_ICON = theme.load_icon("changes-prevent-symbolic", 16, 0) if theme.lookup_icon(
"system-lock-screen", 16, 0) else _IMAGE_MISSING
HIDE_ICON = theme.load_icon("go-jump", 16, 0) if theme.lookup_icon("go-jump", 16, 0) else _IMAGE_MISSING
TV_ICON = theme.load_icon("tv-symbolic", 16, 0) if theme.lookup_icon("tv-symbolic", 16, 0) else _IMAGE_MISSING
IPTV_ICON = theme.load_icon("emblem-shared", 16, 0) if theme.lookup_icon("emblem-shared", 16, 0) else None
EPG_ICON = theme.load_icon("gtk-index", 16, 0) if theme.lookup_icon("gtk-index", 16, 0) else None
DEFAULT_ICON = theme.load_icon("emblem-default", 16, 0) if theme.lookup_icon("emblem-default", 16, 0) else None
def get_icon(name, size, default=None):
try:
return theme.load_icon(name, size, 0) if theme.lookup_icon(name, size, 0) else default
except GLib.Error:
return default
_IMAGE_MISSING = get_icon("image-missing", 16)
CODED_ICON = get_icon("emblem-readonly", 16, _IMAGE_MISSING)
LOCKED_ICON = get_icon("changes-prevent-symbolic", 16, _IMAGE_MISSING)
HIDE_ICON = get_icon("go-jump", 16, _IMAGE_MISSING)
TV_ICON = get_icon("tv-symbolic", 16, _IMAGE_MISSING)
IPTV_ICON = get_icon("emblem-shared", 16, _IMAGE_MISSING)
LINK_ICON = get_icon("emblem-symbolic-link", 16, _IMAGE_MISSING)
FOLDER_ICON = get_icon("folder-symbolic" if IS_DARWIN else "folder", 16, _IMAGE_MISSING)
EPG_ICON = get_icon("gtk-index", 16, _IMAGE_MISSING)
DEFAULT_ICON = get_icon("emblem-default", 16, get_icon("emblem-default-symbolic", 16, _IMAGE_MISSING))
@lru_cache(maxsize=1)
@@ -107,7 +143,7 @@ def get_yt_icon(icon_name, size=24):
if n_theme.has_icon(icon_name):
return n_theme.load_icon(icon_name, size, 0)
return default_theme.load_icon("emblem-important-symbolic", size, 0)
return get_icon("emblem-important-symbolic", size, LINK_ICON)
def show_notification(message, timeout=10000, urgency=1):
@@ -127,6 +163,18 @@ def show_notification(message, timeout=10000, urgency=1):
notify.show()
class HeaderBar(Gtk.HeaderBar):
""" Custom header bar widget. """
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.set_visible(True)
self.set_show_close_button(True)
if IS_DARWIN:
self.set_decoration_layout("close,minimize,maximize")
class Page(Enum):
""" Main stack widget page. """
INFO = "info"
@@ -140,20 +188,13 @@ class Page(Enum):
CONTROL = "control"
class FavClickMode(IntEnum):
""" Double click mode on the service in the bouquet(FAV) list. """
DISABLED = 0
STREAM = 1
PLAY = 2
ZAP = 3
ZAP_PLAY = 4
class ViewTarget(Enum):
""" Used for set target view. """
BOUQUET = 0
FAV = 1
SERVICES = 2
IPTV = 3
ALT = 4
class BqGenType(Enum):
@@ -224,6 +265,23 @@ class Column(IntEnum):
REC_LEN = 3
REC_FILE = 4
REC_DESC = 5
# IPTV view
IPTV_SERVICE = 0
IPTV_TYPE = 1
IPTV_PICON = 2
IPTV_REF = 3
IPTV_URL = 4
IPTV_FAV_ID = 5
IPTV_PICON_ID = 6
IPTV_TOOLTIP = 7
# EPG view
EPG_SERVICE = 0
EPG_TITLE = 1
EPG_START = 2
EPG_END = 3
EPG_LENGTH = 4
EPG_DESC = 5
EPG_DATA = 6
def __index__(self):
""" Overridden to get the index in slices directly """
@@ -232,14 +290,8 @@ class Column(IntEnum):
# *************** Keyboard keys *************** #
class BaseKeyboardKey(Enum):
@classmethod
def value_exist(cls, value):
return value in (val.value for val in cls.__members__.values())
if IS_LINUX:
class KeyboardKey(BaseKeyboardKey):
class KeyboardKey(IntEnum):
""" The raw(hardware) codes [Linux] of the keyboard keys. """
E = 26
R = 27
@@ -262,10 +314,13 @@ if IS_LINUX:
LEFT = 113
RIGHT = 114
F2 = 68
F4 = 70
F5 = 71
F7 = 73
SPACE = 65
DELETE = 119
BACK_SPACE = 22
RETURN = 36
CTRL_L = 37
CTRL_R = 105
# Laptop codes
@@ -274,8 +329,14 @@ if IS_LINUX:
PAGE_UP_KP = 81
PAGE_DOWN_KP = 89
UNDEFINED = -1
@classmethod
def _missing_(cls, value):
return cls.UNDEFINED
elif IS_DARWIN:
class KeyboardKey(BaseKeyboardKey):
class KeyboardKey(IntEnum):
""" The raw(hardware) codes [macOS] of the keyboard keys. """
F = 3
E = 14
@@ -300,10 +361,13 @@ elif IS_DARWIN:
LEFT = 123
RIGHT = 123
F2 = 120
F4 = 118
F5 = 96
F7 = 98
SPACE = 49
DELETE = 51
BACK_SPACE = 76
RETURN = 36
CTRL_L = 55
CTRL_R = 55
# Laptop codes.
@@ -312,8 +376,14 @@ elif IS_DARWIN:
PAGE_UP_KP = -1
PAGE_DOWN_KP = -1
UNDEFINED = -1
@classmethod
def _missing_(cls, value):
return cls.UNDEFINED
else:
class KeyboardKey(BaseKeyboardKey):
class KeyboardKey(IntEnum):
""" The raw(hardware) codes [Windows] of the keyboard keys. """
E = 69
R = 82
@@ -336,10 +406,13 @@ else:
LEFT = 37
RIGHT = 39
F2 = 113
F4 = 115
F5 = 116
F7 = 118
SPACE = 32
DELETE = 46
BACK_SPACE = 8
RETURN = 13
CTRL_L = 17
CTRL_R = 163
# Laptop codes.
@@ -348,6 +421,12 @@ else:
PAGE_UP_KP = -1
PAGE_DOWN_KP = -1
UNDEFINED = -1
@classmethod
def _missing_(cls, value):
return cls.UNDEFINED
# Keys for move in lists. KEY_KP_(NAME) for laptop!
MOVE_KEYS = {KeyboardKey.UP, KeyboardKey.PAGE_UP,
KeyboardKey.DOWN, KeyboardKey.PAGE_DOWN,
@@ -355,5 +434,27 @@ MOVE_KEYS = {KeyboardKey.UP, KeyboardKey.PAGE_UP,
KeyboardKey.HOME_KP, KeyboardKey.END_KP,
KeyboardKey.PAGE_UP_KP, KeyboardKey.PAGE_DOWN_KP}
class LoadingProgressBar(Gtk.ProgressBar):
""" A custom class for a progress bar.
Used as an alternative to Gtk.Spinner to reduce CPU load.
"""
__gtype_name__ = "LoadingProgressBar"
def __init__(self, **properties):
super().__init__(**properties)
self.connect("notify::visible", self.on_visible)
def on_visible(self, bar, param):
if self.get_visible():
GLib.timeout_add(500, self.update, priority=GLib.PRIORITY_LOW)
def update(self):
self.pulse()
return self.get_visible()
if __name__ == "__main__":
pass

View File

@@ -1,8 +1,28 @@
* {
-GtkDialog-action-area-border: 6;
-GtkDialog-action-area-border: 12;
}
button {
min-height: 24px;
min-width: 24px;
switch {
margin-right: 2px;
}
spinbutton entry {
min-height: 16px;
}
button > image {
padding: 2px;
}
grid > button {
padding-left: 15px;
padding-right: 15px;
}
popover .view {
background-color: transparent;
}
headerbar .titlebutton > image {
padding: 0;
}

0
app/ui/xml/__init__.py Normal file
View File

1736
app/ui/xml/dialogs.glade Normal file

File diff suppressed because it is too large Load Diff

1131
app/ui/xml/dialogs.py Normal file

File diff suppressed because it is too large Load Diff

587
app/ui/xml/edit.py Normal file
View File

@@ -0,0 +1,587 @@
# -*- coding: utf-8 -*-
#
# The MIT License (MIT)
#
# Copyright (c) 2018-2026 Dmitriy Yefremov
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
# Author: Dmitriy Yefremov
#
from enum import Enum
from pyexpat import ExpatError
from gi.repository import GLib
from app.commons import run_idle
from app.connections import DownloadType
from app.eparser import get_satellites, write_satellites, Satellite, Transponder
from app.eparser.ecommons import (POLARIZATION, FEC, SYSTEM, MODULATION, T_SYSTEM, BANDWIDTH, CONSTELLATION, T_FEC,
GUARD_INTERVAL, TRANSMISSION_MODE, HIERARCHY, Inversion, FEC_DEFAULT, C_MODULATION,
Terrestrial, Cable, CableTransponder, TerTransponder)
from app.eparser.satxml import get_terrestrial, get_cable, write_terrestrial, write_cable, get_pos_str
from .dialogs import (SatelliteDialog, SatellitesUpdateDialog, TerrestrialDialog, CableDialog, SatTransponderDialog,
CableTransponderDialog, TerTransponderDialog)
from ..dialogs import show_dialog, DialogType, get_chooser_dialog, translate, get_builder
from ..main_helper import move_items, on_popup_menu, scroll_to
from ..uicommons import Gtk, Gdk, UI_RESOURCES_PATH, MOVE_KEYS, KeyboardKey, MOD_MASK, Page
class SatellitesTool(Gtk.Box):
""" Class to processing *.xml data. """
class DVB(str, Enum):
SAT = "satellites"
TERRESTRIAL = "terrestrial"
CABLE = "cable"
def __str__(self):
return self.value
def __init__(self, app, settings, **kwargs):
super().__init__(**kwargs)
self._app = app
self._app.connect("data-open", self.on_open)
self._app.connect("data-save", self.on_save)
self._app.connect("data-save-as", self.on_save_as)
self._app.connect("data-receive", self.on_download)
self._app.connect("data-send", self.on_upload)
self._settings = settings
self._current_sat_path = None
self._current_ter_path = None
self._current_cable_path = None
self._dvb_type = self.DVB.SAT
handlers = {"on_satellite_view_realize": self.on_satellite_view_realize,
"on_terrestrial_view_realize": self.on_terrestrial_view_realize,
"on_cable_view_realize": self.on_cable_view_realize,
"on_update": self.on_update,
"on_up": self.on_up,
"on_down": self.on_down,
"on_button_press": self.on_button_press,
"on_tr_button_press": self.on_tr_button_press,
"on_add": self.on_add,
"on_edit": self.on_edit,
"on_remove": self.on_remove,
"on_transponder_add": self.on_transponder_add,
"on_transponder_edit": self.on_transponder_edit,
"on_transponder_remove": self.on_transponder_remove,
"on_key_press": self.on_key_press,
"on_tr_key_press": self.on_tr_key_press,
"on_visible_page": self.on_visible_page,
"on_satellite_selection": self.on_satellite_selection,
"on_terrestrial_selection": self.on_terrestrial_selection,
"on_cable_selection": self.on_cable_selection,
"on_sat_model_changed": self.on_sat_model_changed,
"on_sat_tr_model_changed": self.on_sat_tr_model_changed,
"on_ter_model_changed": self.on_ter_model_changed,
"on_ter_tr_model_changed": self.on_ter_tr_model_changed,
"on_cable_model_changed": self.on_cable_model_changed,
"on_cable_tr_model_changed": self.on_cable_tr_model_changed}
builder = get_builder(f"{UI_RESOURCES_PATH}xml/editor.glade", handlers)
self._satellite_view = builder.get_object("satellite_view")
self._terrestrial_view = builder.get_object("terrestrial_view")
self._cable_view = builder.get_object("cable_view")
self._sat_tr_view = builder.get_object("sat_tr_view")
self._ter_tr_view = builder.get_object("ter_tr_view")
self._cable_tr_view = builder.get_object("cable_tr_view")
self._sat_count_label = builder.get_object("sat_count_label")
self._sat_tr_count_label = builder.get_object("sat_tr_count_label")
self._ter_count_label = builder.get_object("ter_count_label")
self._ter_tr_count_label = builder.get_object("ter_tr_count_label")
self._cable_count_label = builder.get_object("cable_count_label")
self._cable_tr_count_label = builder.get_object("cable_tr_count_label")
self._transponders_stack = builder.get_object("transponders_stack")
self._add_header_button = builder.get_object("add_header_button")
self._update_header_button = builder.get_object("update_header_button")
self.pack_start(builder.get_object("main_paned"), True, True, 0)
self._app.connect("profile-changed", self.on_profile_changed)
# Custom renderers.
renderer = builder.get_object("sat_pos_renderer")
builder.get_object("sat_pos_column").set_cell_data_func(renderer, self.sat_pos_func)
# Satellite.
renderer = builder.get_object("sat_pol_renderer")
builder.get_object("pol_column").set_cell_data_func(renderer, self.sat_pol_func)
renderer = builder.get_object("sat_fec_renderer")
builder.get_object("fec_column").set_cell_data_func(renderer, self.sat_fec_func)
renderer = builder.get_object("sat_sys_renderer")
builder.get_object("sys_column").set_cell_data_func(renderer, self.sat_sys_func)
renderer = builder.get_object("sat_mod_renderer")
builder.get_object("mod_column").set_cell_data_func(renderer, self.sat_mod_func)
# Terrestrial.
renderer = builder.get_object("ter_system_renderer")
builder.get_object("ter_system_column").set_cell_data_func(renderer, self.ter_sys_func)
renderer = builder.get_object("ter_bandwidth_renderer")
builder.get_object("ter_bandwidth_column").set_cell_data_func(renderer, self.ter_bandwidth_func)
renderer = builder.get_object("ter_constellation_renderer")
builder.get_object("ter_constellation_column").set_cell_data_func(renderer, self.ter_constellation_func)
renderer = builder.get_object("ter_rate_hp_renderer")
builder.get_object("ter_rate_hp_column").set_cell_data_func(renderer, self.ter_fec_hp_func)
renderer = builder.get_object("ter_rate_lp_renderer")
builder.get_object("ter_rate_lp_column").set_cell_data_func(renderer, self.ter_fec_lp_func)
renderer = builder.get_object("ter_guard_renderer")
builder.get_object("ter_guard_column").set_cell_data_func(renderer, self.ter_guard_func)
renderer = builder.get_object("ter_tr_mode_renderer")
builder.get_object("ter_tr_mode_column").set_cell_data_func(renderer, self.ter_transmission_func)
renderer = builder.get_object("ter_hierarchy_renderer")
builder.get_object("ter_hierarchy_column").set_cell_data_func(renderer, self.ter_hierarchy_func)
renderer = builder.get_object("ter_inversion_renderer")
builder.get_object("ter_inversion_column").set_cell_data_func(renderer, self.ter_inversion_func)
# Cable.
renderer = builder.get_object("cable_fec_renderer")
builder.get_object("cable_fec_column").set_cell_data_func(renderer, self.cable_fec_func)
renderer = builder.get_object("cable_mod_renderer")
builder.get_object("cable_mod_column").set_cell_data_func(renderer, self.cable_mod_func)
self.show()
# ******************** Custom renderers ******************** #
def sat_pos_func(self, column, renderer, model, itr, data):
""" Converts and sets the satellite position value to a readable format. """
renderer.set_property("text", get_pos_str(int(model.get_value(itr, 2))))
def sat_pol_func(self, column, renderer, model, itr, data):
renderer.set_property("text", POLARIZATION.get(model.get_value(itr, 2), None))
def sat_fec_func(self, column, renderer, model, itr, data):
renderer.set_property("text", FEC.get(model.get_value(itr, 3), None))
def sat_sys_func(self, column, renderer, model, itr, data):
renderer.set_property("text", SYSTEM.get(model.get_value(itr, 4), None))
def sat_mod_func(self, column, renderer, model, itr, data):
renderer.set_property("text", MODULATION.get(model.get_value(itr, 5), None))
def ter_sys_func(self, column, renderer, model, itr, data):
renderer.set_property("text", T_SYSTEM.get(model.get_value(itr, 1), None))
def ter_bandwidth_func(self, column, renderer, model, itr, data):
renderer.set_property("text", BANDWIDTH.get(model.get_value(itr, 2), None))
def ter_constellation_func(self, column, renderer, model, itr, data):
renderer.set_property("text", CONSTELLATION.get(model.get_value(itr, 3), None))
def ter_fec_hp_func(self, column, renderer, model, itr, data):
renderer.set_property("text", T_FEC.get(model.get_value(itr, 4), None))
def ter_fec_lp_func(self, column, renderer, model, itr, data):
renderer.set_property("text", T_FEC.get(model.get_value(itr, 5), None))
def ter_guard_func(self, column, renderer, model, itr, data):
renderer.set_property("text", GUARD_INTERVAL.get(model.get_value(itr, 6), None))
def ter_transmission_func(self, column, renderer, model, itr, data):
renderer.set_property("text", TRANSMISSION_MODE.get(model.get_value(itr, 7), None))
def ter_hierarchy_func(self, column, renderer, model, itr, data):
renderer.set_property("text", HIERARCHY.get(model.get_value(itr, 8), None))
def ter_inversion_func(self, column, renderer, model, itr, data):
value = model.get_value(itr, 9)
if value:
value = Inversion(value).name
renderer.set_property("text", value)
def cable_fec_func(self, column, renderer, model, itr, data):
renderer.set_property("text", FEC_DEFAULT.get(model.get_value(itr, 2), None))
def cable_mod_func(self, column, renderer, model, itr, data):
renderer.set_property("text", C_MODULATION.get(model.get_value(itr, 3), None))
def on_satellite_view_realize(self, view):
self.load_satellites_list()
def on_terrestrial_view_realize(self, view):
self.load_terrestrial_list()
def on_cable_view_realize(self, view):
self.load_cable_list()
def load_satellites_list(self, path=None):
gen = self.on_satellites_list_load(path)
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
def load_terrestrial_list(self, path=None):
gen = self.on_terrestrial_list_load(path)
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
def load_cable_list(self, path=None):
gen = self.on_cable_list_load(path)
GLib.idle_add(lambda: next(gen, False), priority=GLib.PRIORITY_LOW)
def on_visible_page(self, stack, param):
self._dvb_type = self.DVB(stack.get_visible_child_name())
self._transponders_stack.set_visible_child_name(self._dvb_type)
self._update_header_button.set_sensitive(self._dvb_type is self.DVB.SAT)
def on_satellite_selection(self, view):
model = self._sat_tr_view.get_model()
model.clear()
self._current_sat_path, column = view.get_cursor()
if self._current_sat_path:
sat_model = view.get_model()
list(map(model.append, sat_model[self._current_sat_path][-1]))
def on_terrestrial_selection(self, view):
model = self._ter_tr_view.get_model()
model.clear()
self._current_ter_path, column = view.get_cursor()
if self._current_ter_path:
ter_model = view.get_model()
list(map(model.append, ter_model[self._current_ter_path][-1]))
def on_cable_selection(self, view):
model = self._cable_tr_view.get_model()
model.clear()
self._current_cable_path, column = view.get_cursor()
if self._current_cable_path:
cable_model = view.get_model()
list(map(model.append, cable_model[self._current_cable_path][-1]))
def on_sat_model_changed(self, model, path, itr=None):
self._sat_count_label.set_text(str(len(model)))
def on_sat_tr_model_changed(self, model, path, itr=None):
self._sat_tr_count_label.set_text(str(len(model)))
def on_ter_model_changed(self, model, path, itr=None):
self._ter_count_label.set_text(str(len(model)))
def on_ter_tr_model_changed(self, model, path, itr=None):
self._ter_tr_count_label.set_text(str(len(model)))
def on_cable_model_changed(self, model, path, itr=None):
self._cable_count_label.set_text(str(len(model)))
def on_cable_tr_model_changed(self, model, path, itr=None):
self._cable_tr_count_label.set_text(str(len(model)))
def on_up(self, item):
move_items(KeyboardKey.UP, self._satellite_view)
def on_down(self, item):
move_items(KeyboardKey.DOWN, self._satellite_view)
def on_button_press(self, menu, event):
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS:
self.on_edit()
else:
on_popup_menu(menu, event)
def on_tr_button_press(self, menu, event):
if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS:
self.on_transponder_edit()
else:
on_popup_menu(menu, event)
def on_key_press(self, view, event):
""" Handling keystrokes. """
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
ctrl = event.state & MOD_MASK
if key is KeyboardKey.DELETE:
self.on_remove(view)
elif key is KeyboardKey.INSERT:
self.on_edit(force=True)
elif ctrl and key is KeyboardKey.E:
self.on_edit()
elif ctrl and key in MOVE_KEYS:
move_items(key, view)
elif key is KeyboardKey.LEFT or key is KeyboardKey.RIGHT:
view.do_unselect_all(view)
def on_tr_key_press(self, view, event):
""" Handling transponder view keystrokes. """
key = KeyboardKey(event.hardware_keycode)
if key is KeyboardKey.UNDEFINED:
return
ctrl = event.state & MOD_MASK
if key is KeyboardKey.DELETE:
self.on_transponder_remove()
elif key is KeyboardKey.INSERT:
self.on_transponder_edit(force=True)
elif ctrl and key is KeyboardKey.E:
self.on_transponder_edit()
elif ctrl and key in MOVE_KEYS:
move_items(key, view)
elif key is KeyboardKey.LEFT or key is KeyboardKey.RIGHT:
view.do_unselect_all(view)
def on_satellites_list_load(self, path=None):
""" Load satellites data into model """
path = path or f"{self._settings.profile_data_path}satellites.xml"
yield from self.load_data(self._satellite_view, get_satellites, path)
def on_terrestrial_list_load(self, path=None):
path = path or f"{self._settings.profile_data_path}terrestrial.xml"
yield from self.load_data(self._terrestrial_view, get_terrestrial, path)
def on_cable_list_load(self, path=None):
path = path or f"{self._settings.profile_data_path}cables.xml"
yield from self.load_data(self._cable_view, get_cable, path)
def load_data(self, view, func, path):
model = view.get_model()
model.clear()
try:
data = func(path)
yield True
except FileNotFoundError as e:
msg = translate("Please, download files from receiver or setup your path for read data!")
self._app.show_error_message(f"{e}\n{msg}")
except ExpatError as e:
msg = f"The file [{path}] is not formatted correctly or contains invalid characters! Cause: {e}"
self._app.show_error_message(msg)
else:
for d in data:
yield model.append(d)
def on_add(self, item):
""" Common adding. """
self.on_edit(item, force=True)
def on_transponder_add(self, item):
self.on_transponder_edit(force=True)
def on_edit(self, item=None, force=False):
self.on_data_edit(self.get_active_dvb_view(), force)
def on_transponder_edit(self, item=None, force=False):
self.on_data_edit(self.get_active_transponder_view(), force)
def on_data_edit(self, view, force=False):
""" Common edit. """
if force:
model, paths = view.get_selection().get_selected_rows()
else:
paths = self.check_selection(view, "Please, select only one item!")
if not paths:
return
model = view.get_model()
row = model[paths][:] if paths else None
itr = model.get_iter(paths) if paths else None
if view is self._satellite_view:
self.on_dvb_data_edit(SatelliteDialog, "Satellite", view, None if force else Satellite(*row), itr)
elif view is self._terrestrial_view:
self.on_dvb_data_edit(TerrestrialDialog, "Region", view, None if force else Terrestrial(*row), itr)
elif view is self._cable_view:
self.on_dvb_data_edit(CableDialog, "Provider", view, None if force else Cable(*row), itr)
elif view is self._sat_tr_view:
data = None if force else Transponder(*row)
self.on_transponder_data_edit(SatTransponderDialog, "Transponder", view, self._satellite_view, data, itr)
elif view is self._ter_tr_view:
data = None if force else TerTransponder(*row)
self.on_transponder_data_edit(TerTransponderDialog, "Transponder", view, self._terrestrial_view, data, itr)
elif view is self._cable_tr_view:
data = None if force else CableTransponder(*row)
self.on_transponder_data_edit(CableTransponderDialog, "Transponder", view, self._cable_view, data, itr)
else:
self._app.show_error_message("Not implemented yet!")
def on_dvb_data_edit(self, dialog, title, view, data=None, edited_itr=None):
""" Creates or edits DVB data. """
dialog = dialog(self._app.get_active_window(), title, data)
if dialog.run() == Gtk.ResponseType.OK:
dvb_data = dialog.data
if dvb_data:
model, paths = view.get_selection().get_selected_rows()
if data and edited_itr:
model.set(edited_itr, {i: v for i, v in enumerate(dvb_data)})
else:
if paths:
index = paths[0].get_indices()[0] + 1
model.insert(index, dvb_data)
else:
model.append(dvb_data)
scroll_to(len(model) - 1, view)
dialog.destroy()
def on_transponder_data_edit(self, dialog, title, view, src_view, data=None, edited_itr=None):
""" Creates or edits transponder data. """
paths = self.check_selection(src_view, "Please, select only one item!")
if paths is None:
return
elif len(paths) == 0:
self._app.show_error_message("No source selected!")
return
dialog = dialog(self._app.app_window, title, data)
if dialog.run() == Gtk.ResponseType.OK:
tr = dialog.data
if tr:
src_model = src_view.get_model()
transponders = src_model[paths][-1]
tr_model, tr_paths = view.get_selection().get_selected_rows()
if data and edited_itr:
tr_model.set(edited_itr, {i: v for i, v in enumerate(tr)})
transponders[tr_model.get_path(edited_itr).get_indices()[0]] = tr
else:
index = paths[0].get_indices()[0] + 1
tr_model.insert(index, tr)
transponders.insert(index, tr)
dialog.destroy()
def check_selection(self, view, message):
""" Checks if any row is selected. Shows error dialog if selected more than one.
Returns selected path or None.
"""
model, paths = view.get_selection().get_selected_rows()
if len(paths) > 1:
self._app.show_error_message(message)
return
return paths
def on_remove(self, view=None):
""" Removes selected satellites and transponders. """
view = self.get_active_dvb_view()
selection = view.get_selection()
model, paths = selection.get_selected_rows()
list(map(model.remove, [model.get_iter(path) for path in paths]))
def on_transponder_remove(self, item=None):
view = self.get_active_transponder_view()
trs = None
if view is self._sat_tr_view:
if self._current_sat_path:
trs = self._satellite_view.get_model()[self._current_sat_path][-1]
else:
self._app.show_error_message("No satellite is selected!")
elif view is self._ter_tr_view:
if self._current_ter_path:
trs = self._terrestrial_view.get_model()[self._current_ter_path][-1]
else:
self._app.show_error_message("No terrestrial is selected!")
elif view is self._cable_tr_view:
if self._current_cable_path:
trs = self._cable_view.get_model()[self._current_cable_path][-1]
else:
self._app.show_error_message("No cable is selected!")
if trs:
model, paths = view.get_selection().get_selected_rows()
list(map(trs.pop, sorted(map(lambda p: p.get_indices()[0], paths), reverse=True)))
list(map(model.remove, [model.get_iter(path) for path in paths]))
def get_active_dvb_view(self):
if self._dvb_type is self.DVB.SAT:
return self._satellite_view
elif self._dvb_type is self.DVB.TERRESTRIAL:
return self._terrestrial_view
return self._cable_view
def get_active_transponder_view(self):
if self._dvb_type is self.DVB.SAT:
return self._sat_tr_view
elif self._dvb_type is self.DVB.TERRESTRIAL:
return self._ter_tr_view
return self._cable_tr_view
def on_open(self, app, page):
if page is not Page.SATELLITE:
return
xml_file = "satellites.xml"
if self._dvb_type is self.DVB.TERRESTRIAL:
xml_file = "terrestrial.xml"
elif self._dvb_type is self.DVB.CABLE:
xml_file = "cables.xml"
response = get_chooser_dialog(self._app.app_window, self._settings, xml_file, ("*.xml",))
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
if not str(response).endswith(xml_file):
self._app.show_error_message(f"No {xml_file} file is selected!")
return
if self._dvb_type is self.DVB.SAT:
self.load_satellites_list(response)
elif self._dvb_type is self.DVB.TERRESTRIAL:
self.load_terrestrial_list(response)
else:
self.load_cable_list(response)
@run_idle
def on_profile_changed(self, app, profile):
self.load_satellites_list()
self.load_terrestrial_list()
self.load_cable_list()
@run_idle
def on_save(self, app, page):
if page is Page.SATELLITE and show_dialog(DialogType.QUESTION, self._app.app_window) == Gtk.ResponseType.OK:
self.save_data(self._settings.profile_data_path)
def save_data(self, path):
if self._dvb_type is self.DVB.SAT:
write_satellites((Satellite(*r) for r in self._satellite_view.get_model()), f"{path}satellites.xml")
elif self._dvb_type is self.DVB.TERRESTRIAL:
write_terrestrial((Terrestrial(*r) for r in self._terrestrial_view.get_model()), f"{path}terrestrial.xml")
else:
write_cable((Cable(*r) for r in self._cable_view.get_model()), f"{path}cables.xml")
def on_save_as(self, app, page):
if page is not Page.SATELLITE:
return
buttons = (Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK)
response = show_dialog(DialogType.CHOOSER, self._app.app_window, settings=self._settings, buttons=buttons)
if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT):
return
self.save_data(response)
def on_download(self, app, page):
if page is Page.SATELLITE:
self._app.on_download_data(DownloadType.SATELLITES, files_filter=(f"{self._dvb_type}.xml",))
def on_upload(self, app, page):
if page is Page.SATELLITE:
self._app.upload_data(DownloadType.SATELLITES, files_filter=(f"{self._dvb_type}.xml",))
@run_idle
def on_update(self, item=None):
SatellitesUpdateDialog(self._app.get_active_window(), self._settings, self._satellite_view.get_model()).show()
if __name__ == "__main__":
pass

1404
app/ui/xml/editor.glade Normal file

File diff suppressed because it is too large Load Diff

1353
app/ui/xml/update.glade Normal file

File diff suppressed because it is too large Load Diff

46
build/BUILD_WIN.md Normal file
View File

@@ -0,0 +1,46 @@
## Launch
The best way to run this program from source is using of [MSYS2](https://www.msys2.org/) platform.
1. Download and install the platform as described [here](https://www.msys2.org/) up to point 4.
2. Launch **mingw64** shell.
![mingw64](https://user-images.githubusercontent.com/7511379/161400639-898ceb10-7de8-4557-bde1-25fe32bdfb03.png)
3. Run first `pacman -Suy` After that, you may need to restart the terminal and re-run the update command.
4. Install minimal required packages:
`pacman -S mingw-w64-x86_64-gtk3 mingw-w64-x86_64-python3 mingw-w64-x86_64-python3-gobject mingw-w64-x86_64-python-requests`
Optional: `pacman -S mingw-w64-x86_64-python-pillow mingw-w64-x86_64-python-chardet`
To support streams playback, install the following packages (the list may not be complete):
* 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`
* For [MPV](https://mpv.io/) `pacman -S mingw-w64-x86_64-mpv`,
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).
* Download and extract 7z archive.
* Copy libmpv-2.dll to *C:\msys64\mingw64\bin*
* libmpv.dll.a to *C:\msys64\mingw64\lib*
and folder *include\mpv to *C:\msys64\mingw64\include* path.
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\`
6. Run mingw64 shell. Go to the folder where the program was unpacked. E.g: `cd DemonEditor/`
And run: `./start.py`
## Building a package
To build a standalone package, we can use [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/).
1. Launch mingw64 shell.
2. Install PyInstaller: `pacman -S mingw-w64-x86_64-pyinstaller`
3. Go to the folder where the program was unpacked. E.g: `c:\msys64\home\username\DemonEditor\`
4. Сopy and replace the files from the /build/win/ folder to the root .
5. Go to the folder with the program in the running terminal: `cd DemonEditor/`
6. Give the following command: `pyinstaller.exe DemonEditor.spec`
7. Wait until the operation end. In the dist folder you will find a ready-made build.
### Appearance
To change the look we can use third party [Gtk3 themes and Icon sets](https://www.gnome-look.org).
To set the default theme:
1. Сreate a folder "`\etc\gtk-3.0\`" in the root of the finished build folder.
2. Create a _settings.ini_ file in this folder with the following content:
```
[Settings]
gtk-icon-theme-name = Adwaita
gtk-theme-name = Windows-10
```
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".
Themes and icon sets should be located in the `share\themes` and `share\icons` folders respectively.
To fine-tune the default theme you use, you can use the _win_style.css_ file in the `ui` folder.
You can find more info about changing the appearance of Gtk applications on the Web yourself.

View File

@@ -1,18 +1,17 @@
#!/bin/bash
VER="2.0.0_Alpha"
VER="3.14.4_Beta"
B_PATH="dist/DemonEditor"
DEB_PATH="$B_PATH/usr/share/demoneditor"
mkdir -p $B_PATH
cp -TRv deb $B_PATH
rsync -arv ../../app/ui/lang/* "$B_PATH/usr/share/locale"
rsync --exclude=app/ui/lang --exclude=app/ui/icons --exclude=__pycache__ -arv ../../app $DEB_PATH
rsync --exclude=__pycache__ -arv ../../extensions $DEB_PATH
cd dist
fakeroot dpkg-deb --build DemonEditor
fakeroot dpkg-deb -Zxz --build DemonEditor
mv DemonEditor.deb DemonEditor_$VER.deb
rm -R DemonEditor

View File

@@ -1,5 +1,5 @@
Package: demon-editor
Version: 2.0.0-Alpha
Version: 3.14.4-Beta
Section: utils
Priority: optional
Architecture: all
@@ -8,8 +8,19 @@ Depends: python3 (>= 3.6),
python3-requests,
python3-gi,
python3-gi-cairo,
gir1.2-notify-0.7
Recommends: libmpv1,
python3-chardet
gir1.2-notify-0.7,
p7zip-full
Recommends: ffmpeg,
libmpv1,
python3-chardet,
libgtksourceview (>= 3.0)
Maintainer: Dmitriy Yefremov <dmitry.v.yefremov@gmail.com>
Homepage: https://dyefremov.github.io/DemonEditor
Description: Enigma2 channel and satellite list editor
Editing bouquets, channels, satellites, importing services,
downloading picons and updating satellites from the Web,
extended support of IPTV, assignment of EPG from DVB or
XML for IPTV services, playback of IPTV or other streams
directly from the bouquet list, control panel (via HTTP API),
ability to view EPG and manage timers (via HTTP API),
simple FTP client (experimental).

View File

@@ -5,7 +5,7 @@ Source: https://github.com/DYefremov/DemonEditor
Files: *
MIT License
Copyright (c) 2018-2021 Dmitriy Yefremov
Copyright (c) 2018-2026 Dmitriy Yefremov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,2 +1,2 @@
#!/bin/bash
python3 /usr/share/demoneditor/start.py $1
python3 /usr/share/demoneditor/start.py $@

View File

@@ -1,13 +0,0 @@
[Desktop Entry]
Version=1.0
Name=DemonEditor
Comment=Channel and satellite list editor for Enigma2
Comment[ru]=Редактор списка каналов и спутников для Enigma2
Comment[be]=Рэдактар спіса каналаў і спадарожнікаў для Enigma2
Comment[de]=Programm- und Satellitenlisten-Editor für Enigma2
Icon=demon-editor
Exec=/usr/bin/demon-editor
Terminal=false
Type=Application
Categories=Utility;Application;
StartupNotify=false

View File

@@ -0,0 +1,31 @@
[Desktop Entry]
Version=1.0
Name=DemonEditor
GenericName=Enigma2 bouquets editor
GenericName[be]=Рэдактар букетаў Enigma2
GenericName[de]=Enigma2 Bouquet-Editor
GenericName[es]=Editor de ramos de Enigma2
GenericName[it]=Editor di bouquet Enigma2
GenericName[nl]=Enigma2 boeket editor
GenericName[pl]=Edytor bukietów Enigma2
GenericName[pt]=Editor de buquês Enigma2
GenericName[ru]=Редактор букетов Enigma2
GenericName[tr]=Enigma2 buket düzenleyici
GenericName[zh_CN]=Enigma2频道编辑器
Comment=Channel and satellite list editor for Enigma2
Comment[be]=Рэдактар спісу каналаў і супутнікаў для Enigma2
Comment[de]=Kanal- und Satellitenlisten-Editor für Enigma2
Comment[es]=Editor de lista de canales y satélites para Enigma2
Comment[it]=Editor di elenchi di canali e satelliti per Enigma2
Comment[nl]=Kanaal- en satellietlijsteditor voor Enigma2
Comment[pl]=Edytor list kanałów i satelitów dla Enigma2
Comment[pt]=Editor de lista de canais e satélites para Enigma2
Comment[ru]=Редактор списка каналов и спутников для Enigma2
Comment[tr]=Enigma2 için Kanal ve uydu listesi düzenleyici
Comment[zh_CN]=Enigma2频道和卫星列表编辑器
Icon=demon-editor
Exec=/usr/bin/demon-editor
Terminal=false
Type=Application
Categories=Utility;Application;
StartupNotify=false

Some files were not shown because too many files have changed in this diff Show More